fix: harden birth-time consultation modes

This commit is contained in:
Jesse_Chen
2026-07-21 07:25:02 +08:00
parent fc1e9f1085
commit 8be39d5ad6
21 changed files with 1516 additions and 206 deletions
@@ -182,6 +182,9 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
assert.match(css, /summary, \.conversational-status\):focus-visible/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
assert.match(component, /确认放弃且不应用候选/);
assert.match(component, /onPendingChange/);
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
assert.match(component, /&& onContinueOriginalQuestion &&/);
});
type CdpResponse = Readonly<{
@@ -565,10 +568,11 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
.replace(/^@import[^;]+;\s*/gm, "");
const fixture = `
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { ConversationalRectificationSurface } from ${JSON.stringify(componentPath)};
import { useConversationalRectification } from ${JSON.stringify(hookPath)};
import { UnverifiedBirthTimeChoice } from ${JSON.stringify(join(frontendRoot, "src/components/unverified-birth-time-choice.tsx"))};
const caseA = "00000000-0000-4000-8000-000000000821";
const caseB = "00000000-0000-4000-8000-000000000829";
@@ -619,6 +623,10 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
const [initialTurn, setInitialTurn] = useState(null);
const [transportLabel, setTransportLabel] = useState("first");
const [callbackLabel, setCallbackLabel] = useState("first");
const [screen, setScreen] = useState("rectification");
const [choicePending, setChoicePending] = useState(false);
const [activeChat, setActiveChat] = useState("chat-a");
const returnComposer = useRef(null);
const send = async (command) => {
events.push("send:" + transportLabel + ":" + command.type);
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
@@ -638,12 +646,45 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
useEffect(() => {
globalThis.__rectificationHarness = {
events,
networkCalls: 0,
setActiveChat,
setCallbackLabel,
setChoicePending,
setScreen,
setTransportLabel,
setTurn(name) { setInitialTurn(name === "none" ? null : turns[name]); },
};
globalThis.__rectificationReady = true;
});
useEffect(() => {
if (screen !== "composer" && activeChat === "chat-a") return;
const frame = requestAnimationFrame(() => returnComposer.current?.focus());
return () => cancelAnimationFrame(frame);
}, [activeChat, screen]);
if (screen !== "rectification") {
if (activeChat !== "chat-a" || screen === "composer") {
return <textarea ref={returnComposer} id="choice-return-composer" defaultValue="原问题" />;
}
const canUse = screen === "choice-concrete";
return <UnverifiedBirthTimeChoice
canUseUnverifiedTime={canUse}
unverifiedTime={canUse ? "05:30" : null}
pending={choicePending}
onUseUnverifiedTime={() => events.push("choice:unverified")}
onContinueGenerally={() => {
events.push("choice:general");
setScreen("composer");
}}
onRectifyFirst={() => {
events.push("choice:rectify");
setChoicePending(true);
}}
onCancel={() => {
events.push("choice:cancel");
setScreen("composer");
}}
/>;
}
return <ConversationalRectificationSurface controller={controller} />;
}
createRoot(document.getElementById("root")).render(<Harness />);
@@ -754,7 +795,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
const dialog = await waitFor<{ title: string; description: string; active: string }>(
() => cdp!.evaluate<false | { title: string; description: string; active: string }>(`(() => {
const dialog = document.querySelector('[role=alertdialog]');
if (!dialog) return false;
if (!dialog || document.activeElement?.tagName !== 'BUTTON') return false;
return {
title: document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent ?? '',
description: document.getElementById(dialog.getAttribute('aria-describedby'))?.textContent ?? '',
@@ -800,6 +841,83 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
})()`) ?? Promise.resolve(false),
"terminal dialog close and terminal status focus",
);
await cdp.evaluate("globalThis.__rectificationHarness.setScreen('choice-general')");
const generalChoice = await waitFor<{ role: string; focused: string; buttons: string[] }>(
() => cdp!.evaluate<false | { role: string; focused: string; buttons: string[] }>(`(() => {
const dialog = document.querySelector('[role=alertdialog]');
if (!dialog || document.activeElement?.tagName !== 'BUTTON') return false;
return {
role: dialog.getAttribute('role'),
focused: document.activeElement?.textContent?.trim() ?? '',
buttons: [...dialog.querySelectorAll('button')].map((button) => button.textContent.trim()),
};
})()`),
"minute-free choice initial focus",
);
assert.equal(generalChoice.role, "alertdialog");
assert.equal(generalChoice.focused, "继续不依赖出生分钟的一般咨询");
assert.ok(generalChoice.buttons.includes("先校正再询问"));
const eventsBeforeGeneral = await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length");
await cdp.evaluate("document.activeElement.click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'choice-return-composer'") ?? Promise.resolve(false),
"general choice restores composer focus",
);
assert.deepEqual(
await cdp.evaluate<string[]>(`globalThis.__rectificationHarness.events.slice(${eventsBeforeGeneral})`),
["choice:general"],
);
assert.equal(await cdp.evaluate<number>("globalThis.__rectificationHarness.networkCalls"), 0);
await cdp.evaluate("globalThis.__rectificationHarness.setScreen('choice-general')");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.tagName === 'BUTTON' && document.activeElement?.textContent?.includes('继续不依赖出生分钟')") ?? Promise.resolve(false),
"reopened minute-free choice",
);
await pressEscape(cdp);
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'choice-return-composer'") ?? Promise.resolve(false),
"choice Escape restores composer focus",
);
await cdp.evaluate("globalThis.__rectificationHarness.setScreen('choice-concrete')");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.textContent?.includes('先用 05:30')") ?? Promise.resolve(false),
"concrete choice initial focus",
);
await cdp.evaluate("[...document.querySelectorAll('[role=alertdialog] button')].find((button) => button.textContent.includes('先校正再询问')).click()");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const dialog = document.querySelector('[role=alertdialog]');
const buttons = [...dialog.querySelectorAll('button')];
return dialog?.getAttribute('aria-busy') === 'true'
&& buttons.length === 3
&& buttons.every((button) => button.disabled);
})()`) ?? Promise.resolve(false),
"choice pending lock",
);
await pressEscape(cdp);
assert.equal(
await cdp.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))"),
true,
);
await cdp.evaluate("globalThis.__rectificationHarness.setChoicePending(false); globalThis.__rectificationHarness.setScreen('choice-general')");
await waitFor(
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
"choice ready before chat switch",
);
await cdp.evaluate("globalThis.__rectificationHarness.setActiveChat('chat-b')");
await waitFor(
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]') && document.activeElement?.id === 'choice-return-composer'") ?? Promise.resolve(false),
"other chat hides the pending choice",
);
await cdp.evaluate("globalThis.__rectificationHarness.setActiveChat('chat-a')");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.textContent?.includes('继续不依赖出生分钟')") ?? Promise.resolve(false),
"returning chat restores its choice and focus",
);
} finally {
try {
cdp?.close();