import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { renderToString } from "react-dom/server"; import { MOBILE_QUERY } from "@/lib/device"; import { getFeaturedProjects } from "@/lib/portfolio-data"; import { getShowcaseConfig } from "@/lib/project-showcase"; import { ProjectReel } from "./ProjectReel"; import { ShowcaseProvider } from "./ShowcaseProvider"; import { reelOffset } from "./showcase-math"; import { PINNED_QUERY } from "./useShowcaseMedia"; // 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", () => ({ ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox, })); /** * Testes de caminhos-base de ProjectReel (complemento de ProjectReel.test.tsx): * - qual versão da vitrine entra (fixa só com largura e altura de desktop e sem movimento reduzido; lista em * janela baixa; carrossel no celular; nada sem projetos) e a troca ao vivo quando a janela muda; * - measure(): vitrine escondida por CSS, trilho sem rolagem; * - a faixa de projetos que acompanha o ativo, a descarga de vídeos, o seek dos vídeos, o "prime" e a rolagem suave. */ let reducedMotion = false; jest.mock("@/app/hooks/useReducedMotion", () => ({ useReducedMotion: () => reducedMotion, })); const HEADER = 60; let containerHeight = 7000; let pinHeight = 800; let reelTop = HEADER; let phoneWidth = 90; /** Rolagem horizontal da faixa de projetos (tablet e celular). */ let stripScrollWidth = 0; let stripClientWidth = 0; let buttonOffsetLeft = 0; let mediaNetworkState = 0; type Listener = () => void; const mediaState: Record = {}; const mediaListeners = new Map>(); const originalMatchMedia = window.matchMedia; function mockViewport({ phone = false, pinned = !phone, }: { phone?: boolean; pinned?: boolean; } = {}) { mediaState[MOBILE_QUERY] = phone; mediaState[PINNED_QUERY] = pinned; mediaListeners.clear(); window.matchMedia = jest.fn().mockImplementation((query: string) => ({ get matches() { return Boolean(mediaState[query]); }, media: query, addEventListener: (_type: string, listener: Listener) => { if (!mediaListeners.has(query)) mediaListeners.set(query, new Set()); mediaListeners.get(query)?.add(listener); }, removeEventListener: (_type: string, listener: Listener) => { mediaListeners.get(query)?.delete(listener); }, })); } function changeMedia(query: string, matches: boolean) { mediaState[query] = matches; act(() => { for (const listener of mediaListeners.get(query) ?? []) listener(); }); } const projects = getFeaturedProjects(null); const WEIGHTS = [ "sev-modaintima", "lavacao-047", "nutriscan", "sc-plus", "as-servicos", ].map((id) => getShowcaseConfig(id)?.weight ?? 1); const originalRect = Element.prototype.getBoundingClientRect; const descriptors = { offsetHeight: Object.getOwnPropertyDescriptor( HTMLElement.prototype, "offsetHeight", ), offsetWidth: Object.getOwnPropertyDescriptor( HTMLElement.prototype, "offsetWidth", ), offsetLeft: Object.getOwnPropertyDescriptor( HTMLElement.prototype, "offsetLeft", ), scrollWidth: Object.getOwnPropertyDescriptor( Element.prototype, "scrollWidth", ), clientWidth: Object.getOwnPropertyDescriptor( Element.prototype, "clientWidth", ), networkState: Object.getOwnPropertyDescriptor( HTMLMediaElement.prototype, "networkState", ), }; beforeAll(() => { Object.defineProperty(HTMLElement.prototype, "offsetHeight", { configurable: true, get(this: HTMLElement) { if (this.classList.contains("ds-reel")) return containerHeight; if (this.classList.contains("ds-reel-pin")) return pinHeight; return 0; }, }); Object.defineProperty(HTMLElement.prototype, "offsetWidth", { configurable: true, get(this: HTMLElement) { return this.classList.contains("ds-reel-phone") ? phoneWidth : 0; }, }); Object.defineProperty(HTMLElement.prototype, "offsetLeft", { configurable: true, get(this: HTMLElement) { return this.tagName === "BUTTON" ? buttonOffsetLeft : 0; }, }); Object.defineProperty(Element.prototype, "scrollWidth", { configurable: true, get(this: Element) { return this.matches(".ds-reel-index ol") ? stripScrollWidth : 0; }, }); Object.defineProperty(Element.prototype, "clientWidth", { configurable: true, get(this: Element) { return this.matches(".ds-reel-index ol") ? stripClientWidth : 0; }, }); Object.defineProperty(HTMLMediaElement.prototype, "networkState", { configurable: true, get: () => mediaNetworkState, }); HTMLDialogElement.prototype.showModal = function showModal( this: HTMLDialogElement, ) { this.setAttribute("open", ""); }; HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); this.dispatchEvent(new Event("close")); }; Element.prototype.getBoundingClientRect = function rect(this: Element) { if (this.classList.contains("ds-reel")) return { top: reelTop, bottom: reelTop + containerHeight, height: containerHeight, left: 0, right: 1000, width: 1000, x: 0, y: reelTop, } as DOMRect; return originalRect.call(this); }; window.scrollTo = jest.fn(); }); afterAll(() => { Element.prototype.getBoundingClientRect = originalRect; for (const [key, target] of [ ["offsetHeight", HTMLElement.prototype], ["offsetWidth", HTMLElement.prototype], ["offsetLeft", HTMLElement.prototype], ["scrollWidth", Element.prototype], ["clientWidth", Element.prototype], ["networkState", HTMLMediaElement.prototype], ] as const) { const original = descriptors[key]; if (original) Object.defineProperty(target, key, original); else Reflect.deleteProperty(target, key); } }); beforeEach(() => { jest.useFakeTimers(); reducedMotion = false; containerHeight = 7000; pinHeight = 800; reelTop = HEADER; phoneWidth = 90; stripScrollWidth = 0; stripClientWidth = 0; buttonOffsetLeft = 0; mediaNetworkState = 0; mockViewport(); (window.scrollTo as jest.Mock).mockClear(); jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); window.matchMedia = originalMatchMedia; Object.defineProperty(navigator, "connection", { configurable: true, value: undefined, }); }); function renderReel(list = projects) { const view = render( , ); act(() => { jest.advanceTimersByTime(50); }); return view; } function scrollTo(progress: number) { const scrollable = containerHeight - pinHeight; reelTop = HEADER - progress * scrollable; act(() => { window.dispatchEvent(new Event("scroll")); jest.advanceTimersByTime(1500); }); } const reelEl = () => document.querySelector(".ds-reel"); const listEl = () => screen.queryByRole("region", { name: "Projetos de clientes" }); const activeLayer = () => document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement; const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement; describe("ProjectReel: qual versão entra", () => { it("path: no projects in the showcase -> renders nothing at all", () => { const { container } = renderReel([]); expect(container).toBeEmptyDOMElement(); }); it("path: wide and tall window, motion allowed -> the pinned reel, not the list", () => { renderReel(); expect(reelEl()).not.toBeNull(); expect(listEl()).toBeNull(); }); it("path: wide but short window (below 561 px of height) -> desktop list, nothing pinned", () => { mockViewport({ phone: false, pinned: false }); renderReel(); expect(reelEl()).toBeNull(); const list = listEl() as HTMLElement; expect(list).not.toBeNull(); expect(list.querySelectorAll("li[data-device='desktop']")).toHaveLength(5); expect(list.querySelector(".ds-phone")).toBeNull(); expect(list).not.toHaveAttribute("data-pending"); }); it("path: phone -> carousel list with the phone version of every item", () => { mockViewport({ phone: true }); renderReel(); expect(reelEl()).toBeNull(); expect( (listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"), ).toHaveLength(5); }); it("path: phone width and reduced motion at once -> still the list", () => { mockViewport({ phone: true }); reducedMotion = true; renderReel(); expect(reelEl()).toBeNull(); expect(listEl()).not.toBeNull(); }); it("path: server render -> the list marked as pending plus the placeholder, with no media", () => { const html = renderToString( , ); expect(html).toContain("data-pending"); expect(html).toContain("ds-reel-placeholder"); expect(html).not.toContain(" the reel gives way to the list, and back", () => { renderReel(); expect(reelEl()).not.toBeNull(); changeMedia(PINNED_QUERY, false); expect(reelEl()).toBeNull(); expect(listEl()).not.toBeNull(); changeMedia(PINNED_QUERY, true); expect(reelEl()).not.toBeNull(); expect(listEl()).toBeNull(); }); it("path: the window crosses the phone breakpoint while the list shows -> the list switches versions", () => { mockViewport({ phone: false, pinned: false }); renderReel(); expect( (listEl() as HTMLElement).querySelectorAll("li[data-device='desktop']"), ).toHaveLength(5); changeMedia(MOBILE_QUERY, true); expect( (listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"), ).toHaveLength(5); }); }); describe("ProjectReel: measure()", () => { it("path: the reel is hidden by CSS (height 0) -> nothing is measured or downloaded (and it is when visible)", () => { const attachedSources = () => [...document.querySelectorAll(".ds-reel-layer video")].filter((video) => video.getAttribute("src"), ); containerHeight = 0; const hidden = renderReel(); scrollTo(0.5); expect(attachedSources()).toHaveLength(0); hidden.unmount(); containerHeight = 7000; renderReel(); scrollTo(0.5); expect(attachedSources().length).toBeGreaterThan(0); }); it("path: the pinned scene is as tall as the track (nothing to scroll) -> stays on the first project", () => { containerHeight = pinHeight; renderReel(); scrollTo(0.9); expect(activeLayer().querySelector(".ds-bframe-url")).toHaveTextContent( "sevmodaintima.com.br", ); expect(caption()).toHaveTextContent("SEV Moda Íntima"); }); it("path: scrolled to a later project -> that project becomes the active layer", () => { renderReel(); scrollTo(reelOffset(WEIGHTS, 3, 0.5)); expect( document.querySelector('.ds-reel-index [aria-current="true"]'), ).toHaveAttribute("aria-label", "SC Plus Aquicultura"); }); }); describe("ProjectReel: faixa de projetos que acompanha o ativo", () => { it("path: strip wider than its box -> scrolls the active button into view (minus 16 px)", () => { stripScrollWidth = 900; stripClientWidth = 300; buttonOffsetLeft = 140; const scrollToStrip = jest.fn(); HTMLOListElement.prototype.scrollTo = scrollToStrip; try { renderReel(); expect(scrollToStrip).toHaveBeenCalledWith({ left: 124, behavior: "smooth", }); } finally { Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo"); } }); it("path: the active button sits near the start -> never scrolls to a negative position", () => { stripScrollWidth = 900; stripClientWidth = 300; buttonOffsetLeft = 4; const scrollToStrip = jest.fn(); HTMLOListElement.prototype.scrollTo = scrollToStrip; try { renderReel(); expect(scrollToStrip).toHaveBeenCalledWith({ left: 0, behavior: "smooth", }); } finally { Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo"); } }); it("path: strip fits in its box -> no scrolling", () => { stripScrollWidth = 300; stripClientWidth = 300; const scrollToStrip = jest.fn(); HTMLOListElement.prototype.scrollTo = scrollToStrip; try { renderReel(); expect(scrollToStrip).not.toHaveBeenCalled(); } finally { Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo"); } }); it("path: the browser has no Element.scrollTo -> no error and no scrolling", () => { stripScrollWidth = 900; stripClientWidth = 300; expect(() => renderReel()).not.toThrow(); }); }); describe("ProjectReel: vídeos", () => { const videos = () => [ ...document.querySelectorAll(".ds-reel-layer video"), ]; function ready( video: HTMLVideoElement, { readyState = 4, duration = 30, seeking = false } = {}, ) { Object.defineProperty(video, "readyState", { configurable: true, value: readyState, }); Object.defineProperty(video, "duration", { configurable: true, value: duration, }); Object.defineProperty(video, "seeking", { configurable: true, value: seeking, }); let time = 0; Object.defineProperty(video, "currentTime", { configurable: true, get: () => time, set: (value: number) => { time = value; }, }); } it("path: videos that hold a file reference but are not attached -> released with load()", () => { mediaNetworkState = 1; reelTop = 6000; // longe da vitrine: ninguém fica anexado renderReel(); expect(HTMLMediaElement.prototype.load).toHaveBeenCalled(); }); it("path: videos that never loaded anything (network empty) -> nothing to release", () => { mediaNetworkState = 0; reelTop = 6000; renderReel(); expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled(); }); it("path: the attached project keeps its file while the others are released", () => { mediaNetworkState = 1; renderReel(); scrollTo(0); jest.mocked(HTMLMediaElement.prototype.load).mockClear(); act(() => { jest.advanceTimersByTime(400); }); const attached = videos().filter((video) => video.getAttribute("src")); expect(attached.length).toBeGreaterThan(0); // quem soltou foi pedido a recarregar; quem ficou com src não foi tocado const loaded = jest.mocked(HTMLMediaElement.prototype.load).mock .instances as unknown as HTMLMediaElement[]; for (const video of attached) expect(loaded).not.toContain(video); }); it("path: a video that is still seeking -> its time is left alone", () => { renderReel(); for (const video of videos()) ready(video, { seeking: true }); scrollTo(reelOffset(WEIGHTS, 0, 0.5)); expect(videos()[0].currentTime).toBe(0); }); it("path: a video whose metadata has not loaded (readyState 0) or has no duration -> never sought", () => { renderReel(); const [first, second] = videos(); ready(first, { readyState: 0 }); ready(second, { duration: 0 }); scrollTo(reelOffset(WEIGHTS, 0, 0.5)); expect(first.currentTime).toBe(0); expect(second.currentTime).toBe(0); }); it("path: a video that is ready and idle -> sought to the scroll position", () => { renderReel(); for (const video of videos()) ready(video); scrollTo(reelOffset(WEIGHTS, 0, 0.5)); expect(videos()[0].currentTime).toBeGreaterThan(0); }); it("path: first frame decoded -> primed once (play then pause) even if loadeddata fires twice", async () => { renderReel(); const video = videos()[0]; fireEvent(video, new Event("loadeddata")); fireEvent(video, new Event("loadeddata")); await act(async () => { await Promise.resolve(); }); expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1); expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1); expect(video.dataset.primed).toBe("true"); }); it("path: priming is refused by the browser -> swallowed, no pause", async () => { jest .mocked(HTMLMediaElement.prototype.play) .mockRejectedValue(new DOMException("blocked", "NotAllowedError")); renderReel(); fireEvent(videos()[0], new Event("loadeddata")); await act(async () => { await Promise.resolve(); }); expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled(); }); it("path: metadata loads before any scroll work (reel hidden by CSS, nothing measured) -> sought to the start, no error", () => { containerHeight = 0; renderReel(); const video = videos()[0]; ready(video); expect(() => fireEvent(video, new Event("loadedmetadata"))).not.toThrow(); expect(video.currentTime).toBe(0); }); it("path: metadata loads when the scroll target is known but no frame ran yet (reel far away) -> uses the target", () => { reelTop = 6000; renderReel(); const video = videos()[0]; ready(video); fireEvent(video, new Event("loadedmetadata")); expect(video.currentTime).toBe(0); }); it("path: metadata loads after the scroll already moved -> the video jumps to where the scroll is", () => { renderReel(); scrollTo(reelOffset(WEIGHTS, 0, 0.5)); const video = videos()[0]; ready(video); fireEvent(video, new Event("loadedmetadata")); expect(video.currentTime).toBeGreaterThan(0); }); }); describe("ProjectReel: estado das camadas, traços de capítulo e legenda", () => { const states = (selector: string, attribute = "data-state") => [...document.querySelectorAll(selector)].map((element) => element.getAttribute(attribute), ); it("path: a middle project in scene -> layers before it are 'before', the ones after are 'after'", () => { renderReel(); scrollTo(reelOffset(WEIGHTS, 2, 0.5)); const layers = states(".ds-reel-layer"); expect(layers.length).toBeGreaterThan(3); expect(layers).toEqual( layers.map((_, i) => (i < 2 ? "before" : i === 2 ? "active" : "after")), ); }); it("path: first project, first chapter -> the ticks read now, todo, todo", () => { renderReel(); scrollTo(0); expect(states(".ds-reel-ticks li")).toEqual(["now", "todo", "todo"]); expect(caption().querySelector(".ds-reel-chapter-count")).toHaveTextContent( "01/03", ); }); it("path: end of the project -> the chapter ticks before the current one are 'done', the ones after 'todo'", () => { renderReel(); scrollTo(reelOffset(WEIGHTS, 0, 0.95)); const ticks = states(".ds-reel-ticks li"); const current = ticks.indexOf("now"); expect(current).toBeGreaterThan(0); expect(ticks).toEqual( ticks.map((_, i) => i < current ? "done" : i === current ? "now" : "todo", ), ); expect(ticks.filter((state) => state === "done")).toHaveLength(current); }); it("path: project with a subtitle -> shown under the title", () => { expect(projects[0].subtitle).toBeTruthy(); renderReel(); expect(caption().querySelector(".ds-reel-sub")).toHaveTextContent( projects[0].subtitle, ); }); it("path: project without a subtitle -> no empty subtitle line in the caption", () => { renderReel([{ ...projects[0], subtitle: "" }, ...projects.slice(1)]); expect(caption().querySelector(".ds-reel-sub")).toBeNull(); expect(caption().querySelector(".ds-reel-title")).toHaveTextContent( projects[0].title, ); }); }); describe("ProjectReel: rolagem suave até um projeto", () => { function pressProject(name: string) { fireEvent.click( within( screen.getByRole("navigation", { name: "Projetos da vitrine" }), ).getByRole("button", { name }), ); } it("path: pressing a project -> scrolls in animation frames and ends exactly on the target", () => { renderReel(); pressProject("SC Plus Aquicultura"); act(() => { jest.advanceTimersByTime(2000); }); const calls = (window.scrollTo as jest.Mock).mock.calls; expect(calls.length).toBeGreaterThan(2); for (const [options] of calls) expect(options.behavior).toBe("instant"); const tops = calls.map(([options]) => options.top as number); expect(tops).toEqual([...tops].sort((a, b) => a - b)); expect(tops[tops.length - 1]).toBeGreaterThan(tops[0]); }); it("path: the wheel during the scroll -> the animation stops where it is", () => { renderReel(); pressProject("SC Plus Aquicultura"); act(() => { jest.advanceTimersByTime(100); }); act(() => { window.dispatchEvent(new Event("wheel")); }); const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length; act(() => { jest.advanceTimersByTime(2000); }); expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt); }); it("path: touching the screen during the scroll -> the animation stops too", () => { renderReel(); pressProject("SC Plus Aquicultura"); act(() => { jest.advanceTimersByTime(100); }); act(() => { window.dispatchEvent(new Event("touchstart")); }); const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length; act(() => { jest.advanceTimersByTime(2000); }); expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt); }); it("path: a second press while the first scroll runs -> the first is cancelled, only the new target is reached", () => { renderReel(); pressProject("AS Serviços"); act(() => { jest.advanceTimersByTime(100); }); (window.scrollTo as jest.Mock).mockClear(); pressProject("Lavação 047"); act(() => { jest.advanceTimersByTime(2000); }); const tops = (window.scrollTo as jest.Mock).mock.calls.map( ([options]) => options.top as number, ); const finalTop = tops[tops.length - 1]; // o destino da Lavação (2º projeto) fica bem antes do da AS Serviços (último) expect(finalTop).toBeLessThan(reelTopFor(4)); }); function reelTopFor(index: number) { const scrollable = containerHeight - pinHeight; return reelOffset(WEIGHTS, index, 0) * scrollable; } }); describe("ProjectReel: altura do cabeçalho descontada na rolagem até um projeto", () => { function pressAndLand(name: string) { (window.scrollTo as jest.Mock).mockClear(); const view = renderReel(); fireEvent.click( within( screen.getByRole("navigation", { name: "Projetos da vitrine" }), ).getByRole("button", { name }), ); act(() => { jest.advanceTimersByTime(2000); }); const calls = (window.scrollTo as jest.Mock).mock.calls; view.unmount(); return calls[calls.length - 1][0].top as number; } function setWidth(width: number) { Object.defineProperty(window, "innerWidth", { configurable: true, value: width, }); } afterEach(() => { document.documentElement.style.removeProperty("--site-header-height"); }); it("path: no --site-header-height -> 60 px; set later at the same window width -> still the cached value; a new width -> read again", () => { setWidth(1111); const withoutVariable = pressAndLand("SC Plus Aquicultura"); document.documentElement.style.setProperty("--site-header-height", "80px"); const sameWidth = pressAndLand("SC Plus Aquicultura"); expect(sameWidth).toBe(withoutVariable); setWidth(1112); const newWidth = pressAndLand("SC Plus Aquicultura"); // o cabeçalho de 80 px deixa 20 px a menos de rolagem do que o de 60 px expect(newWidth).toBe(withoutVariable - 20); }); });