import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { environmentThemeDefinition, publishedThemeDefinitions } from "./useEnvironmentTheme"; import { getDefaultThemeColors, getThemeDefinition, installCustomTheme, invalidateCustomThemes, setEnvironmentThemes, THEME_COLOR_ROLES, } from "../themePalette"; const NIGHTFALL_THEME = { id: "nightfall", name: "Nightfall", appearance: "dark", canvas: "#1a1b26", accent: "#7aa2f7", } as const; afterEach(() => { setEnvironmentThemes([]); }); describe("environment themes", () => { it("generates every role from the two published seeds", () => { const theme = environmentThemeDefinition(NIGHTFALL_THEME); expect(theme.id).toBe("nightfall"); expect(theme.label).toBe("Nightfall"); expect(theme.appearance).toBe("dark"); for (const role of THEME_COLOR_ROLES) { expect(theme.colors[role], `missing ${role}`).toBeTruthy(); } }); it("layers published roles over the generated palette", () => { const generated = environmentThemeDefinition(NIGHTFALL_THEME); const overridden = environmentThemeDefinition({ ...NIGHTFALL_THEME, colors: { terminalSelection: "#292e42", error: "#f7768e" }, }); expect(overridden.colors.terminalSelection).not.toBe(generated.colors.terminalSelection); expect(overridden.colors.error).not.toBe(generated.colors.error); // Roles the machine did not publish keep the generated value. expect(overridden.colors.sidebar).toBe(generated.colors.sidebar); }); // The standard exported theme file — the Download button's output — is a // valid published theme, so any shared theme can be dropped into the // machine's themes directory as-is. it("renders an exported theme file on the stock defaults", () => { const theme = environmentThemeDefinition({ id: "shared-light", version: 1, name: "Shared Light", appearance: "light", colors: { canvas: "oklch(0.95 0.01 250)", accent: "#1e66f5" }, variants: { dark: { canvas: "#1a1b26" } }, }); expect(theme.label).toBe("Shared Light"); expect(theme.colors.accent).toBeTruthy(); expect(theme.variants?.dark?.canvas).toBeTruthy(); for (const role of THEME_COLOR_ROLES) { expect(theme.colors[role], `missing ${role}`).toBeTruthy(); expect(theme.variants?.dark?.[role], `missing dark ${role}`).toBeTruthy(); } }); // The generator follows the seed canvas's luminance, so a dark theme's // seeds must never produce its light variant: the variant builds on that // appearance's stock defaults instead. it("builds a seeded theme's variant on the variant appearance's defaults", () => { const theme = environmentThemeDefinition({ ...NIGHTFALL_THEME, variants: { light: { canvas: "#eff1f5" } }, }); const lightDefaults = getDefaultThemeColors("light"); expect(theme.variants?.light?.text).toBe(lightDefaults.text); expect(theme.variants?.light?.canvas).not.toBe(theme.colors.canvas); }); // A published `t3-iris.json` would show its palette on a card that applies // the built-in, and a published `dark.json` would capture everyone whose // stored preference is the stock "dark". Reserved ids never become cards. it("drops published themes with reserved ids", () => { const definitions = publishedThemeDefinitions([ NIGHTFALL_THEME, { ...NIGHTFALL_THEME, id: "t3-iris", name: "Impostor Iris" }, { ...NIGHTFALL_THEME, id: "ocean", name: "Impostor Ocean" }, { ...NIGHTFALL_THEME, id: "dark", name: "Impostor Dark" }, ]); expect(definitions.map((definition) => definition.id)).toEqual(["nightfall"]); }); it("drops published palettes with no usable colors", () => { const theme = { id: "invalid", name: "Invalid", appearance: "dark" } as const; const definitions = publishedThemeDefinitions([ { ...theme, colors: { canvas: "not-a-color" } }, { ...theme, id: "unknown-roles", colors: { futureRole: "#ffffff" } }, { ...theme, id: "ignored-variant", colors: { canvas: "not-a-color" }, variants: { dark: { canvas: "#112233" } }, }, ]); expect(definitions).toEqual([]); }); it("keeps seeds, partial palettes, and usable alternate appearances", () => { const theme = { name: "Shared", appearance: "dark" } as const; const definitions = publishedThemeDefinitions([ NIGHTFALL_THEME, { ...theme, id: "partial", colors: { canvas: "not-a-color", text: "#ffffff", futureRole: "#ffffff" }, }, { ...theme, id: "light-variant", colors: { canvas: "not-a-color" }, variants: { light: { canvas: "#eff1f5" } }, }, ]); expect(definitions.map((definition) => definition.id)).toEqual([ "nightfall", "partial", "light-variant", ]); }); // A machine may publish roles a newer client added; an older one has to // ignore them rather than render a broken palette. it("ignores published roles this build does not render", () => { const theme = environmentThemeDefinition({ ...NIGHTFALL_THEME, colors: { notARole: "#ff0000", text: "#ffffff" }, }); expect(theme.colors).not.toHaveProperty("notARole"); for (const role of THEME_COLOR_ROLES) { expect(theme.colors[role], `missing ${role}`).toBeTruthy(); } }); // ThemeEditorPanel opens Duplicate in the guided editor for managed themes, // and the guided editor regenerates from canvas and accent -- which would // discard any role the machine tuned by hand. it("marks only the pure seeded form as managed", () => { expect(environmentThemeDefinition(NIGHTFALL_THEME).managed).toBe(true); expect( environmentThemeDefinition({ ...NIGHTFALL_THEME, colors: { error: "#f7768e" } }).managed, ).toBeUndefined(); expect( environmentThemeDefinition({ id: "shared-light", version: 1, name: "Shared Light", appearance: "light", colors: { canvas: "#eff1f5", accent: "#1e66f5" }, }).managed, ).toBeUndefined(); }); it("resolves published ids only while the machine publishes them", () => { expect(getThemeDefinition("nightfall")).toBe(null); setEnvironmentThemes([environmentThemeDefinition(NIGHTFALL_THEME)]); expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); // The palettes are never saved, so they have to disappear with the // machine that published them rather than linger as stale entries. setEnvironmentThemes([]); expect(getThemeDefinition("nightfall")).toBe(null); }); // Published ids share one namespace with the user's saved themes, and the // user was here first: their theme keeps working even if the machine later // publishes under the same id. it("lets a theme the user saved win an id collision", () => { const store = new Map(); vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn(), localStorage: { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => store.set(key, value), removeItem: (key: string) => store.delete(key), }, }); invalidateCustomThemes(); try { const environment = environmentThemeDefinition(NIGHTFALL_THEME); setEnvironmentThemes([environment]); installCustomTheme({ ...environment, label: "My Nightfall" }); expect(getThemeDefinition("nightfall")?.label).toBe("My Nightfall"); } finally { vi.unstubAllGlobals(); invalidateCustomThemes(); } expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); }); });