Files
Jyotisha/frontend/tests/rectification-range-delivery-20260907.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

455 lines
18 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 { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
buildRangeDelivery,
d10DivergenceLabel,
d9DivergenceLabel,
} from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { D10_TYPE_TABLE, D9_TYPE_TABLE } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts";
import {
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
deliveryTurnNarration,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { appendPostAdoptProspectiveWindows } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
function cand(
time: string,
range: readonly [string, string],
posterior = 20,
): InferenceCandidate {
return {
id: time,
time,
cluster_range: range,
prior_score: posterior,
posterior_score: posterior,
probability: 0.5,
status: "active",
rank: 1,
strong_conflict_count: 0,
};
}
function inference(overrides: Partial<InferenceState> = {}): InferenceState {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: "04:48-04:59:04:50,04:56",
revision: 1,
phase: "discrimination",
result_status: "credible_range",
range_start: "04:48",
range_end: "04:59",
candidates: [
cand("04:50", ["04:48", "04:53"]),
cand("04:56", ["04:54", "04:59"]),
],
events: [
{ id: "e1", domain: "career", year: 2018, precision: "month", usage: "training" },
{ id: "e2", domain: "education", year: 2016, precision: "year", usage: "training" },
],
probes: [],
answered_probes: [],
rounds: [],
entropy: 1,
representative_time: "04:50",
credible_range: ["04:48", "04:59"],
...overrides,
};
}
function d9Probe() {
return {
id: "probe:varga.d9",
semantic_key: "varga.d9.天秤座/天蝎座",
candidate_split_hash: "varga.d9",
domain: "relationship",
year: 0,
question: "相处更像哪一种?",
candidate_ids: ["04:50", "04:56"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:50"], conflicts: ["04:56"] },
{ answer_class: "weak_yes" as const, supports: ["04:56"], conflicts: ["04:50"] },
],
information_gain: 1,
source: "varga_contrast",
choice_kind: "varga_style" as const,
style_options: [
{ sign: "天秤座", label: D9_TYPE_TABLE.天秤座.userChoice, answer_class: "yes" as const },
{ sign: "天蝎座", label: D9_TYPE_TABLE.天蝎座.userChoice, answer_class: "weak_yes" as const },
],
};
}
function d10Probe() {
return {
id: "probe:varga.d10",
semantic_key: "varga.d10.巨蟹座/狮子座",
candidate_split_hash: "varga.d10",
domain: "career",
year: 0,
question: "做事更像哪一种?",
candidate_ids: ["04:50", "04:56"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:50"], conflicts: ["04:56"] },
{ answer_class: "weak_yes" as const, supports: ["04:56"], conflicts: ["04:50"] },
],
information_gain: 1,
source: "varga_contrast",
choice_kind: "varga_style" as const,
style_options: [
{ sign: "巨蟹座", label: D10_TYPE_TABLE.巨蟹座.userChoice, answer_class: "yes" as const },
{ sign: "狮子座", label: D10_TYPE_TABLE.狮子座.userChoice, answer_class: "weak_yes" as const },
],
};
}
const publicCandidates = [
{ candidateId: ID_A, time: "04:50" },
{ candidateId: ID_B, time: "04:56" },
];
function deliveryResult(
delivery: ReturnType<typeof buildRangeDelivery>,
selectedTime: string | null = null,
): RectificationCandidateResult {
return {
resultId: "result-1",
candidates: delivery.columns.map((column, index) => ({
candidateId: column.candidate_id,
rank: index + 1,
time: column.time,
relativeSupport: 10,
tiedMinuteCount: 1,
})),
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
decisionReceipt: null,
representativeTime: delivery.representative_time,
selectedTime,
selectionKind: selectedTime ? "accepted" : null,
houseTable: null,
houseTablesByTime: {},
natalRecast: null,
techniqueAudit: [],
windowTransitions: [],
eventDashaLedger: [],
dashaAgreement: null,
lagnaContrast: null,
nakshatraBoundary: null,
precisionStage: null,
oosBlindPrompts: [],
confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
validated: false,
completionStatus: null,
sessionOutcome: "adopt_representative",
credibleRange: delivery.range,
rangeDelivery: delivery,
verificationReportMarkdown: delivery.verification_markdown,
};
}
test("delivery columns rank by posterior and write D9/D10 on every column", () => {
// 原值: rows 代表分钟第一、差异句来自类型表、相同则空
// 新值: 至多三列,按后验排序,每列写全 D9/D10,不预标排盘用
// 原因: BUG-597 决策 1–2
const delivery = buildRangeDelivery({
inference: inference({
probes: [d9Probe(), d10Probe()],
candidates: [
{ ...cand("04:50", ["04:48", "04:53"]), probability: 0.55, posterior_score: 30 },
{ ...cand("04:56", ["04:54", "04:59"]), probability: 0.35, posterior_score: 20 },
],
}),
publicCandidates,
credibleRange: ["04:48", "04:59"],
representativeTime: "04:50",
});
assert.equal(delivery.columns[0]?.time, "04:50");
assert.equal(delivery.columns[0]?.probability_percent, 55);
assert.ok(delivery.columns[0]?.traits.d9.includes(d9DivergenceLabel("天秤座") ?? "missing"));
assert.ok(delivery.columns[0]?.traits.d10.includes(d10DivergenceLabel("巨蟹座") ?? "missing"));
assert.equal(delivery.columns[1]?.time, "04:56");
assert.ok(delivery.columns[1]?.traits.d9.includes(d9DivergenceLabel("天蝎座") ?? "missing"));
assert.ok(delivery.columns[1]?.traits.d10.includes(d10DivergenceLabel("狮子座") ?? "missing"));
assert.equal(delivery.event_count, 2);
assert.equal(delivery.boundary, REPRESENTATIVE_MINUTE_DISCLAIMER);
assert.equal(delivery.representative_candidate_id, ID_A);
assert.equal(delivery.more_count, 0);
});
test("same D9/D10 lifts the shared sentences to the card top", () => {
const sharedD9 = {
...d9Probe(),
candidate_ids: ["04:50", "04:51"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:50", "04:51"], conflicts: [] },
{ answer_class: "weak_yes" as const, supports: [], conflicts: ["04:50", "04:51"] },
],
};
const sharedD10 = {
...d10Probe(),
candidate_ids: ["04:50", "04:51"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:50", "04:51"], conflicts: [] },
{ answer_class: "weak_yes" as const, supports: [], conflicts: ["04:50", "04:51"] },
],
};
const delivery = buildRangeDelivery({
inference: inference({
candidates: [
{ ...cand("04:50", ["04:48", "04:53"]), probability: 0.6 },
{ ...cand("04:51", ["04:48", "04:53"]), probability: 0.4 },
],
probes: [sharedD9, sharedD10],
representative_time: "04:50",
}),
publicCandidates: [
{ candidateId: ID_A, time: "04:50" },
{ candidateId: ID_B, time: "04:51" },
],
credibleRange: ["04:50", "04:51"],
representativeTime: "04:50",
});
assert.equal(delivery.columns.length, 2);
assert.ok(delivery.shared_traits.some((line) => line.includes("两个时间的关系盘都在")));
assert.ok(delivery.shared_traits.some((line) => line.includes("两个时间的事业盘都在")));
assert.equal(delivery.columns[0]?.traits.d9, "");
assert.equal(delivery.columns[1]?.traits.d9, "");
assert.equal(delivery.columns[0]?.traits.d10, "");
assert.equal(delivery.columns[1]?.traits.d10, "");
});
test("range-delivery DOM is one button per row and adopted is aria-pressed", () => {
const delivery = buildRangeDelivery({
inference: inference({ probes: [d9Probe()] }),
publicCandidates,
credibleRange: ["04:48", "04:59"],
representativeTime: "04:50",
verificationMarkdown: "| 方法 | 结果 |\n| --- | --- |\n| 1 | 吻合 |",
});
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: deliveryResult(delivery, "04:50"),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
}));
assert.equal([...html.matchAll(/<button\b/g)].length, delivery.columns.length);
assert.match(html, /aria-pressed="true"/);
assert.match(html, /查看验证报告/);
assert.match(html, /更像这个|已采用/);
assert.doesNotMatch(html, / open[=> ]/);
assert.doesNotMatch(html, /按这个范围用/);
assert.doesNotMatch(html, /再补一件经历/);
assert.doesNotMatch(html, /都不像/);
assert.doesNotMatch(html, /说不好/);
assert.doesNotMatch(html, /排盘用/);
const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
assert.match(source, /onAccept\(column\.candidate_id\)/);
assert.match(source, /aria-pressed=\{adopted\}/);
});
test("range-delivery never shows a post-card tie-break button", () => {
const delivery = buildRangeDelivery({
inference: inference({ probes: [d9Probe()] }),
publicCandidates,
credibleRange: ["04:48", "04:59"],
representativeTime: "04:50",
tieBreakAvailable: true,
});
const shown = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: deliveryResult({ ...delivery, tie_break_available: true }, "04:50"),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
}));
// 原值: tie_break_available + onTieBreak 时出现「再答两道参考题微调排序」
// 新值: 卡上不再渲染该入口,参考题只在出卡前问
// 原因: BUG-706,产品决定删掉卡上第二条路
assert.doesNotMatch(shown, /再答两道参考题微调排序/);
assert.doesNotMatch(shown, /onTieBreak/);
assert.equal([...shown.matchAll(/<button\b/g)].length, delivery.columns.length);
const locked = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: deliveryResult({ ...delivery, tie_break_available: true }, "04:50"),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
adoptLocked: true,
}));
assert.match(locked, /先答完上面这道,再选时间/);
});
test("the range-delivery component does not render relative support or the old two actions", () => {
const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.doesNotMatch(source, /相对支持度/);
assert.doesNotMatch(source, /当前推荐/);
assert.doesNotMatch(source, /采用此时间/);
assert.doesNotMatch(source, /排盘用/);
// 原值: 不含 rangeDeliveryAdopt / rangeDeliveryContinue;`Adopt[^e]` 会误伤 rangeDeliveryAdopting
// 新值: 只禁旧标识符单词本身
// 原因: BUG-595 决策 1
assert.doesNotMatch(source, /\brangeDeliveryAdopt\b|\brangeDeliveryContinue\b/);
assert.doesNotMatch(chat, /相对支持度/);
assert.doesNotMatch(chat, /RectificationCandidateCards/);
assert.match(chat, /<RectificationRangeDelivery/);
assert.match(source, /rangeDeliveryAdopting/);
assert.match(source, /rangeDeliveryMoreLikeThis/);
assert.match(source, /RectificationVerificationReport/);
});
test("chat pins the card on the offering message without a dismissed-range continue hint", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.match(chat, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/);
assert.doesNotMatch(chat, /dismissedRangeResultId/);
assert.doesNotMatch(chat, /RangeDeliveryContinueHint/);
assert.doesNotMatch(chat, /rectification-composer-meta/);
});
const CAREER_WINDOW_PROBE = {
candidate_label: "A",
domain: "career",
window_label: "2027 年 3 月附近",
user_meaning: "候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。这是预测窗口,不是承诺;下次发生时回来补一条,可进一步分辨。",
used_for_scoring: false,
};
test("post-adopt first-round narration appends prospective windows when present", () => {
const base = "2013 年前后,钱上有没有明显变化?";
const withWindows = appendPostAdoptProspectiveWindows(base, {
prospective_probes: [CAREER_WINDOW_PROBE],
});
assert.match(withWindows, /预测下一次事业变动/);
assert.match(withWindows, /2013 年前后/);
const without = appendPostAdoptProspectiveWindows(base, { prospective_probes: [] });
assert.equal(without, base);
const idle = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
const start = idle.indexOf("export async function persistNextInterviewIfIdle");
const slice = idle.slice(start, start + 1800);
assert.match(slice, /appendPostAdoptProspectiveWindows/);
assert.match(slice, /dossier\.case\.acceptedTime/);
});
test("delivery turn narration is three sentences without eight-method tables", () => {
const text = deliveryTurnNarration({
credibleRange: ["04:51", "04:59"],
representativeTime: "04:53",
eventCount: 7,
fitPercent: 80,
});
const sentences = text.split(/(?<=。)/).filter(Boolean);
assert.equal(sentences.length, 3);
assert.doesNotMatch(text, /方法1|Technique Audit/);
});
function ledgerRow(
match: "strong" | "medium" | "weak" | "none",
yearMonth: string,
domain: string,
) {
return {
summary: `${domain} ${yearMonth}`,
match,
match_label: match,
user_meaning: `${domain} ${yearMonth}`,
gochara: null,
domain,
year_month: yearMonth,
};
}
test("posterior top three still get fit when engine score order differs", () => {
const ID_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3";
const delivery = buildRangeDelivery({
inference: inference({
candidates: [
{ ...cand("04:49", ["04:47", "04:53"]), probability: 0.51, posterior_score: 51 },
{ ...cand("04:47", ["04:47", "04:53"]), probability: 0.24, posterior_score: 24 },
{ ...cand("04:53", ["04:47", "04:53"]), probability: 0.24, posterior_score: 24 },
],
representative_time: "04:49",
range_start: "04:47",
range_end: "04:53",
credible_range: ["04:47", "04:53"],
}),
publicCandidates: [
{ candidateId: ID_A, time: "04:49" },
{ candidateId: ID_B, time: "04:47" },
{ candidateId: ID_C, time: "04:53" },
],
credibleRange: ["04:47", "04:53"],
representativeTime: "04:49",
eventDashaLedgerByTime: {
"04:47": [ledgerRow("strong", "2016-09", "education"), ledgerRow("none", "2024-05", "relationship")],
"04:49": [ledgerRow("strong", "2016-09", "education"), ledgerRow("medium", "2018-03", "career")],
"04:53": [ledgerRow("weak", "2016-09", "education"), ledgerRow("none", "2024-05", "relationship")],
},
prospectiveWindowsByTime: {
"04:47": [{ domain: "career", from: "2027-03", to: "2027-03" }],
"04:49": [{ domain: "career", from: "2027-03", to: "2027-03" }],
"04:53": [],
},
});
assert.equal(delivery.columns.map((column) => column.time).join(","), "04:49,04:47,04:53");
assert.ok(delivery.columns.every((column) => column.fit != null));
assert.ok(delivery.columns.every((column) => column.windows != null));
assert.match(delivery.columns[0]?.fit_line ?? "", /2 件经历里 2 件对得上/);
assert.match(delivery.columns[1]?.fit_line ?? "", /最不合的是 2024 年 5 月那段感情/);
assert.equal(delivery.columns[2]?.window_line, RECTIFICATION_USER_COPY.rangeDeliveryNoWindow);
});
test("missing by_time keys stay null and render 还没对照, never 0 · 0 · 0", () => {
const delivery = buildRangeDelivery({
inference: inference({
probes: [d9Probe()],
candidates: [
{ ...cand("04:50", ["04:48", "04:59"]), probability: 0.6 },
{ ...cand("04:56", ["04:48", "04:59"]), probability: 0.4 },
],
}),
publicCandidates,
credibleRange: ["04:48", "04:59"],
representativeTime: "04:50",
eventDashaLedgerByTime: {
"04:50": [ledgerRow("strong", "2016-09", "education")],
},
prospectiveWindowsByTime: {
"04:50": [{ domain: "career", from: "2027-03", to: "2027-03" }],
},
});
assert.ok(delivery.columns[0]?.fit != null);
assert.equal(delivery.columns[1]?.fit, null);
assert.equal(delivery.columns[1]?.windows, null);
assert.equal(delivery.columns[1]?.fit_line, RECTIFICATION_USER_COPY.rangeDeliveryNotCompared);
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: deliveryResult(delivery),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
}));
assert.match(html, /这一分钟还没对照/);
assert.doesNotMatch(html, /0 · 0 · 0/);
assert.doesNotMatch(html, /强相关/);
assert.doesNotMatch(html, /<h3/);
assert.doesNotMatch(html, /经历对照/);
assert.doesNotMatch(html, /往后 12 个月/);
});