Files
Jyotisha/frontend/tests/birth-time-dynamic-question-service.test.ts
T
2026-07-19 04:57:43 +08:00

274 lines
11 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 test from "node:test";
import { BirthTimeDynamicBindingError } from "../src/lib/birth-time-dynamic-question-validator.ts";
import type { CandidateDifferenceBuild, PersistedDynamicChoiceQuestion } from "../src/lib/birth-time-dynamic-choice-internal.ts";
import {
deterministicIds,
differenceBuild,
dynamicService,
generationCommand,
generatorFrom,
opportunityId,
validDynamicSelection,
} from "./fixtures/birth-time-dynamic-question-fixture.ts";
test("real Task2 packet retries invalid output then persists localized fallback", async () => {
let calls = 0;
const persisted: PersistedDynamicChoiceQuestion[] = [];
const result = await dynamicService({
generator: generatorFrom(() => { calls += 1; return "{}"; }),
onCommit: (question) => { if (question) persisted.push(question); },
}).generateQuestion("owner-1", generationCommand);
assert.equal(calls, 2);
assert.equal(result.nextAction.kind, "ask_dynamic_choice");
if (result.nextAction.kind !== "ask_dynamic_choice") throw new Error("expected a question");
assert.equal(result.nextAction.question.prompt, differenceBuild.packet.opportunities[0]?.fallbackPrompt);
assert.equal(persisted[0]?.source, "fallback");
});
test("a valid Agent selection commits server copy and correct private scores", async () => {
const persisted: PersistedDynamicChoiceQuestion[] = [];
const result = await dynamicService({
generator: generatorFrom(() => JSON.stringify(validDynamicSelection)),
onCommit: (question) => { if (question) persisted.push(question); },
}).generateQuestion("owner-1", generationCommand);
assert.equal(result.nextAction.kind, "ask_dynamic_choice");
assert.equal(persisted[0]?.source, "agent");
assert.equal(persisted[0]?.prompt, differenceBuild.packet.opportunities[0]?.fallbackPrompt);
assert.deepEqual(persisted[0]?.options[0]?.candidateScores, { "04:00": 1, "04:01": 0 });
});
test("no server opportunity ends without invoking the Agent", async () => {
let calls = 0;
const result = await dynamicService({
build: { ...differenceBuild, packet: { ...differenceBuild.packet, opportunities: [] } },
generator: generatorFrom(() => { calls += 1; return "{}"; }),
}).generateQuestion("owner-1", generationCommand);
assert.equal(calls, 0);
assert.equal(result.nextAction.kind, "present_low_result");
});
test("raw tea-water note is omitted and old free-copy output cannot be accepted", async () => {
const prompts: string[] = [];
const persisted: PersistedDynamicChoiceQuestion[] = [];
await dynamicService({
generator: generatorFrom((prompt) => {
prompts.push(prompt);
return JSON.stringify({
...validDynamicSelection,
prompt: "开始工作后,你爱喝茶还是喝水发生变化了吗?",
options: [
{ partitionId: "server-a", label: "经常喝茶" },
{ partitionId: "server-b", label: "经常喝水" },
],
});
}),
onCommit: (question) => { if (question) persisted.push(question); },
}).generateQuestion("owner-1", {
...generationCommand,
unmatchedNote: "接下来问我爱喝茶还是喝水",
});
assert.equal(prompts.length, 2);
const expectedOpportunities = differenceBuild.packet.opportunities.map((opportunity) => ({
opportunityId: opportunity.opportunityId,
dimensionCode: opportunity.dimensionCode,
neutralContext: opportunity.neutralContext,
}));
for (const serialized of prompts) {
const projection: unknown = JSON.parse(serialized);
if (typeof projection !== "object" || projection === null) {
throw new Error("expected a prompt projection object");
}
assert.deepEqual(Object.keys(projection).sort(), ["opportunities", "task"]);
assert.equal(Reflect.get(projection, "task"), "select_dynamic_choice_opportunity");
const opportunities: unknown = Reflect.get(projection, "opportunities");
assert.ok(Array.isArray(opportunities));
for (const opportunity of opportunities) {
if (typeof opportunity !== "object" || opportunity === null) {
throw new Error("expected an opportunity projection object");
}
assert.deepEqual(Object.keys(opportunity).sort(), [
"dimensionCode", "neutralContext", "opportunityId",
]);
}
assert.deepEqual(opportunities, expectedOpportunities);
}
assert.equal(persisted[0]?.source, "fallback");
});
test("no-useful and unknown selections remain advisory and fall back", async () => {
for (const output of [
{ kind: "no_useful_question" },
{ kind: "question", opportunityId: "unknown-opportunity" },
]) {
let calls = 0;
const result = await dynamicService({
generator: generatorFrom(() => { calls += 1; return JSON.stringify(output); }),
}).generateQuestion("owner-1", generationCommand);
assert.equal(result.nextAction.kind, "ask_dynamic_choice");
if (output.kind === "question") assert.equal(calls, 2);
}
});
function unsortedBuild(): CandidateDifferenceBuild {
const low = differenceBuild.packet.opportunities[0];
const privateLow = differenceBuild.scoringPartitions[opportunityId];
if (!low || !privateLow) throw new Error("missing test opportunity");
const highId = "higher-opportunity";
const highPartitions = low.partitions.map((item, index) => ({
...item,
partitionId: `higher-partition-${index}`,
}));
return {
...differenceBuild,
packet: {
...differenceBuild.packet,
opportunities: [low, {
...low,
opportunityId: highId,
estimatedInformationGain: low.estimatedInformationGain + 1,
candidatePartitionFingerprint: "higher-fingerprint",
fallbackPrompt: "哪一个时间段更接近一次明显的职业变化?",
partitions: highPartitions,
}],
},
scoringPartitions: {
...differenceBuild.scoringPartitions,
[highId]: privateLow.map((item, index) => ({
...item,
partitionId: `higher-partition-${index}`,
})),
},
};
}
test("unsorted opportunities fall back to maximum information gain", async () => {
const selected: PersistedDynamicChoiceQuestion[] = [];
await dynamicService({
build: unsortedBuild(),
generator: generatorFrom(() => "{}"),
onCommit: (question) => { if (question) selected.push(question); },
}).generateQuestion("owner-1", generationCommand);
assert.equal(selected[0]?.opportunityId, "higher-opportunity");
});
test("equal-gain fallback uses opportunity id rather than packet order", async () => {
const build = unsortedBuild();
const low = build.packet.opportunities[0];
const high = build.packet.opportunities[1];
if (!low || !high) throw new Error("missing test opportunities");
const selected: PersistedDynamicChoiceQuestion[] = [];
await dynamicService({
build: {
...build,
packet: {
...build.packet,
opportunities: [{ ...high, estimatedInformationGain: low.estimatedInformationGain }, low],
},
},
generator: generatorFrom(() => "{}"),
onCommit: (question) => { if (question) selected.push(question); },
}).generateQuestion("owner-1", generationCommand);
assert.equal(selected[0]?.opportunityId, low.opportunityId);
});
test("duplicate server labels propagate without commit or ID allocation", async () => {
const opportunity = differenceBuild.packet.opportunities[0];
const privatePartitions = differenceBuild.scoringPartitions[opportunityId];
if (!opportunity || !privatePartitions) throw new Error("missing test opportunity");
const duplicated = "相同阶段";
const build = {
...differenceBuild,
packet: { ...differenceBuild.packet, opportunities: [{
...opportunity,
partitions: opportunity.partitions.map((item) => ({ ...item, fallbackLabel: duplicated })),
}] },
scoringPartitions: {
[opportunityId]: privatePartitions.map((item) => ({ ...item, fallbackLabel: duplicated })),
},
};
let allocations = 0;
let commits = 0;
await assert.rejects(() => dynamicService({
build,
generator: generatorFrom(() => JSON.stringify(validDynamicSelection)),
createId: deterministicIds(() => { allocations += 1; }),
onCommit: () => { commits += 1; },
}).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError);
assert.equal(allocations, 0);
assert.equal(commits, 0);
});
test("reserved-label collisions propagate without commit or ID allocation", async () => {
const opportunity = differenceBuild.packet.opportunities[0];
const privatePartitions = differenceBuild.scoringPartitions[opportunityId];
if (!opportunity || !privatePartitions) throw new Error("missing test opportunity");
for (const collision of ["不确定 / 不记得", "不 确定 不记得", "都不符合"]) {
let allocations = 0;
let commits = 0;
const publicPartitions = opportunity.partitions.map((item, index) => (
index === 0 ? { ...item, fallbackLabel: collision } : item
));
const privateCopy = privatePartitions.map((item, index) => (
index === 0 ? { ...item, fallbackLabel: collision } : item
));
await assert.rejects(() => dynamicService({
build: {
...differenceBuild,
packet: { ...differenceBuild.packet, opportunities: [{ ...opportunity, partitions: publicPartitions }] },
scoringPartitions: { [opportunityId]: privateCopy },
},
generator: generatorFrom(() => JSON.stringify(validDynamicSelection)),
createId: deterministicIds(() => { allocations += 1; }),
onCommit: () => { commits += 1; },
}).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError);
assert.equal(allocations, 0, collision);
assert.equal(commits, 0, collision);
}
});
test("overlong server prompts propagate without commit or ID allocation", async () => {
const opportunity = differenceBuild.packet.opportunities[0];
if (!opportunity) throw new Error("missing test opportunity");
for (const length of [121, 240]) {
let allocations = 0;
let commits = 0;
const fallbackPrompt = `${"问".repeat(length - 1)}`;
await assert.rejects(() => dynamicService({
build: {
...differenceBuild,
packet: {
...differenceBuild.packet,
opportunities: [{ ...opportunity, fallbackPrompt }],
},
},
generator: generatorFrom(() => JSON.stringify(validDynamicSelection)),
createId: deterministicIds(() => { allocations += 1; }),
onCommit: () => { commits += 1; },
}).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError);
assert.equal(allocations, 0, String(length));
assert.equal(commits, 0, String(length));
}
});
test("invalid server UUIDs propagate without committing a low result", async () => {
let commits = 0;
await assert.rejects(() => dynamicService({
generator: generatorFrom(() => JSON.stringify(validDynamicSelection)),
createId: () => "not-a-uuid",
onCommit: () => { commits += 1; },
}).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError);
assert.equal(commits, 0);
});