Files
Jyotisha/frontend/tests/rectification-v9-entry-routing.test.ts
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 12:58:58 +08:00

292 lines
12 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,
} 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,
});
// 原值: resolveRectificationEntryAction(summary) === "start"、按钮文案「开始新的生时校正」
// 新值: 只锁服务端摘要解析(按钮与文案已删)
// 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(summary.hasResumableCase, false);
assert.equal(summary.hasTerminalCaseWithTime, false);
});
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,
});
// 原值: resolveRectificationEntryAction(summary) === "start"、「开始新的生时校正」
// 新值: 摘要仍读出未完成 case(对话里的提议因此不出卡,见 BUG-1248)
// 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(summary.hasResumableCase, true);
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,
},
});
// 原值: resolveRectificationEntryAction(summary) === "restart"、「再次校正」
// 新值: 只锁摘要解析
// 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(summary.hasTerminalCaseWithTime, true);
});
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,
});
// 原值: resolveRectificationEntryAction(summary) === "start"、文案不是「继续上次校正」
// 新值: 摘要解析不读会话列表(入口按钮已删)
// 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(summary.hasResumableCase, false);
});
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)
// 原值: "10.0.32" / 新值: "10.0.33" / 原因: 新建案绑定active Skill,非历史重开fixture(BUG-1115~1117)
skill_version: "10.0.33",
};
}
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)
// 原值: "10.0.32" / 新值: "10.0.33" / 原因: 新建案返回active Skill,旧案身份不升级(BUG-1115~1117)
assert.equal(response.skillVersion, "10.0.33");
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)
// 原值: "10.0.32" / 新值: "10.0.33" / 原因: 新建案RPC绑定active Skill,旧案重开另测精确绑定(BUG-1115~1117)
assert.equal(openCall.args.p_skill_version, "10.0.33");
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);
});