feat: complete verifiable birth-time rectification flow

This commit is contained in:
Jesse_Chen
2026-07-21 22:16:18 +08:00
parent eb8ed8bee4
commit bfc6870614
117 changed files with 6215 additions and 1038 deletions
@@ -92,14 +92,40 @@ test("rich narrative precedes 24 domain choices while free text remains avail
assert.match(markup, /<h2>当前判断<\/h2>/);
assert.match(markup, /<strong>05:18<\/strong>/);
assert.ok(markup.indexOf("候选时间") < markup.indexOf("当前判断"));
assert.ok(markup.indexOf("当前判断") < markup.indexOf("重要关系"));
assert.equal((markup.match(/data-evidence-domain=/g) ?? []).length, 3);
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /aria-label="经历发生年份"/);
assert.match(markup, /aria-label="经历发生月份"/);
assert.match(markup, /<option value=""[^>]*>不确定<\/option>/);
assert.match(markup, /Ctrl\/⌘ \+ Enter/);
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
});
test("evidence is correctable, technical receipts stay visible, and confirmation is explicit", () => {
test("an uninitialized surface shows progress without a second start card", () => {
const emptyController = controller({
turn: null,
pending: true,
getSnapshot: () => ({
turn: null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: true,
error: "",
}),
});
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: emptyController },
));
assert.match(markup, /正在建立校正记录/);
assert.doesNotMatch(markup, /系统会先说明候选边界|开始生时校正<\/button>/);
});
test("evidence is correctable, secondary controls stay hidden, and confirmation is explicit", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller() },
@@ -108,14 +134,12 @@ test("evidence is correctable, technical receipts stay visible, and confirmation
assert.match(markup, /已记录的真实经历/);
assert.match(markup, /2021 年 7 月/);
assert.match(markup, /更正这条经历:开始第一份长期工作/);
assert.match(markup, /本轮技术回执/);
assert.match(markup, /rectification-technical-v1/);
assert.match(markup, /consult-d9/);
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
assert.match(markup, /待确认 · 未验证/);
assert.match(markup, /确认将 05:18 设为当前排盘时间/);
assert.match(markup, /不会自动采用/);
assert.match(markup, /暂停,稍后继续/);
assert.match(markup, /放弃本次校正/);
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
});
test("correction mode identifies its durable target, can be cancelled, and marks revised recaps", () => {
@@ -179,9 +203,9 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
assert.match(css, /\.conversational-rectification[^}]*overflow-wrap:\s*anywhere/);
assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/);
assert.match(css, /\.conversational-rectification[^}]*:focus-visible/);
assert.match(css, /summary, \.conversational-status\):focus-visible/);
assert.match(css, /:where\(button, textarea\):focus-visible/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
assert.match(component, /确认放弃且不应用候选/);
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
assert.match(component, /onPendingChange/);
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
assert.match(component, /&& onContinueOriginalQuestion &&/);
@@ -458,23 +482,6 @@ async function terminateChildProcess(browser: ChildProcess): Promise<void> {
}
}
async function pressEscape(cdp: CdpSession) {
await cdp.send("Input.dispatchKeyEvent", {
type: "rawKeyDown",
key: "Escape",
code: "Escape",
windowsVirtualKeyCode: 27,
nativeVirtualKeyCode: 27,
});
await cdp.send("Input.dispatchKeyEvent", {
type: "keyUp",
key: "Escape",
code: "Escape",
windowsVirtualKeyCode: 27,
nativeVirtualKeyCode: 27,
});
}
async function launchFixture(htmlPath: string, userDataDirectory: string): Promise<{
browser: ChildProcess;
cdp: CdpSession;
@@ -554,7 +561,7 @@ test("Chromium harness keeps the browser sandbox and bounds every external wait"
assert.match(source, /fetchJsonWithDeadline/);
});
test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", {
test("real Chromium at 390px verifies layout, keyboard focus, streamlined controls, and live hook inputs", {
timeout: 30_000,
}, async () => {
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
@@ -568,11 +575,10 @@ 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, useRef, useState } from "react";
import React, { useEffect, 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";
@@ -623,10 +629,6 @@ 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));
@@ -646,45 +648,12 @@ 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 />);
@@ -731,19 +700,24 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
scrollWidth: number;
surfaceWidth: number;
shortestButton: number;
dateFieldsShareRow: boolean;
}>(`(() => {
const buttons = [...document.querySelectorAll('.conversational-rectification button')];
const year = document.querySelector('[aria-label="经历发生年份"]').getBoundingClientRect();
const month = document.querySelector('[aria-label="经历发生月份"]').getBoundingClientRect();
return {
viewport: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
surfaceWidth: document.querySelector('.conversational-rectification').getBoundingClientRect().width,
shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
dateFieldsShareRow: Math.abs(year.top - month.top) < 2,
};
})()`);
assert.equal(layout.viewport, 390);
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
assert.equal(layout.dateFieldsShareRow, true, "year and month should stay on one row at 390px");
await cdp.evaluate("document.querySelector('[aria-label^=\"更正这条经历\"]').click()");
await waitFor(
@@ -767,157 +741,19 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
"domain-to-composer focus",
);
await cdp.evaluate("globalThis.__rectificationHarness.setTransportLabel('second'); globalThis.__rectificationHarness.setCallbackLabel('second')");
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('暂停,稍后继续')).click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.body.textContent.includes('继续校正')") ?? Promise.resolve(false),
"paused response",
);
assert.deepEqual(
await cdp.evaluate<string[]>("globalThis.__rectificationHarness.events.slice()"),
["send:second:pause", "turn:second:paused"],
);
const beforeContinue = await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length");
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('继续校正')).click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'conversational-rectification-answer' && document.body.textContent.includes('现在可以继续填写')") ?? Promise.resolve(false),
"local paused continuation feedback",
);
assert.equal(await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length"), beforeContinue);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
await waitFor(
() => cdp?.evaluate<boolean>("document.body.textContent.includes('放弃本次校正') && !document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
"newer same-case initial turn",
);
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
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 || document.activeElement?.tagName !== 'BUTTON') return false;
return {
title: document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent ?? '',
description: document.getElementById(dialog.getAttribute('aria-describedby'))?.textContent ?? '',
active: document.activeElement?.textContent?.trim() ?? '',
};
})()`),
"abandon alertdialog",
);
assert.match(dialog.title, /确认放弃/);
assert.match(dialog.description, /不会应用任何候选时间/);
assert.equal(dialog.active, "返回校正");
await pressEscape(cdp);
await waitFor(
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]') && document.activeElement?.textContent?.includes('放弃本次校正')") ?? Promise.resolve(false),
"Escape close and trigger focus restoration",
);
await cdp.evaluate("document.activeElement.click()");
await waitFor(
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
"reopened abandon dialog",
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeB1')");
await waitFor(
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
"case-switch dialog reset",
);
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
await waitFor(
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
"case-B abandon dialog",
);
await cdp.evaluate("[...document.querySelectorAll('[role=alertdialog] button')].find((button) => button.textContent.includes('确认放弃且不应用候选')).click()");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const status = document.querySelector('.conversational-status');
return !document.querySelector('[role=alertdialog]')
&& document.body.textContent.includes('本次校正已放弃')
&& status?.tabIndex === -1
&& document.activeElement === status;
const text = document.body.textContent;
return text.includes('候选时间')
&& !text.includes('本轮技术回执')
&& !text.includes('暂停,稍后继续')
&& !text.includes('放弃本次校正')
&& !document.querySelector('[role=alertdialog]');
})()`) ?? Promise.resolve(false),
"terminal dialog close and terminal status focus",
"streamlined rectification controls",
);
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();