Files
Jyotisha/frontend/tests/synastry-report-chat.test.ts
T
jesse-uxandCursor 549e37b995 fix(synastry): check the dasha sentences the report actually writes
A shifted antardasha or English mahadasha was passing because the checker only recognized 大运 and 子运.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 07:36:32 +08:00

328 lines
12 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 { readFileSync } from "node:fs";
import test from "node:test";
import { chatSessionCreateSchema } from "../src/lib/chat-session-write-contract.ts";
import {
chatProfileDisplayName,
chatSessionSubjectLocked,
sessionMatchesSubject,
} from "../src/lib/chat-profile-picker-model.ts";
import { SYNASTRY_CHAT_INSTRUCTIONS } from "../src/mastra/synastry-report-chat.ts";
import {
SYNASTRY_FIXED_REPLY,
auditSynastryAnswer,
settleSynastryDrafts,
} from "../src/lib/synastry-chat-fact-check.ts";
import {
SYNASTRY_CHAT_CHAR_BUDGET,
boundSynastryRead,
synastryChatContextChars,
synastrySectionMaterial,
} from "../src/lib/synastry-report-material.ts";
import { beginSynastrySectionChat } from "../src/lib/synastry-report-chat-client.ts";
import { authoritativeSynastrySession, synastrySectionQuestion } from "../src/lib/synastry-report-session.ts";
import { runSynastryReportTurn, synastryMissingCoverage } from "../src/lib/synastry-report-turn.ts";
import { buildTimelineSegments, type TimingPeriod } from "../src/lib/synastry-full-timeline.ts";
import { isListedSidebarSession } from "../src/lib/session-list-filter.ts";
import { synastryReportClosed } from "../src/lib/rectification-session-composer-guard.ts";
import type { ChatSession } from "../src/lib/home-types.ts";
const reportId = "83000000-0000-4000-8000-000000000083";
const otherReportId = "83000000-0000-4000-8000-000000000183";
const sessionId = "83000000-0000-4000-8000-000000000283";
function celebrityNote(): string {
const golden = JSON.parse(readFileSync(new URL("../../tests/golden/upstream_sync2/relationship_einstein.json", import.meta.url), "utf8")) as unknown;
const found = findNote(golden);
if (!found) throw new Error("celebrity note missing");
return found;
}
function findNote(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
if (Array.isArray(value)) {
for (const item of value) {
const found = findNote(item);
if (found) return found;
}
return null;
}
const row = value as Record<string, unknown>;
if (typeof row.note === "string" && row.note.includes("Venus在10宫")) return row.note;
for (const item of Object.values(row)) {
const found = findNote(item);
if (found) return found;
}
return null;
}
function celebrityPayload() {
const note = celebrityNote();
const dasha = "土星大运 1903-01 至 1922-01";
return {
names: { a: "Albert", b: "Mileva" },
zh: {
sections: [{
id: "climate",
title: "关系基调",
text: note,
reasons: [{ basis: `${note};${dasha}`, plain: "感情与事业阶段叠在一起。" }],
}],
},
dossier: {},
};
}
function session(change: Partial<ChatSession>): ChatSession {
return {
id: sessionId,
title: "Albert × Mileva · 合盘追问",
theme: "marriage",
modelId: "m",
messages: [],
updatedAt: 1,
sessionType: "synastry_report",
rectificationCaseId: null,
chartProfileId: "83000000-0000-4000-8000-000000000383",
chartProfileName: "Albert × Mileva",
chartProfileRole: "other",
synastryReportId: reportId,
synastrySection: "climate",
synastryPartnerSubjectId: "self",
pinned: false,
archivedAt: null,
messagesHydrated: true,
...change,
};
}
test("synastry create requires an owned section and refuses context inheritance fields", () => {
const accepted = chatSessionCreateSchema.safeParse({
id: sessionId,
title: "Albert × Mileva · 合盘追问",
theme: "marriage",
model_id: "m",
messages: [],
session_type: "synastry_report",
rectification_case_id: null,
synastry_report_id: reportId,
synastry_section: "climate",
});
assert.equal(accepted.success, true);
assert.equal(chatSessionCreateSchema.safeParse({
id: sessionId,
title: "Albert × Mileva · 合盘追问",
theme: "marriage",
model_id: "m",
messages: [],
session_type: "synastry_report",
rectification_case_id: null,
synastry_section: "practices",
}).success, false);
assert.equal(chatSessionCreateSchema.safeParse({
id: sessionId,
title: "新对话",
theme: "general",
model_id: "m",
messages: [],
session_type: "consultation",
rectification_case_id: null,
synastry_report_id: reportId,
synastry_section: "climate",
}).success, false);
});
test("session binding reads only the caller's ready synastry report", async () => {
const bound = await authoritativeSynastrySession({
reportId,
section: "timeline",
loadOwnedReport: async () => ({
report_type: "synastry_full",
status: "ready",
subject_low: "self",
subject_high: "83000000-0000-4000-8000-000000000383",
report_document: { names: { a: "Albert", b: "Mileva" } },
}),
});
assert.equal(bound.chart_profile_name, "Albert × Mileva");
assert.equal(bound.synastry_partner_subject_id, "83000000-0000-4000-8000-000000000383");
assert.equal(bound.rectification_case_id, null);
await assert.rejects(
authoritativeSynastrySession({ reportId: otherReportId, section: "climate", loadOwnedReport: async () => null }),
/synastry_report_not_owned/,
);
await assert.rejects(
authoritativeSynastrySession({ reportId, section: "practices", loadOwnedReport: async () => ({ report_type: "synastry_full", status: "ready" }) }),
/synastry_section_invalid/,
);
});
test("opening a section chat only creates a session and prefills the question", async () => {
const calls: string[] = [];
const store = new Map<string, string>();
const previous = globalThis.sessionStorage;
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => { store.set(key, value); },
removeItem: (key: string) => { store.delete(key); },
},
});
try {
const opened = await beginSynastrySectionChat({
reportId,
sectionId: "climate",
language: "zh",
names: { a: "Albert", b: "Mileva" },
fetcher: async (input, init) => {
const url = String(input);
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.endsWith("/api/models")) {
return new Response(JSON.stringify({ defaultModelId: "model-a" }), { status: 200 });
}
const body = JSON.parse(String(init?.body)) as { session_type?: string };
assert.equal(body.session_type, "synastry_report");
return new Response("{}", { status: 201 });
},
open: (href) => { calls.push(href); },
});
assert.equal(calls.some((call) => call.includes("/api/consult")), false);
assert.match(opened.question, /合盘报告里「关系基调」/);
assert.match(calls.at(-1) ?? "", /\/\?c=/);
assert.equal(synastrySectionQuestion("climate", "en").includes("Relationship Climate"), true);
} finally {
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: previous });
}
});
test("celebrity material accepts a stored placement and rejects an invented house or a shifted dasha", async () => {
const payload = celebrityPayload();
const material = synastrySectionMaterial({ payload, sectionId: "climate" });
assert.equal(auditSynastryAnswer(celebrityNote(), material).ok, true);
assert.equal(auditSynastryAnswer("金星在 7 宫。", material).ok, false);
assert.equal(auditSynastryAnswer("土星大运 1904-01 至 1922-01。", material).ok, false);
assert.equal(synastryChatContextChars(SYNASTRY_CHAT_INSTRUCTIONS, material) <= SYNASTRY_CHAT_CHAR_BUDGET, true);
const wrong = await runSynastryReportTurn({
question: "依据是什么?",
sectionId: "climate",
history: [],
readMaterial: () => material,
generate: async () => ({ text: "金星在 7 宫。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }),
});
assert.equal(wrong.outcome, "fixed");
assert.equal(wrong.text, SYNASTRY_FIXED_REPLY);
const shifted = settleSynastryDrafts({
toolCalled: true,
material,
first: "土星大运 1904-01 至 1922-01。",
second: "土星大运 1904-01 至 1923-01。",
});
assert.equal(shifted.outcome, "fixed");
assert.equal(shifted.text, SYNASTRY_FIXED_REPLY);
const outside = await runSynastryReportTurn({
question: "他们哪一年领证?",
sectionId: "climate",
history: [],
readMaterial: () => material,
generate: async () => ({ text: "这份合盘没算到,建议回到各自的盘单独问。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }),
});
assert.equal(outside.outcome, "pass");
assert.equal(synastryMissingCoverage(outside.text), true);
const missing = await runSynastryReportTurn({
question: "依据是什么?",
sectionId: "climate",
history: [],
readMaterial: () => material,
generate: async () => ({ text: "金星在 10 宫。", toolCalls: 0, inputTokens: 1, outputTokens: 1 }),
});
assert.equal(missing.outcome, "tool_missing");
assert.equal(missing.text, "");
});
test("the read tool stays on the bound report and the new files do not recast", () => {
const read = boundSynastryRead({
boundReportId: reportId,
requestedReportId: otherReportId,
sectionId: "climate",
payload: celebrityPayload(),
});
assert.equal(read.reportId, reportId);
assert.notEqual(read.reportId, otherReportId);
for (const path of [
"../src/mastra/synastry-report-chat.ts",
"../src/lib/synastry-report-consult.ts",
"../src/lib/synastry-report-turn.ts",
"../src/lib/synastry-report-material.ts",
]) {
const source = readFileSync(new URL(path, import.meta.url), "utf8");
assert.equal(source.includes("run-jyotish-consultation"), false, path);
}
});
test("sidebar keeps an empty synastry chat for either person and locks the pair", () => {
const row = session({});
assert.equal(isListedSidebarSession(row), true);
assert.equal(sessionMatchesSubject(row, "self"), true);
assert.equal(sessionMatchesSubject(row, row.chartProfileId ?? ""), true);
assert.equal(sessionMatchesSubject(row, otherReportId), false);
assert.equal(chatSessionSubjectLocked(row), true);
assert.equal(chatProfileDisplayName({ session: row, status: "loading", library: [] }), "Albert × Mileva");
assert.equal(synastryReportClosed(session({ synastryReportId: null })), true);
assert.equal(synastryReportClosed(row), false);
});
function timelineBasis(language: "zh" | "en"): string {
const periodsA: TimingPeriod[] = [
{ level: "md", lord: "Saturn", lordCn: "土星", start: "1903-01-01", end: "1922-01-01" },
{ level: "ad", lord: "Venus", lordCn: "金星", start: "1905-03-01", end: "1907-11-01" },
];
const periodsB: TimingPeriod[] = [
{ level: "md", lord: "Jupiter", lordCn: "木星", start: "1900-01-01", end: "1916-01-01" },
{ level: "ad", lord: "Moon", lordCn: "月亮", start: "1905-01-01", end: "1906-06-01" },
];
return buildTimelineSegments({
nameA: "Albert",
nameB: "Mileva",
periodsA,
periodsB,
from: "1905-06-01",
language,
}).map((segment) => segment.basis).join("\n");
}
function shiftFirstYear(sentence: string): string {
return sentence.replace(/(\d{4})/, (year) => String(Number(year) + 1));
}
test("timeline basis lines reject a shifted antardasha or English mahadasha", () => {
const zh = timelineBasis("zh");
const minor = zh.match(/金星小运 \d{4}-\d{2} 至 \d{4}-\d{2}/u)?.[0];
assert.ok(minor, zh);
assert.equal(auditSynastryAnswer(minor, { basis: zh }).ok, true);
assert.equal(auditSynastryAnswer(shiftFirstYear(minor), { basis: zh }).ok, false);
const en = timelineBasis("en");
const major = en.match(/Saturn mahadasha \d{4}-\d{2} to \d{4}-\d{2}/u)?.[0];
assert.ok(major, en);
assert.equal(auditSynastryAnswer(major, { basis: en }).ok, true);
assert.equal(auditSynastryAnswer(shiftFirstYear(major), { basis: en }).ok, false);
const antardasha = en.match(/Venus antardasha \d{4}-\d{2} to \d{4}-\d{2}/u)?.[0];
assert.ok(antardasha, en);
assert.equal(auditSynastryAnswer(shiftFirstYear(antardasha), { basis: en }).ok, false);
const shifted = settleSynastryDrafts({
toolCalled: true,
material: { basis: zh },
first: shiftFirstYear(minor),
second: shiftFirstYear(minor).replace(/至 (\d{4})/u, (_match, year: string) => `至 ${Number(year) + 1}`),
});
assert.equal(shifted.outcome, "fixed");
assert.equal(shifted.text, SYNASTRY_FIXED_REPLY);
});