Files
Jyotisha/frontend/tests/rectification-targeted-card-live-20260913.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

185 lines
6.9 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 { readFileSync } from "node:fs";
import test from "node:test";
import {
CHOICE_SKIP_QUESTION_LABEL,
TARGETED_COLLECT_KEEP_HINT,
TARGETED_COLLECT_OPTION_A,
TARGETED_COLLECT_OPTION_B,
TARGETED_COLLECT_OPTION_C,
TARGETED_COLLECT_OPTION_D,
parseRectificationChoiceCard,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
buildMethodFollowupPlan,
projectRectificationChoiceCard,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
interviewChoiceCardUnavailable,
interviewCollectWaiting,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const FOCUS_ID = "abababab-abab-4bab-8bab-abababababab";
const TARGETED_PROMPT = "收入明显变过或有过大笔进出吗?";
const TARGETED_COPY = {
prompt: TARGETED_PROMPT,
option_a: TARGETED_COLLECT_OPTION_A,
option_b: TARGETED_COLLECT_OPTION_B,
option_c: TARGETED_COLLECT_OPTION_C,
option_d: TARGETED_COLLECT_OPTION_D,
options: [
{ key: "A" as const, label: TARGETED_COLLECT_OPTION_A, answer_class: "yes" as const },
{ key: "B" as const, label: TARGETED_COLLECT_OPTION_B, answer_class: "no" as const },
{ key: "C" as const, label: TARGETED_COLLECT_OPTION_C, answer_class: "unsure" as const },
{ key: "D" as const, label: TARGETED_COLLECT_OPTION_D, answer_class: "weak_yes" as const },
],
};
const DATED_EVIDENCE = [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
},
] as const;
function targetedSchema() {
return {
targeted_collect: true,
collect_kind: "targeted:finance",
scoring: false,
choice: TARGETED_COPY,
prompt: TARGETED_PROMPT,
};
}
function targetedFocus(questionId: string) {
return {
id: FOCUS_ID,
questionId,
intent: "collect_method_evidence",
targetDomain: "finance",
targetKind: "targeted:finance",
expectedAnswerSchema: targetedSchema(),
};
}
function project(questionId: string) {
return projectRectificationChoiceCard({
evidence: DATED_EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
remainingLayers: ["d9", "d10", "d2", "d11"],
remainingSplitTimes: ["04:48", "05:07"],
remainingCandidateCount: 5,
activeFocus: targetedFocus(questionId),
});
}
test("GET projects a live targeted existence card from the persisted copy", () => {
const card = project("collect:targeted:finance");
const question = projectCurrentQuestion(targetedFocus("collect:targeted:finance"));
assert.ok(card, "targeted existence must stay tappable after a snapshot reread");
assert.equal(card.focus_id, FOCUS_ID);
assert.equal(card.question_id, "collect:targeted:finance");
assert.equal(card.prompt, TARGETED_PROMPT);
assert.equal(card.options.length, 4);
assert.equal(card.options[0]?.label, TARGETED_COLLECT_OPTION_A);
assert.equal(card.scoring, false);
assert.equal(card.stop_label, CHOICE_SKIP_QUESTION_LABEL);
assert.equal(card.skip_this_probe, undefined);
assert.equal(question?.kind, "choice");
assert.equal(card.focus_id, question?.focus_id);
assert.equal(parseRectificationChoiceCard(card)?.focus_id, FOCUS_ID);
});
test("GET still projects when persist added a :next suffix", () => {
const card = project("collect:targeted:finance:next");
assert.ok(card, ":next must not be the identity of a targeted card");
assert.equal(card.question_id, "collect:targeted:finance:next");
assert.equal(card.focus_id, FOCUS_ID);
assert.equal(card.scoring, false);
assert.equal(card.options.length, 4);
});
test("recast keep rebuilds the targeted choice_frame and does not ask the model to rewrite the stem", () => {
const plan = buildMethodFollowupPlan({
evidence: DATED_EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
remainingLayers: ["d9", "d10", "d2", "d11"],
remainingSplitTimes: ["04:48", "05:07"],
remainingCandidateCount: 5,
activeFocus: targetedFocus("collect:targeted:relocation:next"),
});
const followup = plan.next_followup;
assert.ok(followup?.choice_frame, "keep must rebuild the A–D frame");
assert.equal(followup.choice_frame.question_id, "collect:targeted:relocation:next");
assert.equal(followup.choice_frame.scoring, false);
assert.equal(followup.user_prompt_hint, TARGETED_COLLECT_KEEP_HINT);
assert.doesNotMatch(followup.user_prompt_hint, /spokenPrompt 写出题干/);
});
test("a choice question without a GET card is the repair path, not collect waiting", () => {
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true);
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false);
assert.equal(interviewChoiceCardUnavailable({ questionKind: "collect_spoken", hasChoiceCard: false }), false);
// 原值: collect_spoken 不是 deadChoice
// 新值: collect:targeted:<domain> 存在题的口述形态是 deadChoice
// 原因: BUG-673 复发路径不经过 kind=choice
assert.equal(
interviewChoiceCardUnavailable({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionId: "collect:targeted:family",
}),
true,
);
assert.equal(
interviewCollectWaiting({
sessionOutcome: "collect_evidence",
questionMissing: false,
}),
false,
);
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: false,
questionLoadFailed: true,
questionPersisted: false,
collectWaiting: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
}),
"unavailable",
);
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.match(chat, /interviewChoiceCardUnavailable/);
assert.match(chat, /questionSource === "unavailable" \|\| deadChoice/);
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/);
// 原值: 容器 map 内有 `unansweredDeadChoice`。
// 新值: 同一判定在行组件内。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /unansweredDeadChoice/);
});