Files
Jyotisha/frontend/tests/synastry-full-report.test.ts
T
jesse-uxandCursor 6a761bc51e fix(synastry): show timeline spans and keep old chats on the default model
English reports hid the analysis fold, timeline titles left out the month span, and deleting a pinned model stopped the next consultation turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:28:04 +08:00

558 lines
26 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.
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import test from "node:test";
import { readFileSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx";
import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
import { EVIDENCE_LABELS, PRIVATE_KEY_NAMES, sectionEvidence, SYNASTRY_LENGTH, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts";
import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
import { buildTimelineSegments } from "../src/lib/synastry-full-timeline.ts";
import { timelineHeading } from "../src/lib/synastry-timeline-label.ts";
const sections = ["climate", "emotion", "communication", "safety", "conflict", "daily", "direction"] as const;
function prose() {
return {
text: "你们之间更需要把靠近说清楚。",
reasons: [
{ plain: "靠近时先确认对方听完了。", basis: "证据甲", evidence: "证据甲", label: "靠近" },
{ plain: "忙的时候先说什么时候能接着谈。", basis: "证据乙", evidence: "证据乙", label: "节奏" },
],
};
}
test("timeline windows stay between three and five and keep both people in the basis", () => {
const segments = buildTimelineSegments({
nameA: "甲",
nameB: "乙",
from: "2026-01-01",
periodsA: [
{ level: "md", lord: "jupiter", lordCn: "木星", start: "2024-01-01", end: "2028-06-01" },
{ level: "ad", lord: "saturn", lordCn: "土星", start: "2026-01-01", end: "2026-08-01" },
],
periodsB: [
{ level: "md", lord: "venus", lordCn: "金星", start: "2025-01-01", end: "2030-01-01" },
],
});
assert.ok(segments.length >= 3 && segments.length <= 5);
assert.match(segments[0]?.basis ?? "", /甲/);
assert.match(segments[0]?.basis ?? "", /乙/);
});
test("english prompt does not carry the chinese section body", () => {
const chineseBody = "这段关系的核心不是谁对谁错,而是你们怎么把话接住。";
const prompt = userPrompt({
language: "en",
names: { a: "A", b: "B" },
sectionId: "climate",
evidence: { reader_section_brief: "pair climate" },
});
assert.equal(prompt.includes(chineseBody), false);
assert.match(prompt, /^Names: /);
});
test("a failed gate rewrites only the failed section once", async () => {
const calls: SynastrySectionId[] = [];
let checks = 0;
const result = await writeSynastryLanguage({
language: "zh",
names: { a: "甲", b: "乙" },
dossier: { execution_receipt: { individual_technique_audit: [{ technique: "Niryaana Shoola", status: "not_applicable" }] } },
segments: [
{ id: "s1", start: "2026-01", end: "2027-01", basis: "甲:木星大运 2026-01 至 2027-01。乙:金星大运 2026-01 至 2027-01。" },
{ id: "s2", start: "2027-01", end: "2028-01", basis: "甲:木星大运 2027-01 至 2028-01。乙:金星大运 2027-01 至 2028-01。" },
{ id: "s3", start: "2028-01", end: "2029-01", basis: "甲:木星大运 2028-01 至 2029-01。乙:金星大运 2028-01 至 2029-01。" },
],
signal: new AbortController().signal,
timeoutMs: 1000,
write: async ({ sectionId }) => {
calls.push(sectionId);
if (sectionId === "timeline") {
return { body: { segments: [{ id: "s1", phase: "靠近", text: "这段时间先把日常节奏说清楚。" }, { id: "s2", phase: "磨合", text: "这段时间把分歧说完再放下。" }, { id: "s3", phase: "协作", text: "这段时间一起把安排写下来。" }] }, inputTokens: 1, outputTokens: 1 };
}
if (sectionId === "practices") {
return { body: { items: [{ text: "每周留一次不被打断的谈话。", evidence: "证据甲" }] }, inputTokens: 1, outputTokens: 1 };
}
return { body: prose(), inputTokens: 1, outputTokens: 1 };
},
gate: async () => {
checks += 1;
return checks === 1
? { ok: false, failures: [{ section: "climate", code: "term_ban" }] }
: { ok: true, failures: [] };
},
});
assert.equal(checks, 2);
assert.equal(calls.filter((id) => id === "climate").length, 2);
for (const id of sections) {
if (id === "climate") continue;
assert.equal(calls.filter((item) => item === id).length, 1);
}
assert.equal(result.snapshotParts.technical.length, 6);
assert.match(result.snapshotParts.technical[5]?.lines.at(-1) ?? "", /外部资料与真实案例核对:未进行/);
});
test("technical appendix has six program blocks and the fixed closing line", () => {
const blocks = buildTechnicalBasis({
dossier: { execution_receipt: { pair_execution_tasks: [{ task: "Kuja Dosha", status: "not_applicable" }] } },
names: { a: "甲", b: "乙" },
contacts: ["第七宫与金星有相位"],
timing: ["甲:木星大运 2026-01 至 2028-01"],
});
assert.deepEqual(blocks.map((block) => block.id), ["parameters", "structures", "contacts", "timing", "traditional", "execution"]);
assert.equal(blocks[5]?.lines.at(-1), "外部资料与真实案例核对:未进行,本报告是计算层归纳");
assert.equal(JSON.stringify(blocks).includes("1879"), false);
});
test("the synastry page is a static shell and reads the flag in the browser", () => {
const page = readFileSync(new URL("../src/app/(app)/synastry/page.tsx", import.meta.url), "utf8");
const picker = readFileSync(new URL("../src/components/synastry/synastry-picker.tsx", import.meta.url), "utf8");
assert.doesNotMatch(page, /isProductEnabled|合盘服务暂未开放|force-dynamic/);
assert.match(page, /<SynastryPicker \/>/);
assert.match(picker, /\/api\/product-access/);
// 原值:`[serviceClosed, setServiceClosed] = useState(false)`,首屏就画出选人区
// 新值:access 从 pending 开始,开关结果回来前只有标题和副标题
// 原因:BUG-1403。开关未返回时不再闪一下选人页;读失败改「合盘服务暂时连不上,请稍后再试」
assert.match(picker, /useState<"pending" \| "open" \| "closed" \| "unreachable">\("pending"\)/);
assert.match(picker, /compatibility === false/);
assert.match(picker, /合盘服务暂未开放/);
assert.match(picker, /合盘服务暂时连不上,请稍后再试/);
assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("fetchAccount()"));
const pending = renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SynastryPicker)));
assert.match(pending, /看这段感情往后的走向,和两人之间的助力与考验。/);
assert.doesNotMatch(pending, /开始合盘|第一个人/);
assert.match(picker, /看这段感情往后的走向,和两人之间的助力与考验。/);
assert.match(picker, /开始合盘/);
assert.match(picker, /+ 新增星盘/);
assert.match(picker, /生成一份完整合盘报告,用掉/);
assert.doesNotMatch(picker, /男方|女方|合不合|会不会在一起/);
const dialog = readFileSync(new URL("../src/components/synastry/synastry-add-dialog.tsx", import.meta.url), "utf8");
const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
assert.match(dialog, /称呼/);
assert.match(dialog, /出生日期/);
assert.match(dialog, /出生时间/);
assert.match(dialog, /出生地/);
assert.match(dialog, /保存并选中/);
assert.match(wait, /算两张完整命盘/);
assert.match(wait, /整理两人之间的证据/);
assert.match(wait, /正在写英文版…/);
assert.match(wait, /大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。/);
});
test("old synastry links open the pair page instead of a chat draft", () => {
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
const deepLink = readFileSync(new URL("../src/components/people-home-links.tsx", import.meta.url), "utf8");
assert.match(people, /\/synastry\?with=\$\{selected\.id\}/);
assert.doesNotMatch(people, /\?synastry=\$\{selected\.id\}/);
assert.match(deepLink, /location\.replace\(`\/synastry\?with=/);
assert.doesNotMatch(deepLink, /draftSynastryQuestionFromChart|relationship: "other"/);
});
test("report view keeps analysis folded and offers a section ask entry", () => {
const html = renderToStaticMarkup(createElement(SynastryReportView, {
reportId: "11111111-1111-4111-8111-111111111111",
snapshot: {
names: { a: "甲", b: "乙" },
sections: sections.map((id) => ({ id, title: id, text: "你们之间先把话说完。", reasons: prose().reasons })),
timeline: [
{ id: "s1", phase: "靠近", text: "先把日常说清楚。", basis: "甲:木星大运。" },
],
practices: [{ text: "每周谈一次。", evidence: "证据甲" }],
technical: buildTechnicalBasis({ dossier: {}, names: { a: "甲", b: "乙" }, contacts: [], timing: [] }),
},
}));
// 原值:`<details>`
// 新值:`<details class="synastry-why">`
// 原因:分析逻辑仍默认收起,类名用来套原型的收起样式
assert.match(html, /<details class="synastry-why">/);
assert.equal(html.includes("<details open"), false);
// 原值:html 不含「对哪一段想再问清楚」
// 新值:报告末尾有这一句,八节可点,共同练习仍只是标题
// 原因:#83 打开合盘追问入口;共同练习不进入对话
assert.equal(html.includes("对哪一段想再问清楚"), true);
assert.match(html, /<button[^>]*>关系基调<\/button>/);
assert.match(html, /<button[^>]*>关系时间线<\/button>/);
assert.doesNotMatch(html, /<button[^>]*>共同练习<\/button>/);
assert.match(html, /所以:靠近时先确认对方听完了。/);
assert.match(html, /外部资料与真实案例核对:未进行/);
assert.match(html, /· 婚恋合盘/);
assert.match(html, /‹ 婚恋合盘/);
assert.match(html, /已保存,再次打开不扣点/);
assert.match(html, /可以一起聊聊/);
});
const celebrityPeriods = {
nameA: "Albert",
nameB: "Mileva",
from: "1905-06-01",
periodsA: [
{ level: "md" as const, lord: "Saturn", lordCn: "土星", start: "1903-01-01", end: "1922-01-01" },
{ level: "ad" as const, lord: "Venus", lordCn: "金星", start: "1905-03-01", end: "1907-11-01" },
],
periodsB: [
{ level: "md" as const, lord: "Jupiter", lordCn: "木星", start: "1900-01-01", end: "1916-01-01" },
{ level: "ad" as const, lord: "Moon", lordCn: "月亮", start: "1905-01-01", end: "1906-06-01" },
],
};
test("timeline headings show the month span, and the first segment says now when it starts this month", () => {
const today = new Date(2026, 9, 21);
assert.equal(timelineHeading({
start: "2027-09",
end: "2029-03",
phase: "阶段名",
language: "zh",
firstSegment: true,
today,
}), "2027 年 9 月 – 2029 年 3 月 阶段名");
assert.equal(timelineHeading({
start: "2027-09",
end: "2029-03",
phase: "Settling",
language: "en",
firstSegment: true,
today,
}), "Sep 2027 – Mar 2029 Settling");
assert.equal(timelineHeading({
start: "2026-10",
end: "2029-03",
phase: "阶段名",
language: "zh",
firstSegment: true,
today,
}), "现在 – 2029 年 3 月 阶段名");
assert.equal(timelineHeading({
start: "2026-10",
end: "2029-03",
phase: "Settling",
language: "en",
firstSegment: true,
today,
}), "Now – Mar 2029 Settling");
assert.equal(timelineHeading({
start: "2026-10",
end: "2029-03",
phase: "阶段名",
language: "zh",
firstSegment: false,
today,
}), "2026 年 10 月 – 2029 年 3 月 阶段名");
const segments = buildTimelineSegments(celebrityPeriods);
const clock = new Date(1905, 5, 15);
const html = renderToStaticMarkup(createElement(SynastryReportView, {
reportId: "11111111-1111-4111-8111-111111111111",
clock,
snapshot: {
names: { a: "Albert", b: "Mileva" },
sections: sections.map((id) => ({ id, title: id, text: "你们之间先把话说完。", reasons: prose().reasons })),
timeline: segments.map((segment, index) => ({
...segment,
phase: index === 0 ? "靠近" : "磨合",
text: "这段时间先把日常说清楚。",
})),
practices: [{ text: "每周谈一次。", evidence: "证据甲" }],
technical: buildTechnicalBasis({ dossier: {}, names: { a: "Albert", b: "Mileva" }, contacts: [], timing: [] }),
},
}));
assert.match(html, /现在 – 1906 年 6 月 靠近/);
assert.match(html, /Albert × Mileva/);
assert.equal(html.includes("1905 年 6 月 –"), false);
});
test("english report keeps an analysis fold on every prose section and on the timeline", () => {
const segments = buildTimelineSegments({ ...celebrityPeriods, language: "en" });
const englishSections = sections.map((id) => ({
id,
title: id,
text: "Say the next step out loud.",
reasons: [{ plain: "Confirm the other person finished listening.", basis: "Venus contact" }],
}));
const html = renderToStaticMarkup(createElement(SynastryReportView, {
reportId: "11111111-1111-4111-8111-111111111111",
initialLanguage: "en",
clock: new Date(1905, 5, 15),
snapshot: {
names: { a: "Albert", b: "Mileva" },
sections: englishSections,
timeline: segments.map((segment) => ({ ...segment, phase: "Closer", text: "Name the daily rhythm." })),
practices: [{ text: "Talk once a week.", evidence: "evidence" }],
english: {
sections: englishSections,
timeline: segments.map((segment) => ({ ...segment, phase: "Closer", text: "Name the daily rhythm." })),
practices: [{ text: "Talk once a week.", evidence: "evidence" }],
},
},
}));
assert.equal(html.match(/<summary>Analysis<\/summary>/g)?.length, englishSections.length + segments.length);
assert.match(html, /Chart: Venus contact/);
assert.match(html, /So: Confirm the other person finished listening\./);
assert.match(html, /Now – Jun 1906 Closer/);
assert.equal(html.includes("<details open"), false);
assert.equal(html.includes("分析逻辑"), false);
});
test("section evidence follows reader briefs and keeps relationship_axis", () => {
const dossier = {
relationship_axis: { lane: "closeness" },
reader_section_brief: [
{
section: "emotional_safety",
pair_lens: { lane: "emotional_safety", status: "ready_for_comparison" },
d1_recipient_evidence: { person_a: [{ reader_topics: ["emotional_safety_and_lived_experience"], note: "靠近需要说出来" }] },
relationship_axis: { label: "kept" },
update_window: { label: "not a birth date" },
},
],
relationship_lenses: [{ lane: "emotional_safety", status: "ready_for_comparison" }],
pair_interaction_evidence: {
recipient_experience_evidence: {
person_a: [{ reader_topics: ["emotional_safety_and_lived_experience"], note: "靠近需要说出来" }],
},
},
};
const evidence = sectionEvidence(dossier, "emotion") as { reader_section_brief?: unknown[] };
assert.ok(Array.isArray(evidence.reader_section_brief) && evidence.reader_section_brief.length > 0);
const encoded = JSON.stringify(evidence);
assert.match(encoded, /relationship_axis/);
assert.match(encoded, /update_window/);
assert.equal(encoded.includes("\"year\""), false);
});
test("english timeline input has no CJK", () => {
const segments = buildTimelineSegments({
language: "en",
nameA: "Albert",
nameB: "Steve",
from: "2026-01-01",
periodsA: [{ level: "md", lord: "jupiter", lordCn: "木星", start: "2024-01-01", end: "2030-01-01" }],
periodsB: [{ level: "md", lord: "venus", lordCn: "金星", start: "2025-01-01", end: "2031-01-01" }],
});
const evidence = sectionEvidence({ reader_section_brief: [{ section: "relationship_weather", note: "中文" }] }, "timeline", segments, "en");
assert.doesNotMatch(JSON.stringify(evidence), /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/);
assert.match(JSON.stringify(evidence), /mahadasha/);
});
test("a thrown section write retries only that section", async () => {
const calls: SynastrySectionId[] = [];
await writeSynastryLanguage({
language: "zh",
names: { a: "甲", b: "乙" },
dossier: {},
segments: [
{ id: "s1", start: "2026-01", end: "2027-01", basis: "甲。" },
{ id: "s2", start: "2027-01", end: "2028-01", basis: "甲。" },
{ id: "s3", start: "2028-01", end: "2029-01", basis: "甲。" },
],
signal: new AbortController().signal,
timeoutMs: 1000,
write: async ({ sectionId }) => {
calls.push(sectionId);
if (sectionId === "emotion" && calls.filter((id) => id === "emotion").length === 1) throw new Error("timeout");
if (sectionId === "timeline") {
return { body: { segments: [{ id: "s1", phase: "靠近", text: "先把节奏说清楚。" }, { id: "s2", phase: "磨合", text: "把分歧说完。" }, { id: "s3", phase: "协作", text: "一起写下来。" }] }, inputTokens: 1, outputTokens: 1 };
}
if (sectionId === "practices") return { body: { items: [{ text: "每周谈一次。", evidence: "证据甲" }] }, inputTokens: 1, outputTokens: 1 };
return { body: prose(), inputTokens: 1, outputTokens: 1 };
},
gate: async () => ({ ok: true, failures: [] }),
});
assert.equal(calls.filter((id) => id === "emotion").length, 2);
for (const id of SYNASTRY_SECTION_IDS) {
if (id === "emotion") continue;
assert.equal(calls.filter((item) => item === id).length, 1);
}
});
test("prompts carry the gate labels and the relationship example", () => {
const prompt = systemPrompt("zh");
for (const label of EVIDENCE_LABELS) assert.match(prompt, new RegExp(label));
assert.match(prompt, new RegExp(String(SYNASTRY_LENGTH.zhSection)));
assert.match(prompt, new RegExp(String(SYNASTRY_LENGTH.zhTotal)));
assert.match(prompt, /先给天气/);
assert.match(prompt, /六个维度都用关系语言/);
assert.match(prompt, /不要把一方说成问题源/);
assert.match(prompt, /你们这段关系的底色是/);
assert.match(systemPrompt("en"), /Open with the weather/);
assert.match(systemPrompt("en"), new RegExp(String(SYNASTRY_LENGTH.enSection)));
assert.match(systemPrompt("en"), new RegExp(String(SYNASTRY_LENGTH.enTotal)));
const gate = readFileSync(new URL("../../scripts/marriage_match_service/synastry_full_reader_gate.py", import.meta.url), "utf8");
for (const label of EVIDENCE_LABELS) assert.match(gate, new RegExp(`"${label}"`));
assert.match(gate, new RegExp(`ZH_SECTION_MINIMUM = ${SYNASTRY_LENGTH.zhSection}`));
assert.match(gate, new RegExp(`ZH_TOTAL_MINIMUM = ${SYNASTRY_LENGTH.zhTotal}`));
assert.match(gate, new RegExp(`EN_SECTION_MINIMUM = ${SYNASTRY_LENGTH.enSection}`));
assert.match(gate, new RegExp(`EN_TOTAL_MINIMUM = ${SYNASTRY_LENGTH.enTotal}`));
});
test("technical execution lines follow the receipt and raman is named", () => {
const blocks = buildTechnicalBasis({
dossier: {
execution_receipt: {
individual_technique_audit: [{ technique: "Niryaana Shoola", status: "not_applicable" }],
pair_execution_tasks: [{ task: "DK cross-comparison", status: "ready_for_comparison" }],
},
chart_snapshots: { person_a: { darakaraka: "Venus" }, person_b: {} },
},
raman: { rule_profile: { id: "raman_muhurtha_v1" } },
names: { a: "甲", b: "乙" },
contacts: [],
timing: [],
});
assert.deepEqual(blocks[5]?.lines.slice(0, 2), ["Niryaana Shoola:不适用", "DK cross-comparison:已执行"]);
assert.match(blocks[1]?.lines[0] ?? "", /DK Venus/);
assert.match(blocks[4]?.lines.join("\n") ?? "", /raman_muhurtha_v1/);
assert.equal(JSON.stringify(blocks).includes("无法计算"), false);
});
function pythonBin(): string | null {
const candidates = [process.env.PYTHON, "python3", "python"].filter((item): item is string => Boolean(item));
for (const candidate of candidates) {
const probe = spawnSync(candidate, ["-c", "import sys"], { encoding: "utf8" });
if (probe.status === 0) return candidate;
}
return null;
}
function markers(value: unknown, into: Set<string>) {
if (Array.isArray(value)) {
value.forEach((item) => markers(item, into));
return;
}
if (!value || typeof value !== "object") return;
const row = value as Record<string, unknown>;
if (typeof row.id === "string" && row.id) into.add(`id:${row.id}`);
if (typeof row.lane === "string" && row.lane) into.add(`lane:${row.lane}`);
for (const item of Object.values(row)) markers(item, into);
}
function citesSource(evidence: unknown, source: ReadonlySet<string>): boolean {
let found = false;
const walk = (value: unknown) => {
if (found || !value || typeof value !== "object") return;
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
const row = value as Record<string, unknown>;
if (typeof row.id === "string" && source.has(`id:${row.id}`)) found = true;
if (typeof row.lane === "string" && source.has(`lane:${row.lane}`)) found = true;
if (!found) Object.values(row).forEach(walk);
};
walk(evidence);
return found;
}
test("a public pair dossier fills every section without birth keys", { timeout: 180_000 }, (t) => {
const python = pythonBin();
if (!python) {
console.log("skip: python_missing; set PYTHON or provide python3/python");
t.skip("python_missing");
return;
}
const root = fileURLToPath(new URL("../..", import.meta.url));
const output = fileURLToPath(new URL("./synastry-full-dossier-sections.json", import.meta.url));
execFileSync(python, ["-c", [
"import json, sys",
"from pathlib import Path",
"root = Path(sys.argv[1])",
"output = Path(sys.argv[2])",
"sys.path[:0] = [str(root / 'scripts'), str(root)]",
"from tests.test_synastry_full_dossier import EINSTEIN, JOBS",
"from jyotish_api_server import JyotishComputeService",
"from marriage_match_service.full_dossier import build_synastry_full_dossier",
"packet = build_synastry_full_dossier(JyotishComputeService(), EINSTEIN, JOBS)",
"dossier = packet['dossier']",
"kept = {key: dossier.get(key) for key in ('reader_section_brief','relationship_lenses','pair_interaction_evidence','execution_receipt','marriage_match','chart_snapshots','local_structures')}",
"output.write_text(json.dumps(kept), encoding='utf-8')",
].join("\n"), root, output], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
const dossier = JSON.parse(readFileSync(output, "utf8")) as Record<string, unknown>;
const source = new Set<string>();
markers(dossier.relationship_lenses, source);
markers(dossier.pair_interaction_evidence, source);
assert.ok(source.size > 0);
const segments = [
{ id: "s1", start: "2026-01", end: "2027-01", basis: "Albert: jupiter mahadasha 2026-01 to 2027-01" },
{ id: "s2", start: "2027-01", end: "2028-01", basis: "Albert: jupiter mahadasha 2027-01 to 2028-01" },
{ id: "s3", start: "2028-01", end: "2029-01", basis: "Albert: jupiter mahadasha 2028-01 to 2029-01" },
];
for (const id of SYNASTRY_SECTION_IDS) {
const evidence = sectionEvidence(dossier, id, segments);
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, item] of Object.entries(value)) {
keys.add(key);
walk(item);
}
}
};
walk(evidence);
assert.equal([...keys].some((key) => PRIVATE_KEY_NAMES.has(key)), false, id);
assert.equal(citesSource(evidence, source), true, id);
}
const blocks = buildTechnicalBasis({
dossier,
names: { a: "甲", b: "乙" },
contacts: [],
timing: [],
});
const receipt = dossier.execution_receipt as {
individual_technique_audit?: Array<Record<string, unknown>>;
pair_execution_tasks?: Array<Record<string, unknown>>;
};
const statusText: Record<string, string> = {
executed: "已执行",
ready_for_comparison: "已执行",
present: "已执行",
used: "已执行",
available: "已执行",
passed: "已执行",
parameter_sensitive: "参数敏感",
partial: "参数敏感",
weak: "参数敏感",
observation_only: "参数敏感",
not_applicable: "不适用",
not_used: "不适用",
not_used_for_date_intersection: "不适用",
};
const receiptRows = [...(receipt.individual_technique_audit ?? []), ...(receipt.pair_execution_tasks ?? [])];
const executionLines = blocks[5]?.lines ?? [];
let namedRows = 0;
for (const row of receiptRows) {
const name = String(row.technique || row.task || row.label || row.lane || "").trim();
const status = String(row.status || "").trim();
if (!name || !status) continue;
const shown = statusText[status.toLowerCase()] ?? status;
assert.equal(executionLines.some((line) => line.includes(name) && line.includes(shown)), true, `${name} ${shown}`);
namedRows += 1;
}
assert.ok(namedRows > 0);
assert.equal(executionLines.at(-1), "外部资料与真实案例核对:未进行,本报告是计算层归纳");
const structures = dossier.local_structures as Record<string, Record<string, unknown>>;
for (const person of ["person_a", "person_b"]) {
const local = structures[person] ?? {};
const d9 = local.d9 as { ascendant?: unknown } | undefined;
for (const [key, value] of [
["seventh_lord", local.seventh_lord],
["darakaraka", local.darakaraka],
["upapada", local.upapada],
["A7", local.A7],
["d9", d9?.ascendant],
] as const) {
assert.equal(typeof value, "string", `${person} ${key}`);
assert.ok(String(value).length > 0, `${person} ${key}`);
}
}
for (const line of blocks[1]?.lines ?? []) {
assert.equal(/7宫主 未标明|DK 未标明|UL 未标明|A7 未标明|D9 未标明/.test(line), false, line);
}
rmSync(output, { force: true });
});