Files
Jyotisha/frontend/tests/conversational-legacy-import.test.ts
T
2026-07-25 02:08:10 +08:00

340 lines
13 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
projectLegacyCaseForConversationalImport,
type LegacyConversationalImportSource,
} from "../src/lib/conversational-rectification/legacy-import.ts";
import {
createConversationalRectificationService,
type ConversationalRectificationServicePorts,
} from "../src/lib/conversational-rectification/orchestrator.ts";
import { conversationalRectificationTurnSchema } from "../src/lib/conversational-rectification/contracts.ts";
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
import type {
ConversationalRectificationTurnInput,
LoadedConversationalRectificationCase,
} from "../src/lib/conversational-rectification/store.ts";
const userId = "00000000-0000-4000-8000-000000001101";
const legacyCaseId = "00000000-0000-4000-8000-000000001102";
const actionId = "00000000-0000-4000-8000-000000001103";
const competingActionId = "00000000-0000-4000-8000-000000001104";
const lifeEventId = "00000000-0000-4000-8000-000000001105";
const futureEventId = "00000000-0000-4000-8000-000000001106";
const declaredBirthInput = {
source: "approximate" as const,
birthDate: "1990-01-01",
reportedTime: "05:30",
uncertaintyBeforeMinutes: 30 as const,
uncertaintyAfterMinutes: 30 as const,
birthTimeClue: "家人只记得天刚亮",
birthplace: {
countryCode: "CN",
provinceCode: "130000",
cityCode: "130400",
districtCode: "130406",
latitude: 36.420487,
longitude: 114.209936,
timezoneOffset: 8,
},
};
function source(protocol: "legacy-guided-v1" | "dynamic-choice-v2"): LegacyConversationalImportSource {
return {
caseId: legacyCaseId,
userId,
journeyProtocol: protocol,
status: "rectifying",
turnVersion: protocol === "dynamic-choice-v2" ? 4 : 2,
declaredBirthInput,
currentRange: protocol === "dynamic-choice-v2"
? { startTime: "05:18", endTime: "05:42" }
: { startTime: "05:10", endTime: "05:50" },
lifeEvents: [
{ id: lifeEventId, domain: "career", precision: "month", date: "2021-07" },
{ id: futureEventId, domain: "relationship", precision: "month", date: "2099-01" },
],
// These fields deliberately contain the legacy UX material that must not
// cross the protocol boundary.
currentChoicePrompt: "哪一个时间段更接近一次持续的健康压力变化?",
choiceAnswers: [{ optionId: "2006-2011", label: "2006-2011年" }],
};
}
function packet(range = { startTime: "05:18", endTime: "05:42" }): RectificationTechnicalPacket {
return {
calculationVersion: "legacy-import-technical-v1",
candidate: {
status: "pending_validation",
representativeTime: "05:30",
range,
},
useBoundary: "这是继承的待验证候选范围,不是已经确认的出生分钟。",
candidateModelRefs: ["legacy-import-range-v1"],
candidateDifferenceRefs: ["d9-boundary", "d10-boundary"],
candidateWeights: { "05:18": 0.5, "05:42": 0.5 },
partitionIds: [],
d1Stability: "stable",
boundaryDistanceMinutes: 12,
sensitivityScope: {
source: "time_linked_candidate_scan_samples",
rangeStart: range.startTime,
rangeEnd: range.endTime,
sampleTimes: [range.startTime, range.endTime],
},
stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["d1"] }],
sensitiveLayers: [
{ layer: "D9", values: ["Aries", "Taurus"], referenceIds: ["d9-boundary"] },
{ layer: "D10", values: ["Virgo", "Libra"], referenceIds: ["d10-boundary"] },
],
supportedSensitiveLayers: ["D9", "D10"],
scoredHistoricalEvidence: [{
evidenceId: lifeEventId,
domain: "career",
candidateTime: "05:30",
score: 2,
ruleRefs: ["career-rule"],
}],
suggestedDomains: [
{ domain: "relationship", layer: "D9", reason: "D9 区分候选" },
{ domain: "career", layer: "D10", reason: "D10 区分候选" },
],
referenceIds: ["d9-boundary", "d10-boundary", "career-rule"],
futureWindows: [],
};
}
test("v1 and v2 projection preserves only declared facts, latest range, and scoreable past events", () => {
for (const protocol of ["legacy-guided-v1", "dynamic-choice-v2"] as const) {
const legacy = source(protocol);
const projected = projectLegacyCaseForConversationalImport({
source: legacy,
asOfDate: "2026-07-21",
});
assert.equal(projected.legacyCaseId, legacyCaseId);
assert.equal(projected.expectedVersion, legacy.turnVersion);
assert.deepEqual(projected.declaredBirthInput, declaredBirthInput);
assert.deepEqual(projected.currentRange, legacy.currentRange);
assert.deepEqual(projected.evidence.map((item) => ({
id: item.id,
domain: item.domain,
dateValue: item.dateValue,
datePrecision: item.datePrecision,
scoreable: item.scoreable,
})), [{
id: lifeEventId,
domain: "career",
dateValue: "2021-07",
datePrecision: "month",
scoreable: true,
}]);
const serialized = JSON.stringify(projected);
assert.equal(serialized.includes("哪一个时间段"), false);
assert.equal(serialized.includes("2006-2011"), false);
assert.equal(serialized.includes(futureEventId), false);
}
});
test("projection rejects terminal, foreign-owner, and unsupported protocol sources", () => {
for (const candidate of [
{ ...source("legacy-guided-v1"), status: "reported" },
{ ...source("dynamic-choice-v2"), status: "starting" },
{ ...source("dynamic-choice-v2"), status: "active" },
{ ...source("dynamic-choice-v2"), status: "paused" },
{ ...source("legacy-guided-v1"), status: "completed" },
{ ...source("dynamic-choice-v2"), status: "abandoned" },
{ ...source("dynamic-choice-v2"), status: "confirmed" },
{ ...source("dynamic-choice-v2"), userId: competingActionId },
{ ...source("dynamic-choice-v2"), journeyProtocol: "conversational-evidence-v3" },
]) {
assert.throws(() => projectLegacyCaseForConversationalImport({
source: candidate as LegacyConversationalImportSource,
asOfDate: "2026-07-21",
expectedUserId: userId,
}), (error: unknown) => error instanceof ConversationalRectificationError
&& ["case_not_found", "invalid_transition"].includes(error.code));
}
});
test("projection accepts exactly the four legal unfinished legacy case statuses", () => {
for (const status of ["assessing", "rectifying", "candidate", "confirming"]) {
const projected = projectLegacyCaseForConversationalImport({
source: { ...source("dynamic-choice-v2"), status },
asOfDate: "2026-07-21",
expectedUserId: userId,
});
assert.equal(projected.legacyCaseId, legacyCaseId);
}
});
function importedRow(input: {
readonly firstTurn: ConversationalRectificationTurnInput;
readonly privateCandidate: NonNullable<LoadedConversationalRectificationCase["privateCandidate"]>;
readonly evidence: LoadedConversationalRectificationCase["eventEvidence"];
readonly pendingConsultationQuestion: string | null;
}): LoadedConversationalRectificationCase {
return {
caseId: actionId,
userId,
status: "active",
turnVersion: 0,
revisionOfCaseId: null,
importedFromCaseId: legacyCaseId,
baselineActiveTime: "04:58",
pendingConsultationQuestion: input.pendingConsultationQuestion,
billingState: "migration_waived",
latestTurn: conversationalRectificationTurnSchema.parse(input.firstTurn),
declaredBirthInput,
privateCandidate: input.privateCandidate,
eventEvidence: input.evidence,
validationReceipts: [{ modelId: "synthetic-narrator", schemaValidated: true }],
};
}
function harness() {
const cases = new Map<string, LoadedConversationalRectificationCase>();
const events: string[] = [];
let importCount = 0;
let reserved = 0;
let loadLegacyCount = 0;
const ports: ConversationalRectificationServicePorts = {
rectificationPriceCredits: 9,
store: {
async loadCase(input) {
if (input.caseId) return cases.get(input.caseId) ?? null;
return [...cases.values()].at(-1) ?? null;
},
async loadActionReceipt() { return null; },
async createCaseWithFirstTurn() { throw new Error("paid create must not run"); },
async saveTurn() { throw new Error("not used"); },
async pause() { throw new Error("not used"); },
async abandon() { throw new Error("not used"); },
async confirm() { throw new Error("not used"); },
async importLegacy(input) {
importCount += 1;
events.push("import");
assert.deepEqual(input.declaredBirthInput, declaredBirthInput);
assert.deepEqual(input.evidence.map((item) => item.id), [lifeEventId]);
assert.equal(input.firstTurn.evidenceRecap[0]?.id, lifeEventId);
assert.equal(input.firstTurn.narrative.includes("候选"), true);
assert.equal(input.firstTurn.narrative.includes("哪一个时间段"), false);
const row = importedRow({
firstTurn: input.firstTurn,
privateCandidate: input.privateCandidate,
evidence: input.evidence,
pendingConsultationQuestion: input.pendingConsultationQuestion,
});
cases.set(input.caseId, row);
return row;
},
},
billing: {
async reserve() { reserved += 1; throw new Error("must not reserve"); },
async complete() { throw new Error("must not complete"); },
async release() { throw new Error("must not release"); },
},
async loadDeclaredProfile() {
return {
declaredBirthInput,
revisionOfCaseId: null,
legacyCaseId,
};
},
async loadLegacyCase(receivedUserId, receivedLegacyCaseId) {
loadLegacyCount += 1;
assert.equal(receivedUserId, userId);
assert.equal(receivedLegacyCaseId, legacyCaseId);
return source("dynamic-choice-v2");
},
async buildTechnicalPacket(input) {
events.push("packet");
assert.deepEqual({
rangeStart: input.privateCandidate?.rangeStart,
rangeEnd: input.privateCandidate?.rangeEnd,
}, { rangeStart: "05:18", rangeEnd: "05:42" });
assert.deepEqual(input.evidence.map((item) => item.id), [lifeEventId]);
assert.equal(input.preserveCandidateRange, true);
return { packet: packet(), resultId: null };
},
narrativeGenerator: {
modelId: "synthetic-narrator",
async generate() {
events.push("narrative");
return { text: JSON.stringify({
narrative: "05:18—05:42 是继承的待验证候选范围。D1 稳定,D9、D10 对分钟敏感;请补充一件带年月的真实事业或关系事件。",
evidenceRequest: {
domains: ["career", "relationship"],
datePrecision: "month_preferred",
prompt: "请补充一件带年月的真实事业或关系事件。",
followUp: { kind: "new_event", evidenceId: null },
},
}) };
},
},
asOfDate: () => "2026-07-21",
};
return {
service: createConversationalRectificationService(ports),
cases,
events,
counts: () => ({ importCount, reserved, loadLegacyCount }),
};
}
test("start imports an owner-bound unfinished case once, waives billing, and returns a fresh rich turn", async () => {
const value = harness();
const first = await value.service.start(userId, {
type: "start",
actionId,
pendingConsultationQuestion: "我的事业什么时候变化?",
});
assert.equal(first.caseId, actionId);
assert.equal(first.evidenceRecap[0]?.id, lifeEventId);
assert.deepEqual(value.events, ["packet", "narrative", "import"]);
assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 });
const stored = value.cases.get(actionId);
assert.equal(stored?.importedFromCaseId, legacyCaseId);
assert.equal(stored?.billingState, "migration_waived");
assert.equal(stored?.baselineActiveTime, "04:58");
const repeated = await value.service.start(userId, {
type: "start",
actionId,
pendingConsultationQuestion: "我的事业什么时候变化?",
});
assert.deepEqual(repeated, first);
assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 });
});
test("a second action for the same imported legacy case reuses the existing v3 case", async () => {
const value = harness();
const first = await value.service.importLegacyCase(userId, legacyCaseId, actionId, null);
const repeated = await value.service.importLegacyCase(
userId,
legacyCaseId,
competingActionId,
null,
);
assert.deepEqual(repeated, first);
assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 });
});
test("a second import action cannot silently replace the pending consultation question", async () => {
const value = harness();
await value.service.importLegacyCase(userId, legacyCaseId, actionId, null);
await assert.rejects(
value.service.importLegacyCase(
userId,
legacyCaseId,
competingActionId,
"请先看事业变化",
),
(error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "action_conflict",
);
assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 });
});