import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; import { buildRangeDelivery, d10DivergenceLabel, d9DivergenceLabel, } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; import { D10_TYPE_TABLE, D9_TYPE_TABLE } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts"; import { RECTIFICATION_USER_COPY, REPRESENTATIVE_MINUTE_DISCLAIMER, deliveryTurnNarration, } from "../src/lib/rectification-agentic/user-copy.ts"; import { appendPostAdoptProspectiveWindows } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx"; import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2"; function cand( time: string, range: readonly [string, string], posterior = 20, ): InferenceCandidate { return { id: time, time, cluster_range: range, prior_score: posterior, posterior_score: posterior, probability: 0.5, status: "active", rank: 1, strong_conflict_count: 0, }; } function inference(overrides: Partial = {}): InferenceState { return { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "04:48-04:59:04:50,04:56", revision: 1, phase: "discrimination", result_status: "credible_range", range_start: "04:48", range_end: "04:59", candidates: [ cand("04:50", ["04:48", "04:53"]), cand("04:56", ["04:54", "04:59"]), ], events: [ { id: "e1", domain: "career", year: 2018, precision: "month", usage: "training" }, { id: "e2", domain: "education", year: 2016, precision: "year", usage: "training" }, ], probes: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "04:50", credible_range: ["04:48", "04:59"], ...overrides, }; } function d9Probe() { return { id: "probe:varga.d9", semantic_key: "varga.d9.天秤座/天蝎座", candidate_split_hash: "varga.d9", domain: "relationship", year: 0, question: "相处更像哪一种?", candidate_ids: ["04:50", "04:56"], expected_outcomes: [ { answer_class: "yes" as const, supports: ["04:50"], conflicts: ["04:56"] }, { answer_class: "weak_yes" as const, supports: ["04:56"], conflicts: ["04:50"] }, ], information_gain: 1, source: "varga_contrast", choice_kind: "varga_style" as const, style_options: [ { sign: "天秤座", label: D9_TYPE_TABLE.天秤座.userChoice, answer_class: "yes" as const }, { sign: "天蝎座", label: D9_TYPE_TABLE.天蝎座.userChoice, answer_class: "weak_yes" as const }, ], }; } function d10Probe() { return { id: "probe:varga.d10", semantic_key: "varga.d10.巨蟹座/狮子座", candidate_split_hash: "varga.d10", domain: "career", year: 0, question: "做事更像哪一种?", candidate_ids: ["04:50", "04:56"], expected_outcomes: [ { answer_class: "yes" as const, supports: ["04:50"], conflicts: ["04:56"] }, { answer_class: "weak_yes" as const, supports: ["04:56"], conflicts: ["04:50"] }, ], information_gain: 1, source: "varga_contrast", choice_kind: "varga_style" as const, style_options: [ { sign: "巨蟹座", label: D10_TYPE_TABLE.巨蟹座.userChoice, answer_class: "yes" as const }, { sign: "狮子座", label: D10_TYPE_TABLE.狮子座.userChoice, answer_class: "weak_yes" as const }, ], }; } const publicCandidates = [ { candidateId: ID_A, time: "04:50" }, { candidateId: ID_B, time: "04:56" }, ]; function deliveryResult( delivery: ReturnType, selectedTime: string | null = null, ): RectificationCandidateResult { return { resultId: "result-1", candidates: delivery.columns.map((column, index) => ({ candidateId: column.candidate_id, rank: index + 1, time: column.time, relativeSupport: 10, tiedMinuteCount: 1, })), overallConfidence: "medium", selectionAllowed: true, canAdopt: true, confirmationAllowed: false, decisionReceipt: null, representativeTime: delivery.representative_time, selectedTime, selectionKind: selectedTime ? "accepted" : 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, }; } test("delivery columns rank by posterior and write D9/D10 on every column", () => { // 原值: rows 代表分钟第一、差异句来自类型表、相同则空 // 新值: 至多三列,按后验排序,每列写全 D9/D10,不预标排盘用 // 原因: BUG-597 决策 1–2 const delivery = buildRangeDelivery({ inference: inference({ probes: [d9Probe(), d10Probe()], candidates: [ { ...cand("04:50", ["04:48", "04:53"]), probability: 0.55, posterior_score: 30 }, { ...cand("04:56", ["04:54", "04:59"]), probability: 0.35, posterior_score: 20 }, ], }), publicCandidates, credibleRange: ["04:48", "04:59"], representativeTime: "04:50", }); assert.equal(delivery.columns[0]?.time, "04:50"); assert.equal(delivery.columns[0]?.probability_percent, 55); assert.ok(delivery.columns[0]?.traits.d9.includes(d9DivergenceLabel("天秤座") ?? "missing")); assert.ok(delivery.columns[0]?.traits.d10.includes(d10DivergenceLabel("巨蟹座") ?? "missing")); assert.equal(delivery.columns[1]?.time, "04:56"); assert.ok(delivery.columns[1]?.traits.d9.includes(d9DivergenceLabel("天蝎座") ?? "missing")); assert.ok(delivery.columns[1]?.traits.d10.includes(d10DivergenceLabel("狮子座") ?? "missing")); assert.equal(delivery.event_count, 2); assert.equal(delivery.boundary, REPRESENTATIVE_MINUTE_DISCLAIMER); assert.equal(delivery.representative_candidate_id, ID_A); assert.equal(delivery.more_count, 0); }); test("same D9/D10 still writes the type-table sentences on both columns", () => { const sharedD9 = { ...d9Probe(), candidate_ids: ["04:50", "04:51"], expected_outcomes: [ { answer_class: "yes" as const, supports: ["04:50", "04:51"], conflicts: [] }, { answer_class: "weak_yes" as const, supports: [], conflicts: ["04:50", "04:51"] }, ], }; const sharedD10 = { ...d10Probe(), candidate_ids: ["04:50", "04:51"], expected_outcomes: [ { answer_class: "yes" as const, supports: ["04:50", "04:51"], conflicts: [] }, { answer_class: "weak_yes" as const, supports: [], conflicts: ["04:50", "04:51"] }, ], }; const delivery = buildRangeDelivery({ inference: inference({ candidates: [ { ...cand("04:50", ["04:48", "04:53"]), probability: 0.6 }, { ...cand("04:51", ["04:48", "04:53"]), probability: 0.4 }, ], probes: [sharedD9, sharedD10], representative_time: "04:50", }), publicCandidates: [ { candidateId: ID_A, time: "04:50" }, { candidateId: ID_B, time: "04:51" }, ], credibleRange: ["04:50", "04:51"], representativeTime: "04:50", }); assert.equal(delivery.columns.length, 2); assert.equal(delivery.columns[0]?.traits.d9, delivery.columns[1]?.traits.d9); assert.ok((delivery.columns[0]?.traits.d9 ?? "").length > 0); }); test("range-delivery DOM is one button per row and adopted is aria-pressed", () => { const delivery = buildRangeDelivery({ inference: inference({ probes: [d9Probe()] }), publicCandidates, credibleRange: ["04:48", "04:59"], representativeTime: "04:50", verificationMarkdown: "| 方法 | 结果 |\n| --- | --- |\n| 1 | 吻合 |", }); const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, { result: deliveryResult(delivery, "04:50"), acceptingCandidateId: null, readonly: false, onAccept: () => undefined, })); assert.equal([...html.matchAll(/ ]/); assert.doesNotMatch(html, /按这个范围用/); assert.doesNotMatch(html, /再补一件经历/); assert.doesNotMatch(html, /都不像/); assert.doesNotMatch(html, /说不好/); assert.doesNotMatch(html, /排盘用/); const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8"); assert.match(source, /onAccept\(column\.candidate_id\)/); assert.match(source, /aria-pressed=\{adopted\}/); }); test("the range-delivery component does not render relative support or the old two actions", () => { const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.doesNotMatch(source, /相对支持度/); assert.doesNotMatch(source, /当前推荐/); assert.doesNotMatch(source, /采用此时间/); assert.doesNotMatch(source, /排盘用/); // 原值: 不含 rangeDeliveryAdopt / rangeDeliveryContinue;`Adopt[^e]` 会误伤 rangeDeliveryAdopting // 新值: 只禁旧标识符单词本身 // 原因: BUG-595 决策 1 assert.doesNotMatch(source, /\brangeDeliveryAdopt\b|\brangeDeliveryContinue\b/); assert.doesNotMatch(chat, /相对支持度/); assert.doesNotMatch(chat, /RectificationCandidateCards/); assert.match(chat, / { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(chat, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/); assert.doesNotMatch(chat, /dismissedRangeResultId/); assert.doesNotMatch(chat, /RangeDeliveryContinueHint/); assert.doesNotMatch(chat, /rectification-composer-meta/); }); const CAREER_WINDOW_PROBE = { candidate_label: "A", domain: "career", window_label: "2027 年 3 月附近", user_meaning: "候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。这是预测窗口,不是承诺;下次发生时回来补一条,可进一步分辨。", used_for_scoring: false, }; test("post-adopt first-round narration appends prospective windows when present", () => { const base = "2013 年前后,钱上有没有明显变化?"; const withWindows = appendPostAdoptProspectiveWindows(base, { prospective_probes: [CAREER_WINDOW_PROBE], }); assert.match(withWindows, /预测下一次事业变动/); assert.match(withWindows, /2013 年前后/); const without = appendPostAdoptProspectiveWindows(base, { prospective_probes: [] }); assert.equal(without, base); const idle = readFileSync( new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8", ); const start = idle.indexOf("export async function persistNextInterviewIfIdle"); const slice = idle.slice(start, start + 1800); assert.match(slice, /appendPostAdoptProspectiveWindows/); assert.match(slice, /dossier\.case\.acceptedTime/); }); test("delivery turn narration is three sentences without eight-method tables", () => { const text = deliveryTurnNarration({ credibleRange: ["04:51", "04:59"], representativeTime: "04:53", eventCount: 7, fitPercent: 80, }); const sentences = text.split(/(?<=。)/).filter(Boolean); assert.equal(sentences.length, 3); assert.doesNotMatch(text, /方法1|Technique Audit/); });