1import { describe, expect, it } from "vitest";
2import {
3 applyThemeTo,
4 resolveTheme,
5 systemDarkQuery,
6 themeLocalStorageKey,
7 type ThemeDocument,
8} from "./theme.js";
9
10describe("resolveTheme", () => {
11 it("light pref wins over system", () => {
12 expect(resolveTheme("light", true)).toBe("light");
13 });
14 it("dark pref wins over system", () => {
15 expect(resolveTheme("dark", false)).toBe("dark");
16 });
17 it("system follows OS (dark)", () => {
18 expect(resolveTheme("system", true)).toBe("dark");
19 });
20 it("system follows OS (light)", () => {
21 expect(resolveTheme("system", false)).toBe("light");
22 });
23});
24
25describe("constants", () => {
26 it("storage key + media query", () => {
27 expect(themeLocalStorageKey()).toBe("factory.theme");
28 expect(systemDarkQuery()).toBe("(prefers-color-scheme: dark)");
29 });
30});
31
32interface FakeWindow {
33 matchMedia: (q: string) => {
34 matches: boolean;
35 listeners: Array<() => void>;
36 addEventListener(type: string, h: () => void): void;
37 removeEventListener(type: string, h: () => void): void;
38
39 addListener(h: () => void): void;
40 removeListener(h: () => void): void;
41 };
42 calls: string[];
43}
44
45function makeWindow(matches = true): FakeWindow {
46
47 const media: ReturnType<FakeWindow["matchMedia"]> = {
48 matches,
49 listeners: [],
50 addEventListener(_t, h) {
51 media.listeners.push(h);
52 },
53 removeEventListener(_t, h) {
54 const i = media.listeners.indexOf(h);
55 if (i >= 0) media.listeners.splice(i, 1);
56 },
57 addListener(h) {
58 media.listeners.push(h);
59 },
60 removeListener(h) {
61 const i = media.listeners.indexOf(h);
62 if (i >= 0) media.listeners.splice(i, 1);
63 },
64 };
65 return {
66 calls: [],
67 matchMedia: (q) => {
68 expect(q).toBe(systemDarkQuery());
69 return media;
70 },
71 };
72}
73
74function makeDocument(): ThemeDocument {
75 return { dataset: {} };
76}
77
78describe("applyThemeTo", () => {
79 it("sets data-theme for explicit light with a dark system", () => {
80 const doc = makeDocument();
81 const win = makeWindow(true);
82 const resolved = applyThemeTo(doc, "light", win);
83 expect(resolved).toBe("light");
84 expect(doc.dataset.theme).toBe("light");
85 });
86
87 it("sets data-theme for system pref (dark system)", () => {
88 const doc = makeDocument();
89 const resolved = applyThemeTo(doc, "system", makeWindow(true));
90 expect(resolved).toBe("dark");
91 expect(doc.dataset.theme).toBe("dark");
92 });
93
94 it("registers a matchMedia change listener for system pref", () => {
95 const doc = makeDocument();
96 const win = makeWindow(true);
97 applyThemeTo(doc, "system", win);
98 const media = win.matchMedia(systemDarkQuery());
99 expect(media.listeners).toHaveLength(1);
100
101 media.matches = false;
102 media.listeners[0]?.();
103 expect(doc.dataset.theme).toBe("light");
104 });
105
106 it("does not register a listener for explicit prefs", () => {
107 const doc = makeDocument();
108 const win = makeWindow(true);
109 applyThemeTo(doc, "dark", win);
110 expect(win.matchMedia(systemDarkQuery()).listeners).toHaveLength(0);
111 });
112
113 it("unregisters the previous listener when re-applied (idempotent)", () => {
114 const doc = makeDocument();
115 const win = makeWindow(true);
116 applyThemeTo(doc, "system", win);
117 applyThemeTo(doc, "system", win);
118 expect(win.matchMedia(systemDarkQuery()).listeners).toHaveLength(1);
119 applyThemeTo(doc, "light", win);
120 expect(win.matchMedia(systemDarkQuery()).listeners).toHaveLength(0);
121 });
122
123 it("works without matchMedia (SSR guard): resolves from default, no crash", () => {
124 const doc = makeDocument();
125 const resolved = applyThemeTo(doc, "system", undefined);
126 expect(resolved).toBe("light");
127 expect(doc.dataset.theme).toBe("light");
128 });
129});
130