1import { act, type ReactNode, type ReactElement } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5// Render popover content without portals so these tests exercise picker input
6// and persistence behavior without starting a browser.
7vi.mock("../ui/popover", () => ({
8 Popover: ({ children }: { children: ReactNode }) => children,
9 PopoverPopup: ({ children }: { children: ReactNode }) => children,
10 PopoverTrigger: () => null,
11 PopoverClose: ({ render }: { render: ReactElement }) => render,
12}));
13vi.mock("../ui/tooltip", () => ({
14 Tooltip: ({ children }: { children: ReactNode }) => children,
15 TooltipTrigger: ({ render }: { render: ReactElement }) => render,
16 TooltipPopup: () => null,
17}));
18
19import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
20import { ThemeColorField } from "./ThemeColorPicker";
21
22let renderer: ReactTestRenderer | undefined;
23let nextFrameId = 0;
24const frames = new Map<number, FrameRequestCallback>();
25
26beforeEach(() => {
27 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
28 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
29 frames.set(++nextFrameId, callback);
30 return nextFrameId;
31 });
32 vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
33});
34
35afterEach(async () => {
36 await act(async () => renderer?.unmount());
37 renderer = undefined;
38 frames.clear();
39 vi.useRealTimers();
40 vi.unstubAllGlobals();
41});
42
43function slider(label: string) {
44 return renderer!.root.find(
45 (node) =>
46 (node.props.role === "slider" || node.props.type === "range") &&
47 node.props["aria-label"] === label,
48 );
49}
50
51function plane(label: string) {
52 return renderer!.root.findByProps({ role: "group", "aria-label": label });
53}
54
55async function key(label: string, key: string, shiftKey = false) {
56 const preventDefault = vi.fn();
57 await act(async () => slider(label).props.onKeyDown({ key, shiftKey, preventDefault }));
58 return preventDefault;
59}
60
61function pointer(clientX: number, clientY = 0, pointerId = 1) {
62 return {
63 button: 0,
64 clientX,
65 clientY,
66 pointerId,
67 currentTarget: {
68 focus: vi.fn(),
69 setPointerCapture: vi.fn(),
70 getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
71 },
72 };
73}
74
75async function frame() {
76 await act(async () => {
77 const pending = [...frames.values()];
78 frames.clear();
79 for (const callback of pending) callback(0);
80 });
81}
82
83describe("shared color controls in settings", () => {
84 it("adjusts each provider color axis independently and reports the value being changed", async () => {
85 const onCommit = vi.fn();
86 await act(async () => {
87 renderer = create(
88 <ProviderAccentColorPicker displayName="Codex" value="#ff0000" onCommit={onCommit} />,
89 );
90 });
91 const hue = "Accent color hue";
92 const saturation = "Accent color saturation";
93 const brightness = "Accent color brightness";
94 expect(await key(hue, "ArrowLeft")).toHaveBeenCalledOnce();
95 expect(slider(hue).props["aria-valuenow"]).toBe(359);
96 await key(hue, "ArrowRight");
97 expect(onCommit).toHaveBeenLastCalledWith("#ff0000");
98 await key(saturation, "ArrowRight", true);
99 await key(brightness, "ArrowUp", true);
100 expect(onCommit).toHaveBeenLastCalledWith("#ff0000");
101 await key(brightness, "ArrowDown", true);
102 expect(onCommit).toHaveBeenLastCalledWith("#e60000");
103 expect(slider(brightness).props.value).toBe(90);
104 expect(slider(brightness).props["aria-valuetext"]).toBe("90%");
105 expect(slider(saturation).props.value).toBe(100);
106 // Up/Down on saturation must adjust the reported saturation, not brightness.
107 await key(saturation, "ArrowDown", true);
108 expect(onCommit).toHaveBeenLastCalledWith("#e61717");
109 expect(slider(saturation).props.value).toBe(90);
110 expect(slider(saturation).props["aria-valuetext"]).toBe("90%");
111 await key(saturation, "ArrowUp", true);
112 expect(onCommit).toHaveBeenLastCalledWith("#e60000");
113 // Left/Right on brightness must likewise leave saturation unchanged.
114 await key(brightness, "ArrowLeft", true);
115 expect(onCommit).toHaveBeenLastCalledWith("#cc0000");
116 await key(brightness, "ArrowRight", true);
117 expect(onCommit).toHaveBeenLastCalledWith("#e60000");
118 onCommit.mockClear();
119 expect(await key(saturation, "Tab")).not.toHaveBeenCalled();
120 expect(await key(brightness, "Tab")).not.toHaveBeenCalled();
121 expect(onCommit).not.toHaveBeenCalled();
122 });
123
124 it("supports Home/End and clamps each axis without changing the other", async () => {
125 const onCommit = vi.fn();
126 await act(async () => {
127 renderer = create(
128 <ProviderAccentColorPicker displayName="Codex" value="#ff0000" onCommit={onCommit} />,
129 );
130 });
131 const saturation = "Accent color saturation";
132 const brightness = "Accent color brightness";
133 await key(saturation, "Home");
134 await key(saturation, "ArrowLeft");
135 expect(slider(saturation).props.value).toBe(0);
136 expect(onCommit).toHaveBeenLastCalledWith("#ffffff");
137 await key(brightness, "Home");
138 await key(brightness, "ArrowDown");
139 expect(slider(brightness).props.value).toBe(0);
140 expect(onCommit).toHaveBeenLastCalledWith("#000000");
141 await key(saturation, "End");
142 expect(slider(saturation).props.value).toBe(100);
143 expect(slider(brightness).props.value).toBe(0);
144 await key(brightness, "End");
145 await key(brightness, "ArrowUp");
146 expect(onCommit).toHaveBeenLastCalledWith("#ff0000");
147 await key(saturation, "ArrowLeft");
148 expect(slider(saturation).props.value).toBe(98);
149 expect(onCommit).toHaveBeenLastCalledWith("#ff0505");
150 });
151
152 it("persists independent native range changes through theme batching with alpha", async () => {
153 const onChange = vi.fn();
154 await act(async () => {
155 renderer = create(<ThemeColorField role="accent" value="#ff000080" onChange={onChange} />);
156 });
157 const saturation = "Accent color saturation";
158 const brightness = "Accent color brightness";
159 await act(async () =>
160 slider(saturation).props.onChange({ currentTarget: { valueAsNumber: 50 } }),
161 );
162 await act(async () =>
163 slider(brightness).props.onChange({ currentTarget: { valueAsNumber: 50 } }),
164 );
165 expect(onChange).not.toHaveBeenCalled();
166 expect(frames.size).toBe(1);
167 await frame();
168 expect(onChange).toHaveBeenCalledExactlyOnceWith("accent", "#80404080");
169 await key(brightness, "ArrowDown", true);
170 await frame();
171 expect(onChange).toHaveBeenLastCalledWith("accent", "#66333380");
172 expect(slider(saturation).props.value).toBe(50);
173 });
174
175 it("batches theme drag updates and flushes the final color with alpha on pointer release", async () => {
176 const onChange = vi.fn();
177 await act(async () => {
178 renderer = create(<ThemeColorField role="accent" value="#ff000080" onChange={onChange} />);
179 });
180 const hue = "Accent color hue";
181 await act(async () => slider(hue).props.onPointerDown(pointer(25)));
182 await act(async () => slider(hue).props.onPointerMove(pointer(50)));
183 expect(onChange).not.toHaveBeenCalled();
184 expect(frames.size).toBe(1);
185 // A second pointer cannot change or end the active drag.
186 await act(async () => slider(hue).props.onPointerMove(pointer(75, 0, 2)));
187 await act(async () => slider(hue).props.onPointerUp(pointer(75, 0, 2)));
188 expect(onChange).not.toHaveBeenCalled();
189 await act(async () => slider(hue).props.onPointerUp(pointer(50)));
190 expect(onChange).toHaveBeenCalledExactlyOnceWith("accent", "#00ffff80");
191 await act(async () => slider(hue).props.onLostPointerCapture(pointer(50)));
192 await frame();
193 expect(onChange).toHaveBeenCalledTimes(1);
194 });
195
196 it("clamps out-of-bounds drags and flushes theme changes on cancellation and unmount", async () => {
197 const onChange = vi.fn();
198 await act(async () => {
199 renderer = create(<ThemeColorField role="accent" value="#ff000080" onChange={onChange} />);
200 });
201 const planeLabel = "Accent color saturation and brightness";
202 await act(async () => plane(planeLabel).props.onPointerDown(pointer(-50, -50)));
203 await act(async () => plane(planeLabel).props.onPointerCancel(pointer(-50, -50)));
204 expect(onChange).toHaveBeenLastCalledWith("accent", "#ffffff80");
205 await act(async () => plane(planeLabel).props.onPointerDown(pointer(150, 150)));
206 await act(async () => renderer!.unmount());
207 renderer = undefined;
208 expect(onChange).toHaveBeenLastCalledWith("accent", "#00000080");
209 expect(frames.size).toBe(0);
210 });
211
212 it("preserves the selected hue when a grey color is echoed back from theme settings", async () => {
213 const onChange = vi.fn();
214 const render = (value: string) => (
215 <ThemeColorField role="accent" value={value} onChange={onChange} />
216 );
217 await act(async () => {
218 renderer = create(render("#ff0000"));
219 });
220 const hue = "Accent color hue";
221 const planeLabel = "Accent color saturation and brightness";
222 await key(hue, "ArrowRight", true);
223 await act(async () => plane(planeLabel).props.onPointerDown(pointer(0, 0)));
224 await frame();
225 expect(onChange).toHaveBeenLastCalledWith("accent", "#ffffff");
226 await act(async () => renderer!.update(render("#ffffff")));
227 expect(slider(hue).props["aria-valuenow"]).toBe(10);
228 });
229
230 it("adds an accent only after choosing one and clears it back to no accent", async () => {
231 const onCommit = vi.fn();
232 await act(async () => {
233 renderer = create(
234 <ProviderAccentColorPicker displayName="Codex" value={undefined} onCommit={onCommit} />,
235 );
236 });
237 const clearButtons = () =>
238 renderer!.root.findAll(
239 (node) =>
240 node.type === "button" &&
241 Array.isArray(node.props.children) &&
242 node.props.children.includes("Clear color"),
243 );
244 expect(onCommit).not.toHaveBeenCalled();
245 expect(clearButtons()).toHaveLength(0);
246 await key("Accent color hue", "ArrowRight", true);
247 expect(onCommit).toHaveBeenCalledOnce();
248 expect(clearButtons()).toHaveLength(1);
249 await act(async () => clearButtons()[0]!.props.onClick());
250 expect(onCommit).toHaveBeenLastCalledWith("");
251 expect(clearButtons()).toHaveLength(0);
252 });
253
254 it("keeps provider debounce at the consumer and commits pending color on unmount", async () => {
255 vi.useFakeTimers();
256 const onCommit = vi.fn();
257 await act(async () => {
258 renderer = create(
259 <ProviderAccentColorPicker
260 displayName="Codex"
261 value="#ff0000"
262 onCommit={onCommit}
263 commitDelayMs={250}
264 />,
265 );
266 });
267 const hue = "Accent color hue";
268 await key(hue, "ArrowRight", true);
269 await key(hue, "ArrowRight", true);
270 expect(onCommit).not.toHaveBeenCalled();
271 await act(async () => vi.advanceTimersByTime(250));
272 expect(onCommit).toHaveBeenCalledExactlyOnceWith("#ff5500");
273 await key(hue, "ArrowRight", true);
274 await act(async () => renderer!.unmount());
275 renderer = undefined;
276 expect(onCommit).toHaveBeenLastCalledWith("#ff8000");
277 await act(async () => vi.advanceTimersByTime(250));
278 expect(onCommit).toHaveBeenCalledTimes(2);
279 });
280});