91b8b33aa6
Opening a Case now hydrates turns and snapshot in one read (4s budget) before the session switches, so the sidebar no longer flashes a plain transcript, the panel never mounts empty, and the first completed turn no longer remounts the whole surface (the key is the session/Case binding only). Entry feedback is static: the card says 正在打开…, the sidebar row 打开中. A tapped choice or an adopted candidate continues the follow-up turn on the live row already in place, with busy held across the chain, so there is no empty frame and no next card flashing in. The question slot has four pure states — a gap is a timeline live row with timed refetches, then a reload — and no copy asks the reader to wait for the server. A resumed Case with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what streamed and says so; 402 explains before redirecting; the opening row names what it is doing; the tap is echoed as the reader's own line. BUG-479, BUG-480, BUG-481, BUG-482 (echo) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
161 lines
7.8 KiB
TypeScript
161 lines
7.8 KiB
TypeScript
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);
|
|
});
|