Files
Jyotisha/frontend/tests/consult-affliction-reading-20261001.test.ts
T

185 lines
11 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-affliction-reading-20261001 T1–T4 (BUG-1160~1163): every
// consultation turn opens with the shared "afflictions before dignities"
// reading once; every domain but timing carries a local consultation list;
// career / marriage / wealth gain an affliction line; health is named 健康 and
// asks about the constitution first. Checklists may only cite field names the
// evidence card really carries (hard red line 3), checked against the cards
// built from the real-engine golden fixture.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CONDENSED_GENDER_LINES,
CONDENSED_SHARED_READING_LINES,
CONDENSED_SHARED_READING_TITLE,
CONSULT_BRIEF_CHECKLIST_SOURCE,
CONSULTATION_CONDENSED_CHECKLISTS,
} from "../src/lib/consultation-condensed-checklist.ts";
import { consultationMethodologyForDomains } from "../src/lib/consultation-methodology.ts";
import {
consultationDomainDefinition,
consultationDomainIds,
type ConsultationDomain,
} from "../src/lib/consultation-domain-registry.ts";
import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.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 titles = (domains: ConsultationDomain[]) => consultationMethodologyForDomains(domains)!.sections.map((section) => section.title);
test("T1: the shared reading is the first section, once per turn, whatever the plan", () => {
// 原值: consultationDomainIds.filter((id) => !CONSULT_READING_BUDGET_BLOCKED.has(id))(年运、时运除外)
// 新值: 全部 12 个领域
// 原因: 产品 2026-10-02 决定年运 / 时运按事业 / 婚姻 / 财富同口径计字数(BUG-1160/1161 解除)
for (const domain of consultationDomainIds) {
const list = titles([domain]);
assert.equal(list[0], CONDENSED_SHARED_READING_TITLE, domain);
assert.equal(list.filter((title) => title === CONDENSED_SHARED_READING_TITLE).length, 1, domain);
}
for (const plan of [["parents", "children"], ["career", "health", "marriage"], ["annual", "parents"], ["timing", "career"]] as ConsultationDomain[][]) {
const list = titles(plan);
assert.equal(list[0], CONDENSED_SHARED_READING_TITLE, plan.join("+"));
assert.equal(list.filter((title) => title === CONDENSED_SHARED_READING_TITLE).length, 1, plan.join("+"));
}
const text = consultationMethodologyForDomains(["parents"])!.sections[0]!.text;
assert.ok(text.startsWith("1. 先看受冲,再看旺弱。"));
assert.match(text, /两个及以上受冲信号/);
assert.match(text, /逆行不等于「反复、打回来」/);
// The line added on top of the brief's six: only hits are yogas this chart has.
assert.match(text, /瑜伽只认 yogas\.hits/);
// 原值: 7 行
// 新值: 8 行(第 8 行:traditional_yogas 只说明力量,不单独下结论,不算受冲也不抵消受冲)
// 原因: TASK-consult-career-yoga-functional-20261002 T3 / D3 定稿,通用读法加一行(BUG-1175)
assert.equal(CONDENSED_SHARED_READING_LINES.length, 8);
});
// 原值: test "T1/T2 budget blocker is pinned: a single annual or timing turn keeps its pre-brief method"
// (年运单领域只有 Shared mandatory baseline、年运列在 domains_without_strict_checklist;时运无通用读法)
// 新值: 年运、时运单领域轮第一段是通用读法;年运带本产品中文清单,时运带英文路由
// 原因: 产品 2026-10-02 决定年运 / 时运字数口径同事业 / 婚姻 / 财富(契约 + 卡 ≤ 12,000,含清单 ≤ 18,000),阻塞解除
test("annual and timing single-domain turns get the shared reading, and annual its list (budget decided 2026-10-02)", () => {
assert.deepEqual(titles(["annual"]), [CONDENSED_SHARED_READING_TITLE, "Shared mandatory baseline", "年运 · consult-annual · 普通对话精简清单"]);
const annual = consultationMethodologyForDomains(["annual"])!;
assert.deepEqual(annual.domains_without_strict_checklist, []);
assert.equal(annual.sections[2]!.source, CONSULT_BRIEF_CHECKLIST_SOURCE);
for (const line of CONSULTATION_CONDENSED_CHECKLISTS.annual!) assert.ok(annual.sections[2]!.text.includes(line), line.slice(0, 20));
assert.deepEqual(titles(["timing"]), [CONDENSED_SHARED_READING_TITLE, "Shared mandatory baseline", "时运 · event-timing-strict"]);
});
// 原值: 标题「…(annual pending the budget)」,annual 期望 "",年运 / 时运跳过
// 新值: annual / timing 一并核对;annual 期望本产品清单标题
// 原因: 产品 2026-10-02 字数口径决定(BUG-1160/1161 解除)
test("T2: every domain section, in order, and no domain without a list", () => {
const expected: Record<ConsultationDomain, string> = {
career: "事业 · career-timing-strict · 普通对话精简清单",
marriage: "关系 · relationship-timing-strict · 普通对话精简清单",
wealth: "财富 · finance-timing-strict · 普通对话精简清单",
health: "健康 · consult-health · 普通对话精简清单",
education: "学习 · consult-education · 普通对话精简清单",
migration: "迁居 · consult-migration · 普通对话精简清单",
family: "家庭 · consult-family · 普通对话精简清单",
parents: "父母 · consult-parents · 普通对话精简清单",
children: "子女 · consult-children · 普通对话精简清单",
annual: "年运 · consult-annual · 普通对话精简清单",
timing: "时运 · event-timing-strict",
general: "综合 · consult-general · 普通对话精简清单",
};
for (const domain of consultationDomainIds) {
const methodology = consultationMethodologyForDomains([domain])!;
assert.deepEqual(methodology.sections.map((section) => section.title), [
CONDENSED_SHARED_READING_TITLE, "Shared mandatory baseline", expected[domain],
], domain);
assert.deepEqual(methodology.domains_without_strict_checklist, [], domain);
}
const mixed = consultationMethodologyForDomains(["parents", "children", "education", "migration", "family", "general"])!;
assert.deepEqual(mixed.domains_without_strict_checklist, []);
for (const domain of ["parents", "children", "education", "migration", "family", "general", "health"] as const) {
const section = consultationMethodologyForDomains([domain])!.sections[2]!;
assert.equal(section.source, CONSULT_BRIEF_CHECKLIST_SOURCE, domain);
for (const line of CONSULTATION_CONDENSED_CHECKLISTS[domain]!) assert.ok(section.text.includes(line), `${domain}: ${line.slice(0, 20)}`);
}
const parents = CONSULTATION_CONDENSED_CHECKLISTS.parents!.join("\n");
// 原值: assert.match(parents, /不得写成「有分量」「站得住」/)(父母第 2 行要求第一句写「可能不常在你身边」)
// 新值: 第 2 行按通用读法写范围、不断定在不在身边;「有分量」「站得住」的禁令移到通用读法(AFFLICTION_RANGE_RULE)
// 原因: 产品 2026-10-02 改口径(名人回测对照组误报 1→15;盘分不出父母在不在、几段感情、有没有病)
assert.match(parents, /盘分不出父母在不在身边,不替用户断定/);
assert.doesNotMatch(parents, /可能不常在你身边 \/ 很早就有距离/);
assert.match(parents, /照顾你长大的可能不止一位/);
assert.match(CONSULTATION_CONDENSED_CHECKLISTS.health!.join("\n"), /这不是医疗意见/);
assert.match(CONSULTATION_CONDENSED_CHECKLISTS.children!.join("\n"), /孩子性别不写/);
});
test("T3: career, marriage and wealth each carry their affliction line", () => {
assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.career!.some((line) => line.startsWith("AL 落第几宫要说出来")));
// 原值: 婚姻受冲行写「感情可能不止一段 / 可能有断续」
// 新值: 写「感情这条线上有波折的迹象」+ 范围,不断定有几段
// 原因: 产品 2026-10-02 改口径(名人回测对照组误报 1→15;盘分不出父母在不在、几段感情、有没有病)
assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.marriage!.some((line) => line.includes("感情这条线上有波折的迹象") && line.includes("不断定有几段") && line.includes("不预设用户现在有伴侣或单身")));
assert.ok(!CONSULTATION_CONDENSED_CHECKLISTS.marriage!.join("\n").includes("可能不止一段"));
assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.wealth!.some((line) => line.startsWith("8 宫也管版税、继承、意外之财")));
for (const domain of ["career", "marriage", "wealth"] as const) {
const lines = CONSULTATION_CONDENSED_CHECKLISTS[domain]!;
assert.ok(lines.length >= 5 && lines.length <= 8, domain);
}
});
function cardKeys(): Set<string> {
const keys = new Set<string>();
const walk = (value: unknown) => {
if (Array.isArray(value)) value.forEach(walk);
else if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
keys.add(key);
walk(child);
}
}
};
for (const chart of golden.charts) {
for (const domain of consultationDomainIds) {
const workflow = domain === "annual" ? chart.annual_workflow : domain === "timing" ? chart.timing_workflow : chart.workflow;
const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow)));
const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: domain }), domain);
walk(buildEvidenceCard([{ domain, packet, context }]));
}
}
return keys;
}
test("hard red line 3: every field name the lists cite is a key on a real card", () => {
const keys = cardKeys();
const texts = [
...CONDENSED_SHARED_READING_LINES,
...Object.values(CONSULTATION_CONDENSED_CHECKLISTS).flat(),
...Object.values(CONDENSED_GENDER_LINES),
].join("\n");
// Lower-case identifiers in these Chinese texts are field names; "blocked" is the
// only prose word (KP 精确宫头仍 blocked). Static text scan, not intent detection.
const prose = new Set(["blocked"]);
const cited = new Set([...texts.matchAll(/(?<![A-Za-z0-9_])[a-z][a-z0-9_]*/g)].map((match) => match[0]).filter((word) => !prose.has(word)));
for (const required of ["conjunct_with", "aspected_by", "combust", "lord_rows", "significator_rows", "saturn_from_moon_house", "hits", "functional"]) {
assert.ok(cited.has(required), `the lists cite ${required}`);
}
for (const word of cited) assert.ok(keys.has(word), `cited field ${word} is on a card`);
});
test("T4: health is 健康 and asks about the constitution first; its boundary and cap are unchanged", () => {
const health = consultationDomainDefinition("health");
assert.equal(health.label, "健康");
assert.equal(health.prompt, "我的身体底子怎样,哪些方面要多留意?");
assert.ok(health.aliases.includes("身心压力"));
assert.equal(health.claimBoundary, "wellbeing_pressure_patterns_not_medical_diagnosis");
assert.equal(health.confidenceCap, "low");
});