Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
|
import { carouselIndex, ProjectList } from "./ProjectList";
|
|
import { buildShowcaseItems, ShowcaseProvider } from "./ShowcaseProvider";
|
|
|
|
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
|
|
jest.mock("./PinnedReelLazy", () => ({
|
|
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
|
|
}));
|
|
jest.mock("./ProjectLightboxLazy", () => ({
|
|
// o ProjectLightbox deste teste é o dublê logo abaixo
|
|
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
|
|
}));
|
|
|
|
/**
|
|
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
|
|
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente", o botão de ampliar e o pôster como
|
|
* elemento compartilhado da troca de página.
|
|
*/
|
|
|
|
jest.mock("./ProjectLightbox", () => ({
|
|
ProjectLightbox: ({ index }: { index: number }) => (
|
|
<div role="dialog" aria-label="ampliado">
|
|
aberto:{index}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
|
|
jest.mock("react", () =>
|
|
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
|
|
);
|
|
|
|
type Callback = IntersectionObserverCallback;
|
|
/** Cada observador criado, com o elemento que observa. */
|
|
const observers: Array<{ callback: Callback; target?: Element }> = [];
|
|
const Original = global.IntersectionObserver;
|
|
|
|
const projects = getFeaturedProjects(null);
|
|
const items = buildShowcaseItems(projects);
|
|
|
|
beforeEach(() => {
|
|
observers.length = 0;
|
|
global.IntersectionObserver = class {
|
|
private readonly record: { callback: Callback; target?: Element };
|
|
constructor(callback: Callback) {
|
|
this.record = { callback };
|
|
observers.push(this.record);
|
|
}
|
|
observe(target: Element) {
|
|
this.record.target = target;
|
|
}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
} as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.IntersectionObserver = Original;
|
|
});
|
|
|
|
function renderList(props: Partial<Parameters<typeof ProjectList>[0]> = {}) {
|
|
return render(
|
|
<ShowcaseProvider projects={projects}>
|
|
<ProjectList items={items} device="desktop" {...props} />
|
|
</ShowcaseProvider>,
|
|
);
|
|
}
|
|
|
|
function bringNear(target: Element) {
|
|
act(() => {
|
|
for (const observer of observers)
|
|
if (observer.target === target)
|
|
observer.callback(
|
|
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
{} as IntersectionObserver,
|
|
);
|
|
});
|
|
}
|
|
|
|
function bringAllNear(container: HTMLElement) {
|
|
for (const item of container.querySelectorAll("li.ds-reel-item"))
|
|
bringNear(item);
|
|
}
|
|
|
|
describe("ProjectList", () => {
|
|
it("path: device unknown (server / before mount) -> text and links only, no media and no expand button", () => {
|
|
const { container } = renderList({ device: null });
|
|
|
|
expect(container.querySelectorAll("li")).toHaveLength(items.length);
|
|
expect(container.querySelector("[data-device]")).toBeNull();
|
|
expect(container.querySelector(".ds-reel-item-media")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /Ampliar vídeo/ })).toBeNull();
|
|
expect(screen.getAllByRole("link", { name: /Ver case/ })).toHaveLength(
|
|
items.length,
|
|
);
|
|
});
|
|
|
|
it("path: desktop -> browser frame, poster only once the item is near the window", () => {
|
|
const { container } = renderList({ device: "desktop" });
|
|
|
|
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
|
|
expect(container.querySelector(".ds-phone")).toBeNull();
|
|
expect(container.querySelector("img")).toBeNull();
|
|
|
|
bringAllNear(container);
|
|
|
|
const images = [...container.querySelectorAll("img")];
|
|
expect(images).toHaveLength(items.length);
|
|
expect(images[0]).toHaveAttribute(
|
|
"alt",
|
|
`Captura de tela de ${items[0].project.title}`,
|
|
);
|
|
expect(decodeURIComponent(images[0].getAttribute("src") ?? "")).toContain(
|
|
"desktop.webp",
|
|
);
|
|
expect(images[0]).toHaveAttribute("sizes", "50vw");
|
|
});
|
|
|
|
it("path: poster near the window -> shared element named after the case (case-<id>), one name per item", () => {
|
|
const { container } = renderList({ device: "mobile" });
|
|
bringAllNear(container);
|
|
|
|
const names = [...container.querySelectorAll("img")].map((image) =>
|
|
image.parentElement?.getAttribute("data-vt-name"),
|
|
);
|
|
expect(names).toEqual(items.map(({ config }) => `case-${config.id}`));
|
|
const transition = container.querySelector("img")?.parentElement;
|
|
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
|
|
expect(transition).toHaveAttribute("data-vt-default", "none");
|
|
});
|
|
|
|
it("path: mobile -> phone frame with the phone poster and the phone sizes", () => {
|
|
const { container } = renderList({ device: "mobile" });
|
|
bringAllNear(container);
|
|
|
|
expect(container.querySelectorAll(".ds-phone")).toHaveLength(items.length);
|
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
|
const image = container.querySelector("img") as HTMLImageElement;
|
|
expect(decodeURIComponent(image.getAttribute("src") ?? "")).toContain(
|
|
"mobile.webp",
|
|
);
|
|
// o celular da moldura mede ~160 px no carrossel: o pôster de 390 px não viaja inteiro
|
|
expect(image).toHaveAttribute("sizes", "160px");
|
|
});
|
|
|
|
it("path: an item that is not near yet keeps its frame but no image", () => {
|
|
const { container } = renderList({ device: "desktop" });
|
|
bringNear(container.querySelector("li.ds-reel-item") as Element);
|
|
|
|
expect(container.querySelectorAll("img")).toHaveLength(1);
|
|
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
|
|
});
|
|
|
|
it("path: project with a subtitle -> shown; without -> no subtitle paragraph", () => {
|
|
const withSubtitle = items.map((item, index) => ({
|
|
...item,
|
|
project: { ...item.project, subtitle: index === 0 ? "Loja virtual" : "" },
|
|
}));
|
|
|
|
const { container } = render(
|
|
<ShowcaseProvider projects={projects}>
|
|
<ProjectList items={withSubtitle} device="desktop" />
|
|
</ShowcaseProvider>,
|
|
);
|
|
|
|
const subs = container.querySelectorAll(".ds-reel-sub");
|
|
expect(subs).toHaveLength(1);
|
|
expect(subs[0]).toHaveTextContent("Loja virtual");
|
|
});
|
|
|
|
it("path: pending -> the section is marked so CSS can hide it; not pending -> unmarked", () => {
|
|
const pending = renderList({ pending: true, device: null });
|
|
expect(pending.container.querySelector("section")).toHaveAttribute(
|
|
"data-pending",
|
|
);
|
|
pending.unmount();
|
|
|
|
const settled = renderList({ pending: false });
|
|
expect(settled.container.querySelector("section")).not.toHaveAttribute(
|
|
"data-pending",
|
|
);
|
|
});
|
|
|
|
it("path: pressing 'Ampliar vídeo' on an item -> opens the enlarged window on that project", () => {
|
|
renderList({ device: "desktop" });
|
|
const second = screen
|
|
.getByRole("heading", { level: 3, name: items[1].project.title })
|
|
.closest("li") as HTMLElement;
|
|
|
|
fireEvent.click(
|
|
within(second).getByRole("button", { name: /Ampliar vídeo/ }),
|
|
);
|
|
|
|
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
|
"aberto:1",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ProjectList: carrossel do celular", () => {
|
|
/** Dá ao carrossel as medidas que o jsdom não tem: largura de cada cartão, passo e rolagem. */
|
|
function lay(
|
|
list: HTMLElement,
|
|
{ scrollLeft = 0, card = 300, gap = 16 } = {},
|
|
) {
|
|
const cards = Array.from(list.children) as HTMLElement[];
|
|
Object.defineProperty(list, "clientWidth", {
|
|
configurable: true,
|
|
value: 360,
|
|
});
|
|
Object.defineProperty(list, "scrollWidth", {
|
|
configurable: true,
|
|
value: cards.length * (card + gap),
|
|
});
|
|
list.scrollLeft = scrollLeft;
|
|
cards.forEach((element, index) => {
|
|
element.getBoundingClientRect = () =>
|
|
({ left: index * (card + gap) - scrollLeft }) as DOMRect;
|
|
});
|
|
}
|
|
|
|
const list = (container: HTMLElement) =>
|
|
container.querySelector(".ds-reel-items") as HTMLElement;
|
|
const count = (container: HTMLElement) =>
|
|
container.querySelector(".ds-reel-count");
|
|
|
|
it("path: on the phone, shows '1/N' and one dot per project (decorative: the list already says how many there are)", () => {
|
|
const { container } = renderList({ device: "mobile" });
|
|
|
|
const counter = count(container) as HTMLElement;
|
|
expect(counter).toHaveAttribute("aria-hidden", "true");
|
|
expect(counter.textContent).toBe(`1/${items.length}`);
|
|
expect(counter.querySelectorAll("i")).toHaveLength(items.length);
|
|
expect(counter.querySelectorAll("i[data-on]")).toHaveLength(1);
|
|
});
|
|
|
|
it("path: no counter on the desktop grid, before mount, or with one project only", () => {
|
|
expect(count(renderList({ device: "desktop" }).container)).toBeNull();
|
|
expect(count(renderList({ device: null }).container)).toBeNull();
|
|
expect(
|
|
count(
|
|
renderList({ device: "mobile", items: items.slice(0, 1) }).container,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("path: scrolling the strip moves the counter and the dot (one animation frame at a time)", () => {
|
|
jest.useFakeTimers();
|
|
try {
|
|
const { container } = renderList({ device: "mobile" });
|
|
const strip = list(container);
|
|
lay(strip, { scrollLeft: 316 * 2 });
|
|
|
|
act(() => {
|
|
strip.dispatchEvent(new Event("scroll"));
|
|
strip.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
|
|
expect((count(container) as HTMLElement).textContent).toBe(
|
|
`3/${items.length}`,
|
|
);
|
|
const dots = [...(count(container) as HTMLElement).querySelectorAll("i")];
|
|
expect(dots.findIndex((dot) => dot.hasAttribute("data-on"))).toBe(2);
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("path: the strip scrolled to its end -> the last project, even if its start never reaches the edge", () => {
|
|
jest.useFakeTimers();
|
|
try {
|
|
const { container } = renderList({ device: "mobile" });
|
|
const strip = list(container);
|
|
lay(strip);
|
|
// fim da rolagem: scrollLeft + clientWidth = scrollWidth
|
|
strip.scrollLeft = strip.scrollWidth - strip.clientWidth;
|
|
|
|
act(() => {
|
|
strip.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
|
|
expect((count(container) as HTMLElement).textContent).toBe(
|
|
`${items.length}/${items.length}`,
|
|
);
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("carouselIndex: 0 without horizontal scroll, never past the ends, and tolerant of a degenerate stride", () => {
|
|
const strip = document.createElement("ul");
|
|
for (let i = 0; i < 4; i++) strip.append(document.createElement("li"));
|
|
// sem rolagem para o lado (grade das telas maiores)
|
|
Object.defineProperty(strip, "scrollWidth", {
|
|
configurable: true,
|
|
value: 300,
|
|
});
|
|
Object.defineProperty(strip, "clientWidth", {
|
|
configurable: true,
|
|
value: 300,
|
|
});
|
|
expect(carouselIndex(strip)).toBe(0);
|
|
|
|
lay(strip, { scrollLeft: 316 });
|
|
expect(carouselIndex(strip)).toBe(1);
|
|
lay(strip, { scrollLeft: 316 * 10 });
|
|
expect(carouselIndex(strip)).toBe(3);
|
|
// cartões sobrepostos (passo zero): não divide por zero
|
|
for (const child of Array.from(strip.children))
|
|
(child as HTMLElement).getBoundingClientRect = () =>
|
|
({ left: 0 }) as DOMRect;
|
|
strip.scrollLeft = 10;
|
|
Object.defineProperty(strip, "scrollWidth", {
|
|
configurable: true,
|
|
value: 2000,
|
|
});
|
|
Object.defineProperty(strip, "clientWidth", {
|
|
configurable: true,
|
|
value: 360,
|
|
});
|
|
expect(carouselIndex(strip)).toBe(0);
|
|
// um cartão só
|
|
const single = document.createElement("ul");
|
|
single.append(document.createElement("li"));
|
|
expect(carouselIndex(single)).toBe(0);
|
|
});
|
|
|
|
it("path: 'Ampliar vídeo' keeps its accessible name (visually hidden on the phone, by CSS) and 'Ver case' is the first action", () => {
|
|
const { container } = renderList({ device: "mobile" });
|
|
|
|
const first = container.querySelector(".ds-reel-actions") as HTMLElement;
|
|
const names = [...first.querySelectorAll("a, button")].map((element) =>
|
|
element.textContent?.replace(/\s+/g, " ").trim(),
|
|
);
|
|
expect(names[0]).toMatch(/^Ver case/);
|
|
expect(
|
|
within(first).getByRole("button", { name: /^Ampliar vídeo de / }),
|
|
).toBeInTheDocument();
|
|
expect(first.querySelector(".ds-reel-expand-label")?.textContent).toBe(
|
|
"Ampliar vídeo",
|
|
);
|
|
});
|
|
});
|