321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
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<number, FrameRequestCallback>();
|
|
const timers = new Map<number, { callback: () => void; delay: number }>();
|
|
const scrollEndListeners = new Set<EventListener>();
|
|
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([]);
|
|
});
|
|
});
|