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 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: 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
assert.match(choiceCard, //);
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 \{/);
// BUG-509
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 {"));
});