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, //); }); 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('
\n
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, /