Files
Jyotisha/frontend/tests/birth-time-dynamic-persistence.test.ts
2026-07-19 09:12:45 +08:00

279 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { BirthTimeDynamicStateMissingError, createDynamicTurnPersistence } from "../src/lib/birth-time-journey-dynamic-persistence.ts";
import { saveDynamicAssessment } from "../src/lib/birth-time-journey-dynamic-case.ts";
import { BirthTimeJourneyStoreError, loadStoredRectificationCase, StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import type { StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import { assessBirthTime } from "../src/lib/birth-time-journey.ts";
import { dynamicJourneyTurnStateSchema } from "../src/lib/birth-time-journey-turn-protocol.ts";
import { assertLegacyJourneyMutation } from "../src/lib/birth-time-evidence-service.ts";
import { createInitialDynamicState, dynamicPrivateStateSchema } from "../src/lib/birth-time-journey-dynamic-state.ts";
import {
actionId,
caseId,
dynamicCase,
legacyCase,
loadClient,
ownerId,
persistedQuestion,
privateRow,
rpcPersistence,
snapshot,
} from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
import { savedPauseReceipt, withPauseReceipt } from "./birth-time-dynamic-receipt-test-support.ts";
test("v2 load restores the exact private question and candidate model", async () => {
const loaded = await loadStoredRectificationCase(loadClient(privateRow), ownerId, caseId);
assert.equal(loaded?.journeyProtocol, "dynamic-choice-v2");
assert.deepEqual(loaded?.currentChoiceQuestion, persistedQuestion);
assert.deepEqual(loaded?.candidateModel, privateRow.candidate_model);
assert.deepEqual(loaded?.dynamicControl?.questionFingerprints, [persistedQuestion.questionFingerprint]);
assert.deepEqual(loaded?.agentContext, privateRow.agent_context);
assert.deepEqual(loaded?.choiceAnswers, privateRow.choice_answers);
assert.deepEqual(loaded?.choiceEvidence, privateRow.choice_evidence);
});
test("new v2 cases initialize a dated private control state and public generation turn", () => {
const initial = createInitialDynamicState(snapshot, "2026-07-18");
assert.equal(initial.turn.journeyProtocol, "dynamic-choice-v2");
assert.equal(initial.turn.nextAction.kind, "generate_dynamic_question");
assert.equal(initial.privateState.dynamicControl.asOfDate, "2026-07-18");
assert.deepEqual(initial.privateState.choiceAnswers, []);
assert.deepEqual(initial.privateState.choiceEvidence, []);
assert.equal(JSON.stringify(initial.turn).includes("candidateModel"), false);
assert.equal(JSON.stringify(initial.turn).includes("agentContext"), false);
});
test("unknown birth time initializes the full-day dynamic range", () => {
const unknown = assessBirthTime({
date: "1990-01-01",
source: "unknown",
clue: "",
location: { lat: 31.23, lon: 121.47, tz: 8 },
}, { kind: "not_required" });
const initial = createInitialDynamicState(unknown, "2026-07-18");
assert.deepEqual(initial.turn.progress.currentRange, {
startTime: "00:00",
endTime: "23:59",
});
assert.deepEqual(initial.privateState.dynamicControl.recentRanges, [
{ startTime: "00:00", endTime: "23:59" },
]);
});
test("mixed-null reported ranges fail instead of inventing one boundary", () => {
const mixedRange = {
...snapshot,
reportedRange: {
label: "04:00—未知",
startTime: "04:00",
endTime: null,
},
};
assert.throws(() => createInitialDynamicState(mixedRange, "2026-07-18"));
});
test("new v2 case creation crosses one atomic RPC boundary", async () => {
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
const savedId = await saveDynamicAssessment({
async rpc(name, args) {
calls.push({ name, args });
return { data: caseId, error: null };
},
}, {
userId: ownerId,
assessment: {
date: "1993-04-17",
source: "period_only",
period: "early_morning",
location: { lat: 31.23, lon: 121.47, tz: 8 },
},
snapshot,
questionnaire: null,
candidateScan: null,
}, new Date("2026-07-18T08:00:00.000Z"));
assert.equal(savedId, caseId);
assert.equal(calls.length, 1);
assert.equal(calls[0]?.name, "create_birth_time_dynamic_case");
assert.equal(calls[0]?.args.p_user_id, ownerId);
assert.equal(JSON.stringify(calls[0]?.args.p_public_case).includes("candidateModel"), false);
assert.deepEqual(calls[0]?.args.p_profile, {
reportedBirthTime: null,
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
birthTimeClue: null,
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "rectifying",
});
assert.deepEqual(calls[0]?.args.p_private_state, createInitialDynamicState(
snapshot,
"2026-07-18",
).privateState);
});
test("v2 load rejects a missing private row instead of regenerating", async () => {
await assert.rejects(
loadStoredRectificationCase(loadClient(null), ownerId, caseId),
BirthTimeDynamicStateMissingError,
);
});
test("v2 load rejects missing required public persistence fields", async () => {
await assert.rejects(
loadStoredRectificationCase(loadClient(privateRow, true), ownerId, caseId),
BirthTimeJourneyStoreError,
);
});
test("v2 load rejects incoherent public row and JSON turn versions", async () => {
await assert.rejects(
loadStoredRectificationCase(loadClient(privateRow, false, 8), ownerId, caseId),
BirthTimeJourneyStoreError,
);
});
test("v2 cases cannot fall through to legacy mutation paths", () => {
assert.throws(() => assertLegacyJourneyMutation(dynamicCase()), {
name: "GuidedJourneyLegacyMutationError",
});
});
test("saveDynamicTurn persists a private snapshot and a privacy-safe public turn once", async () => {
const fake = rpcPersistence();
const updated = withPauseReceipt(dynamicCase(), actionId);
const first = await fake.persistence.saveDynamicTurn(updated, 7, actionId);
const replay = await fake.persistence.saveDynamicTurn(updated, 7, actionId);
assert.equal(first.turnVersion, 8);
assert.equal(replay.turnVersion, 8);
assert.equal(replay.processedActionIds.filter((value) => value === actionId).length, 1);
assert.equal(fake.calls[0]?.name, "save_birth_time_dynamic_turn");
const publicTurn = JSON.stringify(fake.calls[0]?.args.p_public_turn_state);
assert.equal(publicTurn.includes("partitionId"), false);
assert.equal(publicTurn.includes("candidateScores"), false);
assert.equal(publicTurn.includes("agentContext"), false);
assert.deepEqual(fake.calls[0]?.args.p_private_state, {
candidateModel: updated.candidateModel,
currentChoiceQuestion: updated.currentChoiceQuestion,
choiceAnswers: updated.choiceAnswers,
choiceEvidence: updated.choiceEvidence,
dynamicControl: updated.dynamicControl,
agentContext: updated.agentContext,
});
});
test("saveDynamicTurn reports a stale version for an unprocessed action", async () => {
const fake = rpcPersistence();
await assert.rejects(
fake.persistence.saveDynamicTurn(dynamicCase(), 6, actionId),
StaleJourneyTurnError,
);
});
test("memory replay returns the stored advanced dynamic turn", async () => {
const initial = dynamicCase();
const memory = memoryStore(initial);
const changed = withPauseReceipt({
...initial,
agentContext: ["persisted context"],
}, actionId);
const saved = await memory.store.saveDynamicTurn(changed, 7, actionId);
const replay = await memory.store.saveDynamicTurn(changed, 7, actionId.toUpperCase());
assert.deepEqual(replay, saved);
assert.equal(replay.turnVersion, 8);
assert.deepEqual(replay.agentContext, ["persisted context"]);
assert.deepEqual(replay.processedActionIds, [actionId]);
assert.equal(memory.committedTurnWrites(), 1);
});
test("memory store replays seeded v2 receipts and executes legacy upgrades", async () => {
const initial = dynamicCase();
const seeded = savedPauseReceipt(initial, actionId);
const dynamicMemory = memoryStore(seeded);
const replay = await dynamicMemory.store.saveDynamicTurn(
withPauseReceipt(initial, actionId), 7, actionId,
);
assert.equal(replay, seeded);
const legacyMemory = memoryStore(legacyCase(true));
const upgraded = await legacyMemory.store.upgradeLegacyActiveCase(legacyCase(true));
assert.equal(upgraded.journeyProtocol, "dynamic-choice-v2");
assert.equal(upgraded.dynamicTurnState.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(upgraded.answers, { q1: "A" });
});
test("legacy active upgrade preserves evidence and starts v2 without legacy fingerprints", async () => {
let loaded: StoredRectificationCase = legacyCase(true);
const rpcCalls: string[] = [];
const persistence = createDynamicTurnPersistence({
async rpc(name: string, args: Readonly<Record<string, unknown>>) {
rpcCalls.push(name);
const parsedPrivate = dynamicPrivateStateSchema.parse(args.p_private_state);
const parsedTurn = dynamicJourneyTurnStateSchema.parse(args.p_public_turn_state);
if (loaded.journeyProtocol === "dynamic-choice-v2") {
throw new Error("legacy upgrade replayed through the RPC fake");
}
loaded = {
...loaded,
journeyProtocol: "dynamic-choice-v2",
turnVersion: loaded.turnVersion ?? 0,
processedActionIds: loaded.processedActionIds ?? [],
persistedProgress: loaded.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] },
dynamicTurnState: parsedTurn,
turnState: null,
evidenceDraft: null,
...parsedPrivate,
};
return { data: loaded.turnVersion, error: null };
},
}, async () => loaded, () => "2026-07-18");
const upgraded = await persistence.upgradeLegacyActiveCase(loaded);
assert.equal(upgraded.journeyProtocol, "dynamic-choice-v2");
assert.deepEqual(upgraded.answers, { q1: "A" });
assert.equal(upgraded.dynamicTurnState?.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(upgraded.dynamicControl?.questionFingerprints, []);
assert.equal(upgraded.dynamicControl?.effectiveAnswerCount, 1);
assert.deepEqual(rpcCalls, ["upgrade_birth_time_legacy_case"]);
});
test("terminal legacy cases remain byte-for-byte unchanged", async () => {
const terminal = legacyCase(false);
let rpcCalled = false;
const persistence = createDynamicTurnPersistence({
async rpc() {
rpcCalled = true;
return { data: null, error: null };
},
}, async () => terminal, () => "2026-07-18");
const result = await persistence.upgradeLegacyActiveCase(terminal);
assert.equal(result, terminal);
assert.equal(rpcCalled, false);
});
test("dynamic persistence maps unknown RPC errors to a typed store error", async () => {
const persistence = createDynamicTurnPersistence({
async rpc() { return { data: null, error: { message: "database unavailable" } }; },
}, async () => dynamicCase(), () => "2026-07-18");
await assert.rejects(
persistence.saveDynamicTurn(dynamicCase(), 7, actionId),
BirthTimeJourneyStoreError,
);
});