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 }) => (
aberto:{index}
), })); // o dublê do põe as props num
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[0]> = {}) { return render( , ); } 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-), 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( , ); 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", ); }); });