/* 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 = '
Hero
Content
'; 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 = '
Hero
Details
'; 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 = '
Hero
Details
'; 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 = '
Test
'; 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 = '
Test
'; 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 = '
A
B
'; 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 = '
' + '
Page
'; initScrollSpy(); expect(observerInstances.length).toBe(1); expect(observerInstances[0].observe).toHaveBeenCalledTimes(1); expect(observerInstances[0].observe).toHaveBeenCalledWith(sectionCache.get("page-section")); }); });