259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
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,
|
|
skill_version: "10.0.0",
|
|
};
|
|
}
|
|
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);
|
|
assert.equal(response.skillVersion, "10.0.0");
|
|
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");
|
|
assert.equal(openCall.args.p_skill_version, "10.0.0");
|
|
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);
|
|
});
|