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