Files
Jyotisha/frontend/tests/report-reading-20261027.test.tsx
T
Jesse_ChenandClaude Opus 5.5 e433c0e4a8
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 6m8s
解读校验放过「不一定会」(BUG-1461 复验补修)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-27 11:31:11 +08:00

310 lines
18 KiB
TypeScript

/**
* Issue #113 R1 (product decision 2026-10-27 「R1 C1」): a model-written
* reading in front of the engine report. Fake writers only (no model key in
* CI); facts and charts are fictional.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view";
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page";
import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix";
import { validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot";
import {
REPORT_READING_SECTIONS,
REPORT_READING_SYSTEM,
acceptReportReadingDraft,
writeReportReading,
type ReportReadingWriter,
} from "../src/lib/personal-report-reading";
import { resolveReportRead } from "../src/lib/personal-report-route-core";
import type { PersonalReportRecord } from "../src/lib/personal-report-service-core";
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
import { assembleLongformCharts } from "../src/lib/personal-report-generation";
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 PARAGRAPH = "你的上升落在金牛座,因为管你自己的 1 宫(你自己、身体、给人的第一印象)由金星主管,金星在盘上力气中等,你给人的感觉是稳、慢热、讲究分寸。做事先看清楚再动手,一旦认定就不太轻易改主意,这份耐心是你最可靠的本钱。熟悉的人会觉得你靠得住,刚认识的人可能要多处一阵才知道你心里热。";
function draft(overrides: Partial<Record<string, string[]>> = {}) {
return {
sections: REPORT_READING_SECTIONS.map((section) => ({
id: section.id,
paragraphs: overrides[section.id] ?? [PARAGRAPH],
})),
};
}
const FACTS = {
schema: "report-reading-facts-v1",
minute_confirmed: true,
ascendant: { sign: "Taurus", lord: "Venus" },
planets: { Sun: { d1_sign: "Pisces", d1_house: 11 } },
domains: { career: { varga: "D10", houses: { "10": { lord: "Saturn" } } } },
timing: { md: "Rahu", mahadasha: { lord: "Rahu", start: "2023-07-08", end: "2041-07-08" } },
};
test("a clean eight-part draft becomes the stored reading with our titles", () => {
const accepted = acceptReportReadingDraft(draft());
assert.ok(accepted.ok);
assert.deepEqual(accepted.reading.sections.map((section) => section.title), ["总评", "性格", "事业", "财富", "感情", "健康", "现在这几年", "几条建议"]);
assert.equal(accepted.reading.language, "zh");
});
test("「不一定会」 is not a fate claim and is accepted", () => {
const accepted = acceptReportReadingDraft(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] }));
assert.equal(accepted.ok, true);
});
test("drafts with Latin, field names, fate, disease, investment or stacked disclaimers are rejected", () => {
const cases: Array<[string, unknown]> = [
["latin", draft({ career: [`${PARAGRAPH}这段是 Rahu 大运。`] })],
["field_name", draft({ career: [`${PARAGRAPH}mars_house=2`.replace(/[a-z]/g, "")] })],
["banned_phrase", draft({ love: [`${PARAGRAPH}你注定晚婚。`] })],
["banned_phrase", draft({ now: [`${PARAGRAPH}这几年先苦后甜。`] })],
["banned_phrase", draft({ health: [`${PARAGRAPH}要小心糖尿病。`] })],
["banned_phrase", draft({ wealth: [`${PARAGRAPH}适合炒股。`] })],
["banned_phrase", draft({ overview: [`${PARAGRAPH}北交点在 10 宫。`] })],
["banned_phrase", draft({ overview: [`${PARAGRAPH}本仓功能吉凶层这样判。`] })],
["banned_phrase", draft({ love: [`${PARAGRAPH}明年一定会结婚。`] })],
["disclaimer", draft({ health: [`${PARAGRAPH}仅供参考。`, `${PARAGRAPH}不构成医疗意见。`] })],
["shape", { sections: draft().sections.slice(0, 7) }],
["shape", { sections: [...draft().sections].reverse() }],
["too_short", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, ["很短的一句。"]])))],
["too_long", draft({ career: [PARAGRAPH.repeat(5)] })],
];
for (const [reason, value] of cases) {
const accepted = acceptReportReadingDraft(value);
assert.equal(accepted.ok, false, reason);
assert.equal(!accepted.ok && accepted.reason, reason, JSON.stringify(value).slice(0, 120));
}
});
test("the prompt carries the C1 rules and the red lines, and only engine facts go in", () => {
for (const rule of ["因为……", "宫位第一次出现时", "讲时间时最多说三件", "不写病名", "不给投资指令", "「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON"]) {
assert.ok(REPORT_READING_SYSTEM.includes(rule), rule);
}
});
function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
return async (input) => {
calls.push(input.user);
const body = bodies.shift();
if (body instanceof Error) throw body;
return { body, inputTokens: 1000, outputTokens: 500 };
};
}
test("one retry after a rejected draft; tokens of both attempts are counted", async () => {
const calls: string[] = [];
const outcome = await writeReportReading({ facts: FACTS, birthTimeConfirmed: false, write: writer([draft({ love: ["你注定晚婚。"] }), draft()], calls) });
assert.ok(outcome.reading);
assert.equal(outcome.inputTokens, 2000);
assert.equal(outcome.outputTokens, 1000);
assert.equal(calls.length, 2);
assert.match(calls[0]!, /没有确认到分钟/);
assert.match(calls[0]!, /"Taurus"/);
});
test("no facts, a failing model or a hung model all leave the report without a reading", async () => {
const calls: string[] = [];
const noFacts = await writeReportReading({ facts: { schema: "report-reading-facts-v1", gaps: ["reading_facts_unavailable"] }, birthTimeConfirmed: true, write: writer([draft()], calls) });
assert.deepEqual(noFacts, { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 });
assert.equal(calls.length, 0);
const failed = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([new Error("500"), new Error("500")]) });
assert.equal(failed.reading, null);
assert.equal(failed.unavailable, "model_failed");
// A writer that ignores its abort signal still loses to the timeout.
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: () => new Promise(() => undefined) });
assert.equal(hung.reading, null);
assert.equal(hung.unavailable, "model_timeout");
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
assert.equal(rejected.unavailable, "rejected_banned_phrase");
});
test("a cancelled job still cancels; the reading never swallows the abort", async () => {
const controller = new AbortController();
const pending = writeReportReading({
facts: FACTS, birthTimeConfirmed: true, signal: controller.signal,
write: ({ signal }) => new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))),
});
controller.abort(new Error("job_cancelled"));
await assert.rejects(pending, /job_cancelled/);
});
const identity = {
reportId: "11111111-1111-4111-8111-111111111111",
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
requestId: "22222222-2222-4222-8222-222222222222",
};
function snapshot(extra: Record<string, unknown> = {}) {
const markdown = zhReader.markdown as string;
return {
kind: "longform_calculation_snapshot", version: 2, ...identity, markdown,
contentSha256: hashLongformMarkdown(markdown),
factTables: assembleReportFactTables(engine),
charts: assembleLongformCharts(engine, "ev-cover-longform"),
readerDashaApplicability: "x",
englishUnavailable: "han_leak",
...extra,
};
}
test("snapshots: older ones read without a reading; a stored reading must be well formed", () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
assert.equal(validateLongformSnapshot(snapshot(), identity).version, 2);
const stored = validateLongformSnapshot(snapshot({ reading }), identity);
assert.equal(stored.version === 2 && stored.reading?.sections.length, 8);
assert.equal(validateLongformSnapshot(snapshot({ readingUnavailable: "model_timeout" }), identity).version, 2);
assert.throws(() => validateLongformSnapshot(snapshot({ reading: { ...(reading as object), sections: [] } }), identity));
assert.throws(() => validateLongformSnapshot(snapshot({ readingUnavailable: "Model Timeout!" }), identity));
});
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-10-27T00:00:00.000Z", completedAt: "2026-10-27T00:01:00.000Z", updatedAt: "2026-10-27T00:01:00.000Z",
} as unknown as PersonalReportRecord;
}
async function read(loadLongformReading?: Parameters<typeof resolveReportRead>[0]["loadLongformReading"]) {
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 }),
...(loadLongformReading ? { loadLongformReading } : {}),
validateReadyDocument: () => ({ ok: false }),
});
}
test("the read API returns the reading when there is one and never fails the report over it", async () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
const ready = await read(async () => reading as never);
assert.equal(ready.status, 200);
assert.deepEqual(ready.body.reading, reading);
const none = await read(async () => null);
assert.equal("reading" in none.body, false);
const broken = await read(async () => { throw new Error("db down"); });
assert.equal(broken.status, 200);
assert.equal("reading" in broken.body, false);
assert.equal("reading" in (await read()).body, false);
});
test("the page keeps a reading only in the stored shape and shows it before the engine report in Chinese only", () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: { sections: unknown[] } }).reading;
const envelope = (extra: Record<string, unknown>) => classifyReportEnvelope(200, {
report: { id: identity.reportId, status: "ready", createdAt: "2026-10-27T00:00:00.000Z" },
longformMarkdown: zhReader.markdown, ...extra,
});
const ready = envelope({ reading });
assert.equal(ready.phase === "markdown-ready" && ready.reading?.sections.length, 8);
const bad = envelope({ reading: { ...reading, sections: reading.sections.slice(1) } });
assert.equal(bad.phase === "markdown-ready" && "reading" in bad, false);
const markdown = "# 印度占星星盘报告\n\n## 出生资料与图盘\n\n引擎正文。\n";
const zh = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="zh" />);
const readingAt = zh.indexOf('id="report-reading"');
const basisAt = zh.indexOf("计算依据");
const engineAt = zh.indexOf('id="出生资料与图盘"');
assert.ok(zh.indexOf('href="#report-reading"') > -1 && zh.indexOf('href="#report-reading"') < zh.indexOf('href="#出生资料与图盘"'), "contents lists the reading first");
assert.ok(readingAt > zh.indexOf("印度占星星盘报告") && readingAt < basisAt && basisAt < engineAt, zh.slice(0, 400));
for (const title of ["总评", "现在这几年", "几条建议"]) assert.ok(zh.includes(`<h3>${title}</h3>`), title);
const en = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="en" />);
assert.equal(en.includes("report-reading"), false);
assert.equal(en.includes("计算依据"), false);
const legacy = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} language="zh" />);
assert.equal(legacy.includes("计算依据"), false);
});
test("the reading arrives with the report: no loader of its own, no request after reveal", () => {
const section = readFileSync(new URL("../src/components/personal-report/report-reading-section.tsx", import.meta.url), "utf8");
assert.doesNotMatch(section, /use(State|Effect)|fetch\(|Skeleton|Spinner|加载/);
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
assert.doesNotMatch(page, /fetch\([^)]*reading/);
// The page bundle reads the contract, never the prompt module.
for (const file of ["personal-report-page.tsx", "personal-report-markdown-view.tsx", "report-reading-section.tsx"]) {
const source = readFileSync(new URL(`../src/components/personal-report/${file}`, import.meta.url), "utf8");
assert.doesNotMatch(source, /lib\/personal-report-reading"/, file);
}
});
test("the worker writes the reading in the report job and bills its tokens", () => {
const worker = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
assert.match(worker, /writeReading: createReportReadingWriter\(model\)/);
assert.match(worker, /inputTokens: generated\.usage\?\.inputTokens \?\? 0/);
assert.match(worker, /outputTokens: generated\.usage\?\.outputTokens \?\? 0/);
});
for (const mode of ["reading", "model-down", "no-writer"]) {
test(`report job with the reading: ${mode}`, () => {
const script = `
import assert from "node:assert/strict";
import { mock } from "node:test";
import { readFileSync } from "node:fs";
mock.module("server-only", { defaultExport: {} });
const { generatePersonalReportLongform } = await import("./src/lib/personal-report-longform-generate.ts");
const { readLongformSnapshot } = await import("./src/lib/personal-report-longform-snapshot.ts");
const packet = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-engine.json", "utf8"));
const golden = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-reader.json", "utf8"));
const mode = ${JSON.stringify(mode)};
const draft = ${JSON.stringify(draft())};
const facts = ${JSON.stringify(FACTS)};
const rows = new Map(); const bodies = [];
const admin = { async rpc(name, args) {
const table = "personal_report_sections"; const current = rows.get(table);
if (name === "ensure_personal_report_section" && !current) rows.set(table, { user_id: args.p_user_id, request_id: args.p_request_id, section_id: args.p_section_id, status: "pending", payload: null });
if (name === "complete_personal_report_section" && current?.status === "pending") rows.set(table, { ...current, status: "ready", payload: structuredClone(args.p_payload) });
return { data: [], error: null };
}, from(table) { return {
select() { return this; }, eq() { return this; },
async maybeSingle() { return { data: structuredClone(rows.get(table) ?? null), error: null }; },
async upsert(row) { rows.set(table, structuredClone(row)); return { error: null }; },
}; }};
const report = { id: "123e4567-e89b-12d3-a456-426614174000", userId: "123e4567-e89b-12d3-a456-426614174001", requestId: "123e4567-e89b-12d3-a456-426614174002", reportType: "personal_full", presentationMode: "default", depth: "standard", requestedThemes: ["career"], skillName: "jyotish-vedic-astrology", skillVersion: "6.9.14", skillSourceCommit: null, skillSnapshotSha256: "ab".repeat(32) };
const writeReading = mode === "no-writer" ? undefined : async () => {
if (mode === "model-down") throw new Error("model down");
return { body: draft, inputTokens: 1200, outputTokens: 800 };
};
const result = await generatePersonalReportLongform({ report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri" }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-10-27T12:00:00Z"),
fetchImpl: async (_url, init) => { bodies.push(JSON.parse(init.body)); return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability, ...(mode === "no-writer" ? {} : { reading_facts: facts }) }); },
...(writeReading ? { writeReading } : {}) });
assert.equal(result.status, "ready");
assert.equal(bodies.length, 1);
assert.equal(bodies[0].include_reading_facts, mode === "no-writer" ? undefined : true);
const stored = await readLongformSnapshot(admin, { reportId: report.id, userId: report.userId, requestId: report.requestId });
assert.equal(stored.status, "ready");
assert.equal(stored.snapshot.markdown, golden.markdown);
if (mode === "reading") {
assert.equal(stored.snapshot.reading.sections.length, 8);
assert.equal(stored.snapshot.reading.sections[0].title, "总评");
assert.deepEqual(result.usage, { inputTokens: 1200, outputTokens: 800 });
} else if (mode === "model-down") {
assert.equal(stored.snapshot.reading, undefined);
assert.equal(stored.snapshot.readingUnavailable, "model_failed");
assert.deepEqual(result.usage, { inputTokens: 0, outputTokens: 0 });
} else {
assert.equal(stored.snapshot.reading, undefined);
assert.equal(stored.snapshot.readingUnavailable, undefined);
assert.equal(result.usage, undefined);
}
`;
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], {
cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 120_000,
});
assert.equal(result.status, 0, result.stderr + result.stdout);
});
}