Files
Jyotisha/frontend/tests/account-dialog-inert-20260918.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

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);
});