Files
Jyotisha/frontend/tests/rectification-v9-contracts.test.ts

213 lines
8.8 KiB
TypeScript

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 {
BACKGROUND_ONLY_KINDS,
DISTINCT_KIND_GROUPS,
EVIDENCE_KINDS,
canTransitEvidenceStatus,
isBackgroundEvidenceKind,
isDatePrecision,
isEvidenceDomain,
isEvidenceKind,
isEvidenceStatus,
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";
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");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.0");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
assert.match(skill, /^version: 10\.0\.0$/m);
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 the method in the skill, not a hard-coded questionnaire", () => {
// The skill may only mention the old questionnaire as an explicit
// prohibition, never as a requirement.
assert.match(skill, /不再有固定 10[–-]15 个事件/);
assert.match(skill, /固定 80%\/60% 匹配率/);
assert.match(skill, /固定 A\/B\/C\/D 问卷/);
assert.match(skill, /不得伪造出生分钟/);
assert.match(skill, /日期精度真实保留/);
assert.match(skill, /candidate[\s\S]*accepted[\s\S]*confirmed/);
});
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("career"), false);
assert.equal(isEvidenceDomain("career"), true);
assert.equal(isEvidenceDomain("nope"), false);
for (const precision of ["year", "month", "day", "range", "unknown"]) {
assert.equal(isDatePrecision(precision), true);
}
assert.equal(isDatePrecision("exact_minute"), false);
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年9月离开家去北京工作", "离开家去北京"), true);
assert.equal(quoteIsGroundedInMessage("我去了上海", "去了北京"), false);
assert.equal(quoteIsGroundedInMessage("", "任意"), false);
});
test("family/other are background-only kinds that never advance scoring", () => {
assert.equal(isBackgroundEvidenceKind("family_event"), true);
assert.equal(isBackgroundEvidenceKind("other"), true);
assert.equal(isBackgroundEvidenceKind("career_entry"), false);
assert.equal(BACKGROUND_ONLY_KINDS.size, 2);
});
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.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);
});