import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createControlsPanelScrollController } from "./controls-panel-scroll"; const disposers: Array<() => void> = []; let resize: () => void; let disconnect: ReturnType; function setup(initialScrollTop = 0, height = 1200) { const viewport = document.createElement("div"); viewport.append(document.createElement("div")); document.body.append(viewport); Object.defineProperties(viewport, { clientHeight: { configurable: true, value: 300 }, scrollHeight: { configurable: true, get: () => height }, }); const onScrollTopChange = vi.fn(); const controller = createControlsPanelScrollController({ viewport, initialScrollTop, onScrollTopChange }); disposers.push(controller.dispose); return { viewport, onScrollTopChange, grow(next: number) { height = next; resize(); vi.advanceTimersByTime(20); }, scroll(top: number) { viewport.scrollTop = top; viewport.dispatchEvent(new Event("scroll")); }, ...controller, }; } beforeEach(() => { vi.useFakeTimers(); disconnect = vi.fn(); vi.stubGlobal("ResizeObserver", class { constructor(callback: () => void) { resize = callback; } observe = vi.fn(); disconnect = disconnect; }); }); afterEach(() => { disposers.splice(0).forEach((dispose) => dispose()); document.body.replaceChildren(); vi.useRealTimers(); vi.unstubAllGlobals(); }); describe("controls panel scroll controller", () => { it("restores immediately without recording a new change", () => { const fixture = setup(412.5); expect(fixture.viewport.scrollTop).toBe(412.5); fixture.scroll(412.5); vi.advanceTimersByTime(500); expect(fixture.onScrollTopChange).not.toHaveBeenCalled(); }); it("retries a clamped offset when content arrives, without persisting interim zero", () => { const fixture = setup(650, 300); expect(fixture.viewport.scrollTop).toBe(0); fixture.scroll(0); fixture.grow(550); expect(fixture.viewport.scrollTop).toBe(250); fixture.scroll(250); fixture.flush(); expect(fixture.onScrollTopChange).not.toHaveBeenCalled(); fixture.grow(1200); expect(fixture.viewport.scrollTop).toBe(650); expect(fixture.onScrollTopChange).not.toHaveBeenCalled(); }); it("gives user input priority over a pending restore", () => { const fixture = setup(650, 550); fixture.viewport.dispatchEvent(new Event("wheel")); fixture.scroll(100); fixture.grow(1200); expect(fixture.viewport.scrollTop).toBe(100); vi.advanceTimersByTime(150); expect(fixture.onScrollTopChange).toHaveBeenLastCalledWith(100); }); it("coalesces rapid scrolling and flushes the latest value once", () => { const fixture = setup(); fixture.scroll(50); fixture.scroll(110); expect(fixture.onScrollTopChange).not.toHaveBeenCalled(); fixture.flush(); expect(fixture.onScrollTopChange).toHaveBeenLastCalledWith(110); vi.advanceTimersByTime(500); expect(fixture.onScrollTopChange).toHaveBeenCalledTimes(1); }); it("captures a live offset even if pagehide precedes the scroll event", () => { const fixture = setup(); fixture.viewport.scrollTop = 123; fixture.flush(); expect(fixture.onScrollTopChange).toHaveBeenLastCalledWith(123); }); it("flushes the last observed offset on detach, not detached geometry, and cleans up", () => { const fixture = setup(); fixture.scroll(180); fixture.viewport.remove(); fixture.viewport.scrollTop = 0; fixture.dispose(); expect(fixture.onScrollTopChange).toHaveBeenLastCalledWith(180); fixture.scroll(250); vi.advanceTimersByTime(500); expect(fixture.onScrollTopChange).toHaveBeenCalledTimes(1); expect(disconnect).toHaveBeenCalled(); }); });