import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { buildReportEvidenceBundleV2 } from "../src/lib/personal-report-generation.ts"; import { REPORT_THEME_IDS } from "../src/lib/report-theme-evidence-plan.ts"; type JsonRecord = Record; const GOLDEN = JSON.parse(readFileSync( new URL("./fixtures/consultation-workflow-report-blocked-repairs-golden.json", import.meta.url), "utf8", )) as { themes: Record; }; const SKILL = { name: "jyotish-vedic-astrology", version: "6.9.14", sha256: "a".repeat(64), sourceCommit: "b".repeat(40), } as const; const SUBJECT = { displayName: "冒烟用户", birthTimeStatus: "accepted" as const, birthPlaceLabel: "冒烟市", }; const FOUR_THEMES = ["career", "marriage", "timing", "wealth"] as const; function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function goldenWorkflow(theme: string): JsonRecord { const workflow = clone(GOLDEN.themes[theme] ?? GOLDEN.themes.career); if (theme === "health" || theme === "health_pressure") { const modules = chartModules(workflow); const source = clone((modules.varga_full as JsonRecord).D24_Chaturvimsamsa); (modules.varga_full as JsonRecord).D6_Shashthamsa = clone(source); (modules.varga_full as JsonRecord).D8_Ashtamsa = clone(source); (modules.varga_full as JsonRecord).D30_Trimsamsa = clone(source); const sections = (workflow.machine_evidence_packet as JsonRecord).sections as JsonRecord; sections.D8 = { status: "used", source_path: "modules.varga_full.D8" }; (workflow.chart as JsonRecord).ai_prompt_pack = { evidence_snapshot: { health_narrative: { headline: "非医疗边界:D6/D8/D30 显示压力负荷与恢复节奏需要分层观察。", strengths: ["当前 Dasha 激活 6/8/12 宫主的部分恢复支持。"], risks: ["相关 Shadbala 较弱时需要降低负荷。"], boundaries: ["非医疗边界:本段不构成诊断或治疗建议。"], }, } }; } return workflow; } function buildBundle(themes: readonly string[], workflows?: readonly JsonRecord[]) { return buildReportEvidenceBundleV2({ workflows: themes.map((theme, index) => ({ theme, workflow: workflows?.[index] ?? goldenWorkflow(theme), })), subject: SUBJECT, requestedThemes: [...themes], reportType: "personal_full", presentationMode: "default", skillSnapshot: SKILL, }); } function chartModules(workflow: JsonRecord): JsonRecord { const chart = workflow.chart as JsonRecord; return chart.modules as JsonRecord; } function executedTechniques(bundle: ReturnType): string[] { return bundle.executionLedger .filter((receipt) => receipt.executed && receipt.status !== "blocked") .map((receipt) => receipt.technique) .sort(); } test("golden fixture locks the live engine varga and transits shape", () => { const vargaFull = chartModules(GOLDEN.themes.career).varga_full as JsonRecord; assert.ok(vargaFull.D2_Hora); assert.ok(vargaFull.D9_Navamsa); assert.ok(vargaFull.D10_Dasamsa); assert.ok(vargaFull.D11_Rudramsa); assert.ok(vargaFull.D24_Chaturvimsamsa); for (const key of ["D2_Hora", "D9_Navamsa", "D10_Dasamsa", "D11_Rudramsa", "D24_Chaturvimsamsa"] as const) { const varga = vargaFull[key] as JsonRecord; const ascendant = varga.ascendant as JsonRecord; const planets = varga.planets as JsonRecord; assert.equal(typeof ascendant.sign, "string", `${key} ascendant.sign`); assert.equal(typeof ascendant.sign_index, "number", `${key} ascendant.sign_index`); assert.equal(typeof ascendant.degree, "number", `${key} ascendant.degree`); assert.equal(varga.Ascendant, undefined, `${key} must not use the old Ascendant key as the live shape`); assert.ok(planets.Sun && typeof (planets.Sun as JsonRecord).sign_index === "number", `${key} planets.Sun`); assert.ok(Array.isArray(varga.house_chart)); assert.equal((varga.house_chart as unknown[]).length, 12); } const transits = chartModules(GOLDEN.themes.career).transits as JsonRecord; assert.equal(typeof transits.status, "string"); const karakas = (chartModules(GOLDEN.themes.career).jaimini as JsonRecord).chara_karakas as JsonRecord; assert.equal(typeof (karakas.AmK as JsonRecord).planet, "string"); assert.equal(typeof (karakas.DK as JsonRecord).planet, "string"); }); test("deriveVargaHousesFromEngine reads the live planets-dict shape and the old top-level shape", () => { const goldenBundle = buildBundle(["wealth"], [goldenWorkflow("wealth")]); const required = ["D1", "D2", "D9", "D10", "D11", "D24"]; for (const id of required) { assert.ok(goldenBundle.charts.some((chart) => chart.id === id), `golden missing ${id}`); } const d2 = goldenBundle.charts.find((chart) => chart.id === "D2")!; assert.equal(d2.houses.length, 12); assert.equal(d2.houses[0].sign, "Leo"); assert.ok(d2.houses[0].occupants.includes("Sun")); const oldShape = goldenWorkflow("wealth"); const modules = chartModules(oldShape); // Keep the old 643f6f7a-era shape as a compatibility path: Ascendant/sign_idx // plus top-level planet keys. Live engine responses no longer look like this. modules.varga_full = { D2_Hora: { _meta: { fixture: "legacy-shape" }, Ascendant: { sign: "Cancer", sign_idx: 3, degree_in_sign: 8.25 }, Sun: { sign: "Leo", sign_idx: 4 }, Moon: { sign: "Virgo", sign_idx: 5 }, }, D11_Rudramsa: { Ascendant: { sign: "Scorpio", sign_idx: 7 }, Sun: { sign: "Sagittarius", sign_idx: 8 }, }, }; const oldBundle = buildBundle(["wealth"], [oldShape]); const oldD2 = oldBundle.charts.find((chart) => chart.id === "D2"); const oldD11 = oldBundle.charts.find((chart) => chart.id === "D11"); assert.ok(oldD2); assert.ok(oldD11); assert.equal(oldD2.houses[0].sign, "Cancer"); assert.ok(oldD2.houses[1].occupants.includes("Sun")); assert.equal(oldD11.houses[0].sign, "Scorpio"); }); test("Transit receipt upserts only when modules.transits.status is executed", () => { const executed = buildBundle(["timing"], [goldenWorkflow("timing")]); assert.ok(executed.claimCards.some((card) => card.theme === "timing")); assert.equal(executed.blockedSections.some((section) => section.theme === "timing"), false); const transit = executed.executionLedger.find((receipt) => receipt.technique === "Transit"); assert.ok(transit); assert.equal(transit.executed, true); assert.equal(transit.status, "partial"); const missing = goldenWorkflow("timing"); delete chartModules(missing).transits; const missingBundle = buildBundle(["timing"], [missing]); assert.equal(missingBundle.claimCards.some((card) => card.theme === "timing"), false); assert.ok(missingBundle.blockedSections.some((section) => ( section.theme === "timing" && section.reason.includes("Transit") ))); assert.equal( missingBundle.executionLedger.some((receipt) => receipt.technique === "Transit" && receipt.executed), false, ); const blocked = goldenWorkflow("timing"); (chartModules(blocked).transits as JsonRecord).status = "blocked"; const blockedBundle = buildBundle(["timing"], [blocked]); assert.equal(blockedBundle.claimCards.some((card) => card.theme === "timing"), false); assert.equal( blockedBundle.executionLedger.some((receipt) => receipt.technique === "Transit" && receipt.executed), false, ); }); test("AmK and DK receipts require chara_karakas planet rows", () => { const full = buildBundle(["career", "marriage"]); assert.ok(full.claimCards.some((card) => card.theme === "career")); assert.ok(full.claimCards.some((card) => card.theme === "marriage")); assert.ok(executedTechniques(full).includes("AmK")); assert.ok(executedTechniques(full).includes("DK")); const noKarakas = goldenWorkflow("career"); delete (chartModules(noKarakas).jaimini as JsonRecord).chara_karakas; const careerOnly = buildBundle(["career"], [noKarakas]); const blocked = careerOnly.blockedSections.find((section) => section.theme === "career"); assert.ok(blocked); assert.ok(blocked.reason.includes("AmK")); assert.equal(blocked.reason.includes("Transit"), false); }); test("golden four-theme pipeline emits claim cards and no blocked sections", () => { const bundle = buildBundle(FOUR_THEMES); assert.deepEqual(bundle.claimCards.map((card) => card.theme).sort(), [...FOUR_THEMES].sort()); assert.deepEqual(bundle.blockedSections.map((section) => section.theme), []); const chartIds = bundle.charts.map((chart) => chart.id).sort(); for (const id of ["D1", "D2", "D9", "D10", "D11", "D24"]) { assert.ok(chartIds.includes(id), `charts missing ${id}: ${chartIds.join(",")}`); } const executed = executedTechniques(bundle); for (const technique of ["AmK", "DK", "Transit", "D2", "D11"]) { assert.ok(executed.includes(technique), `ledger missing executed ${technique}`); } assert.equal( bundle.claimCards.some((card) => card.theme === "wealth") && !bundle.blockedSections.some((section) => section.reason.includes("结构化分盘")), true, ); }); test("nine requested themes close from the same golden chart layers", () => { const bundle = buildBundle(REPORT_THEME_IDS); const claimed = new Set(bundle.claimCards.map((card) => card.theme)); const blocked = Object.fromEntries( bundle.blockedSections.map((section) => [section.theme, section.reason]), ); const rows = REPORT_THEME_IDS.map((theme) => ({ theme, status: claimed.has(theme) ? "claim" : "blocked", reason: blocked[theme] ?? null, })); for (const row of rows) { assert.equal(row.status, "claim", `${row.theme} still blocked: ${row.reason}`); } }); test("health requires D1 D6 D8 D30 and Vimshottari while Transit stays optional", () => { const full = goldenWorkflow("health"); delete chartModules(full).transits; const bundle = buildBundle(["health"], [full]); const card = bundle.claimCards.find((item) => item.theme === "health_pressure"); assert.ok(card); assert.match(card.conclusion, /D6\/D8\/D30/); assert.match(card.conclusion, /非医疗/); assert.equal(bundle.blockedSections.some((item) => item.theme === "health_pressure"), false); assert.equal(card.assertionLevel, "parameter_sensitive"); for (const missing of ["D6_Shashthamsa", "D8_Ashtamsa", "D30_Trimsamsa"] as const) { const workflow = goldenWorkflow("health"); delete (chartModules(workflow).varga_full as JsonRecord)[missing]; const technique = missing.split("_")[0]; delete ((workflow.machine_evidence_packet as JsonRecord).sections as JsonRecord)[technique]; const blocked = buildBundle(["health"], [workflow]); assert.ok(blocked.blockedSections.some((item) => item.theme === "health_pressure")); } });