1import { describe, expect, it } from "vitest";
2import { ToastStore, type ToastClock, type ToastKind } from "./toast-store.js";
3
4
5function makeClock(start = 0): {
6 clock: ToastClock;
7 advance(ms: number): void;
8 now(): number;
9} {
10 let nowMs = start;
11 const pending: Array<{ at: number; fn: () => void; cancelled: boolean }> = [];
12 return {
13 now: () => nowMs,
14 clock: {
15 now: () => nowMs,
16 schedule(fn, delayMs) {
17 const entry = { at: nowMs + delayMs, fn, cancelled: false };
18 pending.push(entry);
19 return () => {
20 entry.cancelled = true;
21 };
22 },
23 },
24 advance(ms: number) {
25 nowMs += ms;
26 for (const p of pending) {
27 if (!p.cancelled && p.at <= nowMs) {
28 p.cancelled = true;
29 p.fn();
30 }
31 }
32 },
33 };
34}
35
36describe("ToastStore", () => {
37 it("push adds a toast with injected expiry and notifies subscribers", () => {
38 const { clock } = makeClock(1000);
39 const store = new ToastStore({ ttl: 2000, clock });
40 const seen: number[] = [];
41 store.subscribe((items) => seen.push(items.length));
42 const t = store.push("info", "hello");
43 expect(t.id).toMatch(/^toast-/);
44 expect(t.expiresAt).toBe(3000);
45 expect(store.items()).toHaveLength(1);
46 expect(seen).toEqual([1]);
47 });
48
49 it("multiple pushes keep order; dismiss removes and notifies", () => {
50 const { clock } = makeClock(0);
51 const store = new ToastStore({ ttl: 5000, clock });
52 const a = store.push("success", "a");
53 const b = store.push("error", "b");
54 expect(store.items().map((t) => t.message)).toEqual(["a", "b"]);
55 expect(store.dismiss(a.id)).toBe(true);
56 expect(store.items().map((t) => t.id)).toEqual([b.id]);
57 expect(store.dismiss("missing")).toBe(false);
58 });
59
60 it("expiry fires via the injected clock and updates subscribers", () => {
61 const { clock, advance } = makeClock(0);
62 const store = new ToastStore({ ttl: 1000, clock });
63 const snapshots: number[] = [];
64 store.subscribe((items) => snapshots.push(items.length));
65 store.push("warning", "short");
66 advance(999);
67 expect(store.items()).toHaveLength(1);
68 advance(1);
69 expect(store.items()).toHaveLength(0);
70 expect(snapshots).toEqual([1, 0]);
71 });
72
73 it("ttl=0 is sticky (never expires)", () => {
74 const { clock, advance } = makeClock(0);
75 const store = new ToastStore({ ttl: 0, clock });
76 store.push("info", "sticky");
77 advance(10 * 60_000);
78 expect(store.items()).toHaveLength(1);
79 });
80
81 it("explicit per-push ttl overrides the store default", () => {
82 const { clock, advance } = makeClock(0);
83 const store = new ToastStore({ ttl: 5000, clock });
84 store.push("info", "short", 100);
85 store.push("info", "long");
86 advance(100);
87 expect(store.items().map((t) => t.message)).toEqual(["long"]);
88 advance(5000);
89 expect(store.items()).toHaveLength(0);
90 });
91
92 it("items() never returns expired toasts (lazy sweep)", () => {
93 const { clock, advance } = makeClock(0);
94 const store = new ToastStore({ ttl: 1000, clock });
95 store.push("info", "x");
96 advance(1000);
97
98
99 expect(store.items()).toHaveLength(0);
100 });
101
102 it("unsubscribe stops notifications", () => {
103 const { clock } = makeClock(0);
104 const store = new ToastStore({ clock });
105 let count = 0;
106 const off = store.subscribe(() => {
107 count += 1;
108 });
109 store.push("info", "a");
110 off();
111 store.push("info", "b");
112 expect(count).toBe(1);
113 });
114
115 it("default kind is a valid toast kind union (compile-time guard)", () => {
116 const kinds: ToastKind[] = ["info", "success", "warning", "error"];
117 expect(kinds).toHaveLength(4);
118 });
119});
120