53017d4153
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
167 lines
8.2 KiB
TypeScript
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 });
|
|
});
|