Files
Jyotisha/frontend/tests/rectification-surface-state.test.ts
T
Jesse_Chen 53017d4153 fix(rectification): reveal the surface once — hydrate the Case before the switch, never remount, static entry feedback
Opening a rectification Case used to switch sessions first and read the
turns afterwards, so the reader saw a plain transcript, then an empty
panel, then a remount when the turns arrived (and again after the first
turn settled, because the panel key carried a ready/loading suffix).
The surface hook now reads turns and snapshot in one Case request under
the home reveal budget and only then makes the session active; the
sidebar defers the switch the same way; a session selected at bootstrap
(deep link, refresh) is hydrated during the prepare phase so the reveal
shows the surface itself; the panel key is the session/Case binding only,
later turns fill an empty transcript as a prop update, and unmounting
aborts any stream or snapshot read. The homepage card and the sidebar row
say 正在打开 statically while the Case opens — no spinner after the reveal.

BUG-505

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-03 07:07:31 +00:00

167 lines
8.2 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "../src/lib/home-bootstrap.ts";
import {
declaredBirthTime,
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationAdoptingLabel,
rectificationBoardEmptyCopy,
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
rectificationQuestionRetryActive,
type RectificationQuestionGapInput,
} from "../src/lib/rectification-surface-state.ts";
const gapBase: RectificationQuestionGapInput = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
};
test("conversation state: readonly and busy win, then opening, then empty", () => {
const base = { messageCount: 0, busy: false, readonly: false, shouldStartOpening: false, openingStarted: false };
assert.equal(rectificationConversationState({ ...base, readonly: true }), "readonly");
assert.equal(rectificationConversationState({ ...base, busy: true }), "busy");
assert.equal(rectificationConversationState({ ...base, messageCount: 3 }), "conversation");
assert.equal(rectificationConversationState({ ...base, shouldStartOpening: true }), "opening");
assert.equal(rectificationConversationState({ ...base, openingStarted: true }), "opening");
// A resumed Case with no turns and no server-started opening: the reader needs a way to begin (BUG-507).
assert.equal(rectificationConversationState(base), "empty");
});
test("question gap: a live question inside a message means no gap", () => {
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true, questionMissing: false }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true }), "idle");
});
test("question gap: preparing while retries remain, then unavailable with a reload", () => {
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
// The server names a question that no settled message carries live yet.
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false }), "preparing");
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: 5 }), "unavailable");
// The server itself said the question could not be loaded: no point retrying on a timer.
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionLoadFailed: true }), "unavailable");
assert.equal(rectificationQuestionRetryActive("preparing"), true);
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
assert.equal(rectificationQuestionRetryActive("idle"), false);
});
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false }), "preparing");
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
});
test("question gap: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
assert.equal(rectificationQuestionGapState({ ...gapBase, busy: true }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, readonly: true }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, regenerating: true }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
});
test("live-row labels follow the action that started the turn", () => {
assert.equal(rectificationInitialLiveLabel("opening"), "正在读取你的出生资料,准备第一个问题…");
assert.equal(rectificationInitialLiveLabel("message"), "正在处理…");
assert.equal(rectificationInitialLiveLabel("read_only", "正在记录本次选择…"), "正在记录本次选择…");
assert.equal(rectificationInitialLiveLabel("read_only"), "正在处理…");
assert.equal(rectificationAdoptingLabel("04:53"), "正在采用 04:53…");
});
test("board copy before any candidate shows the declared minute, never invented data", () => {
assert.deepEqual(rectificationBoardEmptyCopy("05:10"), {
clock: "05:10",
lines: ["填报出生时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
});
assert.deepEqual(rectificationBoardEmptyCopy(null), {
clock: null,
lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"],
});
assert.equal(declaredBirthTime({ reportedTime: "5:10", time: "" }), "5:10");
assert.equal(declaredBirthTime({ reportedTime: "", time: "14:30" }), "14:30");
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
assert.equal(declaredBirthTime({ reportedTime: "凌晨", time: "" }), null);
});
test("persisted turns keep their question, offer and tool activities through the parser", () => {
const turns = parsePersistedRectificationTurns([
{
id: "t1",
role: "assistant",
text: "先看事业。",
status: "completed",
question: { focus_id: "f1", kind: "choice" },
offer_result_id: "r1",
receipt: {
status: "ready",
phases: ["compute"],
tools: ["rectification-compare-candidates"],
methods: ["d9-navamsa", "d10-dashamsa"],
tool_activities: [{ tool: "rectification-compare-candidates", status: "completed", methods: ["d9-navamsa"], started_at: null, elapsed_ms: 12 }],
},
},
{ id: "t2", role: "user", text: "2014 年毕业。" },
"junk",
]);
assert.equal(turns.length, 3);
assert.equal(turns[0]?.offer_result_id, "r1");
assert.deepEqual(turns[0]?.question, { focus_id: "f1", kind: "choice" });
assert.deepEqual(turns[0]?.receipt?.methods, ["d9-navamsa", "d10-dashamsa"]);
assert.equal(turns[0]?.receipt?.tool_activities?.[0]?.tool, "rectification-compare-candidates");
assert.equal(turns[1]?.role, "user");
assert.equal(turns[1]?.status, "completed");
assert.equal(turns[1]?.receipt, null);
assert.equal(turns[2]?.id, "");
assert.deepEqual(parsePersistedRectificationTurns(null), []);
});
test("hydration reads turns and snapshot from one Case read and is one constant with the home reveal budget", async () => {
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, BOOTSTRAP_PREPARE_TIMEOUT_MS);
const calls: string[] = [];
const fetchImpl = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return new Response(JSON.stringify({
turns: [{ id: "t1", role: "assistant", text: "hi", status: "completed" }],
current_question: { kind: "choice", prompt: "哪一年?", focus_id: "f1", question_id: "q1" },
case: { status: "collecting" },
}), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
const hydration = await hydrateRectificationCase("case-1", "session-1", { fetchImpl, timeoutMs: 1_000 });
assert.equal(calls.length, 1);
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
assert.equal(hydration.complete, true);
assert.equal(hydration.turns.length, 1);
assert.equal((hydration.snapshot?.case as { status?: unknown } | undefined)?.status, "collecting");
});
test("hydration resolves incomplete on failure and when the deadline passes first", async () => {
const failing = (async () => new Response("nope", { status: 500 })) as typeof fetch;
const failed = await hydrateRectificationCase("case-1", "session-1", { fetchImpl: failing, timeoutMs: 1_000 });
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
let fire: (() => void) | undefined;
const never = (() => new Promise<Response>(() => {})) as typeof fetch;
const late = hydrateRectificationCase("case-1", "session-1", {
fetchImpl: never,
timeoutMs: 4_000,
setTimeoutImpl: (callback) => {
fire = callback;
return 1;
},
clearTimeoutImpl: () => {},
});
assert.ok(fire);
fire?.();
assert.deepEqual(await late, { turns: [], snapshot: null, complete: false });
});