1import type { EnvironmentTheme } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4const NIGHTFALL_THEME = {
5 id: "nightfall",
6 name: "Nightfall",
7 appearance: "dark",
8 canvas: "#1a1b26",
9 accent: "#7aa2f7",
10} as const satisfies EnvironmentTheme;
11
12const LIGHT_THEME = {
13 ...NIGHTFALL_THEME,
14 appearance: "light",
15 canvas: "#eff1f5",
16} as const satisfies EnvironmentTheme;
17
18async function setupThemeSync(mode: "dark" | "system" = "dark") {
19 const storage = new Map<string, string>([["t3code:theme", NIGHTFALL_THEME.id]]);
20 const styles = new Map<string, string>();
21 const classes = new Set<string>();
22 const root = {
23 dataset: {} as Record<string, string>,
24 style: {
25 setProperty: (name: string, value: string) => styles.set(name, value),
26 removeProperty: (name: string) => styles.delete(name),
27 },
28 classList: {
29 add: (name: string) => classes.add(name),
30 remove: (name: string) => classes.delete(name),
31 toggle: (name: string, enabled: boolean) =>
32 enabled ? classes.add(name) : classes.delete(name),
33 contains: (name: string) => classes.has(name),
34 },
35 };
36 vi.stubGlobal("document", { documentElement: root });
37 vi.stubGlobal("window", {
38 localStorage: {
39 getItem: (key: string) => storage.get(key) ?? null,
40 setItem: (key: string, value: string) => storage.set(key, value),
41 removeItem: (key: string) => storage.delete(key),
42 },
43 addEventListener: vi.fn(),
44 removeEventListener: vi.fn(),
45 matchMedia: () => ({
46 matches: true,
47 addEventListener: vi.fn(),
48 removeEventListener: vi.fn(),
49 }),
50 });
51 vi.stubGlobal("requestAnimationFrame", vi.fn());
52
53 let published: ReadonlyArray<EnvironmentTheme> = [NIGHTFALL_THEME];
54 let readSnapshot: (() => unknown) | undefined;
55 const effects: Array<() => void> = [];
56 const lastPublished: { current: ReadonlyArray<EnvironmentTheme> | null } = { current: null };
57 vi.doMock("react", () => ({
58 useCallback: <A>(callback: A) => callback,
59 useEffect: (effect: () => void) => effects.push(effect),
60 useRef: () => lastPublished,
61 useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => {
62 readSnapshot = getSnapshot;
63 return getSnapshot();
64 },
65 }));
66 vi.doMock("@effect/atom-react", () => ({ useAtomValue: () => published }));
67 vi.doMock("../state/server", () => ({ primaryServerEnvironmentThemesAtom: {} }));
68
69 const palette = await import("../themePalette");
70 storage.set(palette.THEME_APPEARANCE_MODE_STORAGE_KEY, mode);
71 const { useTheme } = await import("./useTheme");
72 const { useEnvironmentThemeSync } = await import("./useEnvironmentTheme");
73 const flushEffects = () => {
74 for (const effect of effects.splice(0)) effect();
75 };
76 const publish = (themes: ReadonlyArray<EnvironmentTheme>) => {
77 published = themes;
78 useEnvironmentThemeSync();
79 flushEffects();
80 // A changed store snapshot also runs the consumer's passive theme effect.
81 const theme = useTheme();
82 flushEffects();
83 return theme;
84 };
85 publish(published);
86
87 return { publish, palette, root, styles, readSnapshot: () => readSnapshot?.() };
88}
89
90afterEach(() => {
91 vi.doUnmock("react");
92 vi.doUnmock("@effect/atom-react");
93 vi.doUnmock("../state/server");
94 vi.resetModules();
95 vi.restoreAllMocks();
96 vi.unstubAllGlobals();
97});
98
99describe("published theme refresh", () => {
100 it.each(["dark", "system"] as const)(
101 "updates the React snapshot when appearance changes in %s mode",
102 async (mode) => {
103 const { publish, root, readSnapshot } = await setupThemeSync(mode);
104 const darkSnapshot = readSnapshot();
105
106 expect(publish([LIGHT_THEME]).resolvedTheme).toBe("light");
107 expect(root.classList.contains("dark")).toBe(false);
108 const lightSnapshot = readSnapshot();
109 expect(lightSnapshot).not.toBe(darkSnapshot);
110
111 expect(publish([NIGHTFALL_THEME]).resolvedTheme).toBe("dark");
112 expect(root.classList.contains("dark")).toBe(true);
113 expect(readSnapshot()).not.toBe(lightSnapshot);
114 },
115 );
116
117 it("keeps the React snapshot stable for color-only changes", async () => {
118 const { publish, palette, styles, readSnapshot } = await setupThemeSync();
119 const before = readSnapshot();
120 const accentVariable = palette.getThemeColorVariable("accent");
121 const previousAccent = styles.get(accentVariable);
122
123 publish([{ ...NIGHTFALL_THEME, accent: "#ff0000" }]);
124
125 expect(styles.get(accentVariable)).not.toBe(previousAccent);
126 expect(readSnapshot()).toBe(before);
127 });
128
129 it("keeps a draft visible while updating the stored selection underneath it", async () => {
130 const { publish, palette, root, styles } = await setupThemeSync();
131 const draft = { ...palette.getDefaultThemeColors("light"), canvas: "#234567" };
132 palette.applyThemeColorPreview(draft, "light");
133 const canvasVariable = palette.getThemeColorVariable("canvas");
134
135 const expectPreview = () => {
136 expect(root.dataset.themeId).toBe(palette.THEME_PREVIEW_ID);
137 expect(styles.get(canvasVariable)).toBe(draft.canvas);
138 expect(root.classList.contains("dark")).toBe(false);
139 };
140 expect(publish([LIGHT_THEME]).resolvedTheme).toBe("light");
141 expectPreview();
142 publish([LIGHT_THEME, { ...NIGHTFALL_THEME, id: "unused-theme" }]);
143 expectPreview();
144 expect(publish([]).theme).toBe("system");
145 expectPreview();
146
147 const current = publish([{ ...NIGHTFALL_THEME, canvas: "#112233" }]);
148 expect(current.theme).toBe(NIGHTFALL_THEME.id);
149 expect(current.resolvedTheme).toBe("dark");
150 expectPreview();
151
152 current.refreshTheme();
153 expect(root.dataset.themeId).toBe(NIGHTFALL_THEME.id);
154 expect(styles.get(canvasVariable)).toBe(
155 palette.getThemeDefinition(NIGHTFALL_THEME.id)?.colors.canvas,
156 );
157 expect(root.classList.contains("dark")).toBe(true);
158 expect(palette.getThemePreviewSidebarArtwork()).toBeNull();
159 });
160
161 it("keeps a draft visible when default adoption changes the theme and appearance", async () => {
162 const { publish, palette, root, readSnapshot } = await setupThemeSync();
163 const current = publish([NIGHTFALL_THEME]);
164 palette.applyThemeColorPreview(palette.getDefaultThemeColors("dark"), "dark");
165
166 expect(current.setTheme("ocean")).toBe(true);
167 expect(current.setAppearanceMode("light")).toBe(true);
168 expect(readSnapshot()).toMatchObject({
169 theme: "ocean",
170 appearanceMode: "light",
171 resolvedTheme: "light",
172 });
173 expect(root.dataset.themeId).toBe(palette.THEME_PREVIEW_ID);
174 expect(root.classList.contains("dark")).toBe(true);
175
176 current.refreshTheme();
177 expect(root.dataset.themeId).toBe("ocean");
178 expect(root.classList.contains("dark")).toBe(false);
179 expect(palette.getThemePreviewSidebarArtwork()).toBeNull();
180 });
181});