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>
558 lines
26 KiB
TypeScript
558 lines
26 KiB
TypeScript
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 });
|
||
});
|