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 { SynastryGoNotes, SynastryPicker } from "../src/components/synastry/synastry-picker.tsx"; import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx"; import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { stripLeadingSectionTitle, 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("a section that opens with its title is saved without the duplicate", async () => { const calls: SynastrySectionId[] = []; let checks = 0; const clean = prose(); const result = await writeSynastryLanguage({ language: "zh", names: { a: "甲", b: "乙" }, dossier: {}, 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 }; } const titled = sectionId === "communication" && calls.filter((id) => id === "communication").length === 1; return { body: titled ? { ...clean, text: `沟通与理解 沟通与理解 ${clean.text}` } : clean, inputTokens: 1, outputTokens: 1 }; }, gate: async () => { checks += 1; return { ok: true, failures: [] }; }, }); assert.equal(checks, 2); assert.equal(calls.filter((id) => id === "communication").length, 2); const saved = result.snapshotParts.sections.find((section) => section.id === "communication"); assert.equal(saved?.text, clean.text); assert.equal(saved?.text.startsWith("沟通与理解"), false); assert.equal(stripLeadingSectionTitle("沟通与理解 沟通与理解 沟通的天气很顺。", "沟通与理解"), "沟通的天气很顺。"); assert.equal(stripLeadingSectionTitle("Communication and Understanding The two of you talk.", "Communication and Understanding"), "The two of you talk."); assert.equal(stripLeadingSectionTitle("沟通与理解力还在。", "沟通与理解"), "沟通与理解力还在。"); }); 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, //); 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, /合盘服务暂时连不上,请稍后再试/); // 原值:账户在开关结果之后才去读(setAccess("closed") 早于 fetchAccount()) // 新值:账户与开关同时去读,但开关判定仍在用到账户之前,关着时照旧不画选人区 // 原因:issue #127 合盘页几条互不依赖的请求同一轮发出 assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("unwrap(await accountRead)")); const pending = renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SynastryPicker))); // 原值:开关未返回时静态 HTML 有副标题「看这段感情往后的走向,和两人之间的助力与考验。」 // 新值:这段副标题不在页面上;未返回时仍不出现「开始合盘」和「第一个人」 // 原因:K4 去掉页内重复标题和副标题,顶栏已经有「婚恋合盘」(BUG-1403 的不闪选人区还在) assert.doesNotMatch(pending, /看这段感情往后的走向/); assert.doesNotMatch(pending, /开始合盘|第一个人/); assert.doesNotMatch(picker, /看这段感情往后的走向/); // 原值:两个选择框各自写 aria-label="第一个人" / aria-label="第二个人" // 新值:调用处是 label="第一个人" 和 label="第二个人",触发器 aria-label={props.label} // 原因:两个 Select 共用一个选人框,可访问名称仍是这两个词 assert.match(picker, /label="第一个人"/); assert.match(picker, /label="第二个人"/); assert.match(picker, /aria-label=\{props\.label\}/); assert.match(picker, /HeartHandshake/); assert.doesNotMatch(picker, /Sparkles/); assert.match(picker, /开始合盘/); assert.match(picker, /新增星盘/); assert.doesNotMatch(picker, /+/); // 原值:按钮下方写「生成一份完整合盘报告,用掉 N 点。」 // 新值:按钮上不写价格;确认句是「会用掉 N 点,余额 M 点。」,不够时是「需要 N 点,余额只有 M 点。」 // 原因:K4 点开始先确认扣点,价格从计价表读,余额从账户读 assert.match(picker, /会用掉 \$\{creditCost\} 点,余额 \$\{balance\} 点。/); assert.match(picker, /需要 \$\{creditCost\} 点,余额只有 \$\{balance\} 点。/); assert.match(picker, /去补点数/); assert.match(picker, /accountDialogHref\("billing"\)/); const again = renderToStaticMarkup(createElement(SynastryGoNotes, { creditCost: 19, pricingNote: "", readyCount: 2, generating: false, })); assert.match(again, /这两人已经合过 2 次,再生成会另扣 19 点/); assert.doesNotMatch(again, /生成一份完整合盘报告/); const quiet = renderToStaticMarkup(createElement(SynastryGoNotes, { creditCost: 19, pricingNote: "", readyCount: 2, generating: true, })); assert.doesNotMatch(quiet, /再生成会另扣/); assert.match(picker, /pair: "1"/); 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: [] }), }, })); // 原值:`
` // 新值:`
` // 原因:分析逻辑仍默认收起,类名用来套原型的收起样式 assert.match(html, /
/); assert.equal(html.includes("
]*>关系基调<\/button>/); assert.match(html, /]*>关系时间线<\/button>/); assert.doesNotMatch(html, /]*>共同练习<\/button>/); assert.match(html, /所以:靠近时先确认对方听完了。/); assert.match(html, /外部资料与真实案例核对:未进行/); assert.match(html, /甲 × 乙 · 婚恋合盘/); // 原值:返回链接文字是「‹ 婚恋合盘」 // 新值:链接仍指向 /synastry,文字是「婚恋合盘」,箭头用 ChevronLeft // 原因:K4 去掉手写返回箭头。标题里的 × 还在,第一次打开时两个头像靠拢仍按这个标题拆 assert.match(html, /href="\/synastry"/); assert.match(readFileSync(new URL("../src/components/synastry/synastry-report-view.tsx", import.meta.url), "utf8"), /ChevronLeft/); 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(/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("
{ 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) { if (Array.isArray(value)) { value.forEach((item) => markers(item, into)); return; } if (!value || typeof value !== "object") return; const row = value as Record; 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): 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; 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; const source = new Set(); 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(); 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>; pair_execution_tasks?: Array>; }; const statusText: Record = { 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>; 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 }); }); test("the synastry report scrolls in a full-width outer container", () => { const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8"); const scroll = css.slice(css.indexOf(".synastry-report-scroll {"), css.indexOf(".synastry-report {")); const column = css.slice(css.indexOf(".synastry-report {"), css.indexOf(".synastry-report-back")); assert.match(scroll, /width:\s*100%/); assert.match(scroll, /height:\s*100%/); assert.match(scroll, /overflow-y:\s*auto/); assert.match(scroll, /overflow-x:\s*hidden/); assert.match(column, /width:\s*min\(720px, 100%\)/); assert.match(column, /margin-inline:\s*auto/); assert.doesNotMatch(column, /height:\s*100%/); assert.doesNotMatch(column, /overflow:\s*auto/); assert.match(page, /
\s* css.indexOf("@media print")); assert.match(css.slice(printAt, printAt + 180), /overflow: visible !important/); });