Files
Jyotisha/frontend/tests/rectification-opening-plain-20260926.test.tsx
T
Jesse_ChenandClaude Opus 5.5 408b22749e fix(rectification): settled replies carry no 「已完成 N 步」 receipt; steps stay the waiting state (BUG-1137)
Consultation replies keep their receipt (scope-lock test). Two source/DOM
assertions updated with original/new/reason notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 10:40:29 +08:00

185 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BUG-1049 / BUG-1050 on the real surface: mount `RectificationAgenticChat`,
* let it start the opening (POST action=opening → stream → settle → snapshot
* merge) and count what the reader sees — the body sentences, the stem once
* with its examples, and a receipt with one row per shown step.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { RectificationAgenticChat } from "../src/components/rectification-agentic-chat.tsx";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { GENERIC_COLLECT_QUESTION, openingSpokenBody } from "../src/lib/rectification-agentic/user-copy.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const CASE_ID = "44444444-4444-4444-8444-444444444444";
const SESSION_ID = "33333333-3333-4333-8333-333333333333";
const FOCUS_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const BODY = openingSpokenBody(["14:35", "15:05"]);
const BODY_SENTENCES = [
"我们来把你的出生时间缩小到更准的范围,现在先在 14:35–15:05 之间找。",
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
];
const EXAMPLES = ["上大学", "第一份工作", "搬到别的城市", "谈恋爱或结婚", "家里添丁", "生病住院"];
type RawTurn = { id: string; role: "user" | "assistant"; text: string; status: string };
const OPENING_FOCUS: ConversationFocus = {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "other",
targetKind: null,
expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true },
status: "active",
askedAt: "2026-09-26T08:00:00.000Z",
resolvedAt: null,
askedTurnId: "t0",
answerOption: null,
};
function snapshot(rawTurns: RawTurn[]) {
return {
case: { status: "collecting_evidence" },
question_source: rawTurns.length ? "focus" : null,
current_question: rawTurns.length
? {
kind: "collect_spoken",
prompt: GENERIC_COLLECT_QUESTION,
focus_id: FOCUS_ID,
question_id: OPENING_FOCUS.questionId,
}
: null,
choice_card: null,
turns: attachQuestionsToTurns(rawTurns, rawTurns.length ? [OPENING_FOCUS] : []),
};
}
function ndjson(events: unknown[]): Response {
return new Response(`${events.map((event) => JSON.stringify(event)).join("\n")}\n`, {
status: 200,
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
});
}
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
}
async function mountOpening(streamedText: string, storedText: string) {
const harness = createClientLifecycleHarness();
const win = (globalThis as unknown as { window: Record<string, unknown> }).window;
win.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
win.setInterval = setInterval;
win.clearInterval = clearInterval;
const proto = (harness.container as unknown as { constructor: { prototype: Record<string, unknown> } }).constructor.prototype;
Object.assign(proto, {
querySelector: () => null,
querySelectorAll: () => [],
getBoundingClientRect: () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }),
compareDocumentPosition: () => 0,
scrollTo() {},
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
});
const doc = (globalThis as unknown as { document: { createElement: (tag: string) => { style: object } } }).document;
const create = doc.createElement.bind(doc);
const styled = <T extends { style: object }>(element: T): T => {
Object.assign(element.style, { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" });
return element;
};
doc.createElement = (tag: string) => styled(create(tag));
styled(harness.container as unknown as { style: object });
const originalFetch = globalThis.fetch;
let posted = false;
globalThis.fetch = (async (_resource: RequestInfo | URL, init?: RequestInit) => {
if ((init?.method ?? "GET") === "POST") {
posted = true;
// The same tool sequence as the real-device receipt: read-case, then
// set-focus twice (write the question, then rewrite it).
return ndjson([
{ type: "run.started" },
{ type: "tool.activity", tool: "rectification-read-case", status: "started" },
{ type: "tool.activity", tool: "rectification-read-case", status: "completed" },
{ type: "tool.activity", tool: "rectification-set-focus", status: "started" },
{ type: "tool.activity", tool: "rectification-set-focus", status: "completed" },
{ type: "tool.activity", tool: "rectification-set-focus", status: "started" },
{ type: "tool.activity", tool: "rectification-set-focus", status: "completed" },
{ type: "answer.delta", text: streamedText },
{ type: "run.completed", turnId: "t0" },
]);
}
return json(snapshot(posted ? [{ id: "t0", role: "assistant", text: storedText, status: "completed" }] : []));
}) as typeof fetch;
await harness.render(
<RectificationAgenticChat
caseId={CASE_ID}
sessionId={SESSION_ID}
readonly={false}
shouldStartOpening
initialTurns={[]}
initialSnapshot={snapshot([])}
declaredTime="14:50"
birthDate="1990-06-15"
models={[]}
selectedModelId="model"
onSelectModel={() => {}}
headerSlot={null}
/>,
);
for (let i = 0; i < 8; i += 1) await harness.idle();
type Host = ReturnType<typeof harness.elements>[number];
const visibleText = (node: Host): string => {
if ((node.props.className as string | undefined)?.split(/\s+/).includes("sr-only")) return "";
return node.textContent + node.childNodes.map((child) => visibleText(child as Host)).join("");
};
return {
posted: () => posted,
text: () => visibleText(harness.container as Host),
async close() {
globalThis.fetch = originalFetch;
await harness.close();
},
};
}
for (const [name, streamed, stored] of [
["body only (agent opening)", BODY, BODY],
["body + stem in the text (composed)", composeCollectSpokenAssistantText(BODY, GENERIC_COLLECT_QUESTION), BODY],
] as const) {
test(`opening on the real surface · ${name}: body kept, stem once with examples, receipt de-duplicated`, async () => {
const chat = await mountOpening(streamed, stored);
try {
assert.equal(chat.posted(), true, "the surface started the opening");
const text = chat.text();
for (const sentence of BODY_SENTENCES) assert.ok(text.includes(sentence), sentence);
assert.equal(text.split(GENERIC_COLLECT_QUESTION).length - 1, 1, text);
for (const example of EXAMPLES) assert.equal(text.split(example).length - 1, 1, example);
assert.match(text, /例如「2015 年夏天换了工作」/);
// The opening turn (body + stem). The side board keeps its own
// 「当前盘面」 heading; that panel is not part of this turn.
const turn = text.slice(0, text.indexOf(GENERIC_COLLECT_QUESTION) + GENERIC_COLLECT_QUESTION.length);
// 原值: turn 以「已完成 2 步」开头;「看了你的资料」「准备好下一个问题」各出现 1 次(收起的步骤回执)。
// 新值: 结算后无「已完成」回执,两条步骤名出现 0 次;turn 以开场正文开头。
// 原因: BUG-1137(TASK-rectification-message-cleanup-20261001 D3)——步骤只在生成中作等待态,结算后不渲染;BUG-1050 的「回执去重」随回执一起不再出现。
assert.ok(turn.startsWith(BODY_SENTENCES[0]!), turn);
for (const jargon of ["大运", "盘面", "代表分钟", "精确到秒", "对话焦点", "校正记录"]) {
assert.equal(turn.includes(jargon), false, jargon);
}
assert.doesNotMatch(text, /已完成 \d+ 步/);
assert.equal(text.split("看了你的资料").length - 1, 0);
assert.equal(text.split("准备好下一个问题").length - 1, 0);
} finally {
await chat.close();
}
});
}