Files
Jyotisha/frontend/tests/rectification-surface-contract.test.ts
T
Jesse_ChenandClaude Opus 5 62903b7a12
Independent Staging Quality Gate / validate (push) Failing after 9m13s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 空状态改成「问候 + 输入框 + 两枚入口 pill」
此前空态是 hero 卡(h1 clamp 到 52px)加两张 min-height 228px 的产品卡,
输入框被压在约 800px 内容之下,主动作不是视觉焦点。

改法:问候拆成 StarterGreeting 留在对话区并底对齐,两个产品入口降级成
StarterEntries 渲染在 composer 之后。composer 本身一行未动——它在空态与
会话态是同一个 DOM 位置,首条消息落地时不重挂载,草稿/焦点/排队卡都不受影响。

顺带修掉:空态 composer 1040px、会话态 760px,发首条消息时输入框会当场
变窄一截。两态统一到 --session-column-width,并新增断言锁住。

校正入口保留三个服务端驱动的文案分支(我第一版收成了两个,是
rectification-agentic-entry 打红才发现)。

DESIGN.md:Product entrypoint card 整节重写为 Starter home;删掉
Suggestion card 死文档(它描述的主题卡样式正是本轮删除的 CSS)。

测试 3350 不变,fail 仍 31 且与基线双向零差异;/ 仍 Static;
CSS gzip 41,096→41,044(−0.13%)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 05:31:15 +00:00

170 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 messageEntry = read("../src/components/rectification-message-entry.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"/);
// 原值:initialSnapshot={rectificationSnapshot} 写在 page.tsx JSX
// 新值:initialSnapshot: rectificationSnapshot 写在 hook 的 panel
// 原因:snapshot 只服务子树,随 panel 下沉
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 named in the URL is hydrated before the reveal; bare / does not", () => {
// The resume effect runs during the prepare phase, not only after the reveal.
assert.match(page, /if \(\(!hydrated && bootstrapPhase === "account"\)\s*\|\| !account/);
assert.match(page, /shouldAutoOpenRectificationSession\(\{/);
assert.match(bootstrap, /if \(state\.rectificationApplicable && !state\.rectificationSettled\) return false;/);
assert.match(page, /rectificationApplicable: shouldAutoOpenRectificationSession\(\{/);
assert.match(page, /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\}/);
// 原值 `.product-entrypoint-card[data-opening="true"]` / 新值 `.starter-entry[data-opening="true"]`
// / 原因:入口从卡片降级成 pill。「打开 Case 时只换指针、不出 spinner」这条不变。
assert.match(styles, /\.starter-entry\[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" && \(/);
// 原值:questionGap === "verified_idle" && (
// 新值:questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
// 原因:BUG-620 把收尾句挪到对照卡下,无 copy 或卡仍在时不单独贴一段;门禁仍锁旧字面量会红(BUG-622)
assert.match(chat, /questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && \(/);
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", () => {
// 原值:if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE)
// 新值:气泡 stopped + 灰字 RECTIFICATION_STOPPED_NOTICEerror-message 只留给真正失败
// 原因:停止是用户动作,不能穿报错衣服(BUG-552)
assert.match(chat, /failed: false,\s*stopped: true/);
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
assert.match(chat, /inputDisabled=\{readonly\}/);
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*onOpenBilling\?\.\(\{ source: "rectification" \}\);/);
assert.doesNotMatch(chat, /window\.location\.assign\(membershipHref\("rectification"\)\)/);
});
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, /<Check\b/);
assert.doesNotMatch(choiceCard, /已选择/);
assert.doesNotMatch(choiceCard, /rectification-choice-card__selected/);
assert.match(choiceCard, /data-selected=\{selectedKey === option\.key \? "true" : "false"\}/);
assert.match(choiceCard, /rectification-choice-card__pending/);
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
assert.doesNotMatch(choiceCard, /onMouseEnter/);
assert.doesNotMatch(choiceCard, /rectification-choice-impact/);
assert.doesNotMatch(choiceCard, /hoveredKey/);
assert.doesNotMatch(styles, /\.rectification-choice-card__selected/);
assert.match(styles, /\.rectification-choice-card__pending \{/);
// BUG-518: scoring cards do not render 先这样,先看当前范围; reverse_verify still has 这题跳过.
assert.match(choiceCard, /stop_label !== CHOICE_STOP_LABEL/);
// BUG-509
assert.match(board, /declaredTime: string \| null;/);
assert.match(board, /rectificationBoardEmptyCopy\(declaredTime, birthTimeSource\)/);
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime, birthTimeSource\)/);
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 {"));
});