99a91a97f4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
139 lines
11 KiB
TypeScript
139 lines
11 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 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, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
|
|
});
|
|
|
|
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, /<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 \{/);
|
|
// 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 {"));
|
|
});
|