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, /