1import type { EnvironmentTheme } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4const publishedThemes = vi.hoisted(() => ({
5 atom: null as unknown as import("effect/reactivity").Atom.Writable<
6 ReadonlyArray<EnvironmentTheme>
7 >,
8}));
9vi.mock(import("../state/server"), async () => {
10 const { Atom } = await import("effect/reactivity");
11 publishedThemes.atom = Atom.make<ReadonlyArray<EnvironmentTheme>>([]);
12 return { themeServerEnvironmentThemesAtom: publishedThemes.atom } as never;
13});
14const refreshTheme = vi.hoisted(() => vi.fn());
15vi.mock(import("./useTheme"), () => ({ refreshTheme }) as never);
16
17import { appAtomRegistry } from "../rpc/atomRegistry";
18import {
19 getThemeDefinition,
20 installCustomTheme,
21 invalidateCustomThemes,
22 publishedThemeDefinitions,
23} from "../themePalette";
24
25const NIGHTFALL_THEME = {
26 id: "nightfall",
27 name: "Nightfall",
28 appearance: "dark",
29 canvas: "#1a1b26",
30 accent: "#7aa2f7",
31} as const;
32
33afterEach(() => {
34 appAtomRegistry.set(publishedThemes.atom, []);
35});
36
37describe("environment themes", () => {
38 it("drops published themes with reserved ids", () => {
39 const definitions = publishedThemeDefinitions([
40 NIGHTFALL_THEME,
41 { ...NIGHTFALL_THEME, id: "t3-iris", name: "Impostor Iris" },
42 { ...NIGHTFALL_THEME, id: "ocean", name: "Impostor Ocean" },
43 { ...NIGHTFALL_THEME, id: "dark", name: "Impostor Dark" },
44 ]);
45
46 expect(definitions.map((definition) => definition.id)).toEqual(["nightfall"]);
47 });
48
49 it("drops published palettes with no usable colors", () => {
50 const theme = { id: "invalid", name: "Invalid", appearance: "dark" } as const;
51 const definitions = publishedThemeDefinitions([
52 { ...theme, colors: { canvas: "not-a-color" } },
53 { ...theme, id: "unknown-roles", colors: { futureRole: "#ffffff" } },
54 {
55 ...theme,
56 id: "ignored-variant",
57 colors: { canvas: "not-a-color" },
58 variants: { dark: { canvas: "#112233" } },
59 },
60 ]);
61
62 expect(definitions).toEqual([]);
63 });
64
65 it("keeps seeds, partial palettes, and usable alternate appearances", () => {
66 const theme = { name: "Shared", appearance: "dark" } as const;
67 const definitions = publishedThemeDefinitions([
68 NIGHTFALL_THEME,
69 {
70 ...theme,
71 id: "partial",
72 colors: { canvas: "not-a-color", text: "#ffffff", futureRole: "#ffffff" },
73 },
74 {
75 ...theme,
76 id: "light-variant",
77 colors: { canvas: "not-a-color" },
78 variants: { light: { canvas: "#eff1f5" } },
79 },
80 ]);
81
82 expect(definitions.map((definition) => definition.id)).toEqual([
83 "nightfall",
84 "partial",
85 "light-variant",
86 ]);
87 });
88
89 // Routes without the old root sync component (/connect, prompt-link landings) never saw them.
90 it("resolves published themes without a React subscriber mounted", () => {
91 appAtomRegistry.set(publishedThemes.atom, [NIGHTFALL_THEME]);
92 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall");
93
94 appAtomRegistry.set(publishedThemes.atom, [{ ...NIGHTFALL_THEME, name: "Nightfall 2" }]);
95 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall 2");
96 });
97
98 it("lets a theme the user saved win an id collision", () => {
99 const store = new Map<string, string>();
100 vi.stubGlobal("window", {
101 addEventListener: vi.fn(),
102 removeEventListener: vi.fn(),
103 localStorage: {
104 getItem: (key: string) => store.get(key) ?? null,
105 setItem: (key: string, value: string) => store.set(key, value),
106 removeItem: (key: string) => store.delete(key),
107 },
108 });
109 invalidateCustomThemes();
110 try {
111 appAtomRegistry.set(publishedThemes.atom, [NIGHTFALL_THEME]);
112 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall");
113 const environment = getThemeDefinition("nightfall");
114 if (!environment) throw new Error("expected the published theme");
115 installCustomTheme({ ...environment, label: "My Nightfall" });
116
117 expect(getThemeDefinition("nightfall")?.label).toBe("My Nightfall");
118 } finally {
119 vi.unstubAllGlobals();
120 invalidateCustomThemes();
121 }
122 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall");
123 });
124
125 it("repaints when the environment publishes themes nothing has read yet", async () => {
126 vi.resetModules();
127 const { appAtomRegistry: registry } = await import("../rpc/atomRegistry");
128 const { startEnvironmentThemeRepaint } = await import("./useEnvironmentTheme");
129 const stop = startEnvironmentThemeRepaint();
130 try {
131 registry.set(publishedThemes.atom, [NIGHTFALL_THEME]);
132 expect(refreshTheme).toHaveBeenCalledTimes(1);
133 } finally {
134 stop();
135 }
136 });
137});