1import type { ReactElement } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { reactHookHarness as hooks } from "../../test/reactHookHarness";
5import {
6 getThemeDefinition,
7 installCustomTheme,
8 invalidateCustomThemes,
9 parseThemeFile,
10 removeCustomTheme,
11 THEME_FILE_VERSION,
12 themeColorToHex,
13 updateCustomTheme,
14 type ThemeDefinition,
15} from "../../themePalette";
16import type { ThemeEditorSession } from "./themeEditorStore";
17
18const state = vi.hoisted(() => ({
19 session: null as ThemeEditorSession | null,
20 closeThemeEditor: vi.fn(),
21 onStoreChange: vi.fn(),
22 subscriptions: new Set<() => void>(),
23 theme: {
24 theme: "system",
25 themeHalves: null,
26 setTheme: vi.fn(),
27 refreshTheme: vi.fn(),
28 },
29}));
30
31vi.mock("react", async (importOriginal) => {
32 const actual = await importOriginal<typeof import("react")>();
33 const { reactHookHarness } = await import("../../test/reactHookHarness");
34 return {
35 ...actual,
36 useCallback: reactHookHarness.useCallback,
37 useSyncExternalStore: (
38 subscribe: (listener: () => void) => () => void,
39 getSnapshot: () => unknown,
40 ) => {
41 const subscription = reactHookHarness.useRef<(() => void) | null>(null);
42 if (!subscription.current) {
43 subscription.current = subscribe(() => state.onStoreChange());
44 state.subscriptions.add(subscription.current);
45 }
46 return getSnapshot();
47 },
48 };
49});
50
51vi.mock("react/compiler-runtime", async () => {
52 const { reactHookHarness } = await import("../../test/reactHookHarness");
53 return { c: reactHookHarness.useMemoCache };
54});
55
56vi.mock("../../hooks/useTheme", () => ({ useTheme: () => state.theme }));
57vi.mock("./themeEditorStore", () => ({
58 useThemeEditorStore: (select: (store: typeof state) => unknown) => select(state),
59}));
60vi.mock("../ui/toast", () => ({
61 toastManager: { add: vi.fn() },
62 stackedThreadToast: (value: unknown) => value,
63}));
64
65import { ThemeEditorHost } from "./ThemeEditorHost";
66
67function renderEditor() {
68 hooks.beginRender();
69 const host = ThemeEditorHost() as ReactElement<{
70 children: ReactElement<{
71 editingTheme: ThemeDefinition | null;
72 seedTheme: ThemeDefinition | null;
73 }>;
74 }> | null;
75 return host?.props.children.props ?? null;
76}
77
78describe("ThemeEditorHost", () => {
79 beforeEach(() => {
80 hooks.reset();
81 state.session = null;
82 state.onStoreChange.mockReset();
83 const storage = new Map<string, string>();
84 vi.stubGlobal("window", {
85 localStorage: {
86 getItem: (key: string) => storage.get(key) ?? null,
87 setItem: (key: string, value: string) => storage.set(key, value),
88 },
89 addEventListener: vi.fn(),
90 removeEventListener: vi.fn(),
91 });
92 invalidateCustomThemes();
93 });
94
95 afterEach(() => {
96 for (const unsubscribe of state.subscriptions) unsubscribe();
97 state.subscriptions.clear();
98 vi.unstubAllGlobals();
99 invalidateCustomThemes();
100 });
101
102 it.each(["editingTheme", "seedTheme"] as const)(
103 "reopens the same %s with its saved colors",
104 (field) => {
105 const theme = installCustomTheme(
106 parseThemeFile({
107 version: THEME_FILE_VERSION,
108 id: "saved-colors",
109 name: "Saved colors",
110 appearance: "dark",
111 colors: { accent: "#1f6e4a" },
112 }),
113 );
114 const session = {
115 id: 1,
116 editingThemeId: field === "editingTheme" ? theme.id : null,
117 seedThemeId: field === "seedTheme" ? theme.id : null,
118 seedName: null,
119 initialAppearance: "dark" as const,
120 };
121 state.session = session;
122 expect(themeColorToHex(renderEditor()?.[field]?.colors.accent ?? "")).toBe("#1f6e4a");
123
124 updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } });
125 expect(themeColorToHex(getThemeDefinition(theme.id)?.colors.accent ?? "")).toBe("#7241b8");
126 state.session = null;
127 expect(renderEditor()).toBeNull();
128 state.session = { ...session, id: 2 };
129
130 expect(themeColorToHex(renderEditor()?.[field]?.colors.accent ?? "")).toBe("#7241b8");
131 },
132 );
133
134 it.each(["editingTheme", "seedTheme"] as const)(
135 "refreshes an open %s when the library changes",
136 (field) => {
137 const theme = installCustomTheme(
138 parseThemeFile({
139 version: THEME_FILE_VERSION,
140 id: "updated-theme",
141 name: "Updated theme",
142 appearance: "dark",
143 colors: { accent: "#1f6e4a" },
144 }),
145 );
146 state.session = {
147 id: 1,
148 editingThemeId: field === "editingTheme" ? theme.id : null,
149 seedThemeId: field === "seedTheme" ? theme.id : null,
150 seedName: null,
151 initialAppearance: "dark",
152 };
153 let editor = renderEditor();
154 state.onStoreChange.mockImplementation(() => {
155 editor = renderEditor();
156 });
157
158 updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } });
159
160 expect(themeColorToHex(editor?.[field]?.colors.accent ?? "")).toBe("#7241b8");
161 },
162 );
163
164 it("does not keep editing a theme removed from the library", () => {
165 const theme = installCustomTheme(
166 parseThemeFile({
167 version: THEME_FILE_VERSION,
168 id: "removed-theme",
169 name: "Removed theme",
170 appearance: "dark",
171 colors: { accent: "#1f6e4a" },
172 }),
173 );
174 state.session = {
175 id: 1,
176 editingThemeId: theme.id,
177 seedThemeId: null,
178 seedName: null,
179 initialAppearance: "dark",
180 };
181 let editor = renderEditor();
182 expect(editor?.editingTheme?.id).toBe(theme.id);
183 state.onStoreChange.mockImplementation(() => {
184 editor = renderEditor();
185 });
186
187 removeCustomTheme(theme.id);
188
189 expect(editor?.editingTheme).toBeNull();
190 });
191});