Files
Jyotisha/frontend/tests/synastry-full-report.test.ts
T
Jesse_ChenandClaude Opus 5.5 06d0c7dc37 Cold /chart, /people, /synastry read in one round and stop re-reading the shell's data (issue #127, BUG-1478)
- The HTML early-read script also runs on /chart, /people and /synastry:
  account, people list and the current person's sessions, plus on /chart the
  remembered person's chart-view (bound to that person, page deadline kept).
  The model list stays home-only.
- Account and people-list early reads hand one clone to the shell and one to
  the page, once each, in either order; later reloads go to the network.
- Synastry sends product-access, account, people and history together; access
  is still decided before anything about people paints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:11:03 +08:00

659 lines
32 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 { 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, /<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, /合盘服务暂时连不上,请稍后再试/);
// 原值:账户在开关结果之后才去读(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: [] }),
},
}));
// 原值:`<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, /甲 × 乙 · 婚恋合盘/);
// 原值:返回链接文字是「‹ 婚恋合盘」
// 新值:链接仍指向 /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(/<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 });
});
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, /<div className="synastry-report-scroll">\s*<SynastryReportView /);
const printAt = css.indexOf(".synastry-report-scroll { width: auto !important;");
assert.ok(printAt > css.indexOf("@media print"));
assert.match(css.slice(printAt, printAt + 180), /overflow: visible !important/);
});