import { fireEvent, screen } from "@testing-library/react"; import { FRAGMENT_NAV_BOOT_SCRIPT, focusFragment, isPlainPrimaryClick, onFragmentClick, samePageHashTarget, setScrollRestoration, } from "./fragment-nav"; /* * Âncoras da home num lugar só: a leitura do clique (quem é um link de âncora da mesma página), o único ouvinte * de clique e o único dono de history.scrollRestoration. */ describe("isPlainPrimaryClick", () => { const click = (init: MouseEventInit = {}) => new MouseEvent("click", { cancelable: true, button: 0, ...init }); it("accepts a plain click of the main button", () => { expect(isPlainPrimaryClick(click())).toBe(true); }); it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)( "refuses a click with %s held (new tab, selection)", (modifier) => { expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false); }, ); it.each([1, 2])("refuses mouse button %i", (button) => { expect(isPlainPrimaryClick(click({ button }))).toBe(false); }); it("refuses a click somebody else already cancelled", () => { const event = click(); event.preventDefault(); expect(isPlainPrimaryClick(event)).toBe(false); }); }); describe("samePageHashTarget", () => { afterEach(() => { document.body.innerHTML = ""; }); function clickOn(markup: string, selector: string) { document.body.innerHTML = markup; const target = document.querySelector(selector) as Element; const event = new MouseEvent("click", { bubbles: true }); Object.defineProperty(event, "target", { value: target }); return event; } it("finds the element a same-page #link points to", () => { const event = clickOn( `ir`, "a", ); const hit = samePageHashTarget(event); expect(hit?.hash).toBe("#alvo"); expect(hit?.target.id).toBe("alvo"); }); it("works from inside the link (the text, an icon)", () => { const event = clickOn( `ir`, "#inside", ); expect(samePageHashTarget(event)?.target.id).toBe("alvo"); }); it.each([ ["another site", `x`], ["another page of this site", `x`], ["no anchor", `x`], ["an empty anchor", `x`], ["a new tab", `x`], ["an id that does not exist", `x`], ["a malformed percent-encoding", `x`], ["no link at all", `
x
`], ])("returns null for %s", (_label, markup) => { const event = clickOn( `${markup}`, markup.startsWith(" { const event = clickOn( `x`, "a", ); expect(samePageHashTarget(event)?.target.id).toBe("alvo"); }); it("ignores a click whose target is not an element", () => { const event = new MouseEvent("click", { bubbles: true }); Object.defineProperty(event, "target", { value: document }); expect(samePageHashTarget(event)).toBeNull(); }); }); describe("samePageHashTarget (rules shared with the arrival code)", () => { afterEach(() => { document.body.innerHTML = ""; window.history.replaceState(null, "", "/"); }); function clickOn(markup: string) { document.body.innerHTML = `${markup}`; const target = document.querySelector("a") as Element; const event = new MouseEvent("click", { bubbles: true }); Object.defineProperty(event, "target", { value: target }); return event; } it("refuses a link that downloads a file", () => { expect( samePageHashTarget(clickOn(`x`)), ).toBeNull(); }); it("refuses a link to the same path with another query string", () => { window.history.replaceState(null, "", "/?a=1"); expect( samePageHashTarget(clickOn(`x`)), ).toBeNull(); expect( samePageHashTarget(clickOn(`x`))?.hash, ).toBe("#alvo"); }); }); describe("onFragmentClick", () => { let add: jest.SpyInstance; let remove: jest.SpyInstance; beforeEach(() => { add = jest.spyOn(document, "addEventListener"); remove = jest.spyOn(document, "removeEventListener"); document.body.innerHTML = `irfora`; window.history.replaceState(null, "", "/"); }); afterEach(() => { jest.restoreAllMocks(); document.body.innerHTML = ""; }); const clicks = () => add.mock.calls.filter(([type]) => type === "click"); const unclicks = () => remove.mock.calls.filter(([type]) => type === "click"); const link = (name: string) => screen.getByRole("link", { name }); it("installs ONE document click listener no matter how many subscribe, and removes it with the last", () => { const first = jest.fn(); const second = jest.fn(); const stopFirst = onFragmentClick(first); const stopSecond = onFragmentClick(second); expect(clicks()).toHaveLength(1); expect(clicks()[0][2]).toBe(true); fireEvent.click(link("ir")); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); stopFirst(); expect(unclicks()).toHaveLength(0); fireEvent.click(link("ir")); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(2); stopSecond(); expect(unclicks()).toHaveLength(1); }); it("hands every subscriber the same interpreted hit, in subscription order", () => { const order: string[] = []; const stops = [ onFragmentClick((hit) => order.push(`a:${hit.hash}:${hit.target.id}`)), onFragmentClick((hit) => order.push(`b:${hit.hash}`)), ]; fireEvent.click(link("ir")); expect(order).toEqual(["a:#alvo:alvo", "b:#alvo"]); for (const stop of stops) stop(); }); it("ignores links to other pages, modified clicks and clicks somebody already cancelled", () => { const handler = jest.fn(); const stop = onFragmentClick(handler); fireEvent.click(link("fora")); fireEvent.click(link("ir"), { ctrlKey: true }); fireEvent.click(link("ir"), { button: 1 }); expect(handler).not.toHaveBeenCalled(); stop(); }); it("lets a subscriber cancel the navigation (preventDefault) without the others being skipped", () => { const second = jest.fn(); const stops = [ onFragmentClick((_hit, event) => event.preventDefault()), onFragmentClick(second), ]; expect(fireEvent.click(link("ir"))).toBe(false); expect(second).toHaveBeenCalledTimes(1); for (const stop of stops) stop(); }); it("tolerates a subscriber that leaves while the click is being delivered", () => { const calls: string[] = []; const stopFirst = onFragmentClick(() => { calls.push("first"); stopFirst(); }); const stopSecond = onFragmentClick(() => calls.push("second")); fireEvent.click(link("ir")); expect(calls).toEqual(["first", "second"]); stopSecond(); }); }); describe("focusFragment", () => { afterEach(() => { document.body.innerHTML = ""; }); it("focuses the destination without scrolling and gives it a tabindex only when it has none", () => { document.body.innerHTML = ``; const a = document.getElementById("a") as HTMLElement; const b = document.getElementById("b") as HTMLElement; const focus = jest.spyOn(a, "focus"); focusFragment(a); focusFragment(b); expect(a).toHaveAttribute("tabindex", "-1"); expect(b).toHaveAttribute("tabindex", "0"); expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(b).toHaveFocus(); }); }); describe("history.scrollRestoration ownership", () => { const run = () => new Function(FRAGMENT_NAV_BOOT_SCRIPT)(); // o jsdom não tem performance.getEntriesByType: o teste o define e o tira no fim const navigationType = (type: string) => Object.defineProperty(performance, "getEntriesByType", { configurable: true, value: () => [{ type }], }); afterEach(() => { Reflect.deleteProperty(performance, "getEntriesByType"); window.history.scrollRestoration = "auto"; window.history.replaceState(null, "", "/"); }); it("setScrollRestoration is the runtime writer", () => { setScrollRestoration("manual"); expect(window.history.scrollRestoration).toBe("manual"); setScrollRestoration("auto"); expect(window.history.scrollRestoration).toBe("auto"); }); it("the boot script (run before hydration) goes manual on a fresh arrival at / and back to auto on pagehide", () => { navigationType("navigate"); run(); expect(window.history.scrollRestoration).toBe("manual"); window.dispatchEvent(new Event("pagehide")); expect(window.history.scrollRestoration).toBe("auto"); }); it("the boot script leaves a history traversal to the browser", () => { navigationType("back_forward"); run(); expect(window.history.scrollRestoration).toBe("auto"); }); it("the boot script drops the stale #fragment on a reload", () => { window.history.replaceState(null, "", "/#contato"); navigationType("reload"); run(); expect(window.location.hash).toBe(""); }); it("the boot script does nothing outside the home", () => { window.history.replaceState(null, "", "/cases"); navigationType("navigate"); run(); expect(window.history.scrollRestoration).toBe("auto"); }); });