import { beforeEach, describe, expect, it } from "vitest"; import { canonicalSectionIdFromHash, createNavigationController, validateSectionAnchor, type NavigationRuntime, } from "@/journey/navigation-controller"; import type { SectionId } from "@/content/types"; import { isScrollIntentKey } from "@/composables/useKeyboardNavigation"; const sectionIds = [ "start", "profile", "experience", "infrastructure", "selected-builds", "additional-work", "toolkit", "contact", ] as const satisfies readonly SectionId[]; interface RuntimeHarness { runtime: NavigationRuntime; hash: string; scrollY: number; pushes: string[]; replacements: string[]; scrolls: Array<{ id: string; behavior: ScrollBehavior }>; focused: string[]; runFrame(scrollY?: number): void; runTimer(delay: number): void; emitScrollEnd(): void; } const createRuntime = (supportsScrollEnd: boolean): RuntimeHarness => { const frames = new Map(); const timers = new Map void; delay: number }>(); const scrollEndListeners = new Set(); let nextId = 1; const harness: RuntimeHarness = { hash: "", scrollY: 0, pushes: [], replacements: [], scrolls: [], focused: [], runtime: undefined as never, runFrame(nextScrollY) { if (nextScrollY !== undefined) harness.scrollY = nextScrollY; const entry = frames.entries().next().value as [number, FrameRequestCallback] | undefined; if (entry === undefined) throw new Error("No pending animation frame"); frames.delete(entry[0]); entry[1](0); }, runTimer(delay) { const entry = [...timers.entries()].find( ([, timer]) => timer.delay === delay, ); if (entry === undefined) throw new Error(`No pending ${delay} ms timer`); timers.delete(entry[0]); entry[1].callback(); }, emitScrollEnd() { for (const listener of [...scrollEndListeners]) listener(new Event("scrollend")); }, }; harness.runtime = { getHash: () => harness.hash, pushHash: (hash) => { harness.pushes.push(hash); harness.hash = hash; }, replaceHash: (hash) => { harness.replacements.push(hash); harness.hash = hash; }, scrollTarget: (target, behavior) => harness.scrolls.push({ id: target.id, behavior }), focusHeading: (target) => harness.focused.push(target.id), getScrollY: () => harness.scrollY, requestFrame: (callback) => { const id = nextId++; frames.set(id, callback); return id; }, cancelFrame: (id) => frames.delete(id), setTimer: (callback, delay) => { const id = nextId++; timers.set(id, { callback, delay }); return id; }, clearTimer: (id) => timers.delete(id), supportsScrollEnd, addScrollEndListener: (listener) => scrollEndListeners.add(listener), removeScrollEndListener: (listener) => scrollEndListeners.delete(listener), }; return harness; }; const target = (id: SectionId): HTMLElement => { const section = document.createElement("section"); section.id = id; const heading = document.createElement("h2"); heading.tabIndex = -1; section.append(heading); return section; }; describe("canonical fragment validation", () => { it("recognizes every specified keyboard scroll intent and no activation key", () => { for (const key of [ "PageUp", "PageDown", "Home", "End", " ", "Spacebar", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", ]) { expect(isScrollIntentKey(key)).toBe(true); } expect(isScrollIntentKey("Enter")).toBe(false); expect(isScrollIntentKey("Tab")).toBe(false); }); it("decodes exact canonical IDs and treats an empty fragment as Start", () => { expect(canonicalSectionIdFromHash("", sectionIds)).toBe("start"); expect(canonicalSectionIdFromHash("#selected%2Dbuilds", sectionIds)).toBe( "selected-builds", ); expect(canonicalSectionIdFromHash("#unknown", sectionIds)).toBeNull(); expect(canonicalSectionIdFromHash("#%E0%A4%A", sectionIds)).toBeNull(); }); it("validates a same-document native href and its exact target before interception", () => { const anchor = document.createElement("a"); anchor.href = "#profile"; document.body.append(target("profile"), anchor); expect(validateSectionAnchor(anchor, sectionIds)).toEqual({ id: "profile", target: document.getElementById("profile"), }); anchor.href = "https://example.com/#profile"; expect(validateSectionAnchor(anchor, sectionIds)).toBeNull(); anchor.href = "#unknown"; expect(validateSectionAnchor(anchor, sectionIds)).toBeNull(); }); it("validates the nested hero H1 as the Start navigation target", () => { const section = document.createElement("section"); section.id = "start"; const content = document.createElement("div"); const heading = document.createElement("h1"); heading.id = "start-heading"; heading.tabIndex = -1; content.append(heading); section.append(content); const anchor = document.createElement("a"); anchor.href = "#start"; document.body.append(section, anchor); expect(validateSectionAnchor(anchor, sectionIds)).toEqual({ id: "start", target: section, }); }); }); describe("navigation epochs and effects", () => { beforeEach(() => { document.body.replaceChildren(); }); it("pushes exactly once for a different fragment and zero times for the current fragment", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); expect(controller.navigate("profile", target("profile"))).toBe(1); expect(harness.pushes).toEqual(["#profile"]); expect(harness.scrolls).toEqual([{ id: "profile", behavior: "smooth" }]); expect(controller.navigate("profile", target("profile"))).toBe(2); expect(harness.pushes).toEqual(["#profile"]); expect(harness.scrolls).toEqual([ { id: "profile", behavior: "smooth" }, { id: "profile", behavior: "smooth" }, ]); }); it("increments epochs and cancels older focus work on user scroll intent", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); controller.navigate("profile", target("profile")); expect(controller.noteUserScrollIntent()).toBe(2); harness.emitScrollEnd(); expect(harness.focused).toEqual([]); expect(controller.state()).toEqual({ epoch: 2, source: "passive-scroll", destinationId: null, signDestinationId: null, }); }); it("uses native scrollend and focuses on the next frame only for the current explicit epoch", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); controller.navigate("experience", target("experience")); harness.emitScrollEnd(); expect(harness.focused).toEqual([]); harness.runFrame(); expect(harness.focused).toEqual(["experience"]); }); it("falls back to three consecutive sub-pixel-stable animation frames", () => { const harness = createRuntime(false); const controller = createNavigationController(sectionIds, harness.runtime); controller.navigate("toolkit", target("toolkit")); harness.runFrame(20); harness.runFrame(20.5); harness.runFrame(21.4); expect(harness.focused).toEqual([]); harness.runFrame(21.9); expect(harness.focused).toEqual([]); harness.runFrame(); expect(harness.focused).toEqual(["toolkit"]); }); it("performs one instant final alignment after 1500 ms before focusing", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); controller.navigate("contact", target("contact")); harness.runTimer(1500); expect(harness.scrolls).toEqual([ { id: "contact", behavior: "smooth" }, { id: "contact", behavior: "instant" }, ]); harness.runFrame(); expect(harness.focused).toEqual(["contact"]); }); it("replaces one stable passive fragment after 150 ms and cancels stale replacements", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); controller.noteUserScrollIntent(); controller.notePassiveSection("profile"); controller.notePassiveSection("experience"); harness.runTimer(150); expect(harness.replacements).toEqual(["#experience"]); controller.notePassiveSection("toolkit"); controller.noteUserScrollIntent(); expect(() => harness.runTimer(150)).toThrow("No pending 150 ms timer"); expect(harness.replacements).toEqual(["#experience"]); }); it("restores initial and history fragments without history mutation or focus", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); expect(controller.initialize("toolkit", target("toolkit"))).toBe(1); expect(controller.restore("profile", target("profile"))).toBe(2); harness.runFrame(); expect(harness.pushes).toEqual([]); expect(harness.replacements).toEqual([]); expect(harness.scrolls).toEqual([ { id: "toolkit", behavior: "instant" }, { id: "profile", behavior: "instant" }, ]); expect(harness.focused).toEqual([]); expect(controller.state()).toEqual({ epoch: 2, source: "history-restoration", destinationId: "profile", signDestinationId: null, }); }); it("marks only an explicitly requested navigator epoch for roadside signage", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); controller.navigate("profile", target("profile")); expect(controller.state().signDestinationId).toBeNull(); controller.navigate("experience", target("experience"), { requestSign: true }); expect(controller.state()).toMatchObject({ epoch: 2, source: "explicit-navigation", destinationId: "experience", signDestinationId: "experience", }); }); it("cancels an older deferred restoration during rapid back and forward", () => { const harness = createRuntime(true); const controller = createNavigationController(sectionIds, harness.runtime); controller.restore("profile", target("profile")); controller.restore("experience", target("experience")); expect(harness.scrolls).toEqual([]); harness.runFrame(); expect(harness.scrolls).toEqual([ { id: "experience", behavior: "instant" }, ]); expect(harness.focused).toEqual([]); }); });