summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/tests/theme.test.ts
blob: 63a343d975836cf5caf146dc1d0896ad1a70b5c8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
 * Tests for `src/lib/theme.ts` — the shared theme picker module.
 *
 * Covers the post-Gemini-review fix where `App.svelte` (boot apply)
 * and `SettingsPanel.svelte` (UI picker) used to hand-roll their own
 * defaults and could disagree. After consolidation, both call into
 * this module and the bug class is gone — these tests pin that
 * invariant.
 */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { applyTheme, DEFAULT_THEME, loadStoredTheme, THEMES } from "../src/lib/theme.js";

const LS_KEY = "dispatch-theme";

function makeLocalStorageMock(): Storage {
	const store = new Map<string, string>();
	return {
		getItem: (k: string) => store.get(k) ?? null,
		setItem: (k: string, v: string) => {
			store.set(k, v);
		},
		removeItem: (k: string) => {
			store.delete(k);
		},
		clear: () => {
			store.clear();
		},
		get length() {
			return store.size;
		},
		key: (i: number) => Array.from(store.keys())[i] ?? null,
	};
}

function makeDocumentMock(): { documentElement: { setAttribute: (k: string, v: string) => void } } {
	const attrs = new Map<string, string>();
	return {
		documentElement: {
			setAttribute: (k: string, v: string) => {
				attrs.set(k, v);
			},
			// expose for assertions
			// @ts-expect-error — test-only escape hatch
			_attrs: attrs,
		},
	};
}

beforeEach(() => {
	vi.stubGlobal("localStorage", makeLocalStorageMock());
	vi.stubGlobal("document", makeDocumentMock());
});

describe("DEFAULT_THEME", () => {
	it("is one of the THEMES (sanity check that they can't drift)", () => {
		expect((THEMES as readonly string[]).includes(DEFAULT_THEME)).toBe(true);
	});
});

describe("loadStoredTheme", () => {
	it("returns DEFAULT_THEME when localStorage is empty (first-ever load)", () => {
		expect(loadStoredTheme()).toBe(DEFAULT_THEME);
	});

	it("returns the stored theme when it's a known theme", () => {
		localStorage.setItem(LS_KEY, "dracula");
		expect(loadStoredTheme()).toBe("dracula");
	});

	it("returns DEFAULT_THEME when the stored value isn't a known theme", () => {
		// Guards against a stale storage entry from a removed theme,
		// or a hand-edited bad value, falling through to daisyUI's own
		// fallback (which is `light`, not our DEFAULT).
		localStorage.setItem(LS_KEY, "solarized-rainbow");
		expect(loadStoredTheme()).toBe(DEFAULT_THEME);
	});

	it("returns DEFAULT_THEME when localStorage.getItem throws", () => {
		vi.stubGlobal("localStorage", {
			getItem: () => {
				throw new Error("SecurityError");
			},
			setItem: () => {},
			removeItem: () => {},
			clear: () => {},
			length: 0,
			key: () => null,
		});
		expect(loadStoredTheme()).toBe(DEFAULT_THEME);
	});

	it("returns DEFAULT_THEME when localStorage is undefined (SSR)", () => {
		vi.stubGlobal("localStorage", undefined);
		expect(loadStoredTheme()).toBe(DEFAULT_THEME);
	});
});

describe("applyTheme", () => {
	it("writes data-theme on the document element", () => {
		applyTheme("nord");
		// @ts-expect-error — test mock exposes `_attrs`
		expect(document.documentElement._attrs.get("data-theme")).toBe("nord");
	});

	it("persists the theme to localStorage", () => {
		applyTheme("forest");
		expect(localStorage.getItem(LS_KEY)).toBe("forest");
	});

	it("round-trips through loadStoredTheme", () => {
		applyTheme("luxury");
		expect(loadStoredTheme()).toBe("luxury");
	});

	it("does not throw when localStorage.setItem throws (quota etc.)", () => {
		vi.stubGlobal("localStorage", {
			getItem: () => null,
			setItem: () => {
				throw new Error("QuotaExceededError");
			},
			removeItem: () => {},
			clear: () => {},
			length: 0,
			key: () => null,
		});
		expect(() => applyTheme("cyberpunk")).not.toThrow();
	});

	it("still writes to the DOM even if localStorage write throws", () => {
		vi.stubGlobal("localStorage", {
			getItem: () => null,
			setItem: () => {
				throw new Error("QuotaExceededError");
			},
			removeItem: () => {},
			clear: () => {},
			length: 0,
			key: () => null,
		});
		applyTheme("coffee");
		// @ts-expect-error — test mock exposes `_attrs`
		expect(document.documentElement._attrs.get("data-theme")).toBe("coffee");
	});
});