Files
Jyotisha/frontend/tests/rectification-surface-contract.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

226 lines
17 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 { homeSurface as page } from "./home-surface.ts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { rectificationChatSurface, readRectificationChatFile } from "./rectification-chat-surface.ts";
import { RectificationQuestionGapNotices } from "../src/components/rectification-question-gap-notices.tsx";
import { RECTIFICATION_QUESTION_PREPARING_LABEL } from "../src/lib/rectification-surface-state.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const messageEntry = read("../src/components/rectification-message-entry.tsx");
const hook = read("../src/hooks/use-rectification-surface.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const bootstrap = read("../src/lib/home-bootstrap.ts");
const starter = read("../src/components/starter-home.tsx");
const sidebarRow = read("../src/components/sidebar-session-row.tsx");
const board = read("../src/components/rectification-board.tsx");
const choiceCard = read("../src/components/rectification-choice-card.tsx");
const wrapper = read("../src/components/conversational-birth-time-rectification.tsx");
const timeline = read("../src/components/consultation-run-timeline.tsx");
const styles = read("../src/app/globals.css");
test("the rectification surface is revealed once: Case hydration precedes the switch and the key never remounts", () => {
// BUG-505: open → hydrate (turns + snapshot, one Case read, deadline) → switch, in that order.
assert.match(hook, /const hydration = await hydrateRectificationCase\(opened\.caseId, opened\.sessionId, \{\s*timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,/);
const openBody = hook.slice(hook.indexOf("const hydration = await hydrateRectificationCase"), hook.indexOf("void refreshRectificationEntrySummary();"));
assert.match(openBody, /setRectificationTurns\(hydration\.turns\);[\s\S]*setRectificationSnapshot\(hydration\.snapshot\);[\s\S]*setActiveSessionId\(opened\.sessionId\);/);
assert.match(openBody, /if \(!hydration\.complete\) setComposerNotice\(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE\);/);
assert.doesNotMatch(hook, /void refreshRectificationCase\(opened\.caseId/);
// The selection source is read before any await; selectSession resets it synchronously.
assert.match(hook, /const selectionSource = sessionSelectionSource\.current;[\s\S]*await fetch\("\/api\/rectification\/cases\/open"/);
assert.match(hook, /selectionSource === "user"\) \{\s*writeSessionUrl\(opened\.sessionId, "push"\)/);
// selectSession does not switch to a rectification session before its Case is hydrated.
assert.match(sessions, /const deferredRectificationSwitch = nextSession\?\.sessionType === "birth_time_rectification"\s*&& nextSession\.id !== rectificationSessionId;/);
assert.match(sessions, /if \(!deferredRectificationSwitch\) setActiveSessionId\(sessionId\);/);
assert.match(sessions, /if \(!deferredRectificationSwitch && !uiPreview\.current && sessionSelectionSource\.current === "user"\) \{\s*writeSessionUrl\(sessionId, "push"\);/);
// Panel: one key per binding, snapshot and turns as initial state, later turns as a prop update.
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
// 原值:initialSnapshot={rectificationSnapshot} 写在 page.tsx JSX
// 新值:initialSnapshot: rectificationSnapshot 写在 hook 的 panel
// 原因:snapshot 只服务子树,随 panel 下沉
assert.match(page, /initialSnapshot: rectificationSnapshot/);
// 原值: /declaredTime=\{rectificationDeclaredTime\}/
// 新值: /\{\.\.\.rectificationDeclaration\}/
// 原因: F7——declaredTime 与 birthDate 合成一个对象展开,page.tsx 不增长(BUG-751)
assert.match(page, /\{\.\.\.rectificationDeclaration\}/);
assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/);
assert.match(chat, /useState\(initialSnapshot !== null\)/);
// Later turns fill an empty transcript by render-time adjustment from the previous prop, not an effect.
assert.match(chat, /const \[seededTurns, setSeededTurns\] = useState\(initialTurns\);\s*if \(seededTurns !== initialTurns\) \{\s*setSeededTurns\(initialTurns\);\s*if \(messages\.length === 0 && initialTurns\.length > 0\) setMessages\(messagesFromTurns\(initialTurns\)\);/);
// No self-fetch on mount; unmount ends the stream and any snapshot read.
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*const controller = new AbortController\(\);\s*void fetch\(/);
assert.match(chat, /useEffect\(\(\) => \(\) => \{\s*runAbort\.current\?\.abort\(\);\s*snapshotAbort\.current\?\.abort\(\);\s*\}, \[\]\);/);
});
test("a rectification session named in the URL is hydrated before the reveal; bare / does not", () => {
// The resume effect runs during the prepare phase, not only after the reveal.
assert.match(page, /if \(\(!hydrated && bootstrapPhase === "account"\)\s*\|\| !account/);
assert.match(page, /shouldAutoOpenRectificationSession\(\{/);
assert.match(bootstrap, /if \(state\.rectificationApplicable && !state\.rectificationSettled\) return false;/);
assert.match(page, /rectificationApplicable: shouldAutoOpenRectificationSession\(\{/);
assert.match(page, /rectificationSettled: activeSession\?\.id === rectificationSessionId\s*\|\| rectificationError !== ""\s*\|\| !profileComplete,/);
// One deadline constant for hydration and the home reveal.
assert.match(read("../src/lib/rectification-surface-state.ts"), /export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;/);
// History navigation goes through selectSession, which defers the switch (locked above).
assert.match(sessions, /sessionSelectionSource\.current = "history";\s*selectSession\(requestedId\);/);
});
test("an ordinary composer on a rectification session is disabled until the surface opens", () => {
// 原值:inputDisabled / submitBlocked 只看 rectificationSurfaceOpen
// 新值:另加 activeRectificationSession;placeholder 为「正在打开生时校正…」
// 原因:BUG-924 校正面未开时普通输入框仍可用,问题被发到 /api/consult
assert.match(page, /inputDisabled=\{[^}]*activeRectificationSession/);
assert.match(page, /ordinaryComposerPlaceholder\(/);
assert.match(page, /RectificationComposerReopen/);
assert.match(page, /reopenRectificationSession/);
assert.doesNotMatch(page, /正在打开生时校正[\s\S]*InlineSpinner/);
});
test("entry feedback is static: card and sidebar row say 正在打开, and nothing spins after the reveal", () => {
assert.match(page, /const rectificationCardLabel = rectificationLoading\s*\? RECTIFICATION_OPENING_LABEL/);
assert.match(starter, /data-opening=\{rectificationLoading \? "true" : undefined\}/);
assert.doesNotMatch(starter, /InlineSpinner/);
assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/);
assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/);
// 原值:page.tsx 把 openingSessionId 直接传给 AppSidebar
// 新值:layout 从 registration 传入,首页 registerShellControls 载荷带 openingSessionId
// 原因:T2 外壳搬家,两端都断才等于侧栏行仍拿得到正在打开
const layout = read("../src/app/(app)/layout.tsx");
const shell = read("../src/hooks/use-home-shell-registration.ts");
assert.match(layout, /openingSessionId=\{registration\?\.openingSessionId \?\? null\}/);
assert.match(shell, /openingSessionId: rectificationOpeningSessionId,/);
// 原值 `.product-entrypoint-card[data-opening="true"]` / 新值 `.starter-entry[data-opening="true"]`
// / 原因:入口从卡片降级成 pill。「打开 Case 时只换指针、不出 spinner」这条不变。
assert.match(styles, /\.starter-entry\[data-opening="true"\][^{]*\{ cursor: progress; \}/);
assert.match(styles, /\.session-opening-note \{/);
// The only spinner inside the chat is a timeline live row (generation), never a loading state.
assert.equal((chat.match(/InlineSpinner/g) ?? []).length, 0);
assert.match(timeline, /export function ConsultationTimelineLiveRow/);
assert.match(chat, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
});
test("the question gap is a live row with retries, then a reload; it never tells the reader to wait for the server", () => {
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新|rectification-composer-status/);
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion|showQuestionLoadFailed/);
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/);
assert.match(chat, /questionGap === "preparing" && \(/);
// 原值: questionGap === "unavailable" && (
// 新值: questionGap === "unavailable" && !snapshotFailure && (
// 原因: BUG-969 快照失败走「重新读取」,不得和「接着问」叠两套
assert.match(chat, /questionGap === "unavailable" && !snapshotFailure && \(/);
assert.match(chat, /snapshotFailure && \(/);
assert.match(chat, /RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_SNAPSHOT_RETRY_LABEL/);
assert.match(chat, /setSnapshotFailure\(\{ status: response\.status \}\)/);
assert.match(chat, /setSnapshotFailure\(\{ status: "network" \}\)/);
// 原值:questionGap === "verified_idle" && (
// 新值:questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
// 原因:BUG-620 把收尾句挪到对照卡下,无 copy 或卡仍在时不单独贴一段;门禁仍锁旧字面量会红(BUG-622)
assert.match(chat, /questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && \(/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
// Attempts reset when a turn starts, never because the snapshot merely names a
// current_question. Naming a question that no settled message carries live is
// the gap itself (BUG-525); resetting here spun "正在准备下一个问题…" forever.
assert.doesNotMatch(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
// In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line.
// 原值: chat 源码里 `{questionGap === "preparing" && (` 位于 `{messages.map` 之后、`{savedTime && savedStatus === "confirmed"` 之前。
// 新值: 容器里 `<RectificationQuestionGapNotices` 位于同两个锚点之间;再真渲染该块,preparing 态出 live 行和准备文案。
// 原因: TASK-rectification-code-split-20260926 把缺题块抽成组件,渲染位置不变;渲染断言比源码顺序更强。
const container = readRectificationChatFile("../src/components/rectification-agentic-chat.tsx");
const gapIndex = container.indexOf("<RectificationQuestionGapNotices");
assert.ok(gapIndex > container.indexOf("{messages.map((message) => {"));
assert.ok(gapIndex < container.indexOf("{savedTime && savedStatus === \"confirmed\""));
const preparingHtml = renderToStaticMarkup(createElement(RectificationQuestionGapNotices, {
view: { showStandaloneQuestion: false, questionGap: "preparing", showSelectionCards: false, verifiedIdleCopy: null },
currentQuestion: null,
choiceCard: null,
choiceNonce: 0,
busy: false,
readonly: false,
snapshotFailure: null,
questionRepairAttempts: 0,
questionRepairing: false,
submitChoice() {},
submitStop() {},
async refetchQuestion() {},
async repairQuestion() {},
}));
assert.match(preparingHtml, /class="rectification-message-wrap rectification-message-entry rectification-question-gap"/);
assert.match(preparingHtml, /data-status="live"/);
assert.ok(preparingHtml.includes(RECTIFICATION_QUESTION_PREPARING_LABEL));
assert.match(styles, /\.rectification-question-gap \{/);
});
test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => {
// BUG-506: no effect hop, no removed row, busy held across the chain.
assert.doesNotMatch(chat, /choiceContinuationPending/);
assert.match(chat, /const send = useCallback\(async \(\s*action: "opening" \| "message" \| "read_only",\s*messageText: string,\s*continuation\?: Readonly<\{ reuseAssistantRenderKey: string; label: string \}>,/);
assert.match(chat, /if \(!continuation && busy\) return;/);
assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/);
assert.match(chat, /beginLiveRun\(initialLabel\);/);
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\);/);
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
assert.doesNotMatch(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\),\s*turns,\s*\)\);/);
});
test("an empty Case with no automatic opening offers a way to start", () => {
// BUG-507
assert.match(chat, /conversationState === "empty" && \(/);
assert.match(chat, /RECTIFICATION_EMPTY_COPY/);
assert.match(chat, /onClick=\{startOpeningManually\}>\{RECTIFICATION_EMPTY_ACTION_LABEL\}/);
assert.match(chat, /function startOpeningManually\(\) \{\s*if \(readonly \|\| busy \|\| openingStarted\.current\) return;\s*openingStarted\.current = true;/);
assert.match(styles, /\.rectification-empty-state \{/);
});
test("stopping keeps what streamed and says so; a 402 explains itself before leaving", () => {
// 原值:if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE)
// 新值:气泡 stopped + 灰字 RECTIFICATION_STOPPED_NOTICE,error-message 只留给真正失败
// 原因:停止是用户动作,不能穿报错衣服(BUG-552)
assert.match(chat, /failed: false,\s*stopped: true/);
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
assert.match(chat, /inputDisabled=\{readonly\}/);
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*onOpenBilling\?\.\(\{ source: "rectification" \}\);/);
assert.doesNotMatch(chat, /window\.location\.assign\(membershipHref\("rectification"\)\)/);
});
test("the choice card confirms the tap and the board's first state shows the declared minute", () => {
// BUG-508 / BUG-517: selected fill stays on the option; the Check + 已选择 badge is gone.
assert.doesNotMatch(choiceCard, /<Check\b/);
assert.doesNotMatch(choiceCard, /已选择/);
assert.doesNotMatch(choiceCard, /rectification-choice-card__selected/);
assert.match(choiceCard, /data-selected=\{selectedKey === option\.key \? "true" : "false"\}/);
assert.match(choiceCard, /rectification-choice-card__pending/);
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
assert.doesNotMatch(choiceCard, /onMouseEnter/);
assert.doesNotMatch(choiceCard, /rectification-choice-impact/);
assert.doesNotMatch(choiceCard, /hoveredKey/);
assert.doesNotMatch(styles, /\.rectification-choice-card__selected/);
assert.match(styles, /\.rectification-choice-card__pending \{/);
// BUG-518: scoring cards do not render 先这样,先看当前范围; reverse_verify still has 这题跳过.
assert.match(choiceCard, /stop_label !== CHOICE_STOP_LABEL/);
// BUG-509
assert.match(board, /declaredTime: string \| null;/);
assert.match(board, /rectificationBoardEmptyCopy\(declaredTime, birthTimeSource\)/);
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime, birthTimeSource\)/);
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
});