import { act, fireEvent, render, screen } from "@testing-library/react"; import { resetHomeArrivalState, useHomeFragmentNavigation, } from "./useHomeFragmentNavigation"; /** * Testes de caminhos-base da chegada à home (complemento de CinematicHome.test.tsx): como o navegador chegou * (nova, volta/avança, sem a API de navegação), o destino do fragmento (existe, não existe, ficou velho), as teclas * e gestos que cancelam a chegada, o clique em âncora antes e depois do layout pronto, o hashchange e a limpeza. */ let layoutReady: () => void = () => {}; function Home() { layoutReady = useHomeFragmentNavigation(); return ( <> Sobre
); } const about = () => screen.getByRole("region", { name: "Sobre" }); const contact = () => screen.getByRole("region", { name: "Contato" }); const ready = () => act(() => layoutReady()); /** O jsdom não tem performance.getEntriesByType (é o caso "navegador sem a API"); aqui ela passa a existir. */ function navigationEntries(type?: string) { Object.defineProperty(performance, "getEntriesByType", { configurable: true, writable: true, value: jest.fn().mockReturnValue(type ? [{ type }] : []), }); } let scrollTo: jest.SpyInstance; beforeEach(() => { resetHomeArrivalState(); window.history.replaceState(null, "", "/"); window.history.scrollRestoration = "auto"; scrollTo = jest.spyOn(window, "scrollTo").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); Reflect.deleteProperty(performance, "getEntriesByType"); window.history.replaceState(null, "", "/"); window.history.scrollRestoration = "auto"; }); describe("useHomeFragmentNavigation: como o navegador chegou", () => { it("path: a fresh arrival -> scroll restoration is manual until the layout is ready, then automatic again", () => { navigationEntries("navigate"); render(); expect(window.history.scrollRestoration).toBe("manual"); ready(); expect(window.history.scrollRestoration).toBe("auto"); }); it("path: back/forward traversal with a fragment in the URL -> the browser keeps its own position: no manual mode, no scroll, no focus", () => { navigationEntries("back_forward"); window.history.replaceState(null, "", "/#contact"); render(); expect(window.history.scrollRestoration).toBe("auto"); ready(); expect(scrollTo).not.toHaveBeenCalled(); expect(contact()).not.toHaveFocus(); }); it("path: no navigation entry in the timeline -> treated as a fresh arrival", () => { navigationEntries(); render(); expect(window.history.scrollRestoration).toBe("manual"); ready(); expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "instant", }); }); it("path: a browser without getEntriesByType (the jsdom default) -> treated as a fresh arrival", () => { expect(performance.getEntriesByType).toBeUndefined(); render(); expect(window.history.scrollRestoration).toBe("manual"); }); }); describe("useHomeFragmentNavigation: o destino do fragmento", () => { /** Mede o destino e devolve o elemento (capturado antes de getComputedStyle ser trocado). */ function measure(top: number, padding: string, margin: string) { Object.defineProperty(window, "scrollY", { configurable: true, value: 100, }); const target = about(); target.getBoundingClientRect = () => ({ top }) as DOMRect; jest.spyOn(window, "getComputedStyle").mockImplementation( (element) => ({ scrollPaddingTop: element === document.documentElement ? padding : "", scrollMarginTop: element === target ? margin : "", }) as unknown as CSSStyleDeclaration, ); return target; } afterEach(() => { Reflect.deleteProperty(window, "scrollY"); }); it("path: fragment that exists -> waits for the layout, then focuses it and scrolls under the fixed header and its own margin", () => { window.history.replaceState(null, "", "/#about"); render(); const target = measure(500, "80px", "10px"); expect(scrollTo).not.toHaveBeenCalled(); expect(target).not.toHaveFocus(); ready(); expect(target).toHaveFocus(); expect(scrollTo).toHaveBeenCalledWith({ top: 100 + 500 - 80 - 10, left: 0, behavior: "instant", }); }); it("path: no readable header offset or margin -> both count as zero", () => { window.history.replaceState(null, "", "/#about"); render(); measure(500, "", ""); ready(); expect(scrollTo).toHaveBeenLastCalledWith({ top: 600, left: 0, behavior: "instant", }); }); it("path: a target far above the current position -> never scrolls to a negative position", () => { window.history.replaceState(null, "", "/#about"); render(); measure(-900, "80px", "0px"); ready(); expect(scrollTo).toHaveBeenLastCalledWith({ top: 0, left: 0, behavior: "instant", }); }); it("path: fragment of an element that does not exist -> nothing is focused or scrolled, and nothing throws", () => { window.history.replaceState(null, "", "/#nao-existe"); render(); expect(() => ready()).not.toThrow(); expect(scrollTo).not.toHaveBeenCalled(); expect(document.body).toHaveFocus(); }); it("path: the URL changed without a hashchange event before the layout was ready -> the old fragment is not applied", () => { window.history.replaceState(null, "", "/#about"); render(); window.history.replaceState(null, "", "/#contact"); ready(); expect(about()).not.toHaveFocus(); expect(scrollTo).not.toHaveBeenCalled(); }); }); describe("useHomeFragmentNavigation: o visitante assume a rolagem", () => { it.each([ "ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Tab", ])( "path: the %j key -> the pending arrival is dropped and the browser takes back its scroll restoration", (key) => { window.history.replaceState(null, "", "/#about"); render(); expect(window.history.scrollRestoration).toBe("manual"); fireEvent.keyDown(window, { key }); expect(window.history.scrollRestoration).toBe("auto"); ready(); expect(about()).not.toHaveFocus(); expect(scrollTo).not.toHaveBeenCalled(); }, ); it.each(["a", "Enter", "Escape", "Shift"])( "path: the %j key (not a scroll key) -> the pending arrival still happens", (key) => { window.history.replaceState(null, "", "/#about"); render(); fireEvent.keyDown(window, { key }); ready(); expect(about()).toHaveFocus(); expect(scrollTo).toHaveBeenCalledTimes(1); }, ); it.each([ ["touchmove", () => fireEvent.touchMove(window)], ["pointerdown", () => fireEvent.pointerDown(window)], ["wheel", () => fireEvent.wheel(window, { deltaY: 40 })], ])("path: %s -> the pending arrival is dropped", (_gesture, gesture) => { window.history.replaceState(null, "", "/#about"); render(); gesture(); ready(); expect(about()).not.toHaveFocus(); expect(scrollTo).not.toHaveBeenCalled(); }); }); describe("useHomeFragmentNavigation: cliques e hashchange", () => { it("path: click on an anchor before the layout is ready -> the destination gets the focus now and the scroll waits for the layout", () => { render(); fireEvent.click(screen.getByRole("link", { name: "Sobre" })); // o jsdom não segue a âncora sozinho: o navegador deixaria a URL já no fragmento window.history.replaceState(null, "", "/#about"); expect(about()).toHaveFocus(); expect(scrollTo).not.toHaveBeenCalled(); ready(); // a âncora vira a chegada pendente (não a rolagem ao topo) expect(scrollTo).toHaveBeenCalledTimes(1); }); it("path: click on an anchor after the layout is ready -> the focus moves, no arrival scroll is left pending", () => { render(); ready(); scrollTo.mockClear(); fireEvent.click(screen.getByRole("link", { name: "Sobre" })); ready(); expect(about()).toHaveFocus(); expect(scrollTo).not.toHaveBeenCalled(); }); it("path: hashchange to an element that exists, after the layout is ready -> it gets the focus", () => { render(); ready(); act(() => { window.history.replaceState(null, "", "/#contact"); window.dispatchEvent(new HashChangeEvent("hashchange")); }); expect(contact()).toHaveFocus(); }); it("path: hashchange to an element that does not exist -> nothing happens", () => { render(); ready(); act(() => { window.history.replaceState(null, "", "/#nao-existe"); window.dispatchEvent(new HashChangeEvent("hashchange")); }); expect(document.body).toHaveFocus(); }); it("path: hashchange before the layout is ready -> no focus yet (the arrival is not resolved early)", () => { render(); act(() => { window.history.replaceState(null, "", "/#contact"); window.dispatchEvent(new HashChangeEvent("hashchange")); }); expect(contact()).not.toHaveFocus(); }); }); describe("useHomeFragmentNavigation: desmontagem", () => { it("path: unmount -> scroll restoration goes back to auto and the listeners are gone", () => { window.history.replaceState(null, "", "/#about"); const view = render(); expect(window.history.scrollRestoration).toBe("manual"); view.unmount(); expect(window.history.scrollRestoration).toBe("auto"); window.history.scrollRestoration = "manual"; fireEvent.wheel(window, { deltaY: 40 }); expect(window.history.scrollRestoration).toBe("manual"); }); it("path: the ready callback called after unmount -> nothing to resolve, no error, restoration stays automatic", () => { const view = render(); view.unmount(); expect(() => act(() => layoutReady())).not.toThrow(); expect(window.history.scrollRestoration).toBe("auto"); expect(scrollTo).not.toHaveBeenCalled(); }); });