| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { environmentThemeDefinition, publishedThemeDefinitions } from "./useEnvironmentTheme"; |
| 4 | import { |
| 5 | getDefaultThemeColors, |
| 6 | getThemeDefinition, |
| 7 | installCustomTheme, |
| 8 | invalidateCustomThemes, |
| 9 | setEnvironmentThemes, |
| 10 | THEME_COLOR_ROLES, |
| 11 | } from "../themePalette"; |
| 12 | |
| 13 | const NIGHTFALL_THEME = { |
| 14 | id: "nightfall", |
| 15 | name: "Nightfall", |
| 16 | appearance: "dark", |
| 17 | canvas: "#1a1b26", |
| 18 | accent: "#7aa2f7", |
| 19 | } as const; |
| 20 | |
| 21 | afterEach(() => { |
| 22 | setEnvironmentThemes([]); |
| 23 | }); |
| 24 | |
| 25 | describe("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 | }); |