import { expect, test } from "bun:test"; import { createScrollKeeper } from "../src/scroll"; function fakeEnv(opts: { max?: number; stored?: string | null; ids?: string[] } = {}) { const e = { y: 0, max: opts.max ?? 10_000, t: 0, stored: opts.stored ?? null as string | null, queue: [] as (() => void)[], intoView: [] as string[], getY: () => e.y, scrollTo: (y: number) => { e.y = Math.min(y, e.max); }, maxY: () => e.max, scrollToId: (id: string) => { if (!(opts.ids ?? []).includes(id)) return false; e.intoView.push(id); return true; }, later: (cb: () => void) => { e.queue.push(cb); }, now: () => e.t, load: () => e.stored, save: (s: string) => { e.stored = s; }, flush() { const q = e.queue.splice(0); q.forEach((f) => f()); }, }; return e; } test("PUSH goes to the top, POP restores the saved position of that entry", () => { const env = fakeEnv(); const k = createScrollKeeper(env); k.routeChanged("a", false); env.y = 640; k.onScroll(); k.routeChanged("b", false); expect(env.y).toBe(0); env.y = 120; k.onScroll(); k.routeChanged("a", true); expect(env.y).toBe(640); k.routeChanged("b", true); expect(env.y).toBe(120); k.routeChanged("c", true); // never seen: top expect(env.y).toBe(0); }); test("a hash scrolls its target into view", () => { const env = fakeEnv({ ids: ["part 2"] }); const k = createScrollKeeper(env); env.y = 300; k.routeChanged("a", false, "#part%202"); expect(env.intoView).toEqual(["part 2"]); k.routeChanged("b", false, "#missing"); expect(env.y).toBe(0); }); test("defer() keeps the old entry until settle(), which then applies the route scroll", () => { const env = fakeEnv(); const k = createScrollKeeper(env); k.routeChanged("a", false); env.y = 500; k.onScroll(); k.defer(); k.routeChanged("b", false); expect(env.y).toBe(500); // the old content is still on screen env.y = 520; k.onScroll(); // scrolling the old content still belongs to "a" k.settle(); expect(env.y).toBe(0); k.routeChanged("a", true); expect(env.y).toBe(520); k.settle(); // nothing pending: no-op expect(env.y).toBe(520); }); test("a restore beyond the current page height retries until the page is tall enough", () => { const env = fakeEnv(); const k = createScrollKeeper(env); k.routeChanged("a", false); env.y = 2000; k.onScroll(); k.routeChanged("b", false); env.max = 300; // the old page is still loading k.routeChanged("a", true); expect(env.y).toBe(300); env.flush(); expect(env.y).toBe(300); env.max = 5000; // content arrived env.flush(); expect(env.y).toBe(2000); expect(env.queue.length).toBe(0); }); test("the retry gives up after a second, and a newer route change cancels it", () => { const env = fakeEnv(); const k = createScrollKeeper(env); k.routeChanged("a", false); env.y = 2000; k.onScroll(); k.routeChanged("b", false); env.max = 300; k.routeChanged("a", true); k.onScroll(); // our own clamped scroll must not overwrite the saved 2000 env.t = 1200; env.flush(); expect(env.queue.length).toBe(0); env.max = 5000; k.routeChanged("a", true); expect(env.y).toBe(2000); env.max = 300; k.routeChanged("c", false); k.routeChanged("a", true); k.routeChanged("d", false); // cancels the restore of "a" env.max = 5000; env.flush(); expect(env.y).toBe(0); }); test("positions survive a reload through storage", () => { const env = fakeEnv(); const k = createScrollKeeper(env); k.routeChanged("a", false); env.y = 777; k.onScroll(); env.flush(); const env2 = fakeEnv({ stored: env.stored }); createScrollKeeper(env2).routeChanged("a", true); expect(env2.y).toBe(777); const env3 = fakeEnv({ stored: "{not json" }); createScrollKeeper(env3).routeChanged("a", true); expect(env3.y).toBe(0); });