Files
Jyotisha/frontend/tests/consult-card-affliction-data-20261001.test.ts
T
Jesse_ChenandClaude Opus 5.5 7806c72f2c feat(consult): cards carry functional profile v2; close round-4 backtest residue (BUG-1158, BUG-1176, BUG-1182)
- Evidence-card and biography-backtest goldens regenerated with the capture
  scripts (PYTHONHASHSEED=0, cache TTL 0; byte-identical twice); only the
  functional-role fields change.
- CAREER_FIELD_ASK_RULE: A10 / AL / Sun / 10th lord in 4/6/8/12 say
  resistance or cost only, never "not on stage / go deep", and houses are not
  translated into fields.
- AFFLICTION_RANGE_RULE: no portrait of an afflicted person from the lord's
  house or a benefic occupant ("managing / arranging / cares about your
  future"), actions do not presuppose reaching them, one ask per answer.
- Examples that paired "4th lord in 10th" with "her attention is on your
  future / close" now state the line is unafflicted (Monroe's chart had that
  exact placement and copied it). Retrograde line also bans "drags on".
- Backtest script: several follow-up domains in one run.
Changed assertions carry 原值 / 新值 / 原因 in the test comments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-02 12:32:02 +08:00

239 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TASK-consult-card-affliction-data-20261001 T2–T4 (BUG-1155~1157): the card
// carries who aspects whom, co-tenants and combustion; the parents roles carry
// full rows for their house lords and significators; health / education take
// the houses they were missing; the annual card names transit Saturn's house
// from the natal Moon; yogas are taken by domain tag. Expected values are read
// from the real engine's own layers in the golden fixture
// (scripts/research/capture_consult_evidence_card_golden.py, AGENTS §7.4).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildEvidenceCard,
EVIDENCE_CARD_SPECS,
type EvidenceCard,
} from "../src/lib/consultation-evidence-card.ts";
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
type Json = Record<string, unknown>;
type GoldenChart = { id: string; workflow: Json; annual_workflow: Json; timing_workflow: Json };
const golden = JSON.parse(readFileSync(
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
"utf8",
)) as { charts: GoldenChart[] };
const chartById = (id: string) => golden.charts.find((chart) => chart.id === id)!;
const jobs = chartById("steve_jobs");
const obama = chartById("barack_obama");
const taylor = chartById("elizabeth_taylor");
function dig(value: unknown, ...keys: string[]): unknown {
let current = value;
for (const key of keys) {
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
current = (current as Json)[key];
}
return current;
}
function sources(workflow: Json, domain: ConsultationDomain) {
const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow)));
const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: domain }), domain);
return { domain, packet, context };
}
function card(workflow: Json, domain: ConsultationDomain): EvidenceCard {
return buildEvidenceCard([sources(workflow, domain)]);
}
function layer(workflow: Json, name: string): Json {
return dig(workflow, "chart", "consultation_native_layers", name) as Json;
}
function enginePlanets(workflow: Json): Record<string, Json> {
return dig(workflow, "chart", "planets") as Record<string, Json>;
}
test("base planets carry the engine's aspected_by, combustion (true only) and co-tenants (selection)", () => {
for (const chart of golden.charts) {
const built = card(chart.workflow, "general");
const drishti = layer(chart.workflow, "graha_drishti");
assert.equal(drishti.status, "executed", chart.id);
const planets = built.base.natal.planets as Record<string, Json>;
const engine = enginePlanets(chart.workflow);
for (const [name, row] of Object.entries(planets)) {
const source = dig(drishti, "planets", name) as Json;
const by = source.aspected_by as string[];
if (by.length) assert.deepEqual(row.aspected_by, by, `${chart.id} ${name} aspected_by`);
else assert.equal("aspected_by" in row, false, `${chart.id} ${name} empty aspected_by is left out`);
if (source.combust === true) assert.equal(row.combust, true, `${chart.id} ${name} combust`);
else assert.equal("combust" in row, false, `${chart.id} ${name} combust only when true`);
const coTenants = Object.entries(engine)
.filter(([other, value]) => other !== name && value.house === engine[name]!.house)
.map(([other]) => other);
if (coTenants.length) assert.deepEqual(row.conjunct_with, coTenants, `${chart.id} ${name} conjunct_with`);
else assert.equal("conjunct_with" in row, false, `${chart.id} ${name} alone`);
}
}
});
test("Obama: Saturn in the 1st aspects the Sun in the 7th; Mercury (9th lord) is combust", () => {
const planets = card(obama.workflow, "parents").base.natal.planets as Record<string, Json>;
assert.equal(planets.Saturn!.house, 1);
assert.equal(planets.Sun!.house, 7);
assert.ok((planets.Sun!.aspected_by as string[]).includes("Saturn"));
assert.ok((planets.Mercury!.aspected_by as string[]).includes("Saturn"));
assert.equal(planets.Mercury!.combust, true);
assert.deepEqual(dig(layer(obama.workflow, "graha_drishti"), "planets", "Saturn", "aspects_houses"), [3, 7, 10]);
});
test("a house lord sharing a house with Rahu shows it (Jobs: 9th lord Venus with Rahu in the 4th)", () => {
const planets = card(jobs.workflow, "general").base.natal.planets as Record<string, Json>;
assert.equal(planets.Venus!.house, 4);
assert.ok((planets.Venus!.conjunct_with as string[]).includes("Rahu"));
assert.ok((planets.Rahu!.conjunct_with as string[]).includes("Venus"));
});
test("domain house rows carry the engine's aspected_by for each selected house", () => {
for (const chart of golden.charts) {
for (const domain of ["marriage", "parents", "health"] as const) {
const section = card(chart.workflow, domain).domain_sections[domain]!;
for (const [number, row] of Object.entries(section.houses)) {
assert.deepEqual(
(row as Json).aspected_by,
dig(layer(chart.workflow, "graha_drishti"), "houses", number, "aspected_by"),
`${chart.id}/${domain} house ${number}`,
);
}
}
}
const seventh = card(obama.workflow, "marriage").domain_sections.marriage!.houses["7"] as Json;
assert.deepEqual(seventh.aspected_by, ["Jupiter", "Saturn"]);
});
test("parents roles carry full rows for the house lords and significators, functional class selected", () => {
for (const chart of golden.charts) {
const built = card(chart.workflow, "parents");
const functional = built.base.natal.functional_benefic_malefic;
const roles = built.domain_sections.parents!.roles as Record<string, Json>;
for (const [key, role] of Object.entries(roles)) {
const lordRows = role.lord_rows as Record<string, Json>;
const significatorRows = role.significator_rows as Record<string, Json>;
assert.deepEqual(Object.keys(lordRows), role.house_lords, `${chart.id} ${key} lords`);
assert.deepEqual(Object.keys(significatorRows), role.significators, `${chart.id} ${key} significators`);
for (const [name, row] of [...Object.entries(lordRows), ...Object.entries(significatorRows)]) {
const base = (built.base.natal.planets as Record<string, Json>)[name]!;
for (const field of ["house", "sign", "status", "conjunct_with", "aspected_by", "combust"]) {
assert.deepEqual(row[field], base[field], `${chart.id} ${key} ${name}.${field}`);
}
const expected = (functional.functional_benefics as string[]).includes(name) ? "benefic"
: (functional.functional_malefics as string[]).includes(name) ? "malefic"
: (functional.functional_neutrals as string[]).includes(name) ? "neutral" : undefined;
assert.equal(row.functional, expected, `${chart.id} ${key} ${name}.functional`);
}
const d12 = role.d12_house as Json;
const d12Planets = dig(built.domain_sections.parents!.vargas, "D12", "planets") as Json;
assert.equal(typeof d12.lord, "string", `${chart.id} ${key} D12 lord`);
assert.equal(d12.lord_sign, d12Planets[d12.lord as string], `${chart.id} ${key} D12 lord sign`);
}
}
const father = dig(card(jobs.workflow, "parents").domain_sections.parents, "roles", "father") as Json;
assert.ok((dig(father, "lord_rows", "Venus", "conjunct_with") as string[]).includes("Rahu"));
// 原值: "malefic"(v1 公式:12 宫主一律判凶)
// 新值: "neutral"(profile bphs_ch34_general_with_sign_exceptions_v2:单管 12 宫的星是条件性的,不自动判凶)
// 原因: 产品 2026-10-02 采纳上游功能吉凶 v2(57782aea,BUG-1158);golden 由 capture 脚本重新生成
assert.equal(dig(father, "significator_rows", "Sun", "functional"), "neutral");
});
test("health takes the 1st and 12th houses, education the 4th", () => {
assert.deepEqual(EVIDENCE_CARD_SPECS.health.houses, [1, 6, 8, 12]);
assert.deepEqual(EVIDENCE_CARD_SPECS.education.houses, [4, 5, 9]);
const health = card(taylor.workflow, "health").domain_sections.health!;
assert.deepEqual(Object.keys(health.houses).sort((a, b) => Number(a) - Number(b)), ["1", "6", "8", "12"]);
const education = card(jobs.workflow, "education").domain_sections.education!;
assert.ok((dig(education.houses, "4", "occupants") as string[]).includes("Rahu"), "Jobs: Rahu in the 4th is now on the card");
});
test("the annual card copies transit Saturn's house from the natal Moon (Jobs: Saturn over the Pisces Moon)", () => {
for (const chart of golden.charts) {
const section = card(chart.annual_workflow, "annual").domain_sections.annual!;
assert.equal(
dig(section.slow_transits, "saturn_from_moon_house"),
dig(layer(chart.annual_workflow, "slow_transits"), "saturn_from_moon_house"),
chart.id,
);
}
const section = card(jobs.annual_workflow, "annual").domain_sections.annual!;
assert.equal(dig(section.slow_transits, "current", "Saturn", "sign"), "Pisces");
assert.equal(dig(enginePlanets(jobs.annual_workflow), "Moon", "sign"), "Pisces");
assert.equal(dig(section.slow_transits, "saturn_from_moon_house"), 1);
});
test("yogas are taken by domain tag; hits are the sieve's own matches", () => {
const tagged = (workflow: Json) => (layer(workflow, "yoga_domains").rows as Json[]);
// The annual route sieves every packet (20 rules), so every tag is present.
for (const chart of golden.charts) {
for (const domain of ["career", "wealth", "general", "parents", "children"] as const) {
const yogas = card(chart.annual_workflow, domain).domain_sections[domain]!.yogas as Json;
const wanted = EVIDENCE_CARD_SPECS[domain].yogaTags;
const own = tagged(chart.annual_workflow).filter((row) => wanted.includes(row.domain as string));
assert.deepEqual(yogas.names, own.map((row) => row.name), `${chart.id}/${domain} names`);
assert.deepEqual(yogas.hits, own.filter((row) => row.hit === true).map((row) => row.name), `${chart.id}/${domain} hits`);
}
}
const names = (domain: ConsultationDomain) => (
card(obama.annual_workflow, domain).domain_sections[domain]!.yogas as Json
).names as string[];
for (const domain of ["wealth", "general"] as const) {
assert.equal(names(domain).some((name) => /^Matru(nasa|\s|deer|S)/.test(name) || /Puthra/.test(name)), false, `Obama ${domain}`);
}
assert.ok(names("parents").includes("Matrunasa Yoga"));
assert.ok(names("parents").every((name) => name.startsWith("Matru")));
assert.ok(names("children").every((name) => name.includes("Puthra")) && names("children").length > 0);
assert.deepEqual(
(card(obama.annual_workflow, "parents").domain_sections.parents!.yogas as Json).hits,
["Matrunasa Yoga"],
);
});
test("missing engine layers are gaps, never filled in", () => {
const workflow = structuredClone(obama.workflow);
const layers = dig(workflow, "chart", "consultation_native_layers") as Json;
layers.graha_drishti = { status: "blocked", reason: "fixture" };
layers.yoga_domains = { status: "blocked", reason: "fixture" };
const built = card(workflow, "parents");
assert.ok(built.meta.gaps.includes("base.aspects"));
assert.ok(built.meta.gaps.includes("parents.yoga_domains"));
for (const row of Object.values(built.base.natal.planets as Record<string, Json>)) {
assert.equal("aspected_by" in row, false);
assert.equal("combust" in row, false);
}
for (const row of Object.values(built.domain_sections.parents!.houses)) {
assert.equal("aspected_by" in (row as Json), false);
}
});
test("budget compression keeps every value: antardashas as [lord, start, end], vargottama only when true", () => {
for (const chart of golden.charts) {
const built = card(chart.annual_workflow, "annual");
const boundaries = dig(chart.annual_workflow, "chart", "modules", "dasha_sub_periods", "boundaries") as Json[];
assert.deepEqual(
built.base.timing.vimshottari.antardashas_in_mahadasha,
boundaries.map((row) => [row.lord, row.start, row.end]),
chart.id,
);
const summary = layer(chart.annual_workflow, "d9_summary").planets as Record<string, Json>;
for (const [name, row] of Object.entries(built.base.natal.d9.planets as Record<string, Json>)) {
assert.equal(row.vargottama === true, summary[name]!.vargottama === true, `${chart.id} ${name}`);
assert.notEqual(row.vargottama, false, `${chart.id} ${name}: false is left out`);
}
}
});