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
217 lines
8.9 KiB
TypeScript
217 lines
8.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { createElement, createRef } from "react";
|
|
import { renderToString } from "react-dom/server";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
AccountDialogOverlay,
|
|
type AccountOverlayModel,
|
|
} from "../src/components/account-dialog-overlay.tsx";
|
|
import { warnRectificationCaseGet } from "../src/lib/rectification-case-get-log.ts";
|
|
import {
|
|
attachQuestionsToTurns,
|
|
renderableChoiceStem,
|
|
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
|
import { persistCollectDenialTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
|
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
|
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
|
import { mergeTurnQuestions, type SnapshotTurnMessage } from "../src/lib/rectification-snapshot-messages.ts";
|
|
import {
|
|
RECTIFICATION_SNAPSHOT_RETRY_LABEL,
|
|
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
|
|
} from "../src/lib/rectification-surface-state.ts";
|
|
import {
|
|
CASE_ID,
|
|
FOCUS_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
fakeAccounting,
|
|
} from "./rectification-v9-test-support.ts";
|
|
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
|
|
|
|
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
|
|
|
const EXISTENCE_OPTIONS = [
|
|
{ key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes" },
|
|
{ key: "B" as const, label: "发生过但时间偏了", answer_class: "weak_yes" },
|
|
{ key: "C" as const, label: "没有这回事", answer_class: "no" },
|
|
{ key: "D" as const, label: "这段记不清楚", answer_class: "unsure" },
|
|
];
|
|
|
|
function choiceSchema(prompt: string) {
|
|
return {
|
|
prompt,
|
|
probe_id: "probe:finance:2025-09",
|
|
choice: {
|
|
prompt,
|
|
option_a: EXISTENCE_OPTIONS[0]!.label,
|
|
option_b: EXISTENCE_OPTIONS[1]!.label,
|
|
option_c: EXISTENCE_OPTIONS[2]!.label,
|
|
option_d: EXISTENCE_OPTIONS[3]!.label,
|
|
options: EXISTENCE_OPTIONS,
|
|
},
|
|
};
|
|
}
|
|
|
|
function overlayModel(): AccountOverlayModel {
|
|
return {
|
|
title: "个人资料",
|
|
dialogClass: "settings-modal",
|
|
signingOut: false,
|
|
close() {},
|
|
navigate() {},
|
|
overlayRef: createRef<HTMLElement | null>() as AccountOverlayModel["overlayRef"],
|
|
closeButtonRef: createRef<HTMLButtonElement | null>() as AccountOverlayModel["closeButtonRef"],
|
|
renderProfile() { return null; },
|
|
renderChartLibrary() { return null; },
|
|
renderBilling() { return null; },
|
|
renderGeneral() { return null; },
|
|
renderLogout() { return null; },
|
|
};
|
|
}
|
|
|
|
test("the account dialog and onboarding paywall portal to document.body, outside SidebarInset inert (BUG-968)", () => {
|
|
const overlay = read("../src/components/account-dialog-overlay.tsx");
|
|
const paywall = read("../src/components/onboarding-redeem-paywall.tsx");
|
|
const layout = read("../src/app/(app)/layout.tsx");
|
|
const shell = read("../src/hooks/use-home-shell-registration.ts");
|
|
const sidebar = read("../src/components/ui/sidebar.tsx");
|
|
|
|
assert.match(layout, /<SidebarInset[\s\S]*inert=\{registration\?\.insetInert\}[\s\S]*>\s*\{children\}\s*<\/SidebarInset>/);
|
|
assert.match(shell, /insetInert: modalOpen/);
|
|
assert.match(sidebar, /inert=\{Boolean\(inert\) \|\| \(isMobile && openMobile\)\}/);
|
|
assert.doesNotMatch(layout, /pointer-events:\s*none/);
|
|
assert.match(overlay, /createPortal\(overlay, document\.body\)/);
|
|
assert.match(paywall, /createPortal\(overlay, document\.body\)/);
|
|
|
|
const html = renderToString(createElement(AccountDialogOverlay, {
|
|
open: true,
|
|
dialog: "logout",
|
|
model: overlayModel(),
|
|
}));
|
|
assert.match(html, /role="dialog"/);
|
|
assert.doesNotMatch(html, /inert/);
|
|
});
|
|
|
|
test("GET /api/rectification/cases/[caseId] warns JSON on every non-200 branch (BUG-969)", () => {
|
|
const route = read("../src/app/api/rectification/cases/[caseId]/route.ts");
|
|
assert.match(route, /function failedCaseGet/);
|
|
assert.match(route, /warnRectificationCaseGet/);
|
|
assert.match(route, /jsonForSupabaseSetupFailure[\s\S]*warnRectificationCaseGet/);
|
|
assert.ok((route.match(/failedCaseGet\(/g) ?? []).length >= 5, "401/400/409/404/503 must all warn");
|
|
assert.match(route, /unauthenticated/);
|
|
assert.match(route, /invalid_case_id/);
|
|
assert.match(route, /case_session_mismatch/);
|
|
assert.match(route, /case_not_found/);
|
|
assert.match(route, /rectification_service_failed/);
|
|
|
|
const logged: string[] = [];
|
|
const original = console.warn;
|
|
console.warn = (message?: unknown) => {
|
|
logged.push(String(message));
|
|
};
|
|
try {
|
|
warnRectificationCaseGet({
|
|
caseId: "11111111-1111-4111-8111-111111111111",
|
|
status: 503,
|
|
code: "rectification_service_failed",
|
|
reason: "timeout",
|
|
});
|
|
} finally {
|
|
console.warn = original;
|
|
}
|
|
assert.equal(logged.length, 1);
|
|
const payload = JSON.parse(logged[0] ?? "{}") as Record<string, unknown>;
|
|
assert.equal(payload.event, "rectification_case_get_failed");
|
|
assert.equal(payload.status, 503);
|
|
assert.equal(payload.code, "rectification_service_failed");
|
|
assert.equal(payload.reason, "timeout");
|
|
assert.equal(payload.case_id, "11111111-1111-4111-8111-111111111111");
|
|
assert.equal(JSON.stringify(payload).includes("@"), false);
|
|
});
|
|
|
|
test("a 503 snapshot miss shows status copy and 重新读取, not collect-waiting (BUG-969)", () => {
|
|
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
|
|
const chat = rectificationChatSurface;
|
|
assert.equal(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY, "这一问还没读到。");
|
|
assert.equal(RECTIFICATION_SNAPSHOT_RETRY_LABEL, "重新读取");
|
|
assert.match(chat, /RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY/);
|
|
assert.match(chat, /RECTIFICATION_SNAPSHOT_RETRY_LABEL/);
|
|
assert.match(chat, /setSnapshotFailure\(\{ status: response\.status \}\)/);
|
|
assert.match(chat, /onClick=\{\(\) => void refetchQuestion\(\)\}/);
|
|
assert.match(chat, /!snapshotFailure && questionGap === "collect_waiting"/);
|
|
assert.doesNotMatch(chat, /题目加载失败,请刷新/);
|
|
const hook = read("../src/hooks/use-rectification-surface.ts");
|
|
assert.match(hook, /RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY/);
|
|
});
|
|
|
|
test("collect denial with a next choice streams ack+stem; attachQuestionsToTurns keeps the stem once (BUG-969)", async () => {
|
|
const prompt = "2025 年 9 月前后,钱的方面有没有明显变化?";
|
|
const ack = RECTIFICATION_USER_COPY.collectDeclinedAck;
|
|
const schema = choiceSchema(prompt);
|
|
const focus = {
|
|
id: FOCUS_ID,
|
|
caseId: CASE_ID,
|
|
questionId: "distinguish:finance:2025-09",
|
|
intent: "distinguish_candidates",
|
|
targetEvidenceId: null,
|
|
targetDomain: "finance",
|
|
targetKind: null,
|
|
expectedAnswerSchema: schema,
|
|
status: "active" as const,
|
|
askedAt: "2026-09-18T09:46:00.000Z",
|
|
resolvedAt: null,
|
|
askedTurnId: null,
|
|
answerOption: null,
|
|
};
|
|
assert.equal(renderableChoiceStem(focus), prompt);
|
|
|
|
const accounting = fakeAccounting({
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
focus: { ...focus, asked_turn_id: args.p_asked_turn_id, askedTurnId: args.p_asked_turn_id },
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const result = await persistCollectDenialTurn({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
requestId: "99999999-9999-4999-8999-999999999999",
|
|
userMessage: "想不到了",
|
|
applied: {
|
|
narration: prompt,
|
|
ack,
|
|
closeStatus: "skipped",
|
|
nextInterviewPersisted: true,
|
|
nextChoiceReady: true,
|
|
focus,
|
|
},
|
|
});
|
|
const stored = composeCollectSpokenAssistantText(ack, prompt);
|
|
assert.equal(result.streamText, stored);
|
|
assert.match(result.streamText, /2025 年 9 月/);
|
|
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
|
assert.equal(turn?.args.p_assistant_message, stored);
|
|
|
|
const attached = attachQuestionsToTurns(
|
|
[{ id: TURN_ID, role: "assistant", text: stored }],
|
|
[{ ...focus, askedTurnId: TURN_ID }],
|
|
);
|
|
assert.equal(attached[0]?.text, ack);
|
|
assert.equal(attached[0]?.question?.prompt, prompt);
|
|
assert.equal(attached[0]?.question?.kind, "choice");
|
|
assert.equal((attached[0]?.text ?? "").includes(prompt), false);
|
|
|
|
// BUG-1045: the refresh path above was the only one this test covered. The
|
|
// live transcript keeps the streamed text until the snapshot merge; that
|
|
// merge must drop the stem the same way.
|
|
const [live] = mergeTurnQuestions(
|
|
[{ role: "assistant", turnId: TURN_ID, text: result.streamText, renderKey: "live" } satisfies SnapshotTurnMessage as SnapshotTurnMessage],
|
|
attached,
|
|
);
|
|
assert.equal(live?.text, ack);
|
|
assert.equal(live?.question?.prompt, prompt);
|
|
});
|