import assert from "node:assert/strict"; import test from "node:test"; import { templatePostAdoptExplain, validateAdoptNarration, } from "../src/lib/rectification-agentic/v9/adopt-narration.ts"; import { buildMethodFollowupPlan, buildNextUserAction, spokenFollowupForUser, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { RECTIFICATION_QUESTION_RETRY_LIMIT, rectificationQuestionGapState, rectificationQuestionRetryActive, type RectificationQuestionGapInput, } from "../src/lib/rectification-surface-state.ts"; import { rectificationChatSurface } from "./rectification-chat-surface.ts"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { RectificationQuestionGapNotices } from "../src/components/rectification-question-gap-notices.tsx"; import { RECTIFICATION_QUESTION_RELOAD_LABEL } from "../src/lib/rectification-surface-state.ts"; const STYLE_OPTIONS = [ { label: "明确发生且时间吻合", answer_class: "yes" as const }, { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, { label: "明确没有发生", answer_class: "no" as const }, { label: "这段记不清楚", answer_class: "unsure" as const }, ]; function verifyProbe(input: { domain: "finance" | "relocation"; year: number; semantic: string; split: string; }) { return { year: input.year, year_label: `${input.year} 年前后`, domain: input.domain, event_family: input.domain === "finance" ? "收入明显变化、大笔支出或欠债" : "搬家或长期住到外地", source: "dasha_activation" as const, tracks: ["vimshottari", "narayana"] as const, tracks_agree: true, unique_minute_claim: false as const, user_meaning: `${input.year} 年前后是否有相关经历。`, role: "distinguish" as const, style_options: STYLE_OPTIONS, semantic_key: input.semantic, candidate_split_hash: input.split, }; } const FINANCE_2013 = verifyProbe({ domain: "finance", year: 2013, semantic: "finance.2013.verify", split: "finance-2013-split", }); const RELOC_2017 = verifyProbe({ domain: "relocation", year: 2017, semantic: "relocation.2017.verify", split: "reloc-2017-split", }); const EDUCATION_EVIDENCE = [{ status: "confirmed" as const, domain: "education", datePrecision: "year" as const, occurredFrom: "2008-01-01", occurredTo: null, }]; const gapBase: RectificationQuestionGapInput = { liveQuestionVisible: false, questionMissing: true, questionLoadFailed: false, busy: false, readonly: false, snapshotLoaded: true, resumableCase: true, retryAttempts: 0, }; test("post-adopt reverse-verify skips a probe already present in askedKeys", () => { const remaining = buildMethodFollowupPlan({ evidence: EDUCATION_EVIDENCE, accepted: true, eventProbes: [FINANCE_2013, RELOC_2017], askedProbeKeys: [FINANCE_2013.semantic_key], }); assert.equal(remaining.next_followup?.domain, "relocation"); assert.equal(remaining.next_followup?.semantic_key, RELOC_2017.semantic_key); const none = buildMethodFollowupPlan({ evidence: EDUCATION_EVIDENCE, accepted: true, eventProbes: [FINANCE_2013, RELOC_2017], askedProbeKeys: [FINANCE_2013.semantic_key, RELOC_2017.semantic_key], }); assert.equal(none.next_followup, null); const action = buildNextUserAction({ scorableCount: 1, evidenceCount: 1, hasLatestResult: true, selectionAllowed: true, sessionOutcome: "adopt_representative", nextFollowup: none.next_followup, workingTime: "05:07", accepted: true, }); assert.equal(action.id, "start_consultation"); }); test("post-adopt reverse-verify also dedupes by candidate_split_hash", () => { const plan = buildMethodFollowupPlan({ evidence: EDUCATION_EVIDENCE, accepted: true, eventProbes: [FINANCE_2013, RELOC_2017], askedProbeKeys: [FINANCE_2013.candidate_split_hash], }); assert.equal(plan.next_followup?.domain, "relocation"); assert.notEqual(plan.next_followup?.semantic_key, FINANCE_2013.semantic_key); }); test("empty reverse-verify list forbids a 会拿…核对 promise", () => { const facts = { precision_stage: "ready_to_adopt" as const, already_accepted: false, credible_range: ["05:00", "05:06"] as const, representative_minute: "05:00", runner_up_minute: "05:06", opening_window: ["04:45", "05:15"] as const, active_candidates: [], answered_rounds: 5, stop_facts: [], post_adopt_verification: [], minutes: ["05:00", "05:06"], years: [], }; assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/); const denied = validateAdoptNarration("采用后会拿 2016 年教育题来核对。", facts); assert.equal(denied.ok, false); if (!denied.ok) assert.equal(denied.reason, "promise"); }); test("question gap: start_consultation is verified_idle; missing next_user_action keeps the old path", () => { assert.equal(rectificationQuestionGapState({ ...gapBase, nextUserActionId: "start_consultation", }), "verified_idle"); assert.equal(rectificationQuestionRetryActive("verified_idle"), false); assert.equal(rectificationQuestionGapState(gapBase), "preparing"); assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, }), "unavailable"); }); test("verified_idle UI is a closing line without reload, gap copy, or consult handoff", () => { // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 const chat = rectificationChatSurface; assert.match(chat, /questionGap === "verified_idle"/); assert.match(chat, /postAdoptVerifyDoneCopy/); assert.match(chat, /skip_this_probe/); assert.match(chat, /SKIP_PROBE_ACTION/); assert.doesNotMatch(chat, /rectification-consult-handoff/); // 原值: 从 chat 源码第一处 `questionGap === "verified_idle"` 起取 400 字,断言不含 // rectification-question-gap__copy 与 RECTIFICATION_QUESTION_RELOAD_LABEL。 // 新值: 真渲染缺题块(verified_idle + 收尾句、无对照卡):只有一行 role="status" 的收尾句, // 没有缺题文案、没有「接着问」重载按钮。 // 原因: TASK-rectification-code-split-20260926 该 JSX 原样搬进 rectification-question-gap-notices.tsx; // 渲染断言比按字数截源码更准。 const idleHtml = renderToStaticMarkup(createElement(RectificationQuestionGapNotices, { view: { showStandaloneQuestion: false, questionGap: "verified_idle", showSelectionCards: false, verifiedIdleCopy: "已按 05:07 核对完毕。" }, currentQuestion: null, choiceCard: null, choiceNonce: 0, busy: false, readonly: false, snapshotFailure: null, questionRepairAttempts: 0, questionRepairing: false, submitChoice() {}, submitStop() {}, async refetchQuestion() {}, async repairQuestion() {}, })); assert.equal(idleHtml, '
已按 05:07 核对完毕。
'); assert.doesNotMatch(idleHtml, /rectification-question-gap__copy/); assert.ok(!idleHtml.includes(RECTIFICATION_QUESTION_RELOAD_LABEL)); // 原值: 状态条「已采用 {savedTime}」+「之后新建对话即按此时间排盘」 // 新值: verified_idle 走 postAdoptVerifyDoneCopy;无核对题用新句 // 原因: BUG-595 决策 3 assert.doesNotMatch(chat, /之后新建对话即按此时间排盘/); }); test("family collect spoken stem uses a year cue while probe_year stays dated", () => { const plan = buildMethodFollowupPlan({ evidence: [ { status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2011-01-01", occurredTo: null }, { status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2014-01-01", occurredTo: null }, ], evidenceCollectionProbes: [{ year: 2021, year_label: "2021 年前后", domain: "family", event_family: "家人结婚、添丁或住院", source: "age_band", tracks: ["vimshottari", "narayana"], tracks_agree: false, unique_minute_claim: false, user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", role: "collect", phase: "evidence_collection", information_gain: 0, semantic_key: "family.2021", }], }); // 原值: family + 2021 年前后 // 新值: 训练门关时邀请优先,题干不含年龄段年份 // 原因: 撤回 BUG-642(BUG-648) assert.equal(plan.next_followup?.domain, "other"); assert.equal(plan.next_followup?.collection_key, "collect:invite:more"); assert.equal(plan.next_followup?.choice_frame, null); const spoken = spokenFollowupForUser(plan.next_followup); assert.match(spoken ?? "", /还有吗/); assert.doesNotMatch(spoken ?? "", /2021 年前后/); });