import assert from "node:assert/strict"; import test from "node:test"; import { declaredBirthTime, hydrateRectificationCase, parsePersistedRectificationTurns, RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, RECTIFICATION_QUESTION_RETRY_LIMIT, rectificationAdoptingLabel, rectificationBoardEmptyCopy, rectificationConversationState, rectificationInitialLiveLabel, rectificationQuestionRetryActive, rectificationQuestionSlotState, type RectificationQuestionSlotInput, } from "../src/lib/rectification-surface-state.ts"; const slotBase: RectificationQuestionSlotInput = { questionKind: null, questionPrompt: null, hasChoiceCard: false, choiceAnswered: 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-481). assert.equal(rectificationConversationState(base), "empty"); }); test("question slot: a live choice card and a spoken prompt render as themselves", () => { assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true }), "choice-live"); assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true, choiceAnswered: true }), "preparing"); assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: "哪年上的大学?" }), "spoken-prompt"); }); test("question slot: a gap is preparing while retries remain, then unavailable with a reload", () => { assert.equal(rectificationQuestionSlotState(slotBase), "preparing"); assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: false }), "preparing"); assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: null }), "preparing"); assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable"); assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: 5 }), "unavailable"); assert.equal(rectificationQuestionRetryActive("preparing"), true); assert.equal(rectificationQuestionRetryActive("unavailable"), false); assert.equal(rectificationQuestionRetryActive("choice-live"), false); }); test("question slot: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => { assert.equal(rectificationQuestionSlotState({ ...slotBase, busy: true }), "idle"); assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true }), "idle"); assert.equal(rectificationQuestionSlotState({ ...slotBase, regenerating: true }), "idle"); assert.equal(rectificationQuestionSlotState({ ...slotBase, resumableCase: false }), "idle"); // No snapshot at all (hydration failed and no retry has answered yet): nothing to say about a question. assert.equal(rectificationQuestionSlotState({ ...slotBase, snapshotLoaded: false }), "idle"); // Readonly hides even a live card. assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true, questionKind: "choice", hasChoiceCard: true }), "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.equal(rectificationBoardEmptyCopy(null).clock, null); assert.equal(rectificationBoardEmptyCopy(null).lines.length, 1); assert.equal(declaredBirthTime({ reportedTime: "05:10", time: "05:10" }), "05:10"); assert.equal(declaredBirthTime({ reportedTime: "", time: "7:05" }), "7:05"); assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null); assert.equal(declaredBirthTime({ time: "凌晨" }), null); }); test("persisted turns parse with receipts and tolerate junk", () => { const turns = parsePersistedRectificationTurns([ { id: "t1", role: "user", text: "2014年毕业", status: "completed" }, { id: "t2", role: "assistant", text: "记下了", status: "completed", receipt: { status: "ok", phases: ["a"], tools: ["x"], methods: ["d9-navamsa"], skill_name: "s", skill_version: "1" } }, { id: 3, role: "other", text: 5 }, ]); assert.equal(turns.length, 3); assert.equal(turns[0]?.role, "user"); assert.equal(turns[1]?.receipt?.methods?.[0], "d9-navamsa"); assert.equal(turns[2]?.role, "assistant"); assert.equal(turns[2]?.text, null); assert.deepEqual(parsePersistedRectificationTurns(undefined), []); }); function fakeResponse(body: unknown, ok = true) { return { ok, json: async () => body } as unknown as Response; } test("hydration reads turns and snapshot from one Case read", async () => { const calls: string[] = []; const result = await hydrateRectificationCase("case-1", "session-1", { timeoutMs: 1_000, fetchImpl: async (input) => { calls.push(String(input)); return fakeResponse({ turns: [{ id: "t1", role: "assistant", text: "你好", status: "completed" }], current_question: { kind: "choice", prompt: "哪一个?" }, case: { status: "collecting", accepted_time: null, confirmed_time: null }, }); }, }); assert.equal(calls.length, 1); assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/); assert.equal(result.complete, true); assert.equal(result.turns.length, 1); assert.equal((result.snapshot?.current_question as { kind?: string })?.kind, "choice"); }); test("hydration resolves incomplete, not rejected, on a failed read or a passed deadline", async () => { const failed = await hydrateRectificationCase("case-1", "session-1", { timeoutMs: 1_000, fetchImpl: async () => fakeResponse({ error: "nope" }, false), }); assert.deepEqual(failed, { turns: [], snapshot: null, complete: false }); const thrown = await hydrateRectificationCase("case-1", "session-1", { timeoutMs: 1_000, fetchImpl: async () => { throw new Error("network"); }, }); assert.equal(thrown.complete, false); let fire: (() => void) | undefined; const late = hydrateRectificationCase("case-1", "session-1", { timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, fetchImpl: () => new Promise(() => {}), setTimeoutImpl: (callback, delayMs) => { assert.equal(delayMs, RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS); fire = callback; return 1; }, clearTimeoutImpl: () => {}, }); assert.ok(fire); fire?.(); const timedOut = await late; assert.equal(timedOut.complete, false); assert.deepEqual(timedOut.turns, []); assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, 4_000); });