Files
Jyotisha/frontend/tests/rectification-v9-entry-routing.test.ts
T
Jesse_ChenandClaude Opus 5.5 4e6e8d87ef feat(rectification): 少问几道就出卡,交付卡以范围为主(D1–D4)
- D1 引导窗口题每个校正最多 2 道(前端 GUIDED_WINDOW_CASE_LIMIT;引擎
  GUIDED_COLLECT_LIMIT 未动:event_probes.py 属冻结评分身份,改它需重新冻结)
- D2 七条定向线与跳过线重问问完即出卡,没问到的引导窗口不再挡卡,出卡后也不再挂窗口题
- D3 卡头加副标题「最可能 HH:MM」
- D4 前两列相差 ≥5 个百分点才显示相对可能性,否则一句「这几个时刻目前区分不开……」
- 离线回放 scripts/research/fewer_probes_card_replay.py:真值不降、宽度中位 ±1 分钟、提问 11.4→7.8
- Skill 10.0.29 → 10.0.30;DESIGN / VOICE / CHANGELOG / PROGRESS / 真机清单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 14:27:12 +08:00

276 lines
10 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 {
entrySummaryFromResponse,
isResumableRectificationStatus,
isTerminalRectificationStatus,
openRectificationRequestBody,
openResponseFromPayload,
rectificationEntryLabels,
resolveRectificationEntryAction,
} from "../src/lib/rectification-entry.ts";
import {
openRectificationCaseRequestSchema,
shouldStartOpening,
} from "../src/lib/rectification-agentic/v9/open-request.ts";
import {
mapRectificationRpcError,
openRectificationCase,
RectificationCaseServiceError,
} from "../src/lib/rectification-agentic/v9/case-service.ts";
import {
CASE_ID,
SESSION_ID,
fakeAccounting,
type FakeRpcHandler,
} from "./rectification-v9-test-support.ts";
const uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
test("homepage entry requests a new case and shows 开始新的生时校正", () => {
const body = openRectificationRequestBody("homepage", null);
assert.equal(body.intent, "homepage");
assert.ok(typeof body.requestId === "string" && body.requestId.length > 0);
assert.equal("sessionId" in body, false);
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: false,
latest_resumable: null,
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
});
test("homepage entry with a resumable case still offers a separate new correction", () => {
const summary = entrySummaryFromResponse({
has_resumable_case: true,
has_terminal_case_with_time: false,
latest_resumable: {
case_id: CASE_ID,
status: "collecting_evidence",
last_activity_at: "2026-08-12T10:00:00.000Z",
},
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
const opened = openResponseFromPayload({
disposition: "created",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "draft",
shouldStartOpening: true,
skillVersion: "10.0.0",
});
assert.equal(opened?.disposition, "created");
assert.equal(opened?.shouldStartOpening, true);
assert.equal(opened?.caseId, CASE_ID);
assert.equal(opened?.sessionId, SESSION_ID);
});
test("homepage entry with only a terminal case shows 再次校正 and creates a new case", () => {
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: true,
latest_resumable: null,
latest_terminal: {
case_id: CASE_ID,
status: "confirmed",
has_usable_time: true,
},
});
assert.equal(resolveRectificationEntryAction(summary), "restart");
assert.equal(rectificationEntryLabels.restart, "再次校正");
});
test("legacy completed sessions never derive 继续上次 from the session list", () => {
// The CTA must be driven by the server entry summary, not by scanning
// sessionType in the client session list.
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: false,
latest_resumable: null,
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.notEqual(rectificationEntryLabels.start, "继续上次校正");
});
test("sidebar click passes the exact sessionId and opens exactly that session", () => {
const body = openRectificationRequestBody("session", SESSION_ID);
assert.equal(body.intent, "session");
assert.equal(body.sessionId, SESSION_ID);
});
test("sidebar click on a terminal session opens read-only history", () => {
const opened = openResponseFromPayload({
disposition: "readonly",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "confirmed",
shouldStartOpening: false,
skillVersion: "9.0.0",
});
assert.equal(opened?.disposition, "readonly");
assert.equal(isTerminalRectificationStatus(opened?.status ?? ""), true);
assert.equal(isResumableRectificationStatus(opened?.status ?? ""), false);
assert.equal(opened?.shouldStartOpening, false);
});
test("double-click sends an idempotency key and never business state", () => {
const first = openRectificationRequestBody("homepage", null);
const second = openRectificationRequestBody("homepage", null);
// Both requests carry a requestId; the server serializes on the user and
// dedupes via the open ledger. No caseId/birth data/range is ever sent.
assert.ok(first.requestId);
assert.ok(second.requestId);
for (const body of [first, second]) {
assert.equal("caseId" in body, false);
assert.equal("birth_date" in body, false);
assert.equal("candidate_range" in body, false);
assert.equal("userId" in body, false);
}
});
test("two tabs produce distinct request ids; the open schema stays minimal", () => {
const tabA = openRectificationRequestBody("homepage", null);
const tabB = openRectificationRequestBody("homepage", null);
assert.notEqual(tabA.requestId, tabB.requestId);
for (const parsed of [tabA, tabB]) {
const result = openRectificationCaseRequestSchema.safeParse(parsed);
assert.equal(result.success, true);
}
});
test("non-owner sessionId maps to a 404 business error, never a leak", () => {
const view = mapRectificationRpcError(
new Error("agentic_rectification_session_not_found"),
);
assert.equal(view.status, 404);
assert.equal(view.code, "case_session_not_found");
});
test("profile incomplete prevents case creation with an explicit business error", async () => {
const accounting = fakeAccounting({});
// loadV9RectificationProfile throws profile_incomplete before the open RPC
// runs when the profile row is missing required fields.
const profileClient = {
...accounting.client,
from: () => ({
select: () => ({
eq: () => ({
single: async () => ({ data: { birth_date: null, latitude: null }, error: null }),
}),
}),
}),
} as never;
await assert.rejects(
openRectificationCase(profileClient, "user-1", {
intent: "homepage",
requestId: uuid,
}),
(error: unknown) => error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
);
});
test("shouldStartOpening is server-owned: only freshly created never-started cases", () => {
assert.equal(shouldStartOpening("created", 0), true);
assert.equal(shouldStartOpening("created", 1), false);
assert.equal(shouldStartOpening("resumed", 0), false);
assert.equal(shouldStartOpening("readonly", 3), false);
});
test("empty client message cache never repeats opening when the case has turns", () => {
// The client messages array may be empty after refresh, but exact-session
// recovery stays resumed: shouldStartOpening is false, so the opening turn
// is not re-emitted.
const opened = openResponseFromPayload({
disposition: "resumed",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "collecting_evidence",
shouldStartOpening: false,
skillVersion: "9.0.0",
});
assert.equal(opened?.disposition, "resumed");
assert.equal(opened?.shouldStartOpening, false);
});
test("open RPC passes the pinned skill and server-derived baseline only", async () => {
const rpc: FakeRpcHandler = (fn) => {
if (fn === "open_agentic_rectification_case_v2") {
return {
disposition: "created",
case_id: CASE_ID,
session_id: SESSION_ID,
status: "draft",
should_start_opening: true,
// 原值: "10.0.25";新值: "10.0.26";原因: BUG-668 新案绑定现行 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
skill_version: "10.0.30",
};
}
return null;
};
const profile = {
birth_date: "1997-08-08",
birth_place_label: "河北省邯郸市",
reported_birth_time: "05:00",
active_birth_time: null,
birth_time_source: "family_exact",
birth_time_period: null,
uncertainty_before_minutes: 10,
uncertainty_after_minutes: 10,
latitude: 36.420487,
longitude: 114.209936,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
};
const accounting = fakeAccounting({
open_agentic_rectification_case_v2: rpc,
});
const profileClient = {
...accounting.client,
from: () => ({
select: () => ({
eq: () => ({
single: async () => ({ data: profile, error: null }),
}),
}),
}),
} as never;
const response = await openRectificationCase(profileClient, "user-1", {
intent: "homepage",
requestId: uuid,
});
assert.equal(response.disposition, "created");
assert.equal(response.shouldStartOpening, true);
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(response.skillVersion, "10.0.30");
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
assert.ok(openCall);
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(openCall.args.p_skill_version, "10.0.30");
assert.equal(openCall.args.p_user_id, "user-1");
// The server derives the baseline; the request never carries it from the browser.
assert.equal("birth_date" in openCall.args, false);
});