736 lines
32 KiB
TypeScript
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");
|
|
});
|