274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
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);
|
||
});
|