返回 DeepSeek-Reasonix
theme-editor-keyboard.test.tsx
根目录 / desktop / frontend / src / __tests__ / theme-editor-keyboard.test.tsx
1 // Run: tsx src/__tests__/theme-editor-keyboard.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { ThemeGallery } from "../components/ThemeGallery";
7 import { LocaleProvider } from "../lib/i18n";
8 import type { ThemeExperienceView } from "../lib/themeExperience";
9 import type { ThemePackView } from "../lib/themePack";
10
11 let passed = 0;
12 let failed = 0;
13
14 function ok(value: boolean, label: string) {
15 if (value) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}\n`);
20 failed += 1;
21 }
22 }
23
24 async function flush() {
25 await new Promise((resolve) => setTimeout(resolve, 20));
26 }
27
28 const experience: ThemeExperienceView = {
29 themeMode: "dark",
30 baseStyle: "graphite",
31 effectiveStyle: "graphite",
32 activePack: null,
33 };
34
35 const savedPack: ThemePackView = {
36 id: "my-theme",
37 name: "My Theme",
38 baseStyle: "graphite",
39 builtin: false,
40 kind: "user",
41 active: false,
42 hasBackground: false,
43 tokens: {},
44 recipes: { density: "comfortable", corners: "soft" },
45 };
46
47 console.log("\ntheme editor keyboard ownership");
48
49 const dom = new JSDOM("<!doctype html><html><body><button id=\"opener\">Open editor</button><div id=\"root\"></div></body></html>", {
50 pretendToBeVisual: true,
51 url: "http://localhost/",
52 });
53 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
54 globalThis.window = dom.window as unknown as Window & typeof globalThis;
55 globalThis.document = dom.window.document;
56 globalThis.Node = dom.window.Node;
57 globalThis.Element = dom.window.Element;
58 globalThis.HTMLElement = dom.window.HTMLElement;
59 globalThis.HTMLInputElement = dom.window.HTMLInputElement;
60 globalThis.Event = dom.window.Event;
61 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
62 globalThis.MouseEvent = dom.window.MouseEvent;
63 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
64 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
65 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
66 (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
67 (dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
68
69 let resolveSave: ((pack: ThemePackView) => void) | null = null;
70 const pendingSave = new Promise<ThemePackView>((resolve) => {
71 resolveSave = resolve;
72 });
73 window.go = {
74 main: {
75 App: {
76 ListThemePacks: async () => [],
77 SaveThemePack: async () => pendingSave,
78 },
79 },
80 };
81
82 const rootElement = document.getElementById("root");
83 const opener = document.getElementById("opener") as HTMLButtonElement | null;
84 if (!rootElement || !opener) throw new Error("missing test root");
85 const root = createRoot(rootElement);
86
87 function gallery(key: string) {
88 return (
89 <LocaleProvider>
90 <ThemeGallery
91 key={key}
92 experience={experience}
93 initialCreateBaseStyle="graphite"
94 onExperienceChange={() => {}}
95 onBack={() => {}}
96 />
97 </LocaleProvider>
98 );
99 }
100
101 let outerEscapeCount = 0;
102 const onOuterEscape = (event: KeyboardEvent) => {
103 if (event.key === "Escape") outerEscapeCount += 1;
104 };
105 document.addEventListener("keydown", onOuterEscape);
106
107 opener.focus();
108 await act(async () => {
109 root.render(gallery("normal"));
110 await flush();
111 });
112 const normalDialog = document.querySelector<HTMLElement>(".theme-gallery__editor");
113 if (!normalDialog) throw new Error("theme editor did not render");
114 const normalEscape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
115 await act(async () => {
116 normalDialog.dispatchEvent(normalEscape);
117 await flush();
118 });
119 ok(normalEscape.defaultPrevented, "normal Escape is consumed by the nested editor");
120 ok(outerEscapeCount === 0, "normal Escape does not reach the outer Settings handler");
121 ok(document.querySelector(".theme-gallery__editor") === null, "normal Escape closes only the theme editor");
122 ok(document.activeElement === opener, "normal Escape restores focus to the opener");
123
124 opener.focus();
125 await act(async () => {
126 root.render(gallery("busy"));
127 await flush();
128 });
129 const saveButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".theme-editor__actions button"))
130 .find((button) => button.textContent === "Save");
131 if (!saveButton) throw new Error("save button did not render");
132 await act(async () => {
133 saveButton.click();
134 await flush();
135 });
136 ok(saveButton.disabled, "save puts the editor into its busy state");
137 const busyDialog = document.querySelector<HTMLElement>(".theme-gallery__editor");
138 if (!busyDialog) throw new Error("busy theme editor did not remain mounted");
139 const busyEscape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
140 await act(async () => {
141 busyDialog.dispatchEvent(busyEscape);
142 await flush();
143 });
144 ok(busyEscape.defaultPrevented, "busy Escape is still consumed by the nested editor");
145 ok(outerEscapeCount === 0, "busy Escape does not close the outer Settings panel");
146 ok(document.querySelector(".theme-gallery__editor") !== null, "busy Escape keeps the editor mounted until save completes");
147
148 await act(async () => {
149 resolveSave?.(savedPack);
150 await pendingSave;
151 await flush();
152 });
153
154 document.removeEventListener("keydown", onOuterEscape);
155 await act(async () => root.unmount());
156 dom.window.close();
157
158 console.log(`\n${passed} passed, ${failed} failed`);
159 if (failed > 0) process.exit(1);
160
160 lines Plain Text