Files
Jyotisha/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts
T
jesse-ux a266b6b727
Independent Staging Quality Gate / validate (push) Failing after 10m16s
Independent Staging Quality Gate / publish (push) Skipped
fix(rectification): invite another dated event instead of saying the pool is closed (BUG-689)
When the seven collect lines are closed, the range card still appears, but the copy asks for one more exact-day event of any kind instead of saying the questions are finished. A new dated event after delivery stales the snapshot so the session does not stay delivered.
2026-09-14 22:56:01 +08:00

409 lines
14 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 {
decideRectification,
publicCanAdopt,
sessionOutcomeAllowsDelivery,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { rangeNarrowHint } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
tieBreakGateInput,
tieBreakPersonalityAvailable,
tieBreakPersonalityFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import { applySnapshotTurnsToMessages } from "../src/lib/rectification-snapshot-messages.ts";
import type { TurnQuestion } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
import { alreadyDelivered, markDeliveryTurn, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
function contrastProbe(layer: "d9" | "d10") {
const signs = layer === "d9" ? ["巨蟹座", "狮子座"] as const : ["狮子座", "处女座"];
return {
probeId: `contrast:varga.${layer}.${signs[0]}/${signs[1]}`,
candidateSetVersion: "04:48-05:07",
question: layer === "d9"
? "亲密关系里更接近下面哪一种相处方式?"
: "平时做事,你更接近下面哪一种?",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["04:53"], conflictsCandidateIds: ["05:06"] },
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:06"], conflictsCandidateIds: ["04:53"] },
],
candidateSplitHash: `varga.${layer}.${signs[0]}/${signs[1]}`,
informationGain: layer === "d9" ? 1.4 : 0.9,
sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }],
domain: layer === "d9" ? "relationship" : "career",
year: null,
semanticKey: `varga.${layer}.${signs[0]}/${signs[1]}`,
choiceKind: "varga_style" as const,
styleOptions: [
{ label: "相处里更在意照顾对方的感受", answerClass: "yes" as const, sign: signs[0] },
{ label: "习惯带头,也不排斥站到台前", answerClass: "weak_yes" as const, sign: signs[1] },
],
};
}
const D9 = contrastProbe("d9");
const D10 = contrastProbe("d10");
function planInput(askedProbeKeys: readonly string[] = []) {
return {
evidence: [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "上大学",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "入职实习",
},
{
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "确定关系",
},
],
declinedTopics: [],
sessionOutcome: "discriminate_candidates" as const,
candidatesSeparated: false,
remainingLayers: ["d9", "d10"],
remainingSplitTimes: ["04:48", "05:07"] as const,
remainingCandidateCount: 4,
topCandidateTimes: ["04:48", "04:53", "05:06", "05:07"],
askedProbeKeys,
contrastPacket: {
candidateSetVersion: "04:48-05:07",
vargaDifferences: [],
probes: [D9, D10],
},
};
}
function tiedDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 16 },
{ time: "05:00", score: 16 },
{ time: "05:06", score: 15 },
{ time: "04:51", score: 14 },
{ time: "04:59", score: 13 },
],
discriminatorProbe: null,
holdoutValidation: "unavailable",
datedEventCount: 3,
datedDomainCount: 3,
targetedCollectExhausted: true,
refreshExhausted: true,
...overrides,
});
}
/** Normal convergence: unique first place, methods covered, holdout passed. */
function convergingDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 24 },
{ time: "05:00", score: 14 },
{ time: "05:06", score: 13 },
],
discriminatorProbe: null,
holdoutValidation: "passed",
datedEventCount: 3,
datedDomainCount: 3,
targetedCollectExhausted: true,
refreshExhausted: true,
tieBreakFollowupReady: true,
pendingTieBreak: true,
...overrides,
});
}
test("a tie-break snapshot turn is merged into messages with its question", () => {
type Message = {
role: "assistant";
text: string;
renderKey: string;
turnId: string;
question?: TurnQuestion;
};
const current: Message[] = [{
role: "assistant",
text: "目前范围 04:4805:07",
renderKey: "existing",
turnId: "turn-1",
}];
const turns = [
{ id: "turn-1", role: "assistant", text: "目前范围 04:4805:07" },
{
id: "turn-2",
role: "assistant",
text: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
question: {
focus_id: "focus-d9",
question_id: "varga.d9.巨蟹座/狮子座",
kind: "choice",
prompt: D9.question,
options: [
{ key: "A", label: "相处里更在意照顾对方的感受" },
{ key: "B", label: "习惯带头,也不排斥站到台前" },
{ key: "C", label: "一时说不好" },
{ key: "D", label: "先这样" },
],
status: "active",
answer_option: null,
probe_id: D9.probeId,
},
},
];
const next = applySnapshotTurnsToMessages(current, turns, (incoming): Message[] => (
incoming.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const turn = item as { id?: string; role?: string; text?: string; question?: unknown };
if (turn.role !== "assistant" || typeof turn.id !== "string") return [];
return [{
role: "assistant" as const,
text: turn.text ?? "",
renderKey: turn.id,
turnId: turn.id,
}];
})
));
assert.equal(next.length, 2);
assert.equal(next[1]?.turnId, "turn-2");
assert.equal(next[1]?.text, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck);
assert.equal(next[1]?.question?.prompt, D9.question);
assert.equal(next[1]?.question?.kind, "choice");
});
test("a delivery card plus an unanswered focus is not an idle gap", () => {
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: false,
questionLoadFailed: false,
questionPersisted: true,
offerAwaitingReader: true,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "completed_with_range",
}),
"persisted_question",
);
});
test("a converging case with unused personality questions holds delivery", () => {
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
assert.notEqual(decision.nextAction, "complete_with_range");
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(publicCanAdopt(decision), false);
const followup = tieBreakPersonalityFollowup(planInput());
assert.equal(followup?.choice_kind, "varga_style");
assert.match(followup?.semantic_key ?? "", /varga\.d9/);
});
test("the same tie delivers after both personality questions are used", () => {
const bothAsked = planInput([D9.semanticKey, D10.semanticKey]);
assert.equal(tieBreakPersonalityAvailable(bothAsked), false);
const decision = tiedDecision({ pendingTieBreak: false });
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
assert.equal(rangeDeliveryForSnapshot({
tieBreakAvailable: tieBreakPersonalityAvailable(bothAsked),
tieBreakUsed: true,
stillTied: decision.separation.lead <= 1,
}).tie_break_available, false);
assert.equal(
rangeDeliveryForSnapshot({
tieBreakAvailable: false,
tieBreakUsed: true,
stillTied: true,
}).tie_break_note,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
);
});
test("a two-point lead still asks personality questions before delivery", () => {
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(tieBreakPersonalityFollowup(planInput())?.choice_kind, "varga_style");
});
test("pending style flag without a renderable followup delivers", () => {
const decision = convergingDecision({
pendingTieBreak: true,
tieBreakFollowupReady: false,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("an exhausted first-place tie delivers even if style questions remain", () => {
const decision = tiedDecision({
pendingTieBreak: true,
tieBreakFollowupReady: true,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("already held once without a followup delivers", () => {
const decision = convergingDecision({
pendingTieBreak: true,
tieBreakFollowupReady: true,
tieBreakAlreadyHeld: true,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("a layer with only one rising sign does not occupy the style slot", () => {
const onlyD10 = {
...planInput(),
remainingLayers: ["d10"],
contrastPacket: {
candidateSetVersion: "04:48-05:07",
vargaDifferences: [],
probes: [D10],
},
};
const followup = tieBreakPersonalityFollowup(onlyD10);
assert.equal(followup?.choice_kind, "varga_style");
assert.match(followup?.semantic_key ?? "", /varga\.d10/);
assert.doesNotMatch(followup?.semantic_key ?? "", /varga\.d9/);
});
test("holding for a tie-break does not mark a delivery turn", () => {
resetDeliveryTurnGuardForTests();
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
markDeliveryTurn({ caseId: "case-1", resultId: "result-1" });
assert.equal(alreadyDelivered({
caseId: "case-1",
resultId: "result-1",
hasUserMessage: false,
}), true);
assert.equal(alreadyDelivered({
caseId: "case-1",
resultId: "result-2",
hasUserMessage: false,
}), false);
resetDeliveryTurnGuardForTests();
});
test("range copy omits declined lines and closes when none remain", () => {
const layers = ["d9", "d2", "d7", "d4"];
const evidence = [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-07-01",
occurredTo: null,
eventKind: "relocation",
},
];
const declined = [
{ questionId: "collect:targeted:relationship", status: "declined", target_kind: "targeted:relationship" },
{ questionId: "collect:targeted:finance", status: "declined", target_kind: "targeted:finance" },
{ questionId: "collect:targeted:family", status: "declined", target_kind: "targeted:family" },
];
const openCopy = rangeNarrowHint(layers, evidence, [], ["04:48", "05:07"], 5, ["04:48", "05:07"]);
assert.match(openCopy, /结婚/);
assert.match(openCopy, /收入/);
assert.match(openCopy, /添丁/);
const copy = rangeNarrowHint(layers, evidence, declined, ["04:48", "05:07"], 5, ["04:48", "05:07"]);
// 原值: /结婚|收入|添丁/ 且 /能问的都问完了/
// 新值: 不得再写「哪年结婚或订婚」这类还开着的线;收口改为不限领域、先要确切日期
// 原因: BUG-687 不得把已拒答的线再问一遍;BUG-689 D1b
assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/);
assert.doesNotMatch(copy, /问完了/);
assert.match(copy, /不限领域/);
assert.match(copy, /确切哪一天/);
});
test("GET availability and the POST gate share one input builder", () => {
const openPlan = planInput();
const open = tieBreakGateInput({
evidence: openPlan.evidence,
declinedTopics: [],
catalog: {
remainingLayers: openPlan.remainingLayers,
remainingSplitTimes: openPlan.remainingSplitTimes,
remainingCandidateCount: openPlan.remainingCandidateCount,
contrastPacket: openPlan.contrastPacket,
askedProbeKeys: openPlan.askedProbeKeys,
topCandidateTimes: openPlan.topCandidateTimes,
},
accepted: false,
stage: "minute",
candidateRange: { start_time: "04:45", end_time: "05:15" },
});
const closed = tieBreakGateInput({
evidence: openPlan.evidence,
declinedTopics: [],
catalog: {
remainingLayers: openPlan.remainingLayers,
remainingSplitTimes: openPlan.remainingSplitTimes,
remainingCandidateCount: openPlan.remainingCandidateCount,
contrastPacket: openPlan.contrastPacket,
askedProbeKeys: [D9.semanticKey, D10.semanticKey],
topCandidateTimes: openPlan.topCandidateTimes,
},
accepted: false,
stage: "minute",
candidateRange: { start_time: "04:45", end_time: "05:15" },
});
assert.equal(tieBreakPersonalityAvailable(open), true);
assert.equal(Boolean(tieBreakPersonalityFollowup(open)?.choice_kind === "varga_style"), true);
assert.equal(tieBreakPersonalityAvailable(closed), false);
assert.equal(tieBreakPersonalityFollowup(closed), null);
});