Files
Jyotisha/frontend/tests/report-interpretive-facts.test.ts
T
2026-09-04 02:11:57 +08:00

736 lines
32 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildReportEvidenceBundleV2,
filterReportEvidenceBundleForSection,
} from "../src/lib/personal-report-generation.ts";
import { buildPersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts";
import {
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
validateReportEvidenceBundleV2,
type ReportEvidenceBundleV2,
} from "../src/lib/report-evidence-bundle-v2.ts";
// ---------------------------------------------------------------------------
// Engine response contract fixture (2026-09-01 snapshot).
//
// Synthetic data only — no real birth details. The shapes below mirror what a
// live POST /api/consultation_workflow actually returns today:
// chart.ai_prompt_pack.evidence_snapshot.functional_benefic_malefic present
// chart.ai_prompt_pack.evidence_snapshot.strength.shadbala_ranking present
// ({planet, rupas, level})
// chart.modules.ashtakavarga.sav.{scores,total} present
// (scores keyed by SIGN, not house)
// chart.modules.dasha_sub_periods.current.{mahadasha,antardasha} present
// chart.yogas present
// chart.modules.yogas.yogas candidate
// rule table with hit:true/false
// chart.modules.guided_topics present
// evidence_snapshot.timing.vimshottari / .convergence_top_domains ABSENT
// evidence_snapshot.strength.sav_scores ABSENT
// evidence_snapshot.timing_narrative present after task 6
// evidence_snapshot.{career,relationship,finance}_narrative ABSENT on the consultation API pack
// The narrative payloads and the sign-keyed SAV fallback are still covered here
// because the engine's own `_build_ai_prompt_pack` emits them on other routes;
// the extraction layer must read either shape without inventing data.
// ---------------------------------------------------------------------------
const SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
const ASC_INDEX = 3; // Cancer
type JsonRecord = Record<string, unknown>;
function basePlanets(): JsonRecord {
return {
Sun: { sign: "Cancer", degree_raw: 100.5, house: 1, retrograde: false },
Moon: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: false },
Mars: { sign: "Virgo", degree_raw: 160.5, house: 3, retrograde: false },
Mercury: { sign: "Libra", degree_raw: 190.5, house: 4, retrograde: true },
Jupiter: { sign: "Scorpio", degree_raw: 220.5, house: 5, retrograde: false },
Venus: { sign: "Sagittarius", degree_raw: 250.5, house: 6, retrograde: false },
Saturn: { sign: "Capricorn", degree_raw: 280.5, house: 7, retrograde: true },
Rahu: { sign: "Aquarius", degree_raw: 310.5, house: 8, retrograde: true },
Ketu: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: true },
};
}
function baseHouses(): JsonRecord {
const houses: JsonRecord = {};
for (let index = 0; index < 12; index += 1) {
houses[`house_${index + 1}`] = {
cusp_sign: SIGNS[(ASC_INDEX + index) % 12],
cusp_degree: 100.5 + index * 30,
};
}
return houses;
}
const AVAILABLE_LAYERS = [
"D1", "D2", "D11", "D24", "yogas", "ashtakavarga",
"functional_benefic_malefic", "vimshottari",
];
function machineSections(): JsonRecord {
return {
D1: { status: "used", source_path: "chart.planets+chart.ascendant" },
D2: { status: "used", source_path: "modules.varga_full.D2" },
D11: { status: "used", source_path: "modules.varga_full.D11" },
// Verified layers make the education theme reach multi_system_consensus so
// the seed-less fallback can be observed lowering it.
D24: { status: "verified", source_path: "modules.varga_full.D24" },
yogas: { status: "used", source_path: "modules.yogas" },
ashtakavarga: { status: "used", source_path: "modules.ashtakavarga" },
functional_benefic_malefic: { status: "used", source_path: "modules.functional_benefic_malefic" },
vimshottari: { status: "verified", source_path: "modules.dasha" },
planet_degrees: { status: "used", source_path: "chart.planets" },
house_degrees: { status: "used", source_path: "chart.houses" },
};
}
// Previous 643f6f7a fixture used the old engine-incompatible shape:
// `{ _meta, Ascendant: { sign, sign_idx, degree_in_sign }, Sun/Moon/Mars: { sign, sign_idx } }`.
// That locked the defect this round repairs (frontend only read Ascendant/sign_idx).
// Replaced with golden-derived live `modules.varga_full` values (lowercase
// ascendant, sign_index, planets dict, house_chart).
const GOLDEN_VARGA = (
JSON.parse(readFileSync(new URL("./fixtures/consultation-workflow-report-blocked-repairs-golden.json", import.meta.url), "utf8")) as {
themes: { career: { chart: { modules: { varga_full: Record<string, JsonRecord> } } } };
}
).themes.career.chart.modules.varga_full;
function engineVargaFromGolden(id: "D2" | "D11" | "D24"): JsonRecord {
const aliases: Record<typeof id, readonly string[]> = {
D2: ["D2_Hora", "D2"],
D11: ["D11_Rudramsa", "D11"],
D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"],
};
for (const key of aliases[id]) {
const value = GOLDEN_VARGA[key];
if (value) return value;
}
throw new Error(`golden varga ${id} missing`);
}
type FixtureOptions = Readonly<{
interpretive?: boolean;
financeNarrative?: boolean;
pollutedNames?: boolean;
structuredVargas?: boolean;
timingReady?: boolean;
birthTimeSensitivity?: boolean;
}>;
function workflowFixture(options: FixtureOptions = {}): JsonRecord {
const interpretive = options.interpretive !== false;
const chart: JsonRecord = {
ascendant: { sign: "Cancer", degree: 10.5, degree_in_sign: 10.5 },
planets: basePlanets(),
houses: baseHouses(),
dasha: {
mahadashas: [
{ lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
{ lord: "Jupiter", start: "2029-11-11", end: "2045-11-11" },
],
},
modules: {},
};
const modules = chart.modules as JsonRecord;
// Machine sections already mark D2/D11/D24 executed. Without the actual
// varga_full objects, demoteThemesMissingRequiredCharts strips wealth and
// education claim cards (Gitea run 2298).
if (options.structuredVargas !== false) {
modules.varga_full = {
D2: engineVargaFromGolden("D2"),
D11: engineVargaFromGolden("D11"),
D24: engineVargaFromGolden("D24"),
};
}
if (interpretive) {
chart.yogas = [
{ name: "Amala Yoga", planets: ["Mercury"], category: "extended" },
// Duplicate name from the second detection pass must collapse to one row.
{ name: "Amala Yoga", planets: [], cat: "extended", desc: "..." },
{ name: "Gaja Kesari Yoga", planets: ["Jupiter", "Moon"], category: "raja" },
// Unknown category folds into "other"; unknown planets are dropped.
{ name: "Budha Aditya Yoga", planets: ["Mercury", "Nibiru"], category: "wholly_invented" },
// No usable name: dropped rather than guessed.
{ name: " ", planets: ["Sun"], category: "raja" },
];
modules.yogas = {
status: "ok",
count: 3,
yogas: [
{ name: "Sunaphaa Yoga", rule_id: "bvr_002", hit: false },
{ name: "Chandra Mangala Yoga", rule_id: "bvr_010", hit: true, category: "dhana" },
],
};
modules.ashtakavarga = {
sav: {
// Real engine keys SAV by sign; the projection maps sign -> whole-sign house.
scores: {
Cancer: 24, Leo: 27, Virgo: 28, Libra: 25, Scorpio: 35, Sagittarius: 30,
Capricorn: 26, Aquarius: 28, Pisces: 32, Aries: 30, Taurus: 28, Gemini: 24,
},
total: 337,
},
};
modules.dasha_sub_periods = {
current: {
mahadasha: { lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
antardasha: { lord: "Sun", start: "2026-05-30", end: "2027-04-24" },
},
pratyantar_dasha_timeline: {
status: "ready",
current: { lord: "Sun", start: "2026-05-30", end: "2026-07-12" },
next: { lord: "Moon", start: "2026-07-12", end: "2026-08-20" },
},
};
modules.pratyantar_dasha_timeline = {
status: "ready",
current: { lord: "Sun", start: "2026-05-30", end: "2026-07-12" },
next: { lord: "Moon", start: "2026-07-12", end: "2026-08-20" },
};
modules.planetary_friendship = {
status: "parameter_sensitive",
rows: [
{
planet: "Sun",
great_friends: ["Moon"],
friends: ["Mars"],
neutral: ["Mercury"],
enemies: ["Venus"],
great_enemies: ["Saturn"],
},
],
};
if (options.timingReady) {
modules.narayana_dasha = {
periods: [{ lord: "Aries", start: "2026-01-01", end: "2027-01-01" }],
};
modules.transits = { status: "executed", boundary: "observation windows" };
}
modules.guided_topics = [
{
id: "wealth_risk",
title: "财富、借贷和交易风险怎样用数据拆开",
reality_value: "帮助用户把收入、现金流、借贷、买卖和投资风险分开判断。",
why_worth_exploring: "财富主题必须同时看2宫、11宫、D2/D11 与时间层。",
evidence: [{ label: "required vargas", value: "D2 / D11" }],
confidence: "low",
},
// Product-flow topic: intentionally not mapped onto a report theme.
{ id: "birth_time_rectification", title: "出生时间是否需要微调", reality_value: "x", why_worth_exploring: "y" },
];
chart.ai_prompt_pack = {
schema_version: 1,
evidence_snapshot: {
functional_benefic_malefic: {
status: "used",
ascendant: "Cancer",
functional_benefics: options.pollutedNames
? ["Jupiter", "Moon", "Nibiru", "'; DROP TABLE --"]
: ["Jupiter", "Moon"],
functional_malefics: ["Mercury", "Saturn", "Venus"],
functional_neutrals: ["Sun"],
yogakarakas: ["Mars"],
owned_houses: {
Jupiter: [6, 9], Mars: [5, 10], Mercury: [3, 12], Moon: [1],
Saturn: [7, 8], Sun: [2], Venus: [4, 11], Nibiru: [13],
},
},
strength: {
shadbala_ranking: [
{ planet: "Venus", rupas: 8.35, level: "极强" },
{ planet: "Saturn", rupas: 7.56, level: "极强" },
{ planet: "Mercury", rupas: 7.03, level: "充足" },
{ planet: "Jupiter", rupas: 6.97, level: "充足" },
{ planet: "Sun", rupas: 6.42, level: "强" },
{ planet: "Moon", rupas: 6.42, level: "充足" },
{ planet: "Mars", rupas: 5.79, level: "充足" },
{ planet: "Nibiru", rupas: 99, level: "极强" },
],
},
...(options.financeNarrative
? {
finance_narrative: {
headline: `财富承诺已经成立,${"细".repeat(600)}`,
strengths: ["二宫与十一宫同时得到吉星支持,收入结构偏向可持续积累。"],
risks: [
"支出节奏比收入节奏更快,现金留存能力弱于账面收入。",
"VedAstro 官方财富日窗口尚未取得验证,不能作为时间依据。",
],
boundaries: ["未完成本命财富承诺与双重大运交叉前,不得写成高置信度结论。"],
},
}
: {}),
timing_narrative: {
headline: "应期主干已接入大运与第二时轴,日级窗口仍是候选。",
strengths: ["当前大运为 Rahu,应期主干围绕这条 dasha。"],
risks: ["外部日级窗口未闭环。"],
boundaries: ["候选窗口,不是已核验事件日。"],
},
},
};
}
return {
success: true,
chart,
consumer_context: {
route: "general",
core_status: "ready",
available_layers: AVAILABLE_LAYERS,
missing_route_layers: [],
hard_blockers: [],
answer_policy: { can_answer_direction: true, can_answer_precise_timing: false },
},
machine_evidence_packet: { sections: machineSections(), conflicts: [] },
...(options.birthTimeSensitivity ? {
birth_time_sensitivity: {
schema: "jyotish.report_birth_time_sensitivity.v1",
status: "candidate_window_only",
window: {
start_time: "10:00",
end_time: "10:01",
representative_time: "10:00",
candidate_count: 2,
},
theme_sensitivity: {
career: { status: "sensitive", stable_layers: ["arudha.A10"], sensitive_layers: ["D10.ascendant"] },
marriage: { status: "sensitive", stable_layers: ["arudha.UL"], sensitive_layers: ["D9.ascendant"] },
},
sensitive_evidence: {
evidence_keys: ["D10.ascendant", "D9.ascendant"],
layers: {
"D10.ascendant": { "10:01": "Virgo", "10:00": "Leo" },
"D9.ascendant": { "10:01": "Taurus", "10:00": "Aries" },
},
},
claim_boundary: "Candidate-window comparison only; no minute is selected or confirmed.",
},
} : {}),
};
}
const THEMES = ["general", "wealth", "education"] as const;
function buildBundle(options: FixtureOptions = {}): ReportEvidenceBundleV2 {
const workflow = workflowFixture(options);
return buildReportEvidenceBundleV2({
workflows: THEMES.map((theme) => ({ theme, workflow })),
subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" },
requestedThemes: [...THEMES],
reportType: "personal_full",
presentationMode: "default",
skillSnapshot: {
name: "jyotish-personal-report",
version: "1.0.0",
sha256: "a".repeat(64),
sourceCommit: null,
},
});
}
// ---------------------------------------------------------------------------
// Task 0/1: the extraction layer reads every field the contract snapshot has
// ---------------------------------------------------------------------------
test("bundle carries the interpretive layers the engine actually computes", () => {
const bundle = buildBundle();
const facts = bundle.interpretiveFacts;
// Yogas: chart.yogas detections + modules.yogas rows with hit:true only.
assert.deepEqual(
facts.yogas.map((yoga) => `${yoga.name}/${yoga.category}`).sort(),
[
"Amala Yoga/extended",
"Budha Aditya Yoga/other",
"Chandra Mangala Yoga/dhana",
"Gaja Kesari Yoga/raja",
],
);
assert.ok(!facts.yogas.some((yoga) => yoga.name === "Sunaphaa Yoga"), "hit:false rules are not detections");
const budha = facts.yogas.find((yoga) => yoga.name === "Budha Aditya Yoga")!;
assert.deepEqual([...budha.planets], ["Mercury"], "unknown celestial names are dropped");
assert.ok(facts.yogas.every((yoga) => yoga.evidenceRef === "ev-tech-yoga"));
// Functional roles: yogakaraka wins over the benefic/malefic lists.
assert.equal(facts.functionalRoles.length, 7);
const mars = facts.functionalRoles.find((role) => role.planet === "Mars")!;
assert.equal(mars.role, "yogakaraka");
assert.deepEqual([...mars.ownedHouses], [5, 10]);
assert.deepEqual(
facts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet),
["Mercury", "Saturn", "Venus"],
);
// Shadbala: ranks are re-derived from the ordering, unknown planets dropped.
assert.deepEqual(
facts.shadbalaRanking.map((row) => [row.planet, row.rank]),
// Sun and Moon tie at 6.42 rupas; the tie breaks alphabetically so the
// ranking (and therefore the bundle hash) is reproducible.
[["Venus", 1], ["Saturn", 2], ["Mercury", 3], ["Jupiter", 4], ["Moon", 5], ["Sun", 6], ["Mars", 7]],
);
assert.equal(facts.shadbalaRanking[0].rupa, 8.35);
// SAV: sign-keyed engine scores become whole-sign house scores.
assert.equal(facts.savScores.length, 12);
assert.equal(facts.savScores.find((row) => row.house === 1)!.score, 24, "house 1 == ascendant sign Cancer");
assert.equal(facts.savScores.find((row) => row.house === 2)!.score, 27, "house 2 == Leo");
assert.equal(facts.savTotal, 337);
assert.deepEqual(facts.currentDasha, {
mahadasha: "Rahu", antardasha: "Sun", start: "2011-11-11", end: "2029-11-11",
});
// Absent in the live consultation contract: reported as empty, never faked.
assert.deepEqual([...facts.convergenceDomains], []);
});
test("machine-section D2/D11/D24 receipts without varga_full demote wealth and education", () => {
const bundle = buildBundle({ structuredVargas: false });
assert.equal(bundle.claimCards.some((card) => card.theme === "wealth"), false);
assert.equal(bundle.claimCards.some((card) => card.theme === "education"), false);
assert.ok(bundle.blockedSections.some((section) => section.theme === "wealth"));
assert.ok(bundle.blockedSections.some((section) => section.theme === "education"));
assert.ok(bundle.claimCards.some((card) => card.theme === "general"));
assert.deepEqual(
bundle.themeNarrativeSeeds.map((seed) => seed.theme).filter((theme) => (
theme === "wealth" || theme === "education"
)),
[],
);
});
test("interpretive extraction rejects polluted planet names and over-long narrative text", () => {
const bundle = buildBundle({ pollutedNames: true, financeNarrative: true });
const benefics = bundle.interpretiveFacts.functionalRoles
.filter((role) => role.role === "benefic")
.map((role) => role.planet);
assert.deepEqual(benefics, ["Jupiter", "Moon"], "non-allowlisted names never enter the bundle");
const wealthSeed = bundle.themeNarrativeSeeds.find((seed) => seed.theme === "wealth")!;
assert.equal(wealthSeed.headline.length, 300, "headline is truncated to the schema bound");
assert.ok(wealthSeed.headline.startsWith("财富承诺已经成立"));
assert.deepEqual(
wealthSeed.risks,
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
"seed lines naming an external provider are dropped, not rewritten",
);
});
test("bundle hash is stable under interpretive field ordering", () => {
const bundle = buildBundle();
const { bundleHash: _hash, ...content } = bundle;
void _hash;
const shuffled = finalizeReportEvidenceBundleV2({
...content,
interpretiveFacts: {
...content.interpretiveFacts,
yogas: [...content.interpretiveFacts.yogas].reverse(),
functionalRoles: [...content.interpretiveFacts.functionalRoles].reverse(),
shadbalaRanking: [...content.interpretiveFacts.shadbalaRanking].reverse(),
savScores: [...content.interpretiveFacts.savScores].reverse(),
},
themeNarrativeSeeds: [...content.themeNarrativeSeeds].reverse().map((seed) => ({
...seed,
evidenceRefs: [...seed.evidenceRefs].reverse(),
})),
});
assert.equal(shuffled.bundleHash, bundle.bundleHash);
});
test("birth-time sensitivity carries actual minute changes and hashes independently of ordering", () => {
const workflow = workflowFixture({ birthTimeSensitivity: true });
const build = (value: JsonRecord) => buildReportEvidenceBundleV2({
workflows: ["career", "marriage"].map((theme) => ({ theme, workflow: value })),
subject: { displayName: "冒烟用户", birthTimeStatus: "accepted", birthPlaceLabel: "冒烟市" },
requestedThemes: ["career", "marriage"],
reportType: "personal_full",
presentationMode: "default",
skillSnapshot: { name: "jyotish-personal-report", version: "1.0.0", sha256: "a".repeat(64), sourceCommit: null },
});
const bundle = build(workflow);
const sensitivity = bundle.interpretiveFacts.birthTimeSensitivity;
assert.ok(sensitivity);
assert.deepEqual(sensitivity.themes.find((theme) => theme.theme === "career")?.minuteVariations, [{
layer: "D10.ascendant",
values: [{ minute: "10:00", value: "Leo" }, { minute: "10:01", value: "Virgo" }],
}]);
const shuffledWorkflow = structuredClone(workflow);
const packet = shuffledWorkflow.birth_time_sensitivity as JsonRecord;
packet.theme_sensitivity = Object.fromEntries(Object.entries(packet.theme_sensitivity as JsonRecord).reverse());
const layers = (packet.sensitive_evidence as JsonRecord).layers as JsonRecord;
layers["D10.ascendant"] = Object.fromEntries(Object.entries(layers["D10.ascendant"] as JsonRecord).reverse());
assert.equal(build(shuffledWorkflow).bundleHash, bundle.bundleHash);
const plan = buildPersonalReportSectionPlan(bundle, "standard");
const careerSection = plan.sections.find((section) => section.theme === "career")!;
const careerBundle = filterReportEvidenceBundleForSection(bundle, careerSection);
assert.deepEqual(careerBundle.interpretiveFacts.birthTimeSensitivity?.themes.map((theme) => theme.theme), ["career"]);
assert.deepEqual(
careerBundle.interpretiveFacts.birthTimeSensitivity?.themes.flatMap((theme) => theme.minuteVariations.map((row) => row.layer)),
["D10.ascendant"],
);
});
test("confirmed not-applicable sensitivity leaves the legacy bundle hash unchanged", () => {
const baseline = buildBundle();
const workflow = workflowFixture();
workflow.birth_time_sensitivity = {
schema: "jyotish.report_birth_time_sensitivity.v1",
status: "not_applicable",
accuracy: "confirmed",
};
const confirmed = buildReportEvidenceBundleV2({
workflows: THEMES.map((theme) => ({ theme, workflow })),
subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" },
requestedThemes: [...THEMES],
reportType: "personal_full",
presentationMode: "default",
skillSnapshot: { name: "jyotish-personal-report", version: "1.0.0", sha256: "a".repeat(64), sourceCommit: null },
});
assert.equal(confirmed.bundleHash, baseline.bundleHash);
});
// ---------------------------------------------------------------------------
// Task 1: fail-closed validation of the new fields
// ---------------------------------------------------------------------------
test("validation fails closed on out-of-bounds interpretive facts and seeds", () => {
const bundle = buildBundle();
const mutate = (patch: Partial<ReportEvidenceBundleV2>) => (
{ ...bundle, ...patch } as ReportEvidenceBundleV2
);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
yogas: [{ name: "Ghost Yoga", category: "raja", planets: [], evidenceRef: "ev-tech-not-real" }],
},
})), /report_bundle_invalid_interpretive_ref/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
shadbalaRanking: [
{ planet: "Venus", rank: 1, rupa: 1 },
{ planet: "Saturn", rank: 1, rupa: 1 },
],
},
})), /report_bundle_duplicate_shadbala_rank/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
functionalRoles: [{
planet: "Mars",
// @ts-expect-error unknown role must be rejected by the closed enum
role: "super_benefic",
ownedHouses: [1],
evidenceRef: "ev-tech-functional_benefic_malefic",
}],
},
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: { ...bundle.interpretiveFacts, savScores: [{ house: 13, score: 1 }] },
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "marriage",
headline: "未被请求的主题不得携带种子",
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
}],
})), /report_bundle_seed_theme_not_requested/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "general",
headline: "越界".repeat(200),
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
}],
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "general",
headline: "引用不存在的证据",
strengths: [], risks: [], boundaries: [], evidenceRefs: ["ev-tech-not-real"],
}],
})), /report_bundle_invalid_seed_ref/);
});
// ---------------------------------------------------------------------------
// Task 2: claim cards carry conclusions, not receipts
// ---------------------------------------------------------------------------
test("claim card conclusions state real chart facts instead of an execution receipt", () => {
const bundle = buildBundle();
for (const card of bundle.claimCards) {
assert.ok(
!card.conclusion.includes("服务器已闭合"),
`${card.theme} still uses the receipt template`,
);
assert.ok(card.conclusion.includes("Cancer"), `${card.theme} conclusion names the ascendant`);
}
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
assert.ok(wealth.conclusion.includes("第 2 宫"), "wealth conclusion cites its own SAV houses");
assert.ok(wealth.conclusion.includes("第 11 宫"));
const health = bundle.claimCards.find((card) => card.theme === "general");
assert.ok(health && !health.conclusion.includes("第 2 宫"), "themes do not share one canned conclusion");
// Supporting facts are astrological statements bound to real receipts.
const values = wealth.supportingFacts.map((fact) => fact.value);
assert.ok(values.some((value) => value.includes("Rahu 主运")), "current dasha reaches the claim card");
assert.ok(values.some((value) => value.includes("Amala Yoga")), "detected yogas reach the claim card");
const refIds = new Set(bundle.evidenceRefs.map((ref) => ref.id));
for (const fact of [...wealth.supportingFacts, ...wealth.counterFacts]) {
assert.ok(refIds.has(fact.evidenceRef), `${fact.id} must bind a real evidence ref`);
}
});
test("engine risk lines become counter facts on the matching claim card", () => {
const bundle = buildBundle({ financeNarrative: true });
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
assert.deepEqual(
wealth.counterFacts.map((fact) => fact.value),
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
);
});
test("a theme without any narrative seed keeps the receipt wording and loses consensus", () => {
const seeded = buildBundle();
const bare = buildBundle({ interpretive: false });
const seededEducation = seeded.claimCards.find((card) => card.theme === "education")!;
assert.equal(seededEducation.assertionLevel, "multi_system_consensus");
const bareEducation = bare.claimCards.find((card) => card.theme === "education")!;
assert.equal(bare.themeNarrativeSeeds.length, 0, "no interpretive source means no seed");
assert.ok(bareEducation.conclusion.includes("服务器已闭合"));
assert.equal(
bareEducation.assertionLevel,
"single_system_inference",
"seed-less themes are capped below consensus",
);
});
test("consensus guard still rejects an upgraded assertion level", () => {
const bundle = buildBundle();
const card = bundle.claimCards.find((entry) => entry.theme === "wealth")!;
assert.notEqual(card.assertionLevel, "multi_system_consensus");
assert.throws(() => validateReportEvidenceBundleV2({
...bundle,
claimCards: bundle.claimCards.map((entry) => (
entry.theme === "wealth" ? { ...entry, assertionLevel: "multi_system_consensus" as const } : entry
)),
}), /report_bundle_invalid_consensus/);
});
// ---------------------------------------------------------------------------
// Task 1: per-section trimming
// ---------------------------------------------------------------------------
test("section filtering keeps the universal interpretive layer and trims the rest by theme", () => {
const bundle = buildBundle();
const plan = buildPersonalReportSectionPlan(bundle, "standard");
const wealthSection = plan.sections.find((section) => section.theme === "wealth")!;
const generalSection = plan.sections.find((section) => section.theme === "general")!;
const wealthBundle = filterReportEvidenceBundleForSection(bundle, wealthSection);
assert.deepEqual(wealthBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["wealth"]);
assert.deepEqual(
wealthBundle.interpretiveFacts.savScores.map((row) => row.house),
[2, 11],
"only the theme's own SAV houses survive",
);
assert.equal(wealthBundle.interpretiveFacts.yogas.length, bundle.interpretiveFacts.yogas.length);
assert.equal(
wealthBundle.interpretiveFacts.functionalRoles.length,
bundle.interpretiveFacts.functionalRoles.length,
);
// general only requires D1, yet the chart-wide interpretive receipts ride along.
assert.ok(!generalSection.evidenceRefs.includes("ev-tech-functional_benefic_malefic"));
const generalBundle = filterReportEvidenceBundleForSection(bundle, generalSection);
const generalRefs = generalBundle.evidenceRefs.map((ref) => ref.id);
assert.ok(generalRefs.includes("ev-tech-functional_benefic_malefic"));
assert.ok(generalRefs.includes("ev-tech-yoga"));
assert.equal(generalBundle.interpretiveFacts.functionalRoles.length, 7);
assert.deepEqual(
generalBundle.interpretiveFacts.savScores.map((row) => row.house),
[1, 10],
);
assert.deepEqual(generalBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["general"]);
assert.equal(wealthBundle.interpretiveFacts.planetaryFriendship.length, 1);
assert.equal(generalBundle.interpretiveFacts.planetaryFriendship.length, 1);
assert.equal(wealthBundle.interpretiveFacts.pratyantarTimeline, null);
assert.equal(generalBundle.interpretiveFacts.pratyantarTimeline, null);
});
test("an empty interpretive block still finalizes and hashes", () => {
const bundle = buildBundle();
const { bundleHash: _hash, ...content } = bundle;
void _hash;
const stripped = finalizeReportEvidenceBundleV2({
...content,
interpretiveFacts: emptyReportInterpretiveFacts(),
themeNarrativeSeeds: [],
});
assert.equal(stripped.interpretiveFacts.savTotal, null);
assert.deepEqual(stripped.interpretiveFacts.planetaryFriendship, []);
assert.equal(stripped.interpretiveFacts.pratyantarTimeline, null);
assert.notEqual(stripped.bundleHash, bundle.bundleHash, "the hash covers the new fields");
});
test("planetary friendship is a grade table and pratyantar stays on the timing chapter", () => {
const bundle = buildBundle();
assert.deepEqual(bundle.interpretiveFacts.planetaryFriendship, [{
planet: "Sun",
greatFriends: ["Moon"],
friends: ["Mars"],
neutral: ["Mercury"],
enemies: ["Venus"],
greatEnemies: ["Saturn"],
}]);
assert.equal(bundle.interpretiveFacts.pratyantarTimeline?.current?.lord, "Sun");
assert.equal(bundle.interpretiveFacts.pratyantarTimeline?.next?.lord, "Moon");
const twice = buildBundle();
assert.equal(twice.bundleHash, bundle.bundleHash);
const workflow = workflowFixture({ timingReady: true });
const timing = buildReportEvidenceBundleV2({
workflows: [{ theme: "timing", workflow }],
subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" },
requestedThemes: ["timing"],
reportType: "personal_thematic",
presentationMode: "default",
skillSnapshot: {
name: "jyotish-personal-report",
version: "1.0.0",
sha256: "a".repeat(64),
sourceCommit: null,
},
});
const card = timing.claimCards.find((entry) => entry.theme === "timing");
assert.ok(card, "timing theme must write a claim card");
assert.match(card.conclusion, /2026-05-30/);
assert.match(card.timingBoundary ?? "", /2026-05-30/);
assert.match(card.timingBoundary ?? "", /候选窗口/);
const seed = timing.themeNarrativeSeeds.find((seed) => seed.theme === "timing");
assert.ok(seed, "timing_narrative must seed the timing chapter");
assert.match(seed.headline, /应期/);
const plan = buildPersonalReportSectionPlan(timing, "standard");
const timingSection = plan.sections.find((section) => section.theme === "timing")!;
const filtered = filterReportEvidenceBundleForSection(timing, timingSection);
assert.equal(filtered.interpretiveFacts.pratyantarTimeline?.current?.lord, "Sun");
});