1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { environmentThemeDefinition, publishedThemeDefinitions } from "./useEnvironmentTheme";
4import {
5 getDefaultThemeColors,
6 getThemeDefinition,
7 installCustomTheme,
8 invalidateCustomThemes,
9 setEnvironmentThemes,
10 THEME_COLOR_ROLES,
11} from "../themePalette";
12
13const NIGHTFALL_THEME = {
14 id: "nightfall",
15 name: "Nightfall",
16 appearance: "dark",
17 canvas: "#1a1b26",
18 accent: "#7aa2f7",
19} as const;
20
21afterEach(() => {
22 setEnvironmentThemes([]);
23});
24
25describe("environment themes", () => {
26 it("generates every role from the two published seeds", () => {
27 const theme = environmentThemeDefinition(NIGHTFALL_THEME);
28
29 expect(theme.id).toBe("nightfall");
30 expect(theme.label).toBe("Nightfall");
31 expect(theme.appearance).toBe("dark");
32 for (const role of THEME_COLOR_ROLES) {
33 expect(theme.colors[role], `missing ${role}`).toBeTruthy();
34 }
35 });
36
37 it("layers published roles over the generated palette", () => {
38 const generated = environmentThemeDefinition(NIGHTFALL_THEME);
39 const overridden = environmentThemeDefinition({
40 ...NIGHTFALL_THEME,
41 colors: { terminalSelection: "#292e42", error: "#f7768e" },
42 });
43
44 expect(overridden.colors.terminalSelection).not.toBe(generated.colors.terminalSelection);
45 expect(overridden.colors.error).not.toBe(generated.colors.error);
46 // Roles the machine did not publish keep the generated value.
47 expect(overridden.colors.sidebar).toBe(generated.colors.sidebar);
48 });
49
50 // The standard exported theme file — the Download button's output — is a
51 // valid published theme, so any shared theme can be dropped into the
52 // machine's themes directory as-is.
53 it("renders an exported theme file on the stock defaults", () => {
54 const theme = environmentThemeDefinition({
55 id: "shared-light",
56 version: 1,
57 name: "Shared Light",
58 appearance: "light",
59 colors: { canvas: "oklch(0.95 0.01 250)", accent: "#1e66f5" },
60 variants: { dark: { canvas: "#1a1b26" } },
61 });
62
63 expect(theme.label).toBe("Shared Light");
64 expect(theme.colors.accent).toBeTruthy();
65 expect(theme.variants?.dark?.canvas).toBeTruthy();
66 for (const role of THEME_COLOR_ROLES) {
67 expect(theme.colors[role], `missing ${role}`).toBeTruthy();
68 expect(theme.variants?.dark?.[role], `missing dark ${role}`).toBeTruthy();
69 }
70 });
71
72 // The generator follows the seed canvas's luminance, so a dark theme's
73 // seeds must never produce its light variant: the variant builds on that
74 // appearance's stock defaults instead.
75 it("builds a seeded theme's variant on the variant appearance's defaults", () => {
76 const theme = environmentThemeDefinition({
77 ...NIGHTFALL_THEME,
78 variants: { light: { canvas: "#eff1f5" } },
79 });
80
81 const lightDefaults = getDefaultThemeColors("light");
82 expect(theme.variants?.light?.text).toBe(lightDefaults.text);
83 expect(theme.variants?.light?.canvas).not.toBe(theme.colors.canvas);
84 });
85
86 // A published `t3-iris.json` would show its palette on a card that applies
87 // the built-in, and a published `dark.json` would capture everyone whose
88 // stored preference is the stock "dark". Reserved ids never become cards.
89 it("drops published themes with reserved ids", () => {
90 const definitions = publishedThemeDefinitions([
91 NIGHTFALL_THEME,
92 { ...NIGHTFALL_THEME, id: "t3-iris", name: "Impostor Iris" },
93 { ...NIGHTFALL_THEME, id: "ocean", name: "Impostor Ocean" },
94 { ...NIGHTFALL_THEME, id: "dark", name: "Impostor Dark" },
95 ]);
96
97 expect(definitions.map((definition) => definition.id)).toEqual(["nightfall"]);
98 });
99
100 it("drops published palettes with no usable colors", () => {
101 const theme = { id: "invalid", name: "Invalid", appearance: "dark" } as const;
102 const definitions = publishedThemeDefinitions([
103 { ...theme, colors: { canvas: "not-a-color" } },
104 { ...theme, id: "unknown-roles", colors: { futureRole: "#ffffff" } },
105 {
106 ...theme,
107 id: "ignored-variant",
108 colors: { canvas: "not-a-color" },
109 variants: { dark: { canvas: "#112233" } },
110 },
111 ]);
112
113 expect(definitions).toEqual([]);
114 });
115
116 it("keeps seeds, partial palettes, and usable alternate appearances", () => {
117 const theme = { name: "Shared", appearance: "dark" } as const;
118 const definitions = publishedThemeDefinitions([
119 NIGHTFALL_THEME,
120 {
121 ...theme,
122 id: "partial",
123 colors: { canvas: "not-a-color", text: "#ffffff", futureRole: "#ffffff" },
124 },
125 {
126 ...theme,
127 id: "light-variant",
128 colors: { canvas: "not-a-color" },
129 variants: { light: { canvas: "#eff1f5" } },
130 },
131 ]);
132
133 expect(definitions.map((definition) => definition.id)).toEqual([
134 "nightfall",
135 "partial",
136 "light-variant",
137 ]);
138 });
139
140 // A machine may publish roles a newer client added; an older one has to
141 // ignore them rather than render a broken palette.
142 it("ignores published roles this build does not render", () => {
143 const theme = environmentThemeDefinition({
144 ...NIGHTFALL_THEME,
145 colors: { notARole: "#ff0000", text: "#ffffff" },
146 });
147
148 expect(theme.colors).not.toHaveProperty("notARole");
149 for (const role of THEME_COLOR_ROLES) {
150 expect(theme.colors[role], `missing ${role}`).toBeTruthy();
151 }
152 });
153
154 // ThemeEditorPanel opens Duplicate in the guided editor for managed themes,
155 // and the guided editor regenerates from canvas and accent -- which would
156 // discard any role the machine tuned by hand.
157 it("marks only the pure seeded form as managed", () => {
158 expect(environmentThemeDefinition(NIGHTFALL_THEME).managed).toBe(true);
159 expect(
160 environmentThemeDefinition({ ...NIGHTFALL_THEME, colors: { error: "#f7768e" } }).managed,
161 ).toBeUndefined();
162 expect(
163 environmentThemeDefinition({
164 id: "shared-light",
165 version: 1,
166 name: "Shared Light",
167 appearance: "light",
168 colors: { canvas: "#eff1f5", accent: "#1e66f5" },
169 }).managed,
170 ).toBeUndefined();
171 });
172
173 it("resolves published ids only while the machine publishes them", () => {
174 expect(getThemeDefinition("nightfall")).toBe(null);
175
176 setEnvironmentThemes([environmentThemeDefinition(NIGHTFALL_THEME)]);
177 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall");
178
179 // The palettes are never saved, so they have to disappear with the
180 // machine that published them rather than linger as stale entries.
181 setEnvironmentThemes([]);
182 expect(getThemeDefinition("nightfall")).toBe(null);
183 });
184
185 // Published ids share one namespace with the user's saved themes, and the
186 // user was here first: their theme keeps working even if the machine later
187 // publishes under the same id.
188 it("lets a theme the user saved win an id collision", () => {
189 const store = new Map<string, string>();
190 vi.stubGlobal("window", {
191 addEventListener: vi.fn(),
192 removeEventListener: vi.fn(),
193 localStorage: {
194 getItem: (key: string) => store.get(key) ?? null,
195 setItem: (key: string, value: string) => store.set(key, value),
196 removeItem: (key: string) => store.delete(key),
197 },
198 });
199 invalidateCustomThemes();
200 try {
201 const environment = environmentThemeDefinition(NIGHTFALL_THEME);
202 setEnvironmentThemes([environment]);
203 installCustomTheme({ ...environment, label: "My Nightfall" });
204
205 expect(getThemeDefinition("nightfall")?.label).toBe("My Nightfall");
206 } finally {
207 vi.unstubAllGlobals();
208 invalidateCustomThemes();
209 }
210 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall");
211 });
212});