返回 DeepSeek-Reasonix
model-switcher-refresh.test.tsx
根目录 / desktop / frontend / src / __tests__ / model-switcher-refresh.test.tsx
1 // Run: tsx src/__tests__/model-switcher-refresh.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { ModelSwitcher, normalizeModelInfo } from "../components/ModelSwitcher";
7 import { LocaleProvider } from "../lib/i18n";
8 import type { ModelInfo } from "../lib/types";
9
10 class TestResizeObserver {
11 observe() {}
12 disconnect() {}
13 }
14
15 function deferred<T>() {
16 let resolve!: (value: T) => void;
17 const promise = new Promise<T>((done) => { resolve = done; });
18 return { promise, resolve };
19 }
20
21 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
22 pretendToBeVisual: true,
23 url: "http://localhost/",
24 });
25 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
26 globalThis.window = dom.window as unknown as Window & typeof globalThis;
27 globalThis.document = dom.window.document;
28 globalThis.Event = dom.window.Event;
29 globalThis.MouseEvent = dom.window.MouseEvent;
30 globalThis.HTMLElement = dom.window.HTMLElement;
31 globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
32 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
33 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
34 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
35 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
36 Object.defineProperty(window, "matchMedia", {
37 configurable: true,
38 value: () => ({
39 matches: true,
40 media: "(prefers-reduced-motion: reduce)",
41 onchange: null,
42 addEventListener() {},
43 removeEventListener() {},
44 addListener() {},
45 removeListener() {},
46 dispatchEvent: () => false,
47 }),
48 });
49
50 const stale = deferred<ModelInfo[]>();
51 const fresh = deferred<ModelInfo[]>();
52 let calls = 0;
53 const picked: string[] = [];
54 const pickGates: Array<ReturnType<typeof deferred<boolean>>> = [];
55 let catalogLoader: (() => Promise<ModelInfo[]>) | undefined;
56 const normalizedNullMetadata = normalizeModelInfo({
57 ref: "provider-a/model-a",
58 provider: null,
59 model: null,
60 current: true,
61 } as unknown as ModelInfo);
62 if (normalizedNullMetadata.provider !== "" || normalizedNullMetadata.model !== "") {
63 throw new Error(`null model metadata was not normalized: ${JSON.stringify(normalizedNullMetadata)}`);
64 }
65 let currentCatalog: ModelInfo[] = [
66 { ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
67 ];
68 (window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
69 main: {
70 App: {
71 ModelsForTab: async () => {
72 calls += 1;
73 if (calls === 1) return stale.promise;
74 if (calls === 2) return fresh.promise;
75 if (catalogLoader) return catalogLoader();
76 return currentCatalog;
77 },
78 },
79 },
80 };
81
82 const root = createRoot(document.getElementById("root")!);
83 const renderSwitcher = (label: string, tabId: string) => (
84 <LocaleProvider>
85 <ModelSwitcher
86 label={label}
87 tabId={tabId}
88 onPick={(ref) => {
89 picked.push(ref);
90 return pickGates.shift()?.promise ?? Promise.resolve(true);
91 }}
92 />
93 </LocaleProvider>
94 );
95 await act(async () => {
96 root.render(renderSwitcher("deepseek-v4-flash", "tab-a"));
97 });
98
99 await act(async () => {
100 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
101 fresh.resolve([{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true }]);
102 await fresh.promise;
103 });
104 await act(async () => {
105 stale.resolve([{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: true }]);
106 await stale.promise;
107 });
108 await act(async () => {
109 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
110 await new Promise((resolve) => setTimeout(resolve, 0));
111 });
112
113 const options = Array.from(document.querySelectorAll<HTMLElement>("[role='option']")).map((item) => item.textContent?.trim());
114 if (JSON.stringify(options) !== JSON.stringify(["glm-5.2"])) {
115 throw new Error(`model catalog did not keep the fresh result: ${JSON.stringify(options)}`);
116 }
117 if (calls < 3) throw new Error(`expected mount, settings refresh, and open loads; got ${calls}`);
118
119 await act(async () => {
120 (document.querySelector("[role='option'][aria-selected='true']") as HTMLButtonElement).click();
121 await new Promise((resolve) => setTimeout(resolve, 0));
122 });
123 if (picked.length !== 0) throw new Error(`current model should be a no-op, picked ${JSON.stringify(picked)}`);
124 if ((document.querySelector(".modelsw__trigger") as HTMLButtonElement).getAttribute("aria-expanded") !== "false") {
125 throw new Error("current-model no-op did not close the menu");
126 }
127
128 currentCatalog = [
129 { ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
130 { ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: false },
131 ];
132 const pendingPickGate = deferred<boolean>();
133 pickGates.push(pendingPickGate, pendingPickGate);
134 await act(async () => {
135 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
136 await new Promise((resolve) => setTimeout(resolve, 0));
137 });
138 await act(async () => {
139 await new Promise((resolve) => setTimeout(resolve, 0));
140 });
141 const next = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
142 .find((option) => option.textContent?.includes("deepseek-v4-flash"));
143 if (!next) throw new Error("pending-switch model option did not load");
144 await act(async () => {
145 next.click();
146 await new Promise((resolve) => setTimeout(resolve, 0));
147 });
148 if (picked[0] !== "deepseek/deepseek-v4-flash") {
149 throw new Error(`non-current model was not selected: ${JSON.stringify(picked)}`);
150 }
151
152 // The backend catalog may still identify the outgoing GLM model as current
153 // while the first switch is pending. Selecting it now must enqueue a rollback.
154 await act(async () => {
155 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
156 await new Promise((resolve) => setTimeout(resolve, 0));
157 });
158 await act(async () => {
159 await new Promise((resolve) => setTimeout(resolve, 0));
160 });
161 const rollback = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
162 if (!rollback) throw new Error("pending-switch rollback option did not load");
163 await act(async () => {
164 rollback.click();
165 await new Promise((resolve) => setTimeout(resolve, 0));
166 });
167 if (picked[1] !== "glm-cn/glm-5.2") {
168 throw new Error(`pending current-model rollback was swallowed: ${JSON.stringify(picked)}`);
169 }
170
171 // Pending work belongs to tab A. Reusing the mounted switcher for tab B must
172 // not turn B's settled current model into another rollback request.
173 currentCatalog = [
174 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: true },
175 ];
176 await act(async () => {
177 root.render(renderSwitcher("model-b", "tab-b"));
178 await new Promise((resolve) => setTimeout(resolve, 0));
179 });
180 await act(async () => {
181 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
182 await new Promise((resolve) => setTimeout(resolve, 0));
183 });
184 await act(async () => {
185 await new Promise((resolve) => setTimeout(resolve, 0));
186 });
187 const tabBCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
188 if (!tabBCurrent) throw new Error("tab B current model did not load");
189 await act(async () => {
190 tabBCurrent.click();
191 await new Promise((resolve) => setTimeout(resolve, 0));
192 });
193 if (picked.length !== 2) {
194 throw new Error(`tab A pending picks leaked into tab B: ${JSON.stringify(picked)}`);
195 }
196
197 await act(async () => {
198 pendingPickGate.resolve(true);
199 await pendingPickGate.promise;
200 });
201
202 // A failed latest pick must immediately roll back its optimistic selection,
203 // then reconcile from the authoritative backend catalog. Retrying the same
204 // target before that catalog request completes must remain possible.
205 currentCatalog = [
206 { ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
207 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
208 ];
209 await act(async () => {
210 root.render(renderSwitcher("model-a", "tab-c"));
211 await new Promise((resolve) => setTimeout(resolve, 0));
212 });
213 await act(async () => {
214 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
215 await new Promise((resolve) => setTimeout(resolve, 0));
216 });
217 const failedTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
218 .find((option) => option.textContent?.includes("model-b"));
219 if (!failedTarget) throw new Error("failed-switch target did not load");
220 const failedPickGate = deferred<boolean>();
221 pickGates.push(failedPickGate);
222 const catalogReloadGate = deferred<ModelInfo[]>();
223 catalogLoader = () => catalogReloadGate.promise;
224 await act(async () => {
225 failedTarget.click();
226 await new Promise((resolve) => setTimeout(resolve, 0));
227 });
228 failedPickGate.resolve(false);
229 await act(async () => {
230 await failedPickGate.promise;
231 await new Promise((resolve) => setTimeout(resolve, 0));
232 });
233 await act(async () => {
234 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
235 await new Promise((resolve) => setTimeout(resolve, 0));
236 });
237 const rolledBackCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
238 if (!rolledBackCurrent?.textContent?.includes("model-a")) {
239 throw new Error(`failed pick did not immediately roll back: ${rolledBackCurrent?.textContent ?? "missing"}`);
240 }
241 const retryTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
242 .find((option) => option.textContent?.includes("model-b"));
243 if (!retryTarget) throw new Error("failed-switch target was not available for immediate retry");
244 const retryPickGate = deferred<boolean>();
245 pickGates.push(retryPickGate);
246 await act(async () => {
247 retryTarget.click();
248 await new Promise((resolve) => setTimeout(resolve, 0));
249 });
250 if (picked.filter((ref) => ref === "provider-b/model-b").length !== 2) {
251 throw new Error(`failed target could not be retried: ${JSON.stringify(picked)}`);
252 }
253
254 // The failed attempt's catalog request describes the pre-retry backend state.
255 // Its late completion must not overwrite the newer optimistic retry.
256 catalogReloadGate.resolve(currentCatalog);
257 await act(async () => {
258 await catalogReloadGate.promise;
259 await new Promise((resolve) => setTimeout(resolve, 0));
260 });
261 const retryTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
262 .getAttribute("aria-label") ?? "";
263 if (!retryTriggerLabel.includes("provider-b")) {
264 throw new Error(`stale failure reconciliation overwrote the retry: ${retryTriggerLabel}`);
265 }
266 retryPickGate.resolve(true);
267 await act(async () => {
268 await retryPickGate.promise;
269 await new Promise((resolve) => setTimeout(resolve, 0));
270 });
271 catalogLoader = undefined;
272
273 // An older superseded failure also must not roll back a newer pending pick.
274 currentCatalog = [
275 { ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
276 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
277 { ref: "provider-c/model-c", provider: "provider-c", model: "model-c", current: false },
278 ];
279 await act(async () => {
280 root.render(renderSwitcher("model-a", "tab-d"));
281 await new Promise((resolve) => setTimeout(resolve, 0));
282 });
283 await act(async () => {
284 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
285 await new Promise((resolve) => setTimeout(resolve, 0));
286 });
287 const olderTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
288 .find((option) => option.textContent?.includes("model-b"));
289 if (!olderTarget) throw new Error("older switch target did not load");
290 const olderPickGate = deferred<boolean>();
291 pickGates.push(olderPickGate);
292 await act(async () => {
293 olderTarget.click();
294 await new Promise((resolve) => setTimeout(resolve, 0));
295 });
296 await act(async () => {
297 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
298 await new Promise((resolve) => setTimeout(resolve, 0));
299 });
300 const newerTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
301 .find((option) => option.textContent?.includes("model-c"));
302 if (!newerTarget) throw new Error("newer switch target did not load");
303 const newerPickGate = deferred<boolean>();
304 pickGates.push(newerPickGate);
305 await act(async () => {
306 newerTarget.click();
307 await new Promise((resolve) => setTimeout(resolve, 0));
308 });
309 olderPickGate.resolve(false);
310 await act(async () => {
311 await olderPickGate.promise;
312 await new Promise((resolve) => setTimeout(resolve, 0));
313 });
314 const newerTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
315 .getAttribute("aria-label") ?? "";
316 if (!newerTriggerLabel.includes("provider-c")) {
317 throw new Error(`superseded failure rolled back the newer pick: ${newerTriggerLabel}`);
318 }
319 newerPickGate.resolve(true);
320 await act(async () => {
321 await newerPickGate.promise;
322 });
323
324 // Legacy or malformed settings can surface null display metadata despite the
325 // bridge contract. Catalog loading must normalize it before grouping/search.
326 currentCatalog = [
327 { ref: "provider-a/model-a", provider: null, model: null, current: true } as unknown as ModelInfo,
328 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
329 ];
330 await act(async () => {
331 root.render(renderSwitcher("model-a", "tab-null-metadata"));
332 await new Promise((resolve) => setTimeout(resolve, 0));
333 });
334 await act(async () => {
335 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
336 await new Promise((resolve) => setTimeout(resolve, 0));
337 });
338 const nullSafeOptions = Array.from(document.querySelectorAll<HTMLElement>("[role='option']"));
339 if (nullSafeOptions.length !== 2) {
340 throw new Error(`null model metadata prevented catalog rendering: ${nullSafeOptions.length}`);
341 }
342
343 await act(async () => root.unmount());
344 console.log("model switcher refresh: PASS");
345
345 lines Plain Text