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();
});
});