Files
Jyotisha/frontend/tests/rectification-v9-entry-routing.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

282 lines
11 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)
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
// 原值: "10.0.31" / 新值: "10.0.32" / 原因: 训练门开后只问点选卡、交付正文去吻合率写进 Skill(BUG-1084/1085,2026-09-29)
skill_version: "10.0.32",
};
}
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)
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
// 原值: "10.0.31" / 新值: "10.0.32" / 原因: 训练门开后只问点选卡、交付正文去吻合率写进 Skill(BUG-1084/1085,2026-09-29)
assert.equal(response.skillVersion, "10.0.32");
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)
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
// 原值: "10.0.31" / 新值: "10.0.32" / 原因: 训练门开后只问点选卡、交付正文去吻合率写进 Skill(BUG-1084/1085,2026-09-29)
assert.equal(openCall.args.p_skill_version, "10.0.32");
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);
});