91b8b33aa6
Opening a Case now hydrates turns and snapshot in one read (4s budget) before the session switches, so the sidebar no longer flashes a plain transcript, the panel never mounts empty, and the first completed turn no longer remounts the whole surface (the key is the session/Case binding only). Entry feedback is static: the card says 正在打开…, the sidebar row 打开中. A tapped choice or an adopted candidate continues the follow-up turn on the live row already in place, with busy held across the chain, so there is no empty frame and no next card flashing in. The question slot has four pure states — a gap is a timeline live row with timed refetches, then a reload — and no copy asks the reader to wait for the server. A resumed Case with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what streamed and says so; 402 explains before redirecting; the opening row names what it is doing; the tap is echoed as the reader's own line. BUG-479, BUG-480, BUG-481, BUG-482 (echo) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
133 lines
9.9 KiB
TypeScript
133 lines
9.9 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 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, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
|
|
});
|
|
|
|
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('<div className="rectification-message-wrap rectification-message-entry">\n <section className="rectification-question-slot"');
|
|
assert.ok(slotIndex > 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, /<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 \{/);
|
|
});
|
|
|
|
test("the board's first state shows the declared minute and takes less width", () => {
|
|
// BUG-483
|
|
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 {"));
|
|
});
|