217 lines
9.5 KiB
TypeScript
217 lines
9.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { randomUUID } from "node:crypto";
|
|
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
|
|
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
|
|
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
|
|
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
|
|
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
|
|
|
|
const fixedNow = () => new Date("2026-07-26T12:00:00.000Z");
|
|
const spec: CalculationSpec = {
|
|
version: "rectification-calculation-spec-v4",
|
|
birthDate: "1997-08-08",
|
|
candidateRange: { start: "04:30", end: "05:30" },
|
|
latitude: 36.419,
|
|
longitude: 114.213,
|
|
timezoneOffsetHours: 8,
|
|
ayanamsa: "lahiri",
|
|
nodeMode: "mean",
|
|
minuteStep: 1,
|
|
};
|
|
|
|
|
|
|
|
test("same calculation spec resumes the unfinished case", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
|
const userId = randomUUID();
|
|
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: { ...spec } });
|
|
|
|
assert.equal(resumed.case.id, first.case.id);
|
|
assert.equal(store.cases.size, 1);
|
|
});
|
|
|
|
test("changed calculation spec atomically abandons the old case and stales its job", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
|
const userId = randomUUID();
|
|
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
const queued = await service.answer({
|
|
userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学",
|
|
});
|
|
assert.ok(queued?.job);
|
|
|
|
const replacement = await service.createCase({
|
|
userId,
|
|
actionId: randomUUID(),
|
|
calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } },
|
|
});
|
|
|
|
assert.notEqual(replacement.case.id, first.case.id);
|
|
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
|
|
assert.equal(store.cases.get(first.case.id)?.currentQuestion, null);
|
|
assert.equal(store.jobs.get(queued.job.id)?.status, "stale");
|
|
assert.equal((await service.loadActive(userId))?.case.id, replacement.case.id);
|
|
});
|
|
|
|
test("an accepted range closes the active lifecycle and allows a new case", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
|
const userId = randomUUID();
|
|
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
await store.transitionCase({
|
|
userId,
|
|
caseId: first.case.id,
|
|
actionId: randomUUID(),
|
|
expectedCaseVersion: first.case.version,
|
|
status: "range_ready",
|
|
phase: "complete",
|
|
acceptedRange: { start: "05:13", end: "05:15" },
|
|
now: fixedNow().toISOString(),
|
|
});
|
|
|
|
assert.equal(await service.loadActive(userId), null);
|
|
const next = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
assert.notEqual(next.case.id, first.case.id);
|
|
assert.equal(store.cases.size, 2);
|
|
});
|
|
|
|
test("answer is durably queued and poll remains read only", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
|
const userId = randomUUID();
|
|
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
assert.equal(created.case.status, "awaiting_answer");
|
|
assert.equal(created.case.currentQuestion?.domain, "education");
|
|
const queued = await service.answer({
|
|
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
|
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
|
|
});
|
|
assert.equal(queued?.case.status, "processing");
|
|
assert.equal(queued?.job?.status, "pending");
|
|
const before = JSON.stringify([...store.jobs.values()]);
|
|
const polled = await service.loadCase(userId, created.case.id);
|
|
assert.equal(polled?.job, null);
|
|
assert.equal(JSON.stringify([...store.jobs.values()]), before);
|
|
});
|
|
|
|
test("worker extracts dated events, keeps one question and never confirms an exact minute", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
|
const userId = randomUUID();
|
|
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
const queued = await service.answer({
|
|
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
|
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
|
|
});
|
|
const worker = createRectificationV4Worker({
|
|
store,
|
|
now: fixedNow,
|
|
engine: { async score() { throw new Error("engine must not run before enough events"); } },
|
|
});
|
|
assert.equal(await worker.runOnce(), true);
|
|
const done = await service.loadCase(userId, created.case.id);
|
|
assert.equal(done?.case.status, "awaiting_answer");
|
|
assert.equal(done?.events.length, 2);
|
|
assert.equal(done?.case.currentQuestion?.domain, "relocation");
|
|
assert.equal(done?.case.latestSnapshot, null);
|
|
assert.equal(queued?.job?.status, "pending");
|
|
});
|
|
|
|
test("completed job rejects stale case or calculation hashes", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
|
const userId = randomUUID();
|
|
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学" });
|
|
const claimed = await store.claimNextJob("worker", fixedNow().toISOString());
|
|
assert.ok(claimed);
|
|
await assert.rejects(() => store.completeJob({
|
|
workerId: "worker", jobId: claimed.job.id, expectedCaseVersion: claimed.case.version,
|
|
inputEvidenceSetHash: "0".repeat(64), outputEvidenceSetHash: claimed.case.evidenceSetHash,
|
|
calculationSpecHash: claimed.case.calculationSpecHash, newEventRevisions: [], snapshot: null,
|
|
nextQuestion: claimed.case.currentQuestion, status: "awaiting_answer", phase: "collecting_evidence",
|
|
}, fixedNow().toISOString()), /stale_job/);
|
|
});
|
|
|
|
test("worker moves from domain coverage to targeted date refinement without a null-question dead state", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
|
const userId = randomUUID();
|
|
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
const scoreableCounts: number[] = [];
|
|
const worker = createRectificationV4Worker({
|
|
store,
|
|
now: fixedNow,
|
|
engine: {
|
|
async score({ calculationSpec, events }) {
|
|
scoreableCounts.push(events.length);
|
|
const ids = events.map((event) => event.eventId);
|
|
return {
|
|
resultId: randomUUID(),
|
|
calculationSpecHash: calculationSpecHash(calculationSpec),
|
|
candidates: [
|
|
{ time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] },
|
|
{ time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] },
|
|
{ time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] },
|
|
],
|
|
robustness: {
|
|
neighborSupportMinutes: 3,
|
|
leaveOneOutRetentionRate: 1,
|
|
dateSensitivityRetentionRate: 0.5,
|
|
},
|
|
missingLayers: [],
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
let current = created;
|
|
for (const answer of [
|
|
"2016年高中毕业",
|
|
"2018年8月搬家到北京",
|
|
"2020年5月开始恋爱",
|
|
"2021年3月入职公司",
|
|
"2022年4月收入明显变化",
|
|
"2023年5月住院",
|
|
"2024年6月家庭发生变化",
|
|
]) {
|
|
const queued = await service.answer({
|
|
userId,
|
|
caseId: created.case.id,
|
|
actionId: randomUUID(),
|
|
expectedCaseVersion: current.case.version,
|
|
answer,
|
|
});
|
|
assert.ok(queued?.job);
|
|
assert.equal(await worker.runOnce(), true);
|
|
current = (await service.loadCase(userId, created.case.id))!;
|
|
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
|
|
}
|
|
|
|
const targetEventId = current.case.currentQuestion?.targetEventId;
|
|
assert.ok(targetEventId);
|
|
assert.equal(current.case.status, "awaiting_answer");
|
|
assert.equal(current.case.currentQuestion?.prompt.includes("更具体的日期"), true);
|
|
|
|
const queued = await service.answer({
|
|
userId,
|
|
caseId: created.case.id,
|
|
actionId: randomUUID(),
|
|
expectedCaseVersion: current.case.version,
|
|
answer: "2016年6月8日",
|
|
});
|
|
assert.ok(queued?.job);
|
|
assert.equal(await worker.runOnce(), true);
|
|
current = (await service.loadCase(userId, created.case.id))!;
|
|
|
|
const targetedRevisions = current.events.filter((event) => event.eventId === targetEventId);
|
|
assert.deepEqual(targetedRevisions.map((event) => event.revision), [1, 2]);
|
|
assert.equal(targetedRevisions[1]?.dateRange.precision, "day");
|
|
assert.equal(scoreableCounts.at(-1), 6);
|
|
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
|
|
assert.notEqual(current.case.currentQuestion?.targetEventId, targetEventId);
|
|
});
|