Files
Jyotisha/frontend/tests/rectification-open-collect-invite-20260914.test.ts
T
jesse-ux 039b0a2608
Independent Staging Quality Gate / validate (push) Successful in 9m43s
Independent Staging Quality Gate / publish (push) Successful in 8m50s
fix(rectification): say 这两分钟 only when two candidates remain (BUG-692)
Count-aware closed-pool invite, catch the five stale contract assertions, and mark M1b V1/V2 as not_measured.
2026-09-15 09:43:51 +08:00

434 lines
15 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 { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES,
RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES,
RANGE_DELIVERY_TIE_PERCENT,
rangeDeliveryCollectClosed,
rangeDeliveryShowsOpenCollectInvite,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
COLLECT_KIND_ORDER,
rangeNarrowHint,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import {
decideFromDossier,
scoreableSnapshotCurrentFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { previousInferenceFromReceipt } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
const ID_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3";
const CLOSED_LAYERS = ["d9", "d10", "d4", "d5", "d7", "d2", "d30"] as const;
const DATED_EVIDENCE = [
{
id: "evidence-education",
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
summary: "上大学",
},
{
id: "evidence-career",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "入职",
},
{
id: "evidence-relocation",
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-07-01",
occurredTo: null,
eventKind: "relocation",
summary: "搬家",
},
] as const;
function declineAllTargeted() {
return COLLECT_KIND_ORDER.map((domain) => ({
questionId: `collect:targeted:${domain}`,
status: "declined",
target_kind: `targeted:${domain}`,
intent: "collect_method_evidence",
target_domain: domain,
}));
}
function cand(
time: string,
probability: number,
range: readonly [string, string] = ["04:48", "05:07"],
): InferenceCandidate {
return {
id: time,
time,
cluster_range: range,
prior_score: probability * 100,
posterior_score: probability * 100,
probability,
status: "active",
rank: 1,
strong_conflict_count: 0,
};
}
function inference(overrides: Partial<InferenceState> = {}): InferenceState {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: "04:48-05:07:04:50,04:52,04:56",
revision: 1,
phase: "discrimination",
result_status: "credible_range",
range_start: "04:48",
range_end: "05:07",
candidates: [
cand("04:50", 0.26),
cand("04:52", 0.26),
cand("04:56", 0.20),
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e2", domain: "education", year: 2016, precision: "month", usage: "training" },
{ id: "e3", domain: "relocation", year: 2018, precision: "month", usage: "training" },
],
probes: [],
answered_probes: [],
rounds: [],
entropy: 1,
representative_time: "04:50",
credible_range: ["04:48", "05:07"],
refresh_count: 1,
...overrides,
};
}
function deliveryResult(
delivery: ReturnType<typeof buildRangeDelivery>,
): RectificationCandidateResult {
return {
resultId: "result-open-collect",
candidates: delivery.columns.map((column, index) => ({
candidateId: column.candidate_id,
rank: index + 1,
time: column.time,
relativeSupport: column.probability_percent,
tiedMinuteCount: 1,
})),
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
decisionReceipt: null,
representativeTime: delivery.representative_time,
selectedTime: null,
selectionKind: null,
houseTable: null,
houseTablesByTime: {},
natalRecast: null,
techniqueAudit: [],
windowTransitions: [],
eventDashaLedger: [],
dashaAgreement: null,
lagnaContrast: null,
nakshatraBoundary: null,
precisionStage: null,
oosBlindPrompts: [],
confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
validated: false,
completionStatus: null,
sessionOutcome: "adopt_representative",
credibleRange: delivery.range,
rangeDelivery: delivery,
verificationReportMarkdown: delivery.verification_markdown,
};
}
function renderCard(delivery: ReturnType<typeof buildRangeDelivery>): string {
return renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: deliveryResult(delivery),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
}));
}
function gapFor(decision: { sessionOutcome: string; stopReason?: string | null }): string {
return rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
sessionOutcome: decision.sessionOutcome,
stopReason: decision.stopReason ?? null,
});
}
test("closed collect copy invites another dated event instead of saying the pool is finished", () => {
const copy = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
["04:48", "05:07"],
5,
["04:48", "05:07"],
);
assert.match(copy, /现在还剩 04:4805:07 里 5 个候选/);
assert.doesNotMatch(copy, /问完了|都问完|没有更多/);
assert.match(copy, /不限领域/);
assert.match(copy, /确切哪一天/);
assert.match(copy, /有年月也行/);
assert.match(copy, /接着算/);
assert.doesNotMatch(copy, /定到分钟|精确到分钟/);
const dayHits = RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.filter((item) => copy.includes(item));
assert.ok(dayHits.length >= 2, dayHits.join(","));
const exampleHits = RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.filter((item) => copy.includes(item));
assert.ok(exampleHits.length >= 2, exampleHits.join(","));
// 原值: /结婚|收入|添丁/(空池不得再列已拒答的线)
// 新值: 仍不得写「哪年结婚或订婚」这类还开着的线;D1b 允许用「登记结婚那天」作日精度例子
// 原因: BUG-687 不得把已拒答的线再问一遍;BUG-689 D1b 优先要确切日期
assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/);
assert.ok(copy.includes(rangeDeliveryCollectClosed(5)));
assert.doesNotMatch(copy, /这两分钟/);
assert.match(copy, /现在还剩 04:48–05:07 里 5 个候选。这几个候选按现有信息分不开/);
});
test("closed-pool invite names two minutes only when two candidates remain", () => {
const two = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
["04:48", "05:07"],
2,
["04:48", "05:07"],
);
assert.match(two, /现在还剩 04:48–05:07 里 2 个候选。这两分钟按现有信息分不开/);
assert.doesNotMatch(two, /这几个候选/);
assert.match(two, /不限领域/);
assert.match(two, /确切哪一天/);
assert.match(two, /有年月也行/);
assert.match(two, /接着算/);
const dayHits = RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.filter((item) => two.includes(item));
assert.ok(dayHits.length >= 2, dayHits.join(","));
const exampleHits = RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.filter((item) => two.includes(item));
assert.ok(exampleHits.length >= 2, exampleHits.join(","));
const four = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
["04:47", "04:53"],
4,
["04:47", "04:53"],
);
assert.match(four, /现在还剩 04:47–04:53 里 4 个候选。这几个候选按现有信息分不开/);
assert.doesNotMatch(four, /这两分钟/);
assert.match(four, /不限领域/);
assert.match(four, /登记结婚那天/);
const unknown = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
);
assert.equal(unknown, rangeDeliveryCollectClosed());
assert.doesNotMatch(unknown, /这两分钟|这几个候选/);
assert.match(unknown, /^按现有信息分不开。/);
assert.match(unknown, /不限领域/);
});
test("the range card puts the closed-pool invite in the body only when the top two are tied", () => {
const publicCandidates = [
{ candidateId: ID_A, time: "04:50" },
{ candidateId: ID_B, time: "04:52" },
{ candidateId: ID_C, time: "04:56" },
];
const tied = buildRangeDelivery({
inference: inference(),
publicCandidates,
credibleRange: ["04:48", "05:07"],
representativeTime: "04:50",
evidence: DATED_EVIDENCE,
declinedTopics: declineAllTargeted(),
});
assert.equal(
Math.abs((tied.columns[0]?.probability_percent ?? 0) - (tied.columns[1]?.probability_percent ?? 0))
<= RANGE_DELIVERY_TIE_PERCENT,
true,
);
assert.equal(rangeDeliveryShowsOpenCollectInvite({
hint: tied.narrow_hint,
columns: tied.columns,
}), true);
const tiedHtml = renderCard(tied);
assert.match(tiedHtml, /rectification-range-delivery__invite/);
assert.match(tiedHtml, /不限领域/);
assert.match(tiedHtml, /确切哪一天/);
assert.match(tiedHtml, /换专业/);
assert.match(tiedHtml, /创业/);
assert.match(tiedHtml, /更像这个/);
assert.match(tiedHtml, /这几个候选按现有信息分不开/);
assert.doesNotMatch(tiedHtml, /这两分钟按现有信息分不开。/);
assert.doesNotMatch(tiedHtml, /问完了/);
const caption = tiedHtml.match(
/class="rectification-range-delivery__narrow"[^>]*>([^<]*)</,
)?.[1] ?? "";
assert.doesNotMatch(caption, /不限领域/);
const clearLead = buildRangeDelivery({
inference: inference({
candidates: [
cand("04:50", 0.70),
cand("04:52", 0.20),
cand("04:56", 0.10),
],
}),
publicCandidates,
credibleRange: ["04:48", "05:07"],
representativeTime: "04:50",
evidence: DATED_EVIDENCE,
declinedTopics: declineAllTargeted(),
});
assert.equal(rangeDeliveryShowsOpenCollectInvite({
hint: clearLead.narrow_hint,
columns: clearLead.columns,
}), false);
const leadHtml = renderCard(clearLead);
assert.doesNotMatch(leadHtml, /rectification-range-delivery__invite/);
assert.doesNotMatch(leadHtml, /不限领域/);
assert.match(leadHtml, /更像这个/);
const stillOpen = buildRangeDelivery({
inference: inference({
transitions: [{ layer: "d9", at: "04:52" }],
}),
publicCandidates,
credibleRange: ["04:48", "05:07"],
representativeTime: "04:50",
evidence: DATED_EVIDENCE,
declinedTopics: [],
});
assert.match(stillOpen.narrow_hint ?? "", /还能再收窄/);
assert.equal(rangeDeliveryShowsOpenCollectInvite({
hint: stillOpen.narrow_hint,
columns: stillOpen.columns,
}), false);
const openHtml = renderCard(stillOpen);
assert.doesNotMatch(openHtml, /rectification-range-delivery__invite/);
assert.doesNotMatch(openHtml, /不限领域/);
});
test("a new dated event after delivery stales the snapshot and leaves the delivered gap", () => {
const rows = DATED_EVIDENCE.map((item) => ({ ...item }));
const fingerprint = evidenceLedgerFingerprint(rows as never);
const times = ["04:50", "04:52", "04:56"] as const;
const state = inference({
candidate_set_id: candidateSetId("04:48", "05:07", [...times]),
refresh_count: 1,
refresh_attempts: [{
candidate_set_id: candidateSetId("04:48", "05:07", [...times]),
answer_count: 0,
result: "no_new_probes",
at: "2026-09-14T00:00:00.000Z",
}],
});
const dossier: DecisionDossier = {
evidence: rows,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declineAllTargeted(),
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: times.map((time, index) => ({
candidateId: [ID_A, ID_B, ID_C][index],
time,
rank: index + 1,
relativeSupport: time === "04:56" ? 20 : 26,
posterior_score: time === "04:56" ? 20 : 26,
})),
representativeTime: "04:50",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
},
case: {
acceptedTime: null,
status: "candidate_ready",
candidateRange: { start_time: "04:45", end_time: "05:15" },
},
};
const delivered = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.ok(
delivered.nextAction === "offer_provisional_range"
|| delivered.nextAction === "ready_to_adopt"
|| delivered.nextAction === "complete_with_range",
delivered.nextAction,
);
assert.equal(gapFor(delivered), "delivered");
const nextEvidence = [
...rows,
{
id: "evidence-lawsuit",
status: "confirmed",
domain: "other",
datePrecision: "month",
occurredFrom: "2019-03-01",
occurredTo: null,
eventKind: "legal_case",
summary: "打官司",
},
];
const nextDossier: DecisionDossier = { ...dossier, evidence: nextEvidence };
const inferenceAfter = previousInferenceFromReceipt(nextDossier.latestResult?.decisionReceipt ?? null);
assert.equal(scoreableSnapshotCurrentFromDossier(dossier, undefined, inferenceAfter), true);
assert.equal(scoreableSnapshotCurrentFromDossier(nextDossier, undefined, inferenceAfter), false);
const after = decideFromDossier(nextDossier, { birthDate: "1997-08-08" });
assert.equal(
after.nextAction === "complete_with_range"
|| after.nextAction === "offer_provisional_range"
|| after.nextAction === "ready_to_adopt",
false,
after.nextAction,
);
assert.notEqual(after.stopReason, "tied_first");
assert.notEqual(gapFor(after), "delivered");
});