Files
Jyotisha/frontend/tests/report-english-edition.test.tsx
T

225 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { ReportActions } from "../src/components/personal-report/report-actions";
import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer";
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page";
import { acceptEnglishEdition } from "../src/lib/personal-report-english-edition";
import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix";
import { validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot";
import { resolveReportRead, type EnglishEditionRead } from "../src/lib/personal-report-route-core";
import type { PersonalReportRecord } from "../src/lib/personal-report-service-core";
import { buildReportExportBlocks, exportFilename } from "../src/lib/report-export-blocks";
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
import { assembleLongformCharts } from "../src/lib/personal-report-generation";
import { containsHan, numericTokens } from "../src/lib/report-language";
import { ordinaryOutputLeaks, projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
// TASK-report-english-edition-20260929. Fixtures are real engine output for a
// fictional birth (AGENTS §7-4).
Object.assign(globalThis, { React });
const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional.json", import.meta.url), "utf8"));
const ENGLISH_PROSE = "The Moon sits in the ninth house and its dasha opens long periods of study, travel and teaching that reward patience.";
test("English projection keeps English prose but still drops audit vocabulary", () => {
// Twelve English words in a row are a leak in the Chinese edition, and ordinary prose in the English one.
assert.ok(ordinaryOutputLeaks(ENGLISH_PROSE).length > 0);
assert.deepEqual(ordinaryOutputLeaks(ENGLISH_PROSE, "en"), []);
const source = `## Summary\n\n${ENGLISH_PROSE}\n\nThis section is parameter_sensitive and blocked.\n`;
const projected = projectOrdinaryReportMarkdown(source, "en");
assert.match(projected, /ninth house/);
assert.doesNotMatch(projected, /parameter_sensitive|blocked/);
assert.match(projected, /## Limitations to keep in mind/);
assert.equal(containsHan(projected), false, "limitations are written in English too");
// The Chinese edition is unchanged by the new parameter.
assert.equal(projectOrdinaryReportMarkdown(zhReader.markdown), projectOrdinaryReportMarkdown(zhReader.markdown, "zh"));
});
test("English fact tables list the same rows and numbers as the Chinese ones, with no Han", () => {
const zh = assembleReportFactTables(engine);
const en = assembleReportFactTables(engine, "en");
assert.equal(containsHan(JSON.stringify(en)), false);
assert.deepEqual(en.map(table => table.id), zh.map(table => table.id));
for (const [index, table] of en.entries()) {
const other = zh[index];
assert.equal(table.claimStatus, other.claimStatus, table.id);
assert.equal(table.columns.length, other.columns.length, table.id);
assert.deepEqual(table.rows.map(row => row.sourcePath), other.rows.map(row => row.sourcePath), table.id);
assert.deepEqual(numericTokens(JSON.stringify(table.rows.map(row => row.cells))), numericTokens(JSON.stringify(other.rows.map(row => row.cells))), table.id);
assert.deepEqual((table.subtables ?? []).map(sub => sub.rows.length), (other.subtables ?? []).map(sub => sub.rows.length), table.id);
}
// Default stays the Chinese edition byte for byte.
assert.deepEqual(assembleReportFactTables(engine, "zh"), zh);
});
test("the generator accepts only a clean English edition and otherwise records why", () => {
const ok = acceptEnglishEdition(`# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n`, undefined, engine);
assert.ok(ok.english);
assert.equal(ok.english.contentSha256, hashLongformMarkdown(ok.english.markdown));
assert.equal(containsHan(JSON.stringify(ok.english.factTables)), false);
assert.deepEqual(acceptEnglishEdition("# 报告\n\nMixed 太阳 text\n", undefined, engine), { englishUnavailable: "han_leak" });
assert.deepEqual(acceptEnglishEdition("# Report\n\nThis is parameter_sensitive.\n", undefined, engine), { englishUnavailable: "public_leak" });
assert.deepEqual(acceptEnglishEdition(undefined, "render_error", engine), { englishUnavailable: "render_error" });
assert.deepEqual(acceptEnglishEdition(undefined, "Not A Code!", engine), { englishUnavailable: "engine_missing" });
});
const identity = {
reportId: "11111111-1111-4111-8111-111111111111",
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
requestId: "22222222-2222-4222-8222-222222222222",
};
function snapshot(version: 1 | 2, extra: Record<string, unknown> = {}) {
const markdown = zhReader.markdown as string;
return {
kind: "longform_calculation_snapshot", version, ...identity, markdown,
contentSha256: hashLongformMarkdown(markdown),
factTables: assembleReportFactTables(engine),
charts: assembleLongformCharts(engine, "ev-cover-longform"),
readerDashaApplicability: "x",
...extra,
};
}
test("snapshots: v1 still reads; v2 carries exactly one of the English edition or the reason it is missing", () => {
assert.equal(validateLongformSnapshot(snapshot(1), identity).version, 1);
const english = acceptEnglishEdition(`# Report\n\n${ENGLISH_PROSE}\n`, undefined, engine).english!;
assert.equal(validateLongformSnapshot(snapshot(2, { english }), identity).version, 2);
assert.equal(validateLongformSnapshot(snapshot(2, { englishUnavailable: "han_leak" }), identity).version, 2);
assert.throws(() => validateLongformSnapshot(snapshot(2), identity));
assert.throws(() => validateLongformSnapshot(snapshot(2, { english, englishUnavailable: "han_leak" }), identity));
assert.throws(() => validateLongformSnapshot(snapshot(2, { english: { ...english, contentSha256: "0".repeat(64) } }), identity));
assert.throws(() => validateLongformSnapshot(snapshot(1, { english }), identity), "v1 cannot carry English");
});
function readyRecord(): PersonalReportRecord {
return {
id: identity.reportId, userId: identity.userId, sessionId: null, chartProfileId: null,
requestId: identity.requestId, requestFingerprint: "f".repeat(64), reportType: "personal_full",
presentationMode: "default", depth: "standard", requestedThemes: ["career"], status: "ready",
evidenceHash: null, reportDocument: null, cardSummary: null, failureCode: null,
createdAt: "2026-09-29T00:00:00.000Z", completedAt: "2026-09-29T00:01:00.000Z", updatedAt: "2026-09-29T00:01:00.000Z",
} as unknown as PersonalReportRecord;
}
async function read(english?: () => Promise<EnglishEditionRead>) {
return resolveReportRead({
requestUrl: "https://jyotisha.chat/api/reports/x", origin: null, allowedOrigins: [],
userId: identity.userId, reportId: identity.reportId,
persistence: { async getOwnedById() { return readyRecord(); } },
loadLongformAppendix: async () => ({ status: "ready", lastErrorCode: null, markdown: zhReader.markdown }),
...(english ? { loadLongformEnglish: english } : {}),
validateReadyDocument: () => ({ ok: false }),
});
}
test("the read route says which editions exist and never fails Chinese over English", async () => {
const ready = await read(async () => ({ status: "ready", markdown: `# Report\n\n${ENGLISH_PROSE}\n`, factTables: assembleReportFactTables(engine, "en") }));
assert.equal(ready.status, 200);
assert.equal(ready.body.englishEdition, "ready");
assert.match(String(ready.body.longformMarkdownEn), /ninth house/);
assert.equal(ready.body.longformMarkdown, projectOrdinaryReportMarkdown(zhReader.markdown));
const legacy = await read(async () => ({ status: "legacy" }));
assert.equal(legacy.body.englishEdition, "legacy");
assert.equal("longformMarkdownEn" in legacy.body, false);
const broken = await read(async () => { throw new Error("db down"); });
assert.equal(broken.status, 200);
assert.equal(broken.body.englishEdition, "unavailable");
assert.equal((await read()).body.englishEdition, "legacy");
});
test("the reader trusts an English edition only when it is clean", () => {
const envelope = (extra: Record<string, unknown>) => classifyReportEnvelope(200, {
report: { id: identity.reportId, status: "ready", createdAt: "2026-09-29T00:00:00.000Z" },
longformMarkdown: zhReader.markdown, ...extra,
});
const ready = envelope({ englishEdition: "ready", longformMarkdownEn: `# Report\n\n${ENGLISH_PROSE}\n` });
assert.equal(ready.phase, "markdown-ready");
assert.equal(ready.phase === "markdown-ready" && ready.englishEdition, "ready");
const mixed = envelope({ englishEdition: "ready", longformMarkdownEn: "# Report\n\n太阳\n" });
assert.equal(mixed.phase === "markdown-ready" && mixed.englishEdition, "unavailable");
const legacy = envelope({});
assert.equal(legacy.phase === "markdown-ready" && legacy.englishEdition, "legacy");
});
test("English export blocks, labels and filename", () => {
const markdown = `# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n\n## Strength\n\nBody.\n`;
const blocks = buildReportExportBlocks({ markdown, factTables: assembleReportFactTables(engine, "en"), charts: assembleLongformCharts(engine, "x"), language: "en" });
assert.ok(blocks.length > 3);
assert.equal(containsHan(JSON.stringify(blocks)), false, "no Chinese label sneaks into the English export");
assert.equal(exportFilename("2026-09-29T00:00:00Z", true, "en"), "Personal-Report-2026-09-29-EN");
assert.equal(exportFilename("2026-09-29T00:00:00Z", false, "en"), "Personal-Report-2026-09-29-excerpt-EN");
assert.equal(exportFilename("2026-09-29T00:00:00Z", false), "个人报告-2026-09-29-节选");
});
test("the language switch only exists when both editions do", async () => {
const h = createClientLifecycleHarness();
const picked: string[] = [];
try {
await h.render(<ReportActions markdown="# 报告" englishEdition="legacy" language="zh" onLanguageChange={value => picked.push(value)} />);
assert.ok(!h.elements().some(node => node.getAttribute("aria-label") === "报告语言"));
await h.render(<ReportActions markdown="# 报告" englishEdition="ready" language="zh" onLanguageChange={value => picked.push(value)} />);
const english = h.elements().find(node => node.tagName === "BUTTON" && node.text === "English");
assert.ok(english);
assert.equal(english.getAttribute("aria-pressed"), "false");
await h.event(english);
assert.deepEqual(picked, ["en"]);
} finally { await h.close(); }
});
test("the export drawer suggests English for AI only when it can switch", async () => {
// 原值:中文时出现提示条 +「切到 English」按钮(切换整页语言);英文时说明句「导出的是英文版」
// 新值:导出面板自带「中文 / English」选择(不切换整页),下方一句「拿去问 ChatGPT、Claude 等 AI,建议选 English」;
// 选 English 后导出的就是英文块;没有英文版时只剩一句说明
// 原因:2026-09-30 导出面板重设计(两步:语言 → 整篇 / 自选)
const zhBlocks = buildReportExportBlocks({ markdown: zhReader.markdown });
const enBlocks = buildReportExportBlocks({ markdown: `# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n`, language: "en" });
const h = createClientLifecycleHarness();
try {
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks, en: enBlocks }} onClose={() => {}} englishEdition="ready" />);
assert.ok(h.container.text.includes("建议选 English"));
const english = h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!;
assert.equal(english.getAttribute("aria-checked"), "false");
await h.event(english);
assert.equal(h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!.getAttribute("aria-checked"), "true");
await h.event(h.elements().find(node => node.getAttribute("role") === "radio" && node.text.startsWith("自选章节"))!);
assert.ok(h.container.text.includes("Summary"), "the checklist is the English edition");
await h.render(null);
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks, en: enBlocks }} initialLanguage="en" onClose={() => {}} englishEdition="ready" />);
assert.equal(h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!.getAttribute("aria-checked"), "true", "starts on the edition being read");
await h.render(null);
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks }} onClose={() => {}} englishEdition="legacy" />);
assert.ok(h.container.text.includes("重新生成即可获得中英两版"));
assert.ok(!h.elements().some(node => node.getAttribute("role") === "radio" && node.text === "English"));
} finally { await h.close(); }
});
test("the real engine English golden is accepted whole and matches its Chinese pair", () => {
const en = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional-en.json", import.meta.url), "utf8"));
const zh = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional-zh-pair.json", import.meta.url), "utf8"));
assert.equal(en.fixtureProvenance.fictional, true);
assert.equal(en.fixtureProvenance.language, "en");
const accepted = acceptEnglishEdition(en.markdown, undefined, engine);
assert.ok(accepted.english, accepted.englishUnavailable);
// Nothing for the projection to cut, so the reader sees the whole edition.
assert.equal(projectOrdinaryReportMarkdown(en.markdown, "en"), en.markdown);
const zhLines = (zh.markdown as string).split("\n");
const enLines = (en.markdown as string).split("\n");
assert.equal(enLines.length, zhLines.length, "line-aligned editions");
const mismatched = zhLines.flatMap((line, index) => (
JSON.stringify(numericTokens(line)) === JSON.stringify(numericTokens(enLines[index])) ? [] : [enLines[index]]
));
// The one known difference is a label, not a value: the English yoga name
// spells out the houses (「婚姻障碍(7宫主在凶宫)」 / "Kalatra Dosha (7th Lord in 6/8/12)").
assert.deepEqual(mismatched.map(line => line.split("|")[1]?.trim()), ["Kalatra Dosha (7th Lord in 6/8/12)"]);
const h2 = (markdown: string) => buildReportExportBlocks({ markdown }).filter(block => block.group === "body").length;
assert.equal(h2(en.markdown), h2(zh.markdown), "same chapters");
assert.equal(containsHan(JSON.stringify(buildReportExportBlocks({ markdown: en.markdown, language: "en" }))), false);
});