import { act, render } from "@testing-library/react"; import FilmGrain from "./FilmGrain"; /** * Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): a medição é do * IntersectionObserver (nada ouve a rolagem nem mede a página a cada quadro), uma tela escondida por CSS não * conta, várias telas, telas que a página cria ou remove depois e a limpeza ao desmontar. */ type Observer = { callback: IntersectionObserverCallback; observed: Set; /** quantas vezes observe() foi chamado (um conjunto esconderia repetições) */ calls: number; disconnect: jest.Mock; }; let observers: Observer[] = []; const original = global.IntersectionObserver; beforeEach(() => { observers = []; global.IntersectionObserver = class { private readonly record: Observer; constructor(callback: IntersectionObserverCallback) { this.record = { callback, observed: new Set(), calls: 0, disconnect: jest.fn(), }; observers.push(this.record); } observe(target: Element) { this.record.observed.add(target); this.record.calls += 1; } unobserve(target: Element) { this.record.observed.delete(target); } disconnect() { this.record.disconnect(); } takeRecords() { return []; } } as unknown as typeof IntersectionObserver; }); afterEach(() => { global.IntersectionObserver = original; jest.restoreAllMocks(); document.body.innerHTML = ""; }); function addScreen(className: string) { const element = document.createElement("div"); element.className = className; document.body.append(element); return element; } function intersect(target: Element, isIntersecting: boolean, height = 600) { act(() => { for (const observer of observers) if (observer.observed.has(target)) observer.callback( [ { target, isIntersecting, boundingClientRect: { height }, } as unknown as IntersectionObserverEntry, ], {} as IntersectionObserver, ); }); } function renderGrain() { const view = render(); return { ...view, grain: view.container.firstElementChild as HTMLElement }; } /** Deixa o MutationObserver (microtarefa) avisar. */ const flush = () => act(async () => { await Promise.resolve(); }); describe("FilmGrain: is a project screen in view?", () => { it("path: a visible screen -> the grain steps aside", () => { const screen = addScreen("ds-phone"); const { grain } = renderGrain(); intersect(screen, true); expect(grain).toHaveAttribute("data-over-projects"); }); it("path: a screen with no height (hidden by CSS) -> does not count even if the browser says it intersects", () => { const screen = addScreen("ds-bframe"); const { grain } = renderGrain(); intersect(screen, true, 0); expect(grain).not.toHaveAttribute("data-over-projects"); }); it("path: a screen that is off the window -> does not count", () => { const screen = addScreen("ds-frame"); const { grain } = renderGrain(); intersect(screen, false); expect(grain).not.toHaveAttribute("data-over-projects"); }); it("path: one screen off the window and another in view -> counts (any is enough), and leaves only when the last one leaves", () => { const far = addScreen("ds-reel"); const near = addScreen("ds-phone"); const { grain } = renderGrain(); intersect(far, false); intersect(near, true); expect(grain).toHaveAttribute("data-over-projects"); intersect(far, true); intersect(near, false); expect(grain).toHaveAttribute("data-over-projects"); intersect(far, false); expect(grain).not.toHaveAttribute("data-over-projects"); }); it("path: no project screen on the page -> the grain stays", () => { const { grain } = renderGrain(); expect(grain).not.toHaveAttribute("data-over-projects"); }); }); describe("FilmGrain: screens that appear and disappear", () => { it("path: a screen the page creates after the grain mounted (the pinned reel swapping the list) is observed too", async () => { const { grain } = renderGrain(); expect(observers[0].observed.size).toBe(0); const reel = addScreen("ds-reel"); await flush(); expect(observers[0].observed.has(reel)).toBe(true); intersect(reel, true); expect(grain).toHaveAttribute("data-over-projects"); }); it("path: a screen that is removed while in view stops counting", async () => { const reel = addScreen("ds-reel"); const { grain } = renderGrain(); intersect(reel, true); expect(grain).toHaveAttribute("data-over-projects"); reel.remove(); await flush(); expect(observers[0].observed.has(reel)).toBe(false); expect(grain).not.toHaveAttribute("data-over-projects"); }); it("path: the same screen is never observed twice", async () => { addScreen("ds-reel"); renderGrain(); expect(observers[0].calls).toBe(1); addScreen("ds-phone"); await flush(); // só a tela nova foi pedida ao observador; a primeira não se repete expect(observers[0].calls).toBe(2); expect(observers[0].observed.size).toBe(2); }); }); describe("FilmGrain: no work per frame, cleanup", () => { it("path: nothing listens to the scroll or the window size, and the page is never measured", () => { const screen = addScreen("ds-reel"); const measure = jest.spyOn(screen, "getBoundingClientRect"); const listen = jest.spyOn(window, "addEventListener"); renderGrain(); act(() => { window.dispatchEvent(new Event("scroll")); window.dispatchEvent(new Event("resize")); }); expect(measure).not.toHaveBeenCalled(); const types = listen.mock.calls.map(([type]) => type); expect(types).not.toContain("scroll"); expect(types).not.toContain("resize"); }); it("path: unmount -> the observers are disconnected and later screens are ignored", async () => { const { unmount } = renderGrain(); unmount(); expect(observers[0].disconnect).toHaveBeenCalledTimes(1); addScreen("ds-reel"); await flush(); expect(observers[0].observed.size).toBe(0); }); it("path: a browser without IntersectionObserver -> the grain renders and simply stays", () => { // biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API (global as any).IntersectionObserver = undefined; addScreen("ds-reel"); const { grain } = renderGrain(); expect(grain).toHaveClass("film-grain"); expect(grain).not.toHaveAttribute("data-over-projects"); }); });