import { describe, expect, it } from "vitest"; import { ToastStore, type ToastClock, type ToastKind } from "./toast-store.js"; /** Deterministic manual clock: advance() fires scheduled callbacks in time order. */ function makeClock(start = 0): { clock: ToastClock; advance(ms: number): void; now(): number; } { let nowMs = start; const pending: Array<{ at: number; fn: () => void; cancelled: boolean }> = []; return { now: () => nowMs, clock: { now: () => nowMs, schedule(fn, delayMs) { const entry = { at: nowMs + delayMs, fn, cancelled: false }; pending.push(entry); return () => { entry.cancelled = true; }; }, }, advance(ms: number) { nowMs += ms; for (const p of pending) { if (!p.cancelled && p.at <= nowMs) { p.cancelled = true; p.fn(); } } }, }; } describe("ToastStore", () => { it("push adds a toast with injected expiry and notifies subscribers", () => { const { clock } = makeClock(1000); const store = new ToastStore({ ttl: 2000, clock }); const seen: number[] = []; store.subscribe((items) => seen.push(items.length)); const t = store.push("info", "hello"); expect(t.id).toMatch(/^toast-/); expect(t.expiresAt).toBe(3000); expect(store.items()).toHaveLength(1); expect(seen).toEqual([1]); }); it("multiple pushes keep order; dismiss removes and notifies", () => { const { clock } = makeClock(0); const store = new ToastStore({ ttl: 5000, clock }); const a = store.push("success", "a"); const b = store.push("error", "b"); expect(store.items().map((t) => t.message)).toEqual(["a", "b"]); expect(store.dismiss(a.id)).toBe(true); expect(store.items().map((t) => t.id)).toEqual([b.id]); expect(store.dismiss("missing")).toBe(false); }); it("expiry fires via the injected clock and updates subscribers", () => { const { clock, advance } = makeClock(0); const store = new ToastStore({ ttl: 1000, clock }); const snapshots: number[] = []; store.subscribe((items) => snapshots.push(items.length)); store.push("warning", "short"); advance(999); expect(store.items()).toHaveLength(1); advance(1); // crosses expiry → dismiss → snapshot 0 expect(store.items()).toHaveLength(0); expect(snapshots).toEqual([1, 0]); }); it("ttl=0 is sticky (never expires)", () => { const { clock, advance } = makeClock(0); const store = new ToastStore({ ttl: 0, clock }); store.push("info", "sticky"); advance(10 * 60_000); expect(store.items()).toHaveLength(1); }); it("explicit per-push ttl overrides the store default", () => { const { clock, advance } = makeClock(0); const store = new ToastStore({ ttl: 5000, clock }); store.push("info", "short", 100); store.push("info", "long"); advance(100); expect(store.items().map((t) => t.message)).toEqual(["long"]); advance(5000); expect(store.items()).toHaveLength(0); }); it("items() never returns expired toasts (lazy sweep)", () => { const { clock, advance } = makeClock(0); const store = new ToastStore({ ttl: 1000, clock }); store.push("info", "x"); advance(1000); // expiry is enforced both by the timer callback AND the lazy sweep in // items() — the list is empty under either path (drop-timer safety). expect(store.items()).toHaveLength(0); }); it("unsubscribe stops notifications", () => { const { clock } = makeClock(0); const store = new ToastStore({ clock }); let count = 0; const off = store.subscribe(() => { count += 1; }); store.push("info", "a"); off(); store.push("info", "b"); expect(count).toBe(1); }); it("default kind is a valid toast kind union (compile-time guard)", () => { const kinds: ToastKind[] = ["info", "success", "warning", "error"]; expect(kinds).toHaveLength(4); }); });