Files
Jyotisha/frontend/tests/rectification-post-adopt-verify-20260904.test.ts
T
Jesse_ChenandClaude Opus 5.5 3a66c39f48 refactor(rectification): split the chat component into hooks, parameter functions and blocks
Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API,
copy, DB, Skill or scoring change.

- rectification-agentic-chat.tsx: 2043 -> 737 lines; component body
  1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5,
  useCallback 12 -> 6.
- Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout
  and live label clock -> two small hooks.
- send / submitStructuredChoice / acceptCandidate bodies ->
  lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions
  (bodies verbatim, deps destructured to the same names); copy/regenerate,
  question repair, pure transcript and snapshot helpers and the per-render
  view derivation -> lib/rectification-chat-*.ts (no React hooks).
- Question-gap blocks and the read-only range line ->
  components/rectification-question-gap-notices.tsx.
- Dependency arrays kept exactly as before (lint warnings +3, listed in
  PROGRESS); no dep added to appease the linter.
- Tests: whole-source contracts read tests/rectification-chat-surface.ts
  (container + split files, like home-surface.ts); slices of moved code now
  call the extracted functions or render the extracted block, each with a
  原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:23:52 +08:00

235 lines
9.0 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.
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,
regenerating: 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, '<p class="rectification-pending-note" role="status">已按 05:07 核对完毕。</p>');
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 年前后/);
});