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 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"/); 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\)\);/); // 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 selected at bootstrap is hydrated before the reveal, and popstate defers the same way", () => { // The resume effect runs during the prepare phase, not only after the reveal. assert.match(page, /if \(\(!hydrated && bootstrapPhase === "account"\)\s*\|\| !account/); assert.match(bootstrap, /if \(state\.rectificationApplicable && !state\.rectificationSettled\) return false;/); assert.match(page, /rectificationApplicable: activeSession\?\.sessionType === "birth_time_rectification",\s*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("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/); 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. assert.equal((chat.match(/InlineSpinner/g) ?? []).length, 0); assert.match(timeline, /export function ConsultationTimelineLiveRow/); assert.match(chat, //); }); 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" && \(/); assert.match(chat, /questionGap === "unavailable" && \(/); 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. const gapIndex = chat.indexOf('{questionGap === "preparing" && ('); assert.ok(gapIndex > chat.indexOf("{messages.map((message) => {")); assert.ok(gapIndex < chat.indexOf("{savedTime && savedStatus === \"confirmed\"")); 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", () => { assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/); 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 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, /