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 { buildDivergencePanel, 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 } from "../src/lib/rectification-agentic/user-copy.ts"; import type { NakshatraBoundary } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { appendPostAdoptProspectiveWindows } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { RangeDeliveryContinueHint } from "../src/components/rectification-range-delivery.tsx"; import { STEP_STATE_COPY } from "../src/lib/rectification-agentic/v9/step-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 ID_D = "dddddddd-dddd-4ddd-8ddd-ddddddddddd4"; 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" }, ] as const; test("two clusters with unasked D9 yield two D9 columns", () => { const panel = buildDivergencePanel({ inference: inference({ probes: [d9Probe()] }), publicCandidates, }); assert.equal(panel.eligible, true); assert.equal(panel.columns.length, 2); assert.deepEqual(panel.columns.map((column) => column.rows.map((row) => row.layer)), [["d9"], ["d9"]]); assert.equal(panel.columns[0]?.rows[0]?.sign, "天秤座"); assert.equal(panel.columns[1]?.rows[0]?.sign, "天蝎座"); assert.equal(panel.columns[0]?.rows[0]?.label, d9DivergenceLabel("天秤座")); assert.equal(panel.columns[1]?.rows[0]?.label, d9DivergenceLabel("天蝎座")); assert.equal(panel.columns[0]?.representative_candidate_id, ID_A); assert.equal(panel.columns[1]?.representative_candidate_id, ID_B); }); test("asked D9 with unasked D10 only lists D10", () => { const panel = buildDivergencePanel({ inference: inference({ probes: [d9Probe(), d10Probe()], answered_probes: [{ probe_id: "probe:varga.d9", semantic_key: "varga.d9.天秤座/天蝎座", candidate_split_hash: "varga.d9", answer_class: "yes", classified_from: "choice", }], }), publicCandidates, }); assert.equal(panel.eligible, true); assert.deepEqual(panel.columns.map((column) => column.rows.map((row) => row.layer)), [["d10"], ["d10"]]); assert.equal(panel.columns[0]?.rows[0]?.sign, "巨蟹座"); assert.equal(panel.columns[1]?.rows[0]?.sign, "狮子座"); assert.equal(panel.columns[0]?.rows[0]?.label, d10DivergenceLabel("巨蟹座")); }); test("asked D9 and D10 make the panel ineligible", () => { const panel = buildDivergencePanel({ inference: inference({ probes: [d9Probe(), d10Probe()], answered_probes: [ { probe_id: "probe:varga.d9", semantic_key: "varga.d9.天秤座/天蝎座", candidate_split_hash: "varga.d9", answer_class: "yes", classified_from: "choice", }, { probe_id: "probe:varga.d10", semantic_key: "varga.d10.巨蟹座/狮子座", candidate_split_hash: "varga.d10", answer_class: "yes", classified_from: "choice", }, ], }), publicCandidates, }); assert.equal(panel.eligible, false); assert.equal(panel.columns.length, 0); assert.equal(panel.reason, "layers_asked"); }); test("four clusters make the panel ineligible", () => { const panel = buildDivergencePanel({ inference: inference({ candidates: [ cand("04:48", ["04:48", "04:49"]), cand("04:51", ["04:50", "04:52"]), cand("04:54", ["04:53", "04:55"]), cand("04:58", ["04:56", "04:59"]), ], probes: [d9Probe()], }), publicCandidates: [ { candidateId: ID_A, time: "04:48" }, { candidateId: ID_B, time: "04:51" }, { candidateId: ID_C, time: "04:54" }, { candidateId: ID_D, time: "04:58" }, ], }); assert.equal(panel.eligible, false); assert.equal(panel.reason, "cluster_count"); assert.equal(panel.columns.length, 0); }); test("panel labels hit the D9/D10 type tables and never invent copy", () => { const panel = buildDivergencePanel({ inference: inference({ probes: [d9Probe(), d10Probe()] }), publicCandidates, }); assert.equal(panel.eligible, true); for (const column of panel.columns) { for (const row of column.rows) { if (row.layer === "d9") { assert.ok(row.label.includes(D9_TYPE_TABLE[row.sign]!.userChoice)); assert.equal(row.label, d9DivergenceLabel(row.sign)); } if (row.layer === "d10") { assert.ok(row.label.includes(D10_TYPE_TABLE[row.sign]!.userChoice)); assert.equal(row.label, d10DivergenceLabel(row.sign)); } } } }); test("nakshatra traits appear only when unasked and near a boundary", () => { const boundary: NakshatraBoundary = { near_boundary: true, user_meaning: null, options: [ { key: "A", time_bias: "earlier", traits: ["做事偏急", "话少"] }, { key: "B", time_bias: "later", traits: ["做事偏稳", "话多"] }, ], }; const panel = buildDivergencePanel({ inference: inference(), nakshatraBoundary: boundary, publicCandidates, }); assert.equal(panel.eligible, true); assert.equal(panel.columns[0]?.rows.some((row) => row.layer === "nakshatra"), true); assert.ok(panel.columns[0]?.rows.some((row) => row.label === "做事偏急、话少")); assert.ok(panel.columns[1]?.rows.some((row) => row.label === "做事偏稳、话多")); const asked = buildDivergencePanel({ inference: inference({ answered_probes: [{ probe_id: "probe:nakshatra", semantic_key: "nakshatra-boundary:04:48-04:59:04:50,04:56", candidate_split_hash: "n", answer_class: "yes", classified_from: "choice", }], }), nakshatraBoundary: boundary, publicCandidates, }); assert.equal(asked.eligible, false); }); test("range delivery keeps the representative-minute boundary and event count", () => { const delivery = buildRangeDelivery({ inference: inference({ probes: [d9Probe()] }), rangeReading: { stable_themes: ["career"], sensitive_themes: ["marriage"] }, publicCandidates, credibleRange: ["04:48", "04:59"], representativeTime: "04:50", }); assert.deepEqual(delivery.range, ["04:48", "04:59"]); assert.equal(delivery.representative_time, "04:50"); assert.equal(delivery.representative_candidate_id, ID_A); assert.equal(delivery.event_count, 2); assert.deepEqual(delivery.stable_themes, ["事业方向"]); assert.deepEqual(delivery.sensitive_themes, ["婚恋(D9)"]); assert.equal(delivery.boundary, REPRESENTATIVE_MINUTE_DISCLAIMER); assert.equal(delivery.divergence.eligible, true); }); test("the range-delivery component does not render relative support", () => { 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(chat, /相对支持度/); assert.doesNotMatch(chat, /RectificationCandidateCards/); assert.match(chat, / { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const start = chat.indexOf("showSelectionCards && candidateResult"); assert.ok(start >= 0); const slice = chat.slice(start, start + 280); assert.match(slice, /candidateResult && dismissedRangeResultId !== candidateResult\.resultId/); assert.ok(slice.indexOf("candidateResult &&") < slice.indexOf("dismissedRangeResultId")); }); 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); assert.doesNotMatch(without, /预测下一次事业变动/); 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("dismissing the range card hides it and shows the collect-continue hint", () => { const hidden = renderToStaticMarkup(createElement(RangeDeliveryContinueHint, { visible: false })); assert.equal(hidden, ""); const shown = renderToStaticMarkup(createElement(RangeDeliveryContinueHint, { visible: true })); assert.ok(shown.includes(STEP_STATE_COPY.collect.headline)); assert.ok(shown.includes(RECTIFICATION_USER_COPY.continueCollectFallback)); assert.doesNotMatch(shown, /rectification-step-state/); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(chat, /RangeDeliveryContinueHint/); assert.match(chat, /setDismissedRangeResultId\(candidateResult\.resultId\)/); assert.match(chat, /rangeCardDismissed/); });