/*
Unit tests for scroll spy covering initScrollSpy. It also guards the documented public contract and regression-sensitive edge cases.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1311: Compass header sweep — declares the scroll suite's contract for test-file scaffold coverage.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { initScrollSpy } from "../scroll-spy.ts";
interface MockRect {
top: number;
}
interface MockElement {
id: string;
closest: (sel: string) => object | null;
getBoundingClientRect: () => MockRect;
}
interface MockEntry {
target: MockElement;
isIntersecting: boolean;
boundingClientRect: MockRect;
}
interface MockObserverInstance {
observe: ReturnType;
unobserve: ReturnType;
disconnect: ReturnType;
takeRecords: ReturnType;
}
interface MockDocument {
body: { innerHTML: string };
readyState: string;
querySelector: ReturnType;
querySelectorAll: ReturnType;
getElementById: ReturnType;
addEventListener: ReturnType;
}
let observerCallbacks: Array<(entries: MockEntry[]) => void>;
let observerInstances: MockObserverInstance[];
let mockHistory: { replaceState: ReturnType };
let mockDocument: MockDocument;
let sectionCache: Map;
function parseSections(html: string, selector: string): MockElement[] {
if (selector !== "section[id]") return [];
const sections: MockElement[] = [];
const sectionRegex = /]*>([\s\S]*?)<\/section>/gi;
let match;
while ((match = sectionRegex.exec(html)) !== null) {
const modalBefore = html.substring(0, match.index);
const lastModalOpen = modalBefore.lastIndexOf('class="wl-modal"');
const lastModalClose = modalBefore.lastIndexOf("");
const inModal = lastModalOpen > lastModalClose;
const el = {
id: match[1],
closest: (sel: string) => (sel === ".wl-modal" && inModal ? {} : null),
getBoundingClientRect: () => ({ top: 0 }),
};
sectionCache.set(match[1], el);
sections.push(el);
}
return sections;
}
describe("initScrollSpy", () => {
beforeEach(() => {
observerCallbacks = [];
observerInstances = [];
sectionCache = new Map();
class MockIntersectionObserver {
constructor(cb: (entries: MockEntry[]) => void) {
observerCallbacks.push(cb);
const instance = {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
takeRecords: vi.fn(() => []),
};
observerInstances.push(instance);
return instance;
}
}
mockHistory = { replaceState: vi.fn() };
mockDocument = {
body: { innerHTML: "" },
readyState: "complete",
querySelector: vi.fn(() => null),
querySelectorAll: vi.fn((selector: string) => {
return parseSections(mockDocument.body.innerHTML, selector);
}),
getElementById: vi.fn((id: string) => sectionCache.get(id) ?? null),
addEventListener: vi.fn(),
};
vi.stubGlobal("window", {
IntersectionObserver: MockIntersectionObserver,
addEventListener: vi.fn(),
});
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
vi.stubGlobal("history", mockHistory);
vi.stubGlobal("location", {
pathname: "/page",
search: "",
hash: "",
});
vi.stubGlobal("document", mockDocument);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("AC-1: updates URL hash via history.replaceState when a section is in view", () => {
mockDocument.body.innerHTML =
'';
const cleanup = initScrollSpy();
const section = sectionCache.get("approach")!;
observerCallbacks[0]([
{
target: section,
isIntersecting: true,
boundingClientRect: { top: 100 },
},
]);
expect(mockHistory.replaceState).toHaveBeenCalledWith(null, "", "#approach");
cleanup();
});
it("AC-2: clears URL hash when scrolled above the first section", () => {
mockDocument.body.innerHTML =
'';
const cleanup = initScrollSpy();
const details = sectionCache.get("details")!;
observerCallbacks[0]([
{
target: details,
isIntersecting: true,
boundingClientRect: { top: 100 },
},
]);
expect(mockHistory.replaceState).toHaveBeenCalledWith(null, "", "#details");
observerCallbacks[0]([
{
target: details,
isIntersecting: false,
boundingClientRect: { top: 500 },
},
]);
expect(mockHistory.replaceState).toHaveBeenCalledWith(null, "", "/page");
cleanup();
});
it("keeps a clean URL while the first section is in view", () => {
mockDocument.body.innerHTML =
'';
const cleanup = initScrollSpy();
const hero = sectionCache.get("hero")!;
const details = sectionCache.get("details")!;
observerCallbacks[0]([
{
target: hero,
isIntersecting: true,
boundingClientRect: { top: 0 },
},
]);
expect(mockHistory.replaceState).not.toHaveBeenCalled();
observerCallbacks[0]([
{
target: details,
isIntersecting: true,
boundingClientRect: { top: 50 },
},
]);
expect(mockHistory.replaceState).toHaveBeenCalledWith(null, "", "#details");
observerCallbacks[0]([
{
target: hero,
isIntersecting: true,
boundingClientRect: { top: 0 },
},
{
target: details,
isIntersecting: false,
boundingClientRect: { top: 800 },
},
]);
expect(mockHistory.replaceState).toHaveBeenLastCalledWith(null, "", "/page");
expect(mockHistory.replaceState).not.toHaveBeenCalledWith(null, "", "#hero");
cleanup();
});
it("AC-3: returns a cleanup callback that disconnects the observer", () => {
mockDocument.body.innerHTML = '';
const cleanup = initScrollSpy();
expect(typeof cleanup).toBe("function");
cleanup();
expect(observerInstances[0].disconnect).toHaveBeenCalled();
});
it("AC-4: returns immediately without creating an observer when no section[id] elements exist", () => {
mockDocument.body.innerHTML = "No sections here
";
const cleanup = initScrollSpy();
expect(observerInstances.length).toBe(0);
expect(typeof cleanup).toBe("function");
cleanup();
});
it("AC-5: returns silently without throwing when IntersectionObserver is not supported", () => {
vi.unstubAllGlobals();
vi.stubGlobal("window", { addEventListener: vi.fn() });
vi.stubGlobal("history", mockHistory);
vi.stubGlobal("location", { pathname: "/page", search: "", hash: "" });
vi.stubGlobal("document", mockDocument);
mockDocument.body.innerHTML = '';
sectionCache.clear();
expect(() => initScrollSpy()).not.toThrow();
});
it("AC-9: is idempotent — calling twice disconnects the first observer before creating a new one", () => {
mockDocument.body.innerHTML = '';
initScrollSpy();
expect(observerInstances.length).toBe(1);
initScrollSpy();
expect(observerInstances.length).toBe(2);
expect(observerInstances[0].disconnect).toHaveBeenCalled();
});
it("excludes sections inside .wl-modal elements", () => {
mockDocument.body.innerHTML =
'' +
'';
initScrollSpy();
expect(observerInstances.length).toBe(1);
expect(observerInstances[0].observe).toHaveBeenCalledTimes(1);
expect(observerInstances[0].observe).toHaveBeenCalledWith(sectionCache.get("page-section"));
});
});