Files
Jyotisha/frontend/tests/rectification-v9-contracts.test.ts
T
Jesse_ChenandClaude Opus 5.5 8fc1a5bded fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and
  an example answer again and is server-owned on the zero-evidence opening;
  the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no
  question). Stem de-dup compares whole sentences / near-equality instead of a
  12-char prefix, which had deleted the body's examples sentence since
  aa7ccb30 (BUG-604) + dd8f35f7 (BUG-648).
- BUG-1050: plain step labels; a finished step label shows once and
  「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the
  in-progress wording.
- Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated.
- VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist
  and screenshots.

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

265 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 { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
MAX_RESUMABLE_CASES_PER_USER,
RECTIFICATION_CASE_STATUSES,
RECTIFICATION_SKILL_NAME,
RECTIFICATION_SKILL_VERSION,
RESUMABLE_CASE_STATUSES,
TERMINAL_CASE_STATUSES,
canTransitToTerminal,
evidenceWritesAllowed,
isRectificationCaseStatus,
isResumableStatus,
isTerminalStatus,
} from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
evidenceKindSchema,
evidenceDomainSchema,
BACKGROUND_ONLY_KINDS,
DISTINCT_KIND_GROUPS,
EVIDENCE_KINDS,
canTransitEvidenceStatus,
evidenceSubjectForDomain,
isBackgroundEvidenceKind,
isDatePrecision,
isEvidenceDomain,
isEvidenceKind,
isEvidenceStatus,
datedPrecision,
normalizeQuote,
quoteIsGroundedInMessage,
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
PUBLIC_ACTIVITY_EVENTS,
PUBLIC_RECTIFICATION_PHASES,
PUBLIC_RECTIFICATION_TOOLS,
safeActivityEvent,
} from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import {
openRectificationCaseRequestSchema,
shouldStartOpening,
} from "../src/lib/rectification-agentic/v9/open-request.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const skillDirectory = fileURLToPath(
new URL("../../skills/jyotish-birth-time-rectification", import.meta.url),
);
const skill = readFileSync(`${skillDirectory}/SKILL.md`, "utf8");
const references = [
"evidence-model.md",
"conversation-strategy.md",
"candidate-comparison.md",
"technique-routing.md",
"truth-consent-boundaries.md",
];
test("v9 case status machine separates resumable from terminal statuses", () => {
assert.deepEqual([...RESUMABLE_CASE_STATUSES], [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
]);
assert.deepEqual([...TERMINAL_CASE_STATUSES], [
"confirmed",
"closed",
"abandoned",
"superseded",
]);
for (const status of RECTIFICATION_CASE_STATUSES) {
assert.equal(isRectificationCaseStatus(status), true);
assert.equal(isResumableStatus(status), RESUMABLE_CASE_STATUSES.includes(status));
assert.equal(isTerminalStatus(status), TERMINAL_CASE_STATUSES.includes(status));
}
assert.equal(isRectificationCaseStatus("mystery"), false);
});
test("terminal transitions are one-way and evidence writes stop at terminal", () => {
assert.equal(canTransitToTerminal("candidate_accepted", "confirmed"), true);
assert.equal(canTransitToTerminal("collecting_evidence", "closed"), true);
assert.equal(canTransitToTerminal("confirmed", "closed"), false);
assert.equal(canTransitToTerminal("closed", "candidate_ready"), false);
assert.equal(evidenceWritesAllowed("collecting_evidence"), true);
assert.equal(evidenceWritesAllowed("candidate_accepted"), true);
assert.equal(evidenceWritesAllowed("needs_rebaseline"), true);
assert.equal(evidenceWritesAllowed("confirmed"), false);
assert.equal(evidenceWritesAllowed("closed"), false);
assert.equal(evidenceWritesAllowed("superseded"), false);
assert.equal(MAX_RESUMABLE_CASES_PER_USER, 1);
});
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {
assert.equal(RECTIFICATION_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)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.31");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
// 原值: /^version: 10\.0\.29$/ / 新值: /^version: 10\.0\.30$/ / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
// 原值: /^version: 10\.0\.30$/ / 新值: /^version: 10\.0\.31$/ / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
assert.match(skill, /^version: 10\.0\.31$/m);
assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/);
for (const reference of references) {
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
assert.ok(content.length > 0, `${reference} must be non-empty`);
}
assert.ok(skill.split("\n").length <= 200, "SKILL.md must stay within 200 lines");
});
test("skill keeps Path C A/B/C/D questions and forbids unique-minute claims", () => {
assert.match(skill, /不再有固定 10[–-]15 个事件/);
assert.match(skill, /A\/B\/C\/D 主题问卷/);
assert.match(skill, /80%\/60%/);
assert.match(skill, /事件吻合率/);
assert.match(skill, /D9\/D10 类型表/);
assert.match(skill, /不得伪造出生分钟/);
assert.match(skill, /日期精度真实保留/);
assert.match(skill, /candidate[\s\S]*accepted[\s\S]*confirmed/);
assert.match(skill, /candidate_range_not_birth_time_truth/);
assert.doesNotMatch(skill, /固定 A\/B\/C\/D 问卷、D9\/D10 类型表贴标签/);
});
test("evidence model exposes the full kind/domain/precision/status sets", () => {
for (const kind of EVIDENCE_KINDS) assert.equal(isEvidenceKind(kind), true);
assert.equal(isEvidenceKind("education"), false);
assert.equal(evidenceKindSchema.safeParse("education").success, false);
assert.equal(evidenceKindSchema.safeParse("education_start").success, true);
assert.equal(evidenceDomainSchema.safeParse("education").success, true);
assert.equal(isEvidenceKind("career"), false);
assert.equal(isEvidenceDomain("career"), true);
assert.equal(isEvidenceDomain("nope"), false);
for (const precision of ["year", "month", "quarter", "day", "range", "unknown"]) {
assert.equal(isDatePrecision(precision), true);
}
assert.equal(isDatePrecision("exact_minute"), false);
assert.equal(datedPrecision("quarter"), "year");
assert.equal(datedPrecision("range"), "year");
assert.equal(datedPrecision("day"), "day");
assert.equal(datedPrecision("unknown"), "unknown");
for (const status of ["draft", "pending_confirmation", "confirmed", "superseded", "rejected"]) {
assert.equal(isEvidenceStatus(status), true);
}
assert.equal(isEvidenceStatus("collected"), false);
});
test("semantically distinct kinds are never folded together", () => {
const flat = DISTINCT_KIND_GROUPS.flat();
assert.ok(flat.includes("career_entry") && flat.includes("career_pressure") && flat.includes("career_exit"));
assert.ok(flat.includes("relationship_start") && flat.includes("relationship_commitment") && flat.includes("relationship_separation"));
assert.equal(new Set(flat).size, flat.length);
});
test("only the server confirmation path may produce confirmed evidence", () => {
assert.equal(canTransitEvidenceStatus("draft", "pending_confirmation"), true);
assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true);
assert.equal(canTransitEvidenceStatus("confirmed", "superseded"), true);
assert.equal(canTransitEvidenceStatus("superseded", "confirmed"), false);
assert.equal(canTransitEvidenceStatus("rejected", "confirmed"), false);
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), true);
});
test("quote grounding normalizes whitespace and punctuation", () => {
assert.equal(
normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"),
"2016年9月,我离开家去北京开始工作。".replace(/[\s\u3000,。!?、;:“”‘’()《》·—…,!.;:?]/g, ""),
);
assert.equal(quoteIsGroundedInMessage("2016年6月高考结束", "2016年6月高考结束,"), true);
assert.equal(quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"), true);
assert.equal(quoteIsGroundedInMessage("我去了上海", "去了北京"), false);
assert.equal(quoteIsGroundedInMessage("", "任意"), false);
});
test("family/other background kinds: only other and horary stay off the scoring path", () => {
assert.equal(isBackgroundEvidenceKind("family_event"), false);
assert.equal(isBackgroundEvidenceKind("other"), true);
assert.equal(isBackgroundEvidenceKind("horary_query"), true);
assert.equal(isBackgroundEvidenceKind("occupation_note"), false);
assert.equal(isBackgroundEvidenceKind("career_entry"), false);
assert.equal(isBackgroundEvidenceKind("appearance_note"), false);
assert.equal(BACKGROUND_ONLY_KINDS.size, 2);
assert.equal(evidenceSubjectForDomain("family", "self"), "family");
assert.equal(evidenceSubjectForDomain("career", undefined), "self");
assert.equal(evidenceSubjectForDomain("appearance"), "self");
});
test("public receipt allowlists are exact and deny unknown values", () => {
for (const phase of PUBLIC_RECTIFICATION_PHASES) {
assert.equal(safeActivityEvent(phase), phase);
}
assert.equal(safeActivityEvent("provider.reasoning"), null);
assert.equal(safeActivityEvent("tool.payload"), null);
assert.ok(!(PUBLIC_RECTIFICATION_PHASES as readonly string[]).includes("thinking.delta"));
assert.ok((PUBLIC_RECTIFICATION_PHASES as readonly string[]).includes("activity.changed"));
assert.equal(PUBLIC_ACTIVITY_EVENTS.length, PUBLIC_RECTIFICATION_PHASES.length);
assert.ok(PUBLIC_RECTIFICATION_TOOLS.includes("rectification-read-case"));
assert.ok(!(PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes("rectification-scan"));
});
test("open request schemas reject userId, birth data and range from the browser", () => {
const homepage = openRectificationCaseRequestSchema.safeParse({
intent: "homepage",
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
});
assert.equal(homepage.success, true);
const withUserId = openRectificationCaseRequestSchema.safeParse({
intent: "homepage",
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
userId: "ffffffff-0000-4aaa-9bbb-cccccccccccc",
});
assert.equal(withUserId.success, false);
const withBirthData = openRectificationCaseRequestSchema.safeParse({
intent: "homepage",
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
birth_date: "1997-08-08",
});
assert.equal(withBirthData.success, false);
const withRange = openRectificationCaseRequestSchema.safeParse({
intent: "homepage",
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
candidate_range: { start_time: "04:00", end_time: "06:00" },
});
assert.equal(withRange.success, false);
const supersedeTrue = openRectificationCaseRequestSchema.safeParse({
intent: "new",
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
supersedeActive: true,
});
assert.equal(supersedeTrue.success, false);
});
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("resumed", 5), false);
assert.equal(shouldStartOpening("readonly", 0), false);
});
test("case snapshot includes step_state without a composer strip", () => {
// 原值: 断言读 cases/[caseId]/route.ts
// 新值: 断言读 case-dossier-response.ts
// 原因: Next App Router 不允许 route.ts 导出非 HTTP handler。
const route = readFileSync(new URL("../src/lib/rectification-agentic/v9/case-dossier-response.ts", import.meta.url), "utf8");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.match(route, /step_state: stepStateFromCaseDossier\(dossier\)/);
assert.doesNotMatch(chat, /rectification-step-state/);
});