Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
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 (
|
|
<>
|
|
<a href="/#about">Sobre</a>
|
|
<section id="about" aria-label="Sobre">
|
|
<button type="button">Ler</button>
|
|
</section>
|
|
<section id="contact" aria-label="Contato" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
|
|
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(<Home />);
|
|
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
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(<Home />);
|
|
|
|
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(<Home />);
|
|
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(<Home />);
|
|
view.unmount();
|
|
|
|
expect(() => act(() => layoutReady())).not.toThrow();
|
|
|
|
expect(window.history.scrollRestoration).toBe("auto");
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
});
|