返回 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 import { readFileSync } from "node:fs";
3 import { dirname, resolve } from "node:path";
4 import { fileURLToPath } from "node:url";
5 import { JSDOM } from "jsdom";
6 import React, { act } from "react";
7 import { createRoot } from "react-dom/client";
8 import { AskCard } from "../components/AskCard";
9 import { LocaleProvider } from "../lib/i18n";
10 import type { QuestionAnswer, WireAsk } from "../lib/types";
11 const testDir = dirname(fileURLToPath(import.meta.url)), styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
12 let passed = 0, failed = 0;
13 function ok(value: boolean, label: string) {
14 if (value) {
15 process.stdout.write(` PASS ${label}\n`);
16 passed += 1;
17 } else {
18 process.stdout.write(` FAIL ${label}\n`);
19 failed += 1;
20 }
21 }
22 function eq(actual: unknown, expected: unknown, label: string) {
23 if (actual === expected) ok(true, label);
24 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
25 }
26 function flushTimers(delay = 0): Promise<void> {
27 return new Promise((resolve) => setTimeout(resolve, delay));
28 }
29 function installDom() {
30 const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
31 pretendToBeVisual: true,
32 url: "http://localhost/",
33 });
34 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
35 globalThis.window = dom.window as unknown as Window & typeof globalThis;
36 globalThis.document = dom.window.document;
37 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
38 globalThis.Node = dom.window.Node;
39 globalThis.Element = dom.window.Element;
40 globalThis.HTMLElement = dom.window.HTMLElement;
41 globalThis.Event = dom.window.Event;
42 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
43 globalThis.MouseEvent = dom.window.MouseEvent;
44 globalThis.localStorage = dom.window.localStorage;
45 globalThis.sessionStorage = dom.window.sessionStorage;
46 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
47 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
48 Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
49 configurable: true,
50 get() {
51 if (this.classList.contains("prompt-action__desc")) return 42;
52 if (this.classList.contains("prompt-action__label")) return 20;
53 return 0;
54 },
55 });
56 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
57 configurable: true,
58 get() {
59 if (this.classList.contains("prompt-action__desc")) {
60 return this.textContent?.includes("Reuse the archive flow") ? 84 : 42;
61 }
62 if (this.classList.contains("prompt-action__label")) return 20;
63 return 0;
64 },
65 });
66 Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", {
67 configurable: true,
68 get() {
69 if (this.classList.contains("prompt-action__desc")) return 160;
70 if (this.classList.contains("prompt-action__label")) return 160;
71 return 0;
72 },
73 });
74 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollWidth", {
75 configurable: true,
76 get() {
77 if (this.classList.contains("prompt-action__desc")) {
78 return this.textContent?.includes("Reuse the archive flow") ? 320 : 120;
79 }
80 if (this.classList.contains("prompt-action__label")) {
81 return this.textContent?.includes("Keep every historical migration") ? 480 : 120;
82 }
83 return 0;
84 },
85 });
86 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
87 configurable: true,
88 value() {
89 this.setAttribute("data-scrolled-into-view", "true");
90 },
91 });
92 const style = document.createElement("style");
93 style.textContent = styles;
94 document.head.appendChild(style);
95 return dom;
96 }
97 console.log("\nask card layout");
98 {
99 const dom = installDom();
100 const rootEl = document.getElementById("root");
101 if (!rootEl) throw new Error("missing root");
102 const root = createRoot(rootEl);
103 const answers: QuestionAnswer[][] = [];
104 const ask: WireAsk = {
105 id: "ask-superpowers-decision",
106 questions: [
107 {
108 id: "decision",
109 header: "Review",
110 prompt: "baoguanPutArchive needs a user-owned decision: fully align archive logic, or only repair the current compiler error?",
111 options: [
112 {
113 label: "Full alignment",
114 description: "Reuse the archive flow and keep behavior consistent across every constructor, dynamically computed path, runtime boundary, and release validation step.",
115 },
116 { label: "Minimal repair", description: "Touch only the failing path and keep the patch smaller." },
117 ],
118 },
119 ],
120 };
121 await act(async () => {
122 root.render(
123 React.createElement(LocaleProvider, null,
124 React.createElement(AskCard, {
125 ask,
126 draftScope: `test:${ask.id}`,
127 onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
128 onDismiss: () => undefined,
129 onStop: () => undefined,
130 }),
131 ),
132 );
133 await flushTimers();
134 });
135 const card = document.querySelector(".prompt-shelf__card") as HTMLElement | null;
136 const content = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
137 const meta = document.querySelector(".prompt-shelf__meta") as HTMLElement | null;
138 const footer = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
139 if (!card || !content || !meta || !footer) throw new Error("ask prompt shelf did not render");
140 eq(meta.textContent, ask.questions[0].prompt, "ask question text remains complete in the prompt shelf");
141 const computed = window.getComputedStyle(meta);
142 eq(computed.whiteSpace, "normal", "ask question can wrap instead of staying on one line");
143 eq(computed.overflow, "visible", "ask question is not clipped by the prompt shelf");
144 eq(computed.textOverflow, "clip", "ask question does not render as an ellipsis-only preview");
145 eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
146 ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
147 ok(document.querySelector(".prompt-shelf--decision") != null, "ask uses the unified decision surface layout");
148 eq(parseFloat(window.getComputedStyle(card).maxHeight), Number(Math.min(window.innerHeight * 0.62, 560).toFixed(2)), "Ask card stays bounded by the viewport");
149 eq(window.getComputedStyle(card).overflow, "hidden", "Ask card delegates overflow to one content scroller");
150 eq(window.getComputedStyle(content).overflow, "auto", "Ask title, question, and options share one scroll region");
151 eq(content.contains(footer), false, "Ask confirmation footer stays outside the scrolling content");
152 const secondary = footer.querySelector(".decision-confirm-bar__secondary") as HTMLButtonElement | null;
153 ok(Boolean(secondary?.textContent?.trim()), "Ask skip is a quiet footer action");
154 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
155 // options + custom; skip is a secondary footer action
156 eq(optionButtons.length, 2, "ask renders only selectable options; custom answer is an always-visible input row");
157 ok(
158 optionButtons[0]?.textContent?.includes("Reuse the archive flow") === true,
159 "option descriptions render inline on each decision row",
160 );
161 const actions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
162 const firstOption = optionButtons[0];
163 const firstDescription = firstOption?.querySelector(".prompt-action__desc") as HTMLElement | null;
164 if (!actions || !firstOption || !firstDescription) throw new Error("ask option layout did not render");
165 const actionsStyle = window.getComputedStyle(actions);
166 eq(actionsStyle.gridAutoRows, "max-content", "decision row wrappers accommodate optional external details");
167 eq(actionsStyle.alignContent, "start", "decision rows stay content-sized at the top of the scroll region");
168 eq(actionsStyle.maxHeight, "none", "Ask options do not create a nested scroll region");
169 eq(actionsStyle.overflow, "visible", "Ask option overflow belongs to the shared content scroller");
170 const optionStyle = window.getComputedStyle(firstOption);
171 eq(optionStyle.height, "38px", "desktop decision rows keep a stable compact height");
172 eq(optionStyle.minHeight, "38px", "short decision rows retain a compact click target");
173 eq(optionStyle.alignItems, "center", "single-line decision copy stays vertically centered with the option key");
174 eq(window.getComputedStyle(firstOption.querySelector(".prompt-action__key") as HTMLElement).marginTop, "0px", "decision keys do not carry a top offset");
175 ok(
176 /\.prompt-shelf--decision \.prompt-shelf__actions \.prompt-action__copy \{[^}]*grid-template-columns:\s*fit-content\(44%\) minmax\(0, 1fr\)/s.test(styles),
177 "decision option labels size to content while staying capped at 44% of the row",
178 );
179 ok(
180 !/\.prompt-shelf--decision \.prompt-shelf__actions \.prompt-action__label \{[^}]*max-width:\s*[\d.]/s.test(styles),
181 "decision option labels never resolve their width cap against their own content-sized track",
182 );
183 const descriptionStyle = window.getComputedStyle(firstDescription);
184 eq(descriptionStyle.whiteSpace, "nowrap", "long option descriptions stay on one stable summary line");
185 eq(descriptionStyle.display, "block", "Ask summaries use ordinary single-line flow");
186 eq(descriptionStyle.overflow, "hidden", "collapsed Ask summaries stay inside their row");
187 eq(descriptionStyle.getPropertyValue("-webkit-line-clamp"), "", "selection never changes the summary to two lines");
188 eq(descriptionStyle.textOverflow, "ellipsis", "long summaries end with a clear ellipsis");
189 eq(descriptionStyle.overflowWrap, "normal", "unspaced summaries stay clipped inside the stable row");
190 eq(
191 firstOption.getAttribute("title"),
192 ask.questions[0].options[0].description,
193 "normal short labels preserve the existing description tooltip",
194 );
195 const descriptionToggle = document.querySelector(".prompt-action__description-toggle") as HTMLButtonElement | null;
196 if (!descriptionToggle) throw new Error("long Ask description disclosure did not render");
197 eq(descriptionToggle.textContent?.trim(), "View full description", "truncated descriptions expose an explicit full-text action");
198 eq(descriptionToggle.getAttribute("aria-expanded"), "false", "full description starts collapsed");
199 const descriptionDetail = document.getElementById(`${firstDescription.id}-detail`) as HTMLElement | null;
200 if (!descriptionDetail) throw new Error("long Ask detail region did not render");
201 eq(descriptionToggle.getAttribute("aria-controls"), descriptionDetail.id, "disclosure identifies the separate detail region");
202 eq(descriptionDetail.hidden, true, "full detail region starts hidden");
203 let disclosureEnterDefaultPrevented = true;
204 await act(async () => {
205 const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
206 descriptionToggle.dispatchEvent(event);
207 disclosureEnterDefaultPrevented = event.defaultPrevented;
208 await flushTimers();
209 });
210 eq(answers.length, 0, "Enter on Ask disclosure never confirms the selected answer");
211 eq(disclosureEnterDefaultPrevented, true, "Ask disclosure owns keyboard activation before global shortcuts");
212 eq(descriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands the Ask description");
213 await act(async () => {
214 descriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
215 key: "Enter",
216 bubbles: true,
217 cancelable: true,
218 }));
219 await flushTimers();
220 });
221 eq(descriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses the Ask description again");
222 await act(async () => {
223 descriptionToggle.click();
224 await flushTimers();
225 });
226 eq(window.getComputedStyle(firstOption).height, "38px", "opening details does not resize the selected row");
227 eq(descriptionToggle.getAttribute("aria-expanded"), "true", "expanded state is announced");
228 eq(window.getComputedStyle(firstOption).alignItems, "center", "opening details keeps the selected row vertically centered");
229 eq(window.getComputedStyle(firstDescription).overflow, "hidden", "the row summary remains clipped after opening details");
230 eq(descriptionDetail.hidden, false, "full description opens in a separate region");
231 eq(descriptionDetail.getAttribute("data-scrolled-into-view"), "true", "opened detail scrolls above the fixed decision footer");
232 eq(
233 descriptionDetail.textContent?.includes(ask.questions[0].options[0].description ?? ""),
234 true,
235 "separate detail region reveals the complete text",
236 );
237 await act(async () => {
238 descriptionToggle.click();
239 await flushTimers();
240 });
241 eq(descriptionDetail.hidden, true, "separate detail region can be collapsed again");
242 eq(descriptionToggle.getAttribute("aria-expanded"), "false", "collapsed state is announced");
243 await act(async () => {
244 optionButtons[1].click();
245 await flushTimers(200);
246 });
247 eq(document.querySelector(".prompt-action__description-toggle"), null, "short selected descriptions do not show a redundant disclosure");
248 eq(answers.length, 0, "single-select click advances without submitting the final answer");
249 await act(async () => {
250 (document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement).click();
251 await flushTimers();
252 });
253 eq(answers.length, 1, "confirm submits the selected single-select answer");
254 eq(answers[0]?.[0]?.selected?.[0], "Minimal repair", "submitted answer matches the selected option");
255 await act(async () => {
256 root.unmount();
257 });
258 dom.window.close();
259 }
260 // Legacy or malformed Ask payloads may omit description and put the entire
261 // decision in label. Give those rows the full copy column, then disclose the
262 // original label only when it still overflows. The answer value stays exact.
263 {
264 const dom = installDom();
265 const rootEl = document.getElementById("root");
266 if (!rootEl) throw new Error("missing root");
267 const root = createRoot(rootEl);
268 const answers: QuestionAnswer[][] = [];
269 const longLabel = "Keep every historical migration behavior while rebuilding the release validation path";
270 const ask: WireAsk = {
271 id: "ask-long-label-fallback",
272 questions: [{
273 id: "legacy-choice",
274 prompt: "Choose a compatibility strategy",
275 options: [
276 { label: longLabel },
277 { label: "Minimal repair" },
278 ],
279 }],
280 };
281 await act(async () => {
282 root.render(
283 React.createElement(LocaleProvider, null,
284 React.createElement(AskCard, {
285 ask,
286 draftScope: `test:${ask.id}`,
287 onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
288 onDismiss: () => undefined,
289 onStop: () => undefined,
290 }),
291 ),
292 );
293 await flushTimers(200);
294 });
295 const firstOption = document.querySelector(".prompt-shelf__actions .prompt-action") as HTMLButtonElement | null;
296 const label = firstOption?.querySelector(".prompt-action__label") as HTMLElement | null;
297 const copy = firstOption?.querySelector(".prompt-action__copy") as HTMLElement | null;
298 const toggle = document.querySelector(".prompt-action__description-toggle") as HTMLButtonElement | null;
299 if (!firstOption || !label || !copy || !toggle) throw new Error("long label fallback did not render");
300 eq(window.getComputedStyle(copy).gridTemplateColumns, "minmax(0, 1fr)", "label-only decisions use the full copy width before truncating");
301 eq(window.getComputedStyle(label).textOverflow, "ellipsis", "overflowing legacy labels keep the stable compact row");
302 eq(firstOption.getAttribute("title"), longLabel, "overflowing labels retain their complete native tooltip");
303 eq(toggle.getAttribute("aria-expanded"), "false", "overflowing label detail starts collapsed");
304 await act(async () => {
305 toggle.click();
306 await flushTimers();
307 });
308 const detail = document.getElementById(toggle.getAttribute("aria-controls") ?? "") as HTMLElement | null;
309 if (!detail) throw new Error("long label detail did not render");
310 eq(detail.hidden, false, "overflowing label can be expanded outside the stable row");
311 eq(detail.getAttribute("data-scrolled-into-view"), "true", "opened label detail is brought into the visible decision scroller");
312 eq(detail.textContent?.trim(), longLabel, "label-only detail reveals the original complete decision text once");
313 eq(
314 window.getComputedStyle(detail.querySelector(".prompt-description-detail__label") as HTMLElement).whiteSpace,
315 "normal",
316 "full label detail wraps instead of being ellipsized again",
317 );
318 eq(answers.length, 0, "opening a long label never submits the decision");
319 await act(async () => {
320 (document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement).click();
321 await flushTimers();
322 });
323 eq(answers.length, 1, "long label decision still submits normally");
324 eq(answers[0]?.[0]?.selected?.[0], longLabel, "display fallback does not alter the answer value");
325 await act(async () => {
326 root.unmount();
327 });
328 dom.window.close();
329 }
330 // Multi-select requires at least one choice before confirm advances.
331 {
332 const dom = installDom();
333 const rootEl = document.getElementById("root");
334 if (!rootEl) throw new Error("missing root");
335 const root = createRoot(rootEl);
336 const answers: QuestionAnswer[][] = [];
337 const ask: WireAsk = {
338 id: "ask-multi",
339 questions: [
340 {
341 id: "picks",
342 prompt: "Pick at least one",
343 multi: true,
344 options: [
345 { label: "A", description: "Option A" },
346 { label: "B", description: "Option B" },
347 ],
348 },
349 ],
350 };
351 await act(async () => {
352 root.render(
353 React.createElement(LocaleProvider, null,
354 React.createElement(AskCard, {
355 ask,
356 draftScope: `test:${ask.id}`,
357 onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
358 onDismiss: () => undefined,
359 onStop: () => undefined,
360 }),
361 ),
362 );
363 await flushTimers();
364 });
365 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
366 eq(confirm.disabled, true, "multi-select confirm stays disabled until an option is chosen");
367 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
368 await act(async () => {
369 optionButtons[0].click();
370 await flushTimers();
371 });
372 eq(confirm.disabled, false, "multi-select confirm enables after selecting one option");
373 eq(answers.length, 0, "multi-select click does not submit");
374 await act(async () => {
375 confirm.click();
376 await flushTimers();
377 });
378 eq(answers.length, 1, "multi-select confirm submits once");
379 eq(JSON.stringify(answers[0]?.[0]?.selected), JSON.stringify(["A"]), "multi-select keeps chosen labels");
380 await act(async () => {
381 root.unmount();
382 });
383 dom.window.close();
384 }
385 // Single-select: keyboard cursor is confirmable without a prior click.
386 {
387 const dom = installDom();
388 const rootEl = document.getElementById("root");
389 if (!rootEl) throw new Error("missing root");
390 const root = createRoot(rootEl);
391 const answers: QuestionAnswer[][] = [];
392 const ask: WireAsk = {
393 id: "ask-keyboard-single",
394 questions: [
395 {
396 id: "choice",
397 prompt: "Pick one with the keyboard",
398 options: [
399 { label: "First", description: "Option one" },
400 { label: "Second", description: "Option two" },
401 ],
402 },
403 ],
404 };
405 await act(async () => {
406 root.render(
407 React.createElement(LocaleProvider, null,
408 React.createElement(AskCard, {
409 ask,
410 draftScope: `test:${ask.id}`,
411 onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
412 onDismiss: () => undefined,
413 onStop: () => undefined,
414 }),
415 ),
416 );
417 await flushTimers();
418 });
419 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
420 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
421 eq(optionButtons[0]?.getAttribute("aria-selected"), "true", "initial keyboard cursor marks the first option");
422 eq(confirm.disabled, false, "initial option cursor enables confirm without a click");
423 await act(async () => {
424 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
425 await flushTimers();
426 });
427 eq(optionButtons[0]?.getAttribute("aria-selected"), "false", "ArrowDown moves the single-select cursor off the first row");
428 eq(optionButtons[1]?.getAttribute("aria-selected"), "true", "ArrowDown selects the second option visually");
429 eq(optionButtons[1]?.getAttribute("data-scrolled-into-view"), "true", "keyboard selection stays inside the visible option viewport");
430 eq(confirm.disabled, false, "ArrowDown keeps confirm enabled for the highlighted option");
431 await act(async () => {
432 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
433 await flushTimers();
434 });
435 eq(answers.length, 1, "ArrowDown+Enter submits the highlighted single-select option");
436 eq(answers[0]?.[0]?.selected?.[0], "Second", "submitted answer matches the keyboard cursor");
437 await act(async () => {
438 root.unmount();
439 });
440 dom.window.close();
441 }
442 // Single-select: initial Enter confirms the default-highlighted first option.
443 {
444 const dom = installDom();
445 const rootEl = document.getElementById("root");
446 if (!rootEl) throw new Error("missing root");
447 const root = createRoot(rootEl);
448 const answers: QuestionAnswer[][] = [];
449 const ask: WireAsk = {
450 id: "ask-keyboard-initial-enter",
451 questions: [
452 {
453 id: "choice",
454 prompt: "Confirm the first option with Enter",
455 options: [
456 { label: "Alpha", description: "A" },
457 { label: "Beta", description: "B" },
458 ],
459 },
460 ],
461 };
462 await act(async () => {
463 root.render(
464 React.createElement(LocaleProvider, null,
465 React.createElement(AskCard, {
466 ask,
467 draftScope: `test:${ask.id}`,
468 onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
469 onDismiss: () => undefined,
470 onStop: () => undefined,
471 }),
472 ),
473 );
474 await flushTimers();
475 });
476 await act(async () => {
477 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
478 await flushTimers();
479 });
480 eq(answers.length, 1, "initial Enter submits without a prior click");
481 eq(answers[0]?.[0]?.selected?.[0], "Alpha", "initial Enter uses the first highlighted option");
482 await act(async () => {
483 root.unmount();
484 });
485 dom.window.close();
486 }
487 // Custom answer drafts survive switching to another option and back.
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-custom-draft",
496 questions: [{
497 id: "choice",
498 prompt: "Pick or describe",
499 options: [{ label: "Suggested", description: "Use the suggestion" }],
500 }],
501 };
502 await act(async () => {
503 root.render(React.createElement(LocaleProvider, null,
504 React.createElement(AskCard, {
505 ask,
506 draftScope: `test:${ask.id}`,
507 onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
508 onDismiss: () => undefined,
509 onStop: () => undefined,
510 }),
511 ));
512 await flushTimers();
513 });
514 const actions = [...document.querySelectorAll<HTMLButtonElement>(".prompt-action")];
515 await act(async () => {
516 actions[1]?.click();
517 await flushTimers();
518 });
519 const input = document.querySelector<HTMLInputElement>(".ask-shelf__custom");
520 if (!input) throw new Error("custom input did not open");
521 await act(async () => {
522 const propsKey = Object.keys(input).find((key) => key.startsWith("__reactProps"));
523 const props = propsKey ? (input as unknown as Record<string, { onChange?: (event: { target: { value: string } }) => void }>)[propsKey] : undefined;
524 props?.onChange?.({ target: { value: "Keep this draft" } });
525 await flushTimers();
526 });
527 eq(input.value, "Keep this draft", "custom input accepts a draft");
528 await act(async () => {
529 document.querySelector<HTMLButtonElement>(".prompt-action")?.click();
530 await flushTimers();
531 });
532 eq(document.querySelector<HTMLInputElement>(".ask-shelf__custom")?.value, "", "selecting an option clears the visible custom draft without removing the input row");
533 await act(async () => {
534 document.querySelector<HTMLElement>(".ask-shelf__custom-row")?.click();
535 await flushTimers();
536 });
537 eq(document.querySelector<HTMLInputElement>(".ask-shelf__custom")?.value, "Keep this draft", "reopening custom restores the draft");
538 await act(async () => {
539 document.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
540 await flushTimers();
541 });
542 eq(answers[0]?.[0]?.selected?.[0], "Keep this draft", "custom mode submits the restored draft");
543 await act(async () => { root.unmount(); });
544 dom.window.close();
545 }
546 // Multi-select: keyboard cursor must not look like a checked answer.
547 {
548 const dom = installDom();
549 const rootEl = document.getElementById("root");
550 if (!rootEl) throw new Error("missing root");
551 const root = createRoot(rootEl);
552 const ask: WireAsk = {
553 id: "ask-keyboard-multi",
554 questions: [
555 {
556 id: "picks",
557 prompt: "Cursor is not a check",
558 multi: true,
559 options: [
560 { label: "A", description: "Option A" },
561 { label: "B", description: "Option B" },
562 ],
563 },
564 ],
565 };
566 await act(async () => {
567 root.render(
568 React.createElement(LocaleProvider, null,
569 React.createElement(AskCard, {
570 ask,
571 draftScope: `test:${ask.id}`,
572 onAnswer: () => undefined,
573 onDismiss: () => undefined,
574 onStop: () => undefined,
575 }),
576 ),
577 );
578 await flushTimers();
579 });
580 const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
581 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
582 eq(optionButtons[0]?.getAttribute("aria-selected"), "false", "multi-select cursor alone is not aria-selected");
583 eq(optionButtons[0]?.getAttribute("data-active"), "true", "multi-select marks the keyboard cursor with data-active");
584 eq(confirm.disabled, true, "multi-select confirm stays disabled until an option is checked");
585 await act(async () => {
586 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
587 await flushTimers();
588 });
589 eq(optionButtons[0]?.getAttribute("data-active"), null, "ArrowDown clears the previous multi-select cursor");
590 eq(optionButtons[1]?.getAttribute("data-active"), "true", "ArrowDown moves the multi-select cursor");
591 eq(optionButtons[1]?.getAttribute("aria-selected"), "false", "ArrowDown does not check the multi-select option");
592 eq(confirm.disabled, true, "ArrowDown alone does not enable multi-select confirm");
593 await act(async () => {
594 optionButtons[1].click();
595 await flushTimers();
596 });
597 eq(optionButtons[1]?.getAttribute("aria-selected"), "true", "click checks the multi-select option");
598 eq(confirm.disabled, false, "multi-select confirm enables after a real check");
599 await act(async () => {
600 root.unmount();
601 });
602 dom.window.close();
603 }
604 // A failed asynchronous submit must preserve multi-question progress and
605 // selections instead of remounting the card at question 1.
606 {
607 const dom = installDom();
608 const rootEl = document.getElementById("root");
609 if (!rootEl) throw new Error("missing root");
610 const root = createRoot(rootEl);
611 const submitted: QuestionAnswer[][] = [];
612 let attempts = 0;
613 const ask: WireAsk = {
614 id: "ask-async-retry",
615 questions: [
616 { id: "q1", prompt: "First choice?", options: [{ label: "First A" }, { label: "First B" }] },
617 { id: "q2", prompt: "Second choice?", options: [{ label: "Second A" }, { label: "Second B" }] },
618 ],
619 };
620 await act(async () => {
621 root.render(
622 React.createElement(LocaleProvider, null,
623 React.createElement(AskCard, {
624 ask,
625 draftScope: `test:${ask.id}`,
626 onAnswer: async (_id: string, answers: QuestionAnswer[]) => {
627 attempts += 1;
628 submitted.push(answers);
629 if (attempts === 1) throw new Error("bridge rejected answer");
630 },
631 onDismiss: () => undefined,
632 onStop: () => undefined,
633 }),
634 ),
635 );
636 await flushTimers();
637 });
638 await act(async () => {
639 [...document.querySelectorAll<HTMLButtonElement>(".prompt-action")]
640 .find((button) => button.textContent?.includes("First A"))?.click();
641 await flushTimers();
642 });
643 eq(document.querySelector(".ask-shelf__header-text--progress")?.textContent?.includes("2/2"), true, "multi-question Ask advances to question 2");
644 await act(async () => {
645 document.querySelector<HTMLButtonElement>(".ask-shelf__pager-button")?.click();
646 await flushTimers();
647 });
648 eq(document.querySelector(".ask-shelf__header-text--progress")?.textContent?.includes("1/2"), true, "Back returns to the previous question");
649 await act(async () => {
650 [...document.querySelectorAll<HTMLButtonElement>(".prompt-action")]
651 .find((button) => button.textContent?.includes("First B"))?.click();
652 await flushTimers();
653 });
654 eq(document.querySelector(".ask-shelf__header-text--progress")?.textContent?.includes("2/2"), true, "previous question accepts a replacement choice");
655 await act(async () => {
656 [...document.querySelectorAll<HTMLButtonElement>(".prompt-action")]
657 .find((button) => button.textContent?.includes("Second B"))?.click();
658 document.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
659 await flushTimers();
660 });
661 eq(attempts, 1, "failed final submit is attempted exactly once");
662 eq(document.querySelector(".ask-shelf__header-text--progress")?.textContent?.includes("2/2"), true, "failed final submit stays on question 2");
663 eq(document.querySelector(".ask-shelf__crumbs")?.textContent?.includes("First B"), true, "failed final submit preserves the revised first answer");
664 eq(
665 [...document.querySelectorAll<HTMLElement>(".prompt-action")]
666 .some((button) => button.getAttribute("aria-selected") === "true" && button.textContent?.includes("Second B")),
667 true,
668 "failed final submit preserves the second answer",
669 );
670 eq(document.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.disabled, false, "failed final submit re-enables retry");
671 await act(async () => {
672 document.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
673 await flushTimers();
674 });
675 eq(attempts, 2, "retry submits once after the failure");
676 eq(submitted[1]?.map((answer) => answer.selected?.[0]).join("|"), "First B|Second B", "retry submits the preserved answer batch");
677 await act(async () => { root.unmount(); });
678 dom.window.close();
679 }
680 // Skip advances the current question and submits an empty answer on the last.
681 {
682 const dom = installDom();
683 const rootEl = document.getElementById("root");
684 if (!rootEl) throw new Error("missing root");
685 const root = createRoot(rootEl);
686 const submitted: QuestionAnswer[][] = [];
687 const ask: WireAsk = {
688 id: "ask-skip-retry",
689 questions: [{ id: "q1", prompt: "Skip this?", options: [{ label: "Continue" }] }],
690 };
691 await act(async () => {
692 root.render(
693 React.createElement(LocaleProvider, null,
694 React.createElement(AskCard, {
695 ask,
696 draftScope: `test:${ask.id}`,
697 onAnswer: (_id: string, answers: QuestionAnswer[]) => { submitted.push(answers); },
698 onDismiss: () => undefined,
699 onStop: () => undefined,
700 }),
701 ),
702 );
703 await flushTimers();
704 });
705 await act(async () => {
706 document.querySelector<HTMLButtonElement>(".decision-confirm-bar__secondary")?.click();
707 await flushTimers();
708 });
709 eq(submitted.length, 1, "skipping the final question submits once");
710 eq(submitted[0]?.[0]?.selected?.length, 0, "skipping submits an empty answer");
711 await act(async () => { root.unmount(); });
712 dom.window.close();
713 }
714 // Collapsing an Ask keeps the pending card header visible, while interactive
715 // controls continue to work without toggling the card accidentally.
716 {
717 const dom = installDom();
718 const rootEl = document.getElementById("root");
719 if (!rootEl) throw new Error("missing root");
720 const root = createRoot(rootEl);
721 const ask: WireAsk = {
722 id: "ask-collapse",
723 questions: [{ id: "q1", prompt: "Choose one", options: [{ label: "Keep" }] }],
724 };
725 await act(async () => {
726 root.render(React.createElement(LocaleProvider, null,
727 React.createElement(AskCard, { ask, draftScope: `test:${ask.id}`, onAnswer: () => undefined, onDismiss: () => undefined, onStop: () => undefined }),
728 ));
729 await flushTimers();
730 });
731 const card = document.querySelector(".prompt-shelf__card") as HTMLElement | null;
732 const option = document.querySelector(".prompt-action") as HTMLButtonElement | null;
733 const collapse = [...document.querySelectorAll<HTMLButtonElement>(".prompt-shelf__header-button")]
734 .find((button) => button.getAttribute("aria-label") === "Collapse" || button.getAttribute("aria-label") === "收起");
735 if (!card || !option || !collapse) throw new Error("collapse controls did not render");
736 ok(card.classList.contains("prompt-shelf__card--collapsible"), "Ask exposes the shared collapsible shelf contract");
737 ok(!card.classList.contains("prompt-shelf__card--collapsed"), "Ask starts expanded");
738 await act(async () => { collapse.click(); await flushTimers(); });
739 ok(card.classList.contains("prompt-shelf__card--collapsed"), "collapse action hides the Ask body");
740 ok(Boolean(document.querySelector(".prompt-shelf__header")), "collapsed Ask keeps its header visible");
741 eq(window.getComputedStyle(document.querySelector(".prompt-shelf__actions") as HTMLElement).display, "none", "collapsed Ask hides its option list");
742 eq(window.getComputedStyle(document.querySelector(".prompt-shelf__footer") as HTMLElement).display, "none", "collapsed Ask hides its footer");
743 await act(async () => { collapse.click(); await flushTimers(); });
744 ok(!card.classList.contains("prompt-shelf__card--collapsed"), "expand action restores the Ask body");
745 await act(async () => { option.click(); await flushTimers(); });
746 ok(!card.classList.contains("prompt-shelf__card--collapsed"), "clicking an option does not collapse the Ask");
747 await act(async () => { root.unmount(); });
748 dom.window.close();
749 }
750 // Session-scoped drafts restore when the Ask surface remounts in the same tab.
751 {
752 const dom = installDom();
753 const rootEl = document.getElementById("root");
754 if (!rootEl) throw new Error("missing root");
755 const root = createRoot(rootEl);
756 const ask: WireAsk = {
757 id: "ask-session-draft",
758 questions: [{ id: "q1", prompt: "Choose one", options: [{ label: "Keep" }, { label: "Change" }] }],
759 };
760 const render = async (draftScope = "tab-a") => {
761 await act(async () => {
762 root.render(React.createElement(LocaleProvider, null,
763 React.createElement(AskCard, { ask, draftScope, onAnswer: () => undefined, onDismiss: () => undefined, onStop: () => undefined }),
764 ));
765 await flushTimers();
766 });
767 };
768 await render();
769 await act(async () => {
770 [...document.querySelectorAll<HTMLButtonElement>(".prompt-action")]
771 .find((button) => button.textContent?.includes("Change"))?.click();
772 [...document.querySelectorAll<HTMLButtonElement>(".prompt-shelf__header-button")]
773 .find((button) => button.getAttribute("aria-label") === "Collapse" || button.getAttribute("aria-label") === "收起")?.click();
774 await flushTimers();
775 });
776 await act(async () => { root.unmount(); });
777 const otherRoot = createRoot(rootEl);
778 await act(async () => {
779 otherRoot.render(React.createElement(LocaleProvider, null,
780 React.createElement(AskCard, { ask, draftScope: "tab-b", onAnswer: () => undefined, onDismiss: () => undefined, onStop: () => undefined }),
781 ));
782 await flushTimers();
783 });
784 ok(![...document.querySelectorAll<HTMLElement>(".prompt-action")].some((button) => button.getAttribute("aria-selected") === "true" && button.textContent?.includes("Change")), "Ask drafts do not cross tab scopes");
785 await act(async () => { otherRoot.unmount(); });
786 const remountRoot = createRoot(rootEl);
787 await act(async () => {
788 remountRoot.render(React.createElement(LocaleProvider, null,
789 React.createElement(AskCard, { ask, draftScope: "tab-a", onAnswer: () => undefined, onDismiss: () => undefined, onStop: () => undefined }),
790 ));
791 await flushTimers();
792 });
793 ok(!document.querySelector(".prompt-shelf__card--collapsed"), "a remounted Ask starts expanded like harness");
794 ok([...document.querySelectorAll<HTMLElement>(".prompt-action")]
795 .some((button) => button.getAttribute("aria-selected") === "true" && button.textContent?.includes("Change")), "Ask answer draft restores within the tab scope");
796 await act(async () => { remountRoot.unmount(); });
797 dom.window.close();
798 }
799 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
800 if (failed > 0) process.exit(1);
801
801 lines TYPESCRIPT