Files
Jyotisha/frontend/tests/rectification-settled-render-split.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

160 lines
5.8 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 type { ChatMessageFeedback } from "../src/components/chat-message-actions.tsx";
import {
RectificationMessageEntry,
UnsplitRectificationMessageList,
type RectificationMessageActions,
type RectificationMessageEntryProps,
type RenderMessage,
} from "../src/components/rectification-message-entry.tsx";
import {
disableHomeStreamingRenderProbe,
enableHomeStreamingRenderProbe,
homeStreamingRenderProbeSnapshot,
resetHomeStreamingRenderProbe,
} from "../src/lib/home-streaming-render-probe.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chatSource = rectificationChatSurface;
const entrySource = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
const rowSource = readFileSync(
new URL("../src/components/chat-message-row.tsx", import.meta.url),
"utf8",
);
function settled(role: "user" | "assistant", text: string, key: string): RenderMessage {
return {
role,
text,
renderKey: key,
state: "settled",
};
}
function liveAssistant(text: string): RenderMessage {
return {
role: "assistant",
text,
renderKey: "live-assistant",
state: "streaming",
};
}
function emptyActions(): RectificationMessageEntryProps["actionsRef"] {
const actionsRef = createRef<RectificationMessageActions>() as RectificationMessageEntryProps["actionsRef"];
actionsRef.current = {
submitChoice() {},
submitStop() {},
copyMessage() {},
regenerateMessage() {},
onFeedback() {},
};
return actionsRef;
}
function sharedProps(): Omit<
RectificationMessageEntryProps,
"message" | "regenerating" | "canRegenerate" | "copied" | "feedback"
> {
return {
busy: true,
readonly: false,
currentQuestionFocusId: null,
interactive: false,
liveChoiceCard: null,
choiceNonce: 0,
savedTime: null,
actionsRef: emptyActions(),
};
}
function entryProps(
message: RenderMessage,
extras: Partial<Pick<RectificationMessageEntryProps, "regenerating" | "canRegenerate" | "copied" | "feedback">> = {},
): RectificationMessageEntryProps {
return {
...sharedProps(),
message,
regenerating: extras.regenerating ?? false,
canRegenerate: extras.canRegenerate ?? false,
copied: extras.copied ?? false,
feedback: extras.feedback,
};
}
test("the chat container no longer derives per-row timeline, varga, or choice cards", () => {
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
assert.doesNotMatch(chatSource, /vargaSentenceFromMethods\s*\(/);
assert.doesNotMatch(chatSource, /choiceCardFromQuestion\s*\(/);
assert.match(entrySource, /rectificationTimelineRows\s*\(/);
assert.match(entrySource, /vargaSentenceFromMethods\s*\(/);
assert.match(entrySource, /choiceCardFromQuestion\s*\(/);
});
test("the extracted row is memoised and takes no function props except actionsRef", () => {
assert.match(entrySource, /export const RectificationMessageEntry = memo\(function RectificationMessageEntry/);
const propsBlock = entrySource.slice(
entrySource.indexOf("export type RectificationMessageEntryProps"),
entrySource.indexOf("export function choiceCardFromQuestion"),
);
assert.match(propsBlock, /actionsRef: MutableRefObject<RectificationMessageActions>/);
assert.doesNotMatch(propsBlock, /=>/);
assert.doesNotMatch(propsBlock, /on[A-Z]\w+\?:/);
assert.match(rowSource, /export const ChatMessageRow = memo\(function ChatMessageRow/);
});
test("the split architecture renders settled rectification rows once while streaming tokens", () => {
const history: RenderMessage[] = [
settled("user", "第一件经历", "u1"),
settled("assistant", "记下了第一件。", "a1"),
settled("user", "第二件经历", "u2"),
settled("assistant", "记下了第二件。", "a2"),
];
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
const shared = sharedProps();
const feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>> = {};
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
for (const message of history) {
renderToString(createElement(RectificationMessageEntry, entryProps(message)));
}
for (const streamingText of tokens) {
renderToString(createElement(RectificationMessageEntry, entryProps(liveAssistant(streamingText))));
}
const split = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
for (const streamingText of tokens) {
renderToString(createElement(UnsplitRectificationMessageList, {
...shared,
messages: [...history, liveAssistant(streamingText)],
latestRegeneratableKey: "a2",
copiedMessageKey: null,
feedbackByKey,
regeneratingMessageKey: null,
}));
}
const unsplit = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
assert.equal(split.settledRowRenders, history.length);
assert.ok(split.streamingRowRenders <= tokens.length);
assert.ok(split.streamingRowRenders >= 1);
assert.equal(unsplit.unsplitListRenders, tokens.length);
// Unsplit counts every row on every frame, including the live one.
assert.equal(unsplit.settledRowRenders, (history.length + 1) * tokens.length);
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
});