Files
Jyotisha/frontend/tests/rectification-surface-contract.test.ts
T
Jesse_Chen 91b8b33aa6 fix(rectification): reveal the surface once, keep one live row across choice and adoption, and give an empty Case a start
Opening a Case now hydrates turns and snapshot in one read (4s budget)
before the session switches, so the sidebar no longer flashes a plain
transcript, the panel never mounts empty, and the first completed turn no
longer remounts the whole surface (the key is the session/Case binding
only). Entry feedback is static: the card says 正在打开…, the sidebar row
打开中. A tapped choice or an adopted candidate continues the follow-up turn
on the live row already in place, with busy held across the chain, so there
is no empty frame and no next card flashing in. The question slot has four
pure states — a gap is a timeline live row with timed refetches, then a
reload — and no copy asks the reader to wait for the server. A resumed Case
with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what
streamed and says so; 402 explains before redirecting; the opening row names
what it is doing; the tap is echoed as the reader's own line.

BUG-479, BUG-480, BUG-481, BUG-482 (echo)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:53:59 +00:00

133 lines
9.9 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as page } from "./home-surface.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const chat = read("../src/components/rectification-agentic-chat.tsx");
const hook = read("../src/hooks/use-rectification-surface.ts");
const sessions = read("../src/hooks/use-session-management.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-479: 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\);/);
// 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"/);
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/);
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\)\);/);
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(initialTurns\.length === 0\) return;/);
// 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("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, /aria-busy=\{rectificationLoading \? true : undefined\}/);
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/);
assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/);
assert.match(styles, /\.product-entrypoint-card\[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.
const spinnerUses = chat.match(/InlineSpinner/g) ?? [];
assert.equal(spinnerUses.length, 0);
assert.match(timeline, /export function ConsultationTimelineLiveRow/);
assert.match(chat, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
});
test("the question slot has four states, sits in the transcript flow, and never tells the reader to wait for the server", () => {
assert.doesNotMatch(chat, /等待服务端更新/);
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion/);
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{/);
assert.match(chat, /questionSlot === "preparing" && \(/);
assert.match(chat, /questionSlot === "unavailable" && \(/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionRetrying,\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
// In flow: wrapped like every other entry, after the message loop, before the saved-time line.
const slotIndex = chat.indexOf('<div className="rectification-message-wrap rectification-message-entry">\n <section className="rectification-question-slot"');
assert.ok(slotIndex > chat.indexOf("{messages.map((message) => {"));
assert.ok(slotIndex < chat.indexOf("{savedTime &&"));
});
test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => {
// BUG-480: 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, /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*choiceContinuationPending/);
});
test("an empty Case with no automatic opening offers a way to start", () => {
// BUG-481
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; the live-row label follows the action", () => {
assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/);
assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/);
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*window\.setTimeout\(\(\) => \{\s*window\.location\.assign\(membershipHref\("rectification"\)\);\s*\}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS\);/);
});
test("the tap is echoed as the reader's line and the card confirms the selection", () => {
// BUG-482
assert.match(chat, /\{ role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" \}/);
assert.match(chat, /typeof payload\?\.userMessage === "string"/);
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
assert.match(choiceCard, /<Check aria-hidden="true" \/>/);
assert.match(choiceCard, /已选择/);
assert.match(choiceCard, /rectification-choice-card__pending/);
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
assert.match(styles, /\.rectification-choice-card__selected \{/);
assert.match(styles, /\.rectification-choice-card__pending \{/);
});
test("the board's first state shows the declared minute and takes less width", () => {
// BUG-483
assert.match(board, /declaredTime: string \| null;/);
assert.match(board, /rectificationBoardEmptyCopy\(declaredTime\)/);
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime\)/);
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 {"));
});