Product decision A (TASK-birth-sky-followup-20260928): - every generation path writes subject.birthFingerprint (stored-row fingerprint, same function as the chart page's profileFingerprint); never into the evidence bundle the model reads - GET /api/reports/:id compares on the server and returns only report.coverSubject (subject id or null); legacy reports never get one - report page shows the night cover above the body, paper cover alone on the first printed A4 page; silent on any failure, no waiting state - TS / JSON schema / Python contract accept the optional field - BLOCKED T5 struck through; DESIGN §17, CHANGELOG, checklist, PROGRESS Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
82 lines
4.3 KiB
TypeScript
82 lines
4.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
|
|
import type { BirthSkyPaletteName } from "../src/lib/birth-sky/draw.ts";
|
|
import { loadReportSkyCover } from "../src/lib/birth-sky/report-cover.ts";
|
|
import { goldenSky } from "./birth-sky-test-support.ts";
|
|
|
|
// Report cover (TASK-birth-sky-followup-20260928 F3).
|
|
|
|
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
|
|
const component = readFileSync(new URL("../src/components/birth-sky/report-sky-cover.tsx", import.meta.url), "utf8");
|
|
const css = readFileSync(new URL("../src/components/birth-sky/report-sky-cover.css", import.meta.url), "utf8");
|
|
|
|
function readyEnvelope(coverSubject?: unknown) {
|
|
return {
|
|
report: {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
status: "ready",
|
|
createdAt: "2026-09-28T00:00:00.000Z",
|
|
...(coverSubject === undefined ? {} : { coverSubject }),
|
|
},
|
|
longformMarkdown: "# 报告\n\n正文。",
|
|
};
|
|
}
|
|
|
|
test("matching fingerprint → the ready state carries coverSubject; mismatch or legacy → no cover", () => {
|
|
const bound = classifyReportEnvelope(200, readyEnvelope("self"));
|
|
assert.equal(bound.phase, "markdown-ready");
|
|
assert.equal(bound.phase === "markdown-ready" && bound.coverSubject, "self");
|
|
for (const value of [null, undefined, "", 42]) {
|
|
const state = classifyReportEnvelope(200, readyEnvelope(value));
|
|
assert.equal(state.phase, "markdown-ready");
|
|
assert.equal("coverSubject" in state, false, `coverSubject=${String(value)}`);
|
|
}
|
|
});
|
|
|
|
test("the cover sits above the body, lazy, and only when coverSubject is set", () => {
|
|
assert.match(page, /\{state\.coverSubject \? <ReportSkyCover subjectId=\{state\.coverSubject\} \/> : null\}\s*<PersonalReportMarkdownView/);
|
|
assert.match(page, /const ReportSkyCover = dynamic\(\s*\(\) => import\("@\/components\/birth-sky\/report-sky-cover"\)/);
|
|
// No buttons, no placeholder, no waiting state.
|
|
assert.doesNotMatch(component, /<Button|<button|Spinner|skeleton|正在/i);
|
|
assert.match(component, /if \(!images\) return null;/, "nothing renders before both pictures are painted");
|
|
});
|
|
|
|
test("loader paints night for the screen and paper for print with the same sky", async () => {
|
|
const sky = goldenSky("day");
|
|
const painted: BirthSkyPaletteName[] = [];
|
|
const asked: string[] = [];
|
|
const pngs = await loadReportSkyCover("self", new AbortController().signal, {
|
|
fetchSky: async (subjectId) => { asked.push(subjectId); return sky; },
|
|
render: async (_sky, palette) => { painted.push(palette); return new Blob([palette]); },
|
|
});
|
|
assert.deepEqual(asked, ["self"]);
|
|
assert.deepEqual(painted.sort(), ["night", "paper"]);
|
|
assert.equal(await pngs?.screen.text(), "night");
|
|
assert.equal(await pngs?.print.text(), "paper");
|
|
assert.match(pngs?.description ?? "", /^星星都在,只是还看不见。|太阳/);
|
|
});
|
|
|
|
test("fetch failure, an empty sky or an abort → no cover, and nothing throws into the report", async () => {
|
|
const signal = new AbortController().signal;
|
|
const render = async () => new Blob(["x"]);
|
|
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => { throw new Error("offline"); }, render }), null);
|
|
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => null, render }), null);
|
|
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => goldenSky("day"), render: async () => { throw new Error("canvas"); } }), null);
|
|
const aborted = new AbortController();
|
|
aborted.abort();
|
|
assert.equal(await loadReportSkyCover("self", aborted.signal, { fetchSky: async () => goldenSky("day"), render }), null);
|
|
});
|
|
|
|
test("print: paper picture alone on the first A4 page; the screen picture is hidden", () => {
|
|
assert.match(component, /className="report-sky-cover-print" src=\{images\.print\}/);
|
|
assert.match(css, /\.report-sky-cover-print \{ display: none !important; \}/);
|
|
const print = css.slice(css.indexOf("@media print"));
|
|
assert.match(print, /\.report-sky-cover \{[^}]*break-after: page;/);
|
|
assert.match(print, /\.report-sky-cover-screen \{ display: none !important; \}/);
|
|
assert.match(print, /\.report-sky-cover-print \{[^}]*display: block !important;/);
|
|
assert.match(css, /aspect-ratio: 3 \/ 4;/);
|
|
});
|