feat: coordinate sections signs and native navigation
This commit is contained in:
105
tests/browser/navigation.spec.ts
Normal file
105
tests/browser/navigation.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
|
||||
test("keeps canonical hrefs while creating exactly one or zero explicit history entries", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
const initialLength = await page.evaluate(() => history.length);
|
||||
const start = page.locator('nav a[href="#start"]');
|
||||
const profile = page.locator('nav a[href="#profile"]');
|
||||
|
||||
await start.click();
|
||||
await expect(page).toHaveURL(`${distUrl}/`);
|
||||
expect(await page.evaluate(() => history.length)).toBe(initialLength);
|
||||
await expect(page.locator("#start-heading")).toBeFocused();
|
||||
|
||||
await profile.click();
|
||||
await expect(page).toHaveURL(`${distUrl}/#profile`);
|
||||
expect(await page.evaluate(() => history.length)).toBe(initialLength + 1);
|
||||
await expect(page.locator("#profile-heading")).toBeFocused();
|
||||
|
||||
await profile.click();
|
||||
expect(await page.evaluate(() => history.length)).toBe(initialLength + 1);
|
||||
await expect(page.locator("#profile-heading")).toBeFocused();
|
||||
});
|
||||
|
||||
test("cancels pending explicit focus when wheel intent intervenes", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
const link = page.locator('nav a[href="#infrastructure"]');
|
||||
await link.focus();
|
||||
await page.evaluate(() => {
|
||||
const anchor = document.querySelector<HTMLAnchorElement>(
|
||||
'nav a[href="#infrastructure"]',
|
||||
);
|
||||
anchor?.addEventListener(
|
||||
"click",
|
||||
() =>
|
||||
queueMicrotask(() =>
|
||||
window.dispatchEvent(new WheelEvent("wheel", { deltaY: 40 })),
|
||||
),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
await link.click();
|
||||
await page.waitForTimeout(100);
|
||||
await expect(link).toBeFocused();
|
||||
await expect(page.locator("#infrastructure-heading")).not.toBeFocused();
|
||||
});
|
||||
|
||||
test("passive scrolling replaces a stable fragment without stealing focus", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
const link = page.locator('nav a[href="#start"]');
|
||||
await link.focus();
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new WheelEvent("wheel", { deltaY: 20 }));
|
||||
const heading = document.querySelector<HTMLElement>("#experience-heading");
|
||||
if (heading === null) throw new Error("Missing experience heading");
|
||||
window.scrollTo(0, heading.offsetTop - window.innerHeight * 0.35);
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#experience`, { timeout: 2000 });
|
||||
await expect(link).toBeFocused();
|
||||
await expect(page.locator("#experience-heading")).not.toBeFocused();
|
||||
});
|
||||
|
||||
test("back and forward restore sections without another history mutation or focus move", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.locator('nav a[href="#profile"]').click();
|
||||
await page.locator('nav a[href="#experience"]').click();
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await expect(page.locator("#experience-heading")).toBeFocused();
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(`${distUrl}/#profile`);
|
||||
await expect(page.locator("#profile")).toBeInViewport();
|
||||
expect(await page.evaluate(() => history.length)).toBe(historyLength);
|
||||
await expect(page.locator("#profile-heading")).not.toBeFocused();
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(`${distUrl}/#experience`);
|
||||
await expect(page.locator("#experience")).toBeInViewport();
|
||||
expect(await page.evaluate(() => history.length)).toBe(historyLength);
|
||||
await expect(page.locator("#experience-heading")).not.toBeFocused();
|
||||
});
|
||||
|
||||
test("direct initial fragments align without moving focus", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(`${distUrl}/#toolkit`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.locator("#toolkit")).toBeInViewport();
|
||||
await expect(page.locator("#toolkit-heading")).not.toBeFocused();
|
||||
});
|
||||
283
tests/unit/navigation-controller.test.ts
Normal file
283
tests/unit/navigation-controller.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
137
tests/unit/section-controller.test.ts
Normal file
137
tests/unit/section-controller.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activationLineDocumentY,
|
||||
deriveSectionState,
|
||||
isAtDocumentEnd,
|
||||
selectActiveSection,
|
||||
type SectionHeadingGeometry,
|
||||
} from "@/journey/section-controller";
|
||||
|
||||
const headings: readonly SectionHeadingGeometry[] = [
|
||||
{ id: "start", top: 100 },
|
||||
{ id: "profile", top: 350 },
|
||||
{ id: "experience", top: 700 },
|
||||
{ id: "infrastructure", top: 1100 },
|
||||
{ id: "selected-builds", top: 1500 },
|
||||
{ id: "additional-work", top: 1900 },
|
||||
{ id: "toolkit", top: 2300 },
|
||||
{ id: "contact", top: 2700 },
|
||||
];
|
||||
|
||||
describe("active section selection", () => {
|
||||
it("places the activation line at a literal 35 percent of the visual viewport", () => {
|
||||
expect(
|
||||
activationLineDocumentY({
|
||||
scrollY: 200,
|
||||
visualViewportOffsetTop: 25,
|
||||
visualViewportHeight: 1000,
|
||||
}),
|
||||
).toBe(575);
|
||||
});
|
||||
|
||||
it("forces document end within exactly two CSS pixels using current geometry", () => {
|
||||
expect(
|
||||
isAtDocumentEnd({
|
||||
scrollY: 498,
|
||||
scrollHeight: 1000,
|
||||
visualViewportHeight: 500,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAtDocumentEnd({
|
||||
scrollY: 497.999,
|
||||
scrollHeight: 1000,
|
||||
visualViewportHeight: 500,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAtDocumentEnd({
|
||||
scrollY: 498,
|
||||
scrollHeight: 1200,
|
||||
visualViewportHeight: 500,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the first section before any heading reaches the activation line", () => {
|
||||
expect(
|
||||
selectActiveSection({ headings, activationY: 99, atDocumentEnd: false }),
|
||||
).toBe("start");
|
||||
});
|
||||
|
||||
it("selects the last heading at or above the literal 35 percent activation line", () => {
|
||||
expect(
|
||||
selectActiveSection({
|
||||
headings,
|
||||
activationY: 699.999,
|
||||
atDocumentEnd: false,
|
||||
}),
|
||||
).toBe("profile");
|
||||
expect(
|
||||
selectActiveSection({ headings, activationY: 700, atDocumentEnd: false }),
|
||||
).toBe("experience");
|
||||
});
|
||||
|
||||
it("lets later document order win when heading positions are identical", () => {
|
||||
expect(
|
||||
selectActiveSection({
|
||||
headings: [
|
||||
{ id: "start", top: 0 },
|
||||
{ id: "profile", top: 350 },
|
||||
{ id: "experience", top: 350 },
|
||||
],
|
||||
activationY: 350,
|
||||
atDocumentEnd: false,
|
||||
}),
|
||||
).toBe("experience");
|
||||
});
|
||||
|
||||
it("forces the final canonical section at document end", () => {
|
||||
expect(
|
||||
selectActiveSection({ headings, activationY: 1400, atDocumentEnd: true }),
|
||||
).toBe("contact");
|
||||
});
|
||||
|
||||
it("uses fresh late-growth geometry on every selection", () => {
|
||||
const grown = headings.map((heading) =>
|
||||
heading.id === "experience" ? { ...heading, top: 900 } : heading,
|
||||
);
|
||||
|
||||
expect(
|
||||
selectActiveSection({ headings, activationY: 750, atDocumentEnd: false }),
|
||||
).toBe("experience");
|
||||
expect(
|
||||
selectActiveSection({
|
||||
headings: grown,
|
||||
activationY: 750,
|
||||
atDocumentEnd: false,
|
||||
}),
|
||||
).toBe("profile");
|
||||
});
|
||||
|
||||
it("maps every canonical section to its chapter and arrival state", () => {
|
||||
expect([
|
||||
deriveSectionState("start"),
|
||||
deriveSectionState("profile"),
|
||||
deriveSectionState("experience"),
|
||||
deriveSectionState("infrastructure"),
|
||||
deriveSectionState("selected-builds"),
|
||||
deriveSectionState("additional-work"),
|
||||
deriveSectionState("toolkit"),
|
||||
deriveSectionState("contact"),
|
||||
]).toEqual([
|
||||
{ activeSectionId: "start", chapter: "deep-night", arrived: false },
|
||||
{ activeSectionId: "profile", chapter: "deep-night", arrived: false },
|
||||
{ activeSectionId: "experience", chapter: "systems", arrived: false },
|
||||
{
|
||||
activeSectionId: "infrastructure",
|
||||
chapter: "infrastructure",
|
||||
arrived: false,
|
||||
},
|
||||
{ activeSectionId: "selected-builds", chapter: "builds", arrived: false },
|
||||
{ activeSectionId: "additional-work", chapter: "builds", arrived: false },
|
||||
{ activeSectionId: "toolkit", chapter: "pre-dawn", arrived: false },
|
||||
{ activeSectionId: "contact", chapter: "destination", arrived: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
100
tests/unit/sign-controller.test.ts
Normal file
100
tests/unit/sign-controller.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextSign } from "@/journey/sign-controller";
|
||||
import type { SignSnapshot } from "@/journey/types";
|
||||
|
||||
const passiveSample = (
|
||||
distanceVh: number,
|
||||
overrides: Partial<Parameters<typeof nextSign>[1]> = {},
|
||||
): Parameters<typeof nextSign>[1] => ({
|
||||
source: "passive-scroll",
|
||||
deltaY: 10,
|
||||
viewportHeight: 100,
|
||||
crossedBoundaries: 0,
|
||||
candidateId: "profile",
|
||||
distanceVh,
|
||||
motionAllowed: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("forward-only sign latching", () => {
|
||||
it("latches a first eligible candidate at the exact approach boundary", () => {
|
||||
expect(nextSign(null, passiveSample(1.2))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the literal approach and pass ranges independently", () => {
|
||||
expect(nextSign(null, passiveSample(0.875))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: expect.closeTo(0.5, 14),
|
||||
});
|
||||
expect(nextSign(null, passiveSample(-0.05))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "pass",
|
||||
progress: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses exact stabilized, pass, and completion boundaries", () => {
|
||||
const latched: SignSnapshot = {
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
expect(nextSign(latched, passiveSample(0.55))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "stabilized",
|
||||
progress: 1,
|
||||
});
|
||||
expect(nextSign(latched, passiveSample(0.15))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "pass",
|
||||
progress: 0,
|
||||
});
|
||||
expect(nextSign(latched, passiveSample(-0.25))).toBeNull();
|
||||
});
|
||||
|
||||
it("retains the one latched target while the semantic section advances", () => {
|
||||
const latched: SignSnapshot = {
|
||||
targetId: "profile",
|
||||
phase: "stabilized",
|
||||
progress: 1,
|
||||
};
|
||||
|
||||
expect(
|
||||
nextSign(latched, passiveSample(0.1, { candidateId: "experience" })),
|
||||
).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "pass",
|
||||
progress: expect.closeTo(0.125, 14),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["reverse input", { deltaY: -2 }],
|
||||
["zero-direction input", { deltaY: 0 }],
|
||||
["explicit navigation", { source: "explicit-navigation" as const }],
|
||||
["history restoration", { source: "history-restoration" as const }],
|
||||
["initial positioning", { source: "initial" as const }],
|
||||
["more than one activation boundary", { crossedBoundaries: 2 }],
|
||||
["a jump over 0.75 viewport heights", { deltaY: 76 }],
|
||||
["disabled motion", { motionAllowed: false }],
|
||||
])("clears or suppresses signs for %s", (_label, overrides) => {
|
||||
const latched: SignSnapshot = {
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: 0.5,
|
||||
};
|
||||
expect(nextSign(latched, passiveSample(0.5, overrides))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not latch before visibility or after a jump directly past completion", () => {
|
||||
expect(nextSign(null, passiveSample(1.20001))).toBeNull();
|
||||
expect(nextSign(null, passiveSample(-0.25))).toBeNull();
|
||||
expect(nextSign(null, passiveSample(-1))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user