Files
Jyotisha/frontend/tests/rectification-surface-state.test.ts
T
jesse-ux 824ecff026 fix(web): 账户弹窗移出 inert;校正快照失败不再静默(BUG-968/969)
账户弹窗与入门付费墙 portal 到 document.body,SidebarInset 的 inert 不再罩住弹窗。
GET 校正快照每个非 200 打 JSON warn;客户端失败显示「这一问还没读到」和重新读取。
下一题是可渲染选择题时,确定性回复正文带题干,挂卡后去重。
2026-09-18 19:16:00 +08:00

322 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
persistedQuestionSurface,
interviewStopReasonFromSnapshot,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
rectificationReadonlyRangeCopy,
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);
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
});
test("question gap: collect waiting is not the unavailable repair path", () => {
assert.equal(
interviewCollectWaiting({
stopReason: "insufficient_dated_events",
sessionOutcome: "collect_evidence",
questionMissing: true,
}),
true,
);
assert.equal(
interviewCollectWaiting({
sessionOutcome: "collect_evidence",
questionMissing: true,
}),
true,
);
assert.equal(
rectificationQuestionGapState({
...gapBase,
collectWaiting: true,
questionLoadFailed: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
}),
"collect_waiting",
);
assert.equal(rectificationQuestionRetryActive("collect_waiting"), false);
assert.equal(RECTIFICATION_COLLECT_WAITING_PLACEHOLDER, "再说一件带年月的事");
assert.equal(
interviewStopReasonFromSnapshot({
interview: { stop_reason: "insufficient_domains" },
}),
"insufficient_domains",
);
});
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("a persisted choice with a live card is not the spoken-only prompt surface", () => {
assert.equal(
persistedQuestionSurface({
questionKind: "choice",
hasChoiceCard: true,
questionPersisted: true,
}),
"choice_card",
);
});
test("question gap: a persisted focus question is shown instead of preparing", () => {
assert.equal(
rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionPersisted: true }),
"persisted_question",
);
assert.equal(
rectificationQuestionGapState({
...gapBase,
liveQuestionVisible: true,
questionMissing: false,
questionPersisted: true,
}),
"idle",
);
assert.equal(rectificationQuestionRetryActive("persisted_question"), false);
});
test("question gap: a delivered range is not the unavailable repair path", () => {
assert.equal(
rectificationQuestionGapState({
...gapBase,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "completed_with_range",
}),
"delivered",
);
assert.equal(
rectificationQuestionGapState({
...gapBase,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
stopReason: "tied_first",
}),
"delivered",
);
assert.equal(rectificationQuestionRetryActive("delivered"), false);
});
test("question gap: a choice question without a card is unavailable, not collect waiting", () => {
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true);
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false);
assert.equal(interviewChoiceCardUnavailable({ questionKind: "collect_spoken", hasChoiceCard: false }), false);
// 原值: collect_spoken 一律不是 deadChoice
// 新值: collect:targeted:<domain> 存在题的口述形态也是 deadChoice;year 与普通口述仍不是
// 原因: BUG-673 定向存在题被存成 collect_spoken 时必须走修复入口
assert.equal(
interviewChoiceCardUnavailable({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionId: "collect:targeted:family",
}),
true,
);
assert.equal(
interviewChoiceCardUnavailable({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionId: "collect:other:collect_method_evidence",
}),
false,
);
});
test("readonly range copy never says 收窄 or 才会变", () => {
assert.equal(
rectificationReadonlyRangeCopy({
range: ["04:45", "05:15"],
sessionOutcome: "adopt_representative",
discriminatingEventProbes: [],
contrastProbes: [],
}),
"目前范围 04:45–05:15,选择题已问完,下面是当前范围",
);
// 原值: discriminate_candidates 且无带年月探针 →「选择题已问完,再补带年月的经历才会变」
// 新值: 有交付卡才写「选择题已问完,下面是当前范围」;仍在区分时写「还在核对」
// 原因: BUG-652 没有结果卡时不得写「做不了」口径
const stillOpen = rectificationReadonlyRangeCopy({
range: ["04:45", "05:15"],
sessionOutcome: "discriminate_candidates",
discriminatingEventProbes: [{ year: 2016 }],
});
assert.match(stillOpen, /还在核对/);
assert.doesNotMatch(stillOpen, /收窄|才会变/);
const collecting = rectificationReadonlyRangeCopy({
range: ["04:45", "05:15"],
sessionOutcome: "collect_evidence",
});
assert.match(collecting, /再说一件带年月的事就能继续/);
assert.doesNotMatch(collecting, /选择题已问完|收窄|才会变/);
});
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", () => {
// 原值: 「填报出生时间 05:10」
// 新值: 缺来源时按大概时间;医院记录用「出生记录时间」
// 原因: BUG-690 推算值不得称作「你的出生时间」
assert.deepEqual(rectificationBoardEmptyCopy("05:10"), {
clock: "05:10",
lines: ["你填的大概时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
});
assert.deepEqual(rectificationBoardEmptyCopy("05:10", "hospital_record"), {
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 }],
answer_origin: "host_fallback",
},
},
{ 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[0]?.receipt?.answer_origin, "host_fallback");
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: 503 })) as typeof fetch;
const failed = await hydrateRectificationCase("case-1", "session-1", { fetchImpl: failing, timeoutMs: 1_000 });
// 原值: { turns: [], snapshot: null, complete: false }
// 新值: 同上并带 failure.status(HTTP 状态或 timeout / network)
// 原因: BUG-969 快照失败不得静默,调用方要能区分超时和 503
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false, failure: { status: 503 } });
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, failure: { status: "timeout" } });
});