Files
Jyotisha/frontend/tests/rectification-v10-focus-receipt-consistency.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

161 lines
6.6 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
loadV9TurnReceipt,
setV10ConversationFocus,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
} from "../src/lib/rectification-activity-receipt.ts";
import {
CASE_ID,
EVIDENCE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const migrationFilename = "20260815010000_rectification_focus_receipt_consistency.sql";
const migration = readFileSync(
new URL(`../supabase/migrations/${migrationFilename}`, import.meta.url),
"utf8",
);
const migrationCopy = fileURLToPath(
new URL(`../db/migrations/${migrationFilename}`, import.meta.url),
);
function migrationFunction(name: string, nextMarker: string): string {
const start = migration.indexOf(`create or replace function public.${name}`);
const end = migration.indexOf(nextMarker, start);
assert.notEqual(start, -1, `missing migration function ${name}`);
assert.notEqual(end, -1, `missing boundary after migration function ${name}`);
return migration.slice(start, end);
}
test("focus/receipt consistency migration is forward-only and business-tree only", () => {
assert.ok(migrationFilename > "20260814060000_product_feature_flags.sql");
assert.match(migration, /^-- Rectification focus RPC[\s\S]*\nbegin;[\s\S]*^commit;$/m);
assert.equal(existsSync(migrationCopy), false);
});
test("SQL-shaped focus contract returns the same complete row on create and idempotent replay", () => {
const focusFunction = migrationFunction(
"set_agentic_rectification_conversation_focus",
"revoke all on function public.set_agentic_rectification_conversation_focus",
);
assert.equal((focusFunction.match(/'focus', to_jsonb\(v_focus\)/g) ?? []).length, 2);
assert.match(focusFunction, /'focus', to_jsonb\(v_focus\),\s*'idempotent', true/);
assert.match(focusFunction, /'focus', to_jsonb\(v_focus\),\s*'idempotent', false/);
assert.doesNotMatch(focusFunction, /'focus_id'/);
});
test("tool service accepts the identical SQL focus envelope for create and replay", async () => {
let callCount = 0;
const focus = activeFocusFixture({
targetEvidenceId: EVIDENCE_ID,
expectedAnswerSchema: { required: ["month"] },
});
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
callCount += 1;
return { focus, idempotent: callCount === 2 };
},
});
const input = {
questionId: "career-month-question",
intent: "clarify_event_date",
targetEvidenceId: EVIDENCE_ID,
targetDomain: "career",
targetKind: "career_entry",
expectedAnswerSchema: { required: ["month"] },
} as const;
const created = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, input);
const replayed = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, input);
assert.deepEqual(replayed.focus, created.focus);
assert.equal(created.focus.id, FOCUS_ID);
assert.equal(created.idempotent, false);
assert.equal(replayed.idempotent, true);
});
test("persisted receipt selects the latest attempt and exposes latest terminal tool activity", async () => {
const receiptFunction = migrationFunction(
"get_agentic_rectification_turn_receipt",
"revoke all on function public.get_agentic_rectification_turn_receipt",
);
assert.match(receiptFunction, /where turn_id = p_turn_id\s+order by attempt_number desc\s+limit 1/);
assert.match(receiptFunction, /tr\.status in \('completed', 'failed'\)/);
assert.match(receiptFunction, /select distinct on \(tr\.tool_name\)/);
assert.match(receiptFunction, /'tool_activities', v_tool_activities/);
assert.doesNotMatch(receiptFunction, /where tr\.turn_id = p_turn_id and tr\.status = 'completed'/);
const accounting = fakeAccounting({
get_agentic_rectification_turn_receipt: () => ({
turn_id: TURN_ID,
attempt_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
status: "retryable",
skill_name: "jyotish-birth-time-rectification",
skill_version: "9.0.0",
engine_version: null,
phases: [{ phase: "run.failed", tool: null }],
tool_activities: [
{ tool: "rectification-read-case", status: "completed", methods: [] },
{ tool: "rectification-set-focus", status: "failed", methods: ["d1-rashi"] },
],
tools: ["rectification-read-case"],
methods: [],
started_at: "2026-08-15T01:00:00.000Z",
completed_at: null,
}),
});
const receipt = await loadV9TurnReceipt(accounting.client, USER_ID, CASE_ID, TURN_ID);
assert.ok(receipt);
assert.deepEqual(receipt.toolActivities, [
{ tool: "rectification-read-case", status: "completed", methods: [] },
{ tool: "rectification-set-focus", status: "failed", methods: [] },
]);
const projectActivities = (activities: typeof receipt.toolActivities) => {
let state = createRectificationActivityReceiptState();
for (const activity of activities) {
state = reduceRectificationActivityReceipt(state, activity);
}
return receiptFromRectificationActivityState(state);
};
const persistedView = projectActivities(receipt.toolActivities);
const liveView = projectActivities([
{ tool: "rectification-read-case", status: "completed", methods: [] },
{ tool: "rectification-set-focus", status: "failed", methods: [] },
]);
assert.deepEqual(persistedView, liveView);
assert.deepEqual(persistedView, {
steps: ["rectification-read-case"],
methods: [],
failedTool: "rectification-set-focus",
});
});
const caseRoute = readFileSync(
new URL("../src/lib/rectification-agentic/v9/case-dossier-response.ts", import.meta.url),
"utf8",
);
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
test("refresh projection rebuilds the same activity receipt from persisted terminal states", () => {
assert.match(caseRoute, /tool_activities: receipt\.toolActivities\.map/);
assert.match(chat, /Array\.isArray\(receipt\.tool_activities\)/);
assert.match(chat, /reduceRectificationActivityReceipt\(state/);
assert.match(chat, /receiptFromRectificationActivityState\(state\)/);
});