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(() => {})) 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 }); });