返回 DeepSeek-Reasonix
ask-card-layout.test.ts
根目录 / desktop / frontend / src / __tests__ / ask-card-layout.test.ts
1 // Run: tsx src/__tests__/ask-card-layout.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { JSDOM } from "jsdom";
7 import React from "react";
8 import { act } from "react";
9 import { createRoot } from "react-dom/client";
10 import { AskCard } from "../components/AskCard";
11 import { LocaleProvider } from "../lib/i18n";
12 import type { QuestionAnswer, WireAsk } from "../lib/types";
13
14 const testDir = dirname(fileURLToPath(import.meta.url));
15 const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
16
17 let passed = 0;
18 let failed = 0;
19
20 function ok(value: boolean, label: string) {
21 if (value) {
22 process.stdout.write(` PASS ${label}\n`);
23 passed += 1;
24 } else {
25 process.stdout.write(` FAIL ${label}\n`);
26 failed += 1;
27 }
28 }
29
30 function eq(actual: unknown, expected: unknown, label: string) {
31 if (actual === expected) ok(true, label);
32 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
33 }
34
35 function flushTimers(delay = 0): Promise<void> {
36 return new Promise((resolve) => setTimeout(resolve, delay));
37 }
38
39 function installDom() {
40 const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
41 pretendToBeVisual: true,
42 url: "http://localhost/",
43 });
44 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
45 globalThis.window = dom.window as unknown as Window & typeof globalThis;
46 globalThis.document = dom.window.document;
47 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
48 globalThis.Node = dom.window.Node;
49 globalThis.Element = dom.window.Element;
50 globalThis.HTMLElement = dom.window.HTMLElement;
51 globalThis.Event = dom.window.Event;
52 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
53 globalThis.MouseEvent = dom.window.MouseEvent;
54 globalThis.localStorage = dom.window.localStorage;
55 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
56 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
57 Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
58 configurable: true,
59 get() {
60 if (this.classList.contains("prompt-action__desc")) return 42;
61 if (this.classList.contains("prompt-action__label")) return 20;
62 return 0;
63 },
64 });
65 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
66 configurable: true,
67 get() {
68 if (this.classList.contains("prompt-action__desc")) {
69 return this.textContent?.includes("Reuse the archive flow") ? 84 : 42;
70 }
71 if (this.classList.contains("prompt-action__label")) return 20;
72 return 0;
73 },
74 });
75 Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", {
76 configurable: true,
77 get() {
78 if (this.classList.contains("prompt-action__desc")) return 160;
79 if (this.classList.contains("prompt-action__label")) return 160;
80 return 0;
81 },
82 });
83 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollWidth", {
84 configurable: true,
85 get() {
86 if (this.classList.contains("prompt-action__desc")) {
87 return this.textContent?.includes("Reuse the archive flow") ? 320 : 120;
88 }
89 if (this.classList.contains("prompt-action__label")) {
90 return this.textContent?.includes("Keep every historical migration") ? 480 : 120;
91 }
92 return 0;
93 },
94 });
95 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
96 configurable: true,
97 value() {
98 this.setAttribute("data-scrolled-into-view", "true");
99 },
100 });
101
102 const style = document.createElement("style");
103 style.textContent = styles;
104 document.head.appendChild(style);
105 return dom;
106 }
107
108 console.log("\nask card layout");
109
110 {
111 const dom = installDom();
112 const rootEl = document.getElementById("root");
113 if (!rootEl) throw new Error("missing root");
114 const root = createRoot(rootEl);
115 const answers: QuestionAnswer[][] = [];
116 const ask: WireAsk = {
117 id: "ask-superpowers-decision",
118 questions: [
119 {
120 id: "decision",
121 header: "Review",
122 prompt: "baoguanPutArchive needs a user-owned decision: fully align archive logic, or only repair the current compiler error?",
123 options: [
124 {
125 label: "Full alignment",
126 description: "Reuse the archive flow and keep behavior consistent across every constructor, dynamically computed path, runtime boundary, and release validation step.",
127 },
128 { label: "Minimal repair", description: "Touch only the failing path and keep the patch smaller." },
129 ],
130 },
131 ],
132 };
133
134 await act(async () => {
135 root.render(
136 React.createElement(LocaleProvider, null,
137 React.createElement(AskCard, {
138 ask,
139 onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next),
140 onDismiss: () => undefined,
141 onStop: () => undefined,
142 }),
143 ),
144 );
145 await flushTimers();
146 });
147
148 const card = document.querySelector(".prompt-shelf__card") as HTMLElement | null;
149 const content = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
150 const meta = document.querySelector(".prompt-shelf__meta") as HTMLElement | null;
151 const footer = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
152 if (!card || !content || !meta || !footer) throw new Error("ask prompt shelf did not render");
153
154 eq(meta.textContent, ask.questions[0].prompt, "ask question text remains complete in the prompt shelf");
155
156 const computed = window.getComputedStyle(meta);
157 eq(computed.whiteSpace, "normal", "ask question can wrap instead of staying on one line");
158 eq(computed.overflow, "visible", "ask question is not clipped by the prompt shelf");
159 eq(computed.textOverflow, "clip", "ask question does not render as an ellipsis-only preview");
160 eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
161 ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
162 ok(document.querySelector(".prompt-shelf--decision") != null, "ask uses the unified decision surface layout");
163 eq(window.getComputedStyle(card).maxHeight, "min(62vh, 560px)", "Ask card stays bounded by the viewport");
164 eq(window.getComputedStyle(card).overflow, "hidden", "Ask card delegates overflow to one content scroller");
165 eq(window.getComputedStyle(content).overflow, "auto", "Ask title, question, and options share one scroll region");
166 eq(content.contains(footer), false, "Ask confirmation footer stays outside the scrolling content");
167 const secondary = footer.querySelector(".decision-confirm-bar__secondary") as HTMLButtonElement | null;
168 ok(Boolean(secondary?.textContent?.trim()), "Ask skip is a quiet footer action");
169
170 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
171 // options + custom; skip is a secondary footer action
172 eq(optionButtons.length, 3, "ask renders options plus custom without a skip row");
173 ok(
174 optionButtons[0]?.textContent?.includes("Reuse the archive flow") === true,
175 "option descriptions render inline on each decision row",
176 );
177 const actions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
178 const firstOption = optionButtons[0];
179 const firstDescription = firstOption?.querySelector(".prompt-action__desc") as HTMLElement | null;
180 if (!actions || !firstOption || !firstDescription) throw new Error("ask option layout did not render");
181
182 const actionsStyle = window.getComputedStyle(actions);
183 eq(actionsStyle.gridAutoRows, "max-content", "decision row wrappers accommodate optional external details");
184 eq(actionsStyle.alignContent, "start", "decision rows stay content-sized at the top of the scroll region");
185 eq(actionsStyle.maxHeight, "none", "Ask options do not create a nested scroll region");
186 eq(actionsStyle.overflow, "visible", "Ask option overflow belongs to the shared content scroller");
187
188 const optionStyle = window.getComputedStyle(firstOption);
189 eq(optionStyle.height, "38px", "desktop decision rows keep a stable compact height");
190 eq(optionStyle.minHeight, "38px", "short decision rows retain a compact click target");
191 eq(optionStyle.alignItems, "center", "single-line decision copy stays vertically centered with the option key");
192 eq(window.getComputedStyle(firstOption.querySelector(".prompt-action__key") as HTMLElement).marginTop, "0px", "decision keys do not carry a top offset");
193
194 ok(
195 /\.prompt-shelf--decision \.prompt-shelf__actions \.prompt-action__copy \{[^}]*grid-template-columns:\s*fit-content\(44%\) minmax\(0, 1fr\)/s.test(styles),
196 "decision option labels size to content while staying capped at 44% of the row",
197 );
198 ok(
199 !/\.prompt-shelf--decision \.prompt-shelf__actions \.prompt-action__label \{[^}]*max-width:\s*[\d.]/s.test(styles),
200 "decision option labels never resolve their width cap against their own content-sized track",
201 );
202
203 const descriptionStyle = window.getComputedStyle(firstDescription);
204 eq(descriptionStyle.whiteSpace, "nowrap", "long option descriptions stay on one stable summary line");
205 eq(descriptionStyle.display, "block", "Ask summaries use ordinary single-line flow");
206 eq(descriptionStyle.overflow, "hidden", "collapsed Ask summaries stay inside their row");
207 eq(descriptionStyle.getPropertyValue("-webkit-line-clamp"), "", "selection never changes the summary to two lines");
208 eq(descriptionStyle.textOverflow, "ellipsis", "long summaries end with a clear ellipsis");
209 eq(descriptionStyle.overflowWrap, "normal", "unspaced summaries stay clipped inside the stable row");
210 eq(
211 firstOption.getAttribute("title"),
212 ask.questions[0].options[0].description,
213 "normal short labels preserve the existing description tooltip",
214 );
215
216 const descriptionToggle = document.querySelector(".prompt-action__description-toggle") as HTMLButtonElement | null;
217 if (!descriptionToggle) throw new Error("long Ask description disclosure did not render");
218 eq(descriptionToggle.textContent?.trim(), "View full description", "truncated descriptions expose an explicit full-text action");
219 eq(descriptionToggle.getAttribute("aria-expanded"), "false", "full description starts collapsed");
220 const descriptionDetail = document.getElementById(`${firstDescription.id}-detail`) as HTMLElement | null;
221 if (!descriptionDetail) throw new Error("long Ask detail region did not render");
222 eq(descriptionToggle.getAttribute("aria-controls"), descriptionDetail.id, "disclosure identifies the separate detail region");
223 eq(descriptionDetail.hidden, true, "full detail region starts hidden");
224
225 let disclosureEnterDefaultPrevented = true;
226 await act(async () => {
227 const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
228 descriptionToggle.dispatchEvent(event);
229 disclosureEnterDefaultPrevented = event.defaultPrevented;
230 await flushTimers();
231 });
232 eq(answers.length, 0, "Enter on Ask disclosure never confirms the selected answer");
233 eq(disclosureEnterDefaultPrevented, true, "Ask disclosure owns keyboard activation before global shortcuts");
234 eq(descriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands the Ask description");
235
236 await act(async () => {
237 descriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
238 key: "Enter",
239 bubbles: true,
240 cancelable: true,
241 }));
242 await flushTimers();
243 });
244 eq(descriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses the Ask description again");
245
246 await act(async () => {
247 descriptionToggle.click();
248 await flushTimers();
249 });
250 eq(window.getComputedStyle(firstOption).height, "38px", "opening details does not resize the selected row");
251 eq(descriptionToggle.getAttribute("aria-expanded"), "true", "expanded state is announced");
252 eq(window.getComputedStyle(firstOption).alignItems, "center", "opening details keeps the selected row vertically centered");
253 eq(window.getComputedStyle(firstDescription).overflow, "hidden", "the row summary remains clipped after opening details");
254 eq(descriptionDetail.hidden, false, "full description opens in a separate region");
255 eq(descriptionDetail.getAttribute("data-scrolled-into-view"), "true", "opened detail scrolls above the fixed decision footer");
256 eq(
257 descriptionDetail.textContent?.includes(ask.questions[0].options[0].description ?? ""),
258 true,
259 "separate detail region reveals the complete text",
260 );
261
262 await act(async () => {
263 descriptionToggle.click();
264 await flushTimers();
265 });
266 eq(descriptionDetail.hidden, true, "separate detail region can be collapsed again");
267 eq(descriptionToggle.getAttribute("aria-expanded"), "false", "collapsed state is announced");
268
269 await act(async () => {
270 optionButtons[1].click();
271 await flushTimers(200);
272 });
273 eq(document.querySelector(".prompt-action__description-toggle"), null, "short selected descriptions do not show a redundant disclosure");
274 eq(answers.length, 0, "single-select click only selects and does not auto-advance/submit");
275
276 await act(async () => {
277 (document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement).click();
278 await flushTimers();
279 });
280 eq(answers.length, 1, "confirm submits the selected single-select answer");
281 eq(answers[0]?.[0]?.selected?.[0], "Minimal repair", "submitted answer matches the selected option");
282
283 await act(async () => {
284 root.unmount();
285 });
286 dom.window.close();
287 }
288
289 // Legacy or malformed Ask payloads may omit description and put the entire
290 // decision in label. Give those rows the full copy column, then disclose the
291 // original label only when it still overflows. The answer value stays exact.
292 {
293 const dom = installDom();
294 const rootEl = document.getElementById("root");
295 if (!rootEl) throw new Error("missing root");
296 const root = createRoot(rootEl);
297 const answers: QuestionAnswer[][] = [];
298 const longLabel = "Keep every historical migration behavior while rebuilding the release validation path";
299 const ask: WireAsk = {
300 id: "ask-long-label-fallback",
301 questions: [{
302 id: "legacy-choice",
303 prompt: "Choose a compatibility strategy",
304 options: [
305 { label: longLabel },
306 { label: "Minimal repair" },
307 ],
308 }],
309 };
310
311 await act(async () => {
312 root.render(
313 React.createElement(LocaleProvider, null,
314 React.createElement(AskCard, {
315 ask,
316 onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next),
317 onDismiss: () => undefined,
318 onStop: () => undefined,
319 }),
320 ),
321 );
322 await flushTimers(200);
323 });
324
325 const firstOption = document.querySelector(".prompt-shelf__actions .prompt-action") as HTMLButtonElement | null;
326 const label = firstOption?.querySelector(".prompt-action__label") as HTMLElement | null;
327 const copy = firstOption?.querySelector(".prompt-action__copy") as HTMLElement | null;
328 const toggle = document.querySelector(".prompt-action__description-toggle") as HTMLButtonElement | null;
329 if (!firstOption || !label || !copy || !toggle) throw new Error("long label fallback did not render");
330
331 eq(window.getComputedStyle(copy).gridTemplateColumns, "minmax(0, 1fr)", "label-only decisions use the full copy width before truncating");
332 eq(window.getComputedStyle(label).textOverflow, "ellipsis", "overflowing legacy labels keep the stable compact row");
333 eq(firstOption.getAttribute("title"), longLabel, "overflowing labels retain their complete native tooltip");
334 eq(toggle.getAttribute("aria-expanded"), "false", "overflowing label detail starts collapsed");
335
336 await act(async () => {
337 toggle.click();
338 await flushTimers();
339 });
340 const detail = document.getElementById(toggle.getAttribute("aria-controls") ?? "") as HTMLElement | null;
341 if (!detail) throw new Error("long label detail did not render");
342 eq(detail.hidden, false, "overflowing label can be expanded outside the stable row");
343 eq(detail.getAttribute("data-scrolled-into-view"), "true", "opened label detail is brought into the visible decision scroller");
344 eq(detail.textContent?.trim(), longLabel, "label-only detail reveals the original complete decision text once");
345 eq(
346 window.getComputedStyle(detail.querySelector(".prompt-description-detail__label") as HTMLElement).whiteSpace,
347 "normal",
348 "full label detail wraps instead of being ellipsized again",
349 );
350 eq(answers.length, 0, "opening a long label never submits the decision");
351
352 await act(async () => {
353 (document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement).click();
354 await flushTimers();
355 });
356 eq(answers.length, 1, "long label decision still submits normally");
357 eq(answers[0]?.[0]?.selected?.[0], longLabel, "display fallback does not alter the answer value");
358
359 await act(async () => {
360 root.unmount();
361 });
362 dom.window.close();
363 }
364
365 // Multi-select requires at least one choice before confirm advances.
366 {
367 const dom = installDom();
368 const rootEl = document.getElementById("root");
369 if (!rootEl) throw new Error("missing root");
370 const root = createRoot(rootEl);
371 const answers: QuestionAnswer[][] = [];
372 const ask: WireAsk = {
373 id: "ask-multi",
374 questions: [
375 {
376 id: "picks",
377 prompt: "Pick at least one",
378 multi: true,
379 options: [
380 { label: "A", description: "Option A" },
381 { label: "B", description: "Option B" },
382 ],
383 },
384 ],
385 };
386
387 await act(async () => {
388 root.render(
389 React.createElement(LocaleProvider, null,
390 React.createElement(AskCard, {
391 ask,
392 onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next),
393 onDismiss: () => undefined,
394 onStop: () => undefined,
395 }),
396 ),
397 );
398 await flushTimers();
399 });
400
401 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
402 eq(confirm.disabled, true, "multi-select confirm stays disabled until an option is chosen");
403
404 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
405 await act(async () => {
406 optionButtons[0].click();
407 await flushTimers();
408 });
409 eq(confirm.disabled, false, "multi-select confirm enables after selecting one option");
410 eq(answers.length, 0, "multi-select click does not submit");
411
412 await act(async () => {
413 confirm.click();
414 await flushTimers();
415 });
416 eq(answers.length, 1, "multi-select confirm submits once");
417 eq(JSON.stringify(answers[0]?.[0]?.selected), JSON.stringify(["A"]), "multi-select keeps chosen labels");
418
419 await act(async () => {
420 root.unmount();
421 });
422 dom.window.close();
423 }
424
425 // Single-select: keyboard cursor is confirmable without a prior click.
426 {
427 const dom = installDom();
428 const rootEl = document.getElementById("root");
429 if (!rootEl) throw new Error("missing root");
430 const root = createRoot(rootEl);
431 const answers: QuestionAnswer[][] = [];
432 const ask: WireAsk = {
433 id: "ask-keyboard-single",
434 questions: [
435 {
436 id: "choice",
437 prompt: "Pick one with the keyboard",
438 options: [
439 { label: "First", description: "Option one" },
440 { label: "Second", description: "Option two" },
441 ],
442 },
443 ],
444 };
445
446 await act(async () => {
447 root.render(
448 React.createElement(LocaleProvider, null,
449 React.createElement(AskCard, {
450 ask,
451 onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next),
452 onDismiss: () => undefined,
453 onStop: () => undefined,
454 }),
455 ),
456 );
457 await flushTimers();
458 });
459
460 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
461 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
462 eq(optionButtons[0]?.getAttribute("aria-selected"), "true", "initial keyboard cursor marks the first option");
463 eq(confirm.disabled, false, "initial option cursor enables confirm without a click");
464
465 await act(async () => {
466 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
467 await flushTimers();
468 });
469 eq(optionButtons[0]?.getAttribute("aria-selected"), "false", "ArrowDown moves the single-select cursor off the first row");
470 eq(optionButtons[1]?.getAttribute("aria-selected"), "true", "ArrowDown selects the second option visually");
471 eq(optionButtons[1]?.getAttribute("data-scrolled-into-view"), "true", "keyboard selection stays inside the visible option viewport");
472 eq(confirm.disabled, false, "ArrowDown keeps confirm enabled for the highlighted option");
473
474 await act(async () => {
475 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
476 await flushTimers();
477 });
478 eq(answers.length, 1, "ArrowDown+Enter submits the highlighted single-select option");
479 eq(answers[0]?.[0]?.selected?.[0], "Second", "submitted answer matches the keyboard cursor");
480
481 await act(async () => {
482 root.unmount();
483 });
484 dom.window.close();
485 }
486
487 // Single-select: initial Enter confirms the default-highlighted first option.
488 {
489 const dom = installDom();
490 const rootEl = document.getElementById("root");
491 if (!rootEl) throw new Error("missing root");
492 const root = createRoot(rootEl);
493 const answers: QuestionAnswer[][] = [];
494 const ask: WireAsk = {
495 id: "ask-keyboard-initial-enter",
496 questions: [
497 {
498 id: "choice",
499 prompt: "Confirm the first option with Enter",
500 options: [
501 { label: "Alpha", description: "A" },
502 { label: "Beta", description: "B" },
503 ],
504 },
505 ],
506 };
507
508 await act(async () => {
509 root.render(
510 React.createElement(LocaleProvider, null,
511 React.createElement(AskCard, {
512 ask,
513 onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next),
514 onDismiss: () => undefined,
515 onStop: () => undefined,
516 }),
517 ),
518 );
519 await flushTimers();
520 });
521
522 await act(async () => {
523 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
524 await flushTimers();
525 });
526 eq(answers.length, 1, "initial Enter submits without a prior click");
527 eq(answers[0]?.[0]?.selected?.[0], "Alpha", "initial Enter uses the first highlighted option");
528
529 await act(async () => {
530 root.unmount();
531 });
532 dom.window.close();
533 }
534
535 // Multi-select: keyboard cursor must not look like a checked answer.
536 {
537 const dom = installDom();
538 const rootEl = document.getElementById("root");
539 if (!rootEl) throw new Error("missing root");
540 const root = createRoot(rootEl);
541 const ask: WireAsk = {
542 id: "ask-keyboard-multi",
543 questions: [
544 {
545 id: "picks",
546 prompt: "Cursor is not a check",
547 multi: true,
548 options: [
549 { label: "A", description: "Option A" },
550 { label: "B", description: "Option B" },
551 ],
552 },
553 ],
554 };
555
556 await act(async () => {
557 root.render(
558 React.createElement(LocaleProvider, null,
559 React.createElement(AskCard, {
560 ask,
561 onAnswer: () => undefined,
562 onDismiss: () => undefined,
563 onStop: () => undefined,
564 }),
565 ),
566 );
567 await flushTimers();
568 });
569
570 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
571 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
572 eq(optionButtons[0]?.getAttribute("aria-selected"), "false", "multi-select cursor alone is not aria-selected");
573 eq(optionButtons[0]?.getAttribute("data-active"), "true", "multi-select marks the keyboard cursor with data-active");
574 eq(confirm.disabled, true, "multi-select confirm stays disabled until an option is checked");
575
576 await act(async () => {
577 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
578 await flushTimers();
579 });
580 eq(optionButtons[0]?.getAttribute("data-active"), null, "ArrowDown clears the previous multi-select cursor");
581 eq(optionButtons[1]?.getAttribute("data-active"), "true", "ArrowDown moves the multi-select cursor");
582 eq(optionButtons[1]?.getAttribute("aria-selected"), "false", "ArrowDown does not check the multi-select option");
583 eq(confirm.disabled, true, "ArrowDown alone does not enable multi-select confirm");
584
585 await act(async () => {
586 optionButtons[1].click();
587 await flushTimers();
588 });
589 eq(optionButtons[1]?.getAttribute("aria-selected"), "true", "click checks the multi-select option");
590 eq(confirm.disabled, false, "multi-select confirm enables after a real check");
591
592 await act(async () => {
593 root.unmount();
594 });
595 dom.window.close();
596 }
597
598 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
599 if (failed > 0) process.exit(1);
600
600 lines TYPESCRIPT