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
154 lines
8.2 KiB
TypeScript
154 lines
8.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { resolveServerOwnedChartBirth } from "../src/lib/chart-birth-truth.ts";
|
|
import { prepareChartViewProfile } from "../src/lib/chart-view-profile-read.ts";
|
|
import { safeParseReportDocument } from "../src/lib/personal-report-contract.ts";
|
|
import { buildLongformCoverDocument } from "../src/lib/personal-report-longform-cover.ts";
|
|
import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subject.ts";
|
|
import {
|
|
REPORT_BIRTH_FINGERPRINT_KEY,
|
|
readReportBirthFingerprint,
|
|
reportCoverSubject,
|
|
reportDocumentBirthFingerprint,
|
|
stampReportBirthFingerprint,
|
|
subjectBirthFingerprint,
|
|
withReportBirthFingerprint,
|
|
} from "../src/lib/report-birth-binding.ts";
|
|
|
|
// Report cover binding, product decision A (TASK-birth-sky-followup-20260928 F1/F2).
|
|
// Fictional sample birth only: 1994-05-18 07:40 UTC+8, 30.27N 120.15E.
|
|
|
|
const storedSelf = {
|
|
name: "虚构甲",
|
|
birth_date: "1994-05-18",
|
|
reported_birth_time: "07:40:00",
|
|
birth_time_source: "family_exact",
|
|
birth_time_status: "reported",
|
|
birth_place_label: "虚构城",
|
|
latitude: 30.27,
|
|
longitude: 120.15,
|
|
timezone_offset: 8,
|
|
timezone_id: "Asia/Shanghai",
|
|
ayanamsa: "lahiri",
|
|
};
|
|
|
|
const routeSource = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
|
const itemRouteSource = readFileSync(new URL("../src/app/api/reports/[reportId]/route.ts", import.meta.url), "utf8");
|
|
const workerSource = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
|
|
const longformSource = readFileSync(new URL("../src/lib/personal-report-longform-generate.ts", import.meta.url), "utf8");
|
|
const routeCoreSource = readFileSync(new URL("../src/lib/personal-report-route-core.ts", import.meta.url), "utf8");
|
|
const generationSource = readFileSync(new URL("../src/lib/personal-report-generation.ts", import.meta.url), "utf8");
|
|
|
|
function fakeSupabase(rows: Readonly<{ profiles?: Record<string, unknown> | null }>) {
|
|
return {
|
|
from(table: string) {
|
|
const query = {
|
|
select: () => query,
|
|
eq: () => query,
|
|
maybeSingle: async () => ({
|
|
data: table === "profiles" ? (rows.profiles ?? null) : { rectification_case_id: null },
|
|
error: null,
|
|
}),
|
|
};
|
|
return query;
|
|
},
|
|
};
|
|
}
|
|
|
|
test("the report fingerprint is the chart page's profileFingerprint (same function, stored row)", async () => {
|
|
const withoutOffset = { ...storedSelf, timezone_offset: null };
|
|
const filled = { ...withoutOffset, timezone_offset: 8 };
|
|
const chartView = await prepareChartViewProfile({ row: withoutOffset, resolveTimezone: async () => filled });
|
|
assert.equal(chartView.ok, true);
|
|
assert.equal(subjectBirthFingerprint(withoutOffset), chartView.birth.fingerprint);
|
|
// The in-memory timezone fill changes the fingerprint, so it must be taken before the fill.
|
|
assert.notEqual(resolveServerOwnedChartBirth(filled).fingerprint, chartView.birth.fingerprint);
|
|
const stamped = stampReportBirthFingerprint(withoutOffset, filled);
|
|
assert.equal(readReportBirthFingerprint(stamped), chartView.birth.fingerprint);
|
|
assert.match(String((stamped as Record<string, unknown>)[REPORT_BIRTH_FINGERPRINT_KEY]), /^[0-9a-f]{32}$/);
|
|
});
|
|
|
|
test("worker profile loader stamps the stored-row fingerprint before the timezone fill", async () => {
|
|
const profile = await loadReportWorkerProfile(fakeSupabase({ profiles: storedSelf }), "user-1", "self");
|
|
assert.equal(readReportBirthFingerprint(profile), subjectBirthFingerprint(storedSelf));
|
|
assert.equal((profile as Record<string, unknown>).latitude, 30.27, "the generators still read the same row");
|
|
});
|
|
|
|
test("every generation path writes subject.birthFingerprint from the stamped profile", () => {
|
|
// POST /api/reports (inline and queued create) stamps before the timezone fill.
|
|
assert.match(routeSource, /profile = stampReportBirthFingerprint\(\s*resolved\.row,\s*await resolveMissingBirthTimezoneOffset\(resolved\.row/);
|
|
// Inline route-core generation, the queued writer and the queued longform all pass it on.
|
|
assert.match(routeCoreSource, /birthFingerprint: readReportBirthFingerprint\(profile\),/);
|
|
assert.match(workerSource, /birthFingerprint: readReportBirthFingerprint\(profile\),/);
|
|
assert.match(longformSource, /birthFingerprint: readReportBirthFingerprint\(profile\),/);
|
|
// Both document assemblies (single-call and sectioned) stamp before the guard and the final parse.
|
|
assert.equal(generationSource.match(/applyReportGuard\(withReportBirthFingerprint\(candidate, deps\.birthFingerprint\)/g)?.length, 2);
|
|
// Never into the evidence bundle the model reads.
|
|
assert.doesNotMatch(generationSource, /bundle[^\n]*birthFingerprint/);
|
|
});
|
|
|
|
test("longform cover document carries the fingerprint and still parses; without it nothing is added", () => {
|
|
const input = {
|
|
report: {
|
|
id: "123e4567-e89b-12d3-a456-426614174000",
|
|
reportType: "personal_full" as const,
|
|
presentationMode: "default" as const,
|
|
depth: "standard" as const,
|
|
requestedThemes: ["career"],
|
|
skillName: "jyotish-vedic-astrology",
|
|
skillVersion: "6.9.14",
|
|
skillSourceCommit: null,
|
|
skillSnapshotSha256: "ab".repeat(32),
|
|
},
|
|
subject: { displayName: "虚构甲", birthTimeStatus: "reported" as const, birthPlaceLabel: "虚构城" },
|
|
markdown: "# 个人长报告\n\n## 摘要\n\n事业方向保持观察。\n",
|
|
generatedAt: "2026-09-28T00:00:00.000Z",
|
|
};
|
|
const fingerprint = subjectBirthFingerprint(storedSelf);
|
|
const bound = buildLongformCoverDocument({ ...input, birthFingerprint: fingerprint });
|
|
assert.equal(bound.subject.birthFingerprint, fingerprint);
|
|
assert.equal(reportDocumentBirthFingerprint(bound), fingerprint);
|
|
const legacy = buildLongformCoverDocument(input);
|
|
assert.equal("birthFingerprint" in legacy.subject, false);
|
|
assert.equal(reportDocumentBirthFingerprint(legacy), null);
|
|
});
|
|
|
|
test("documents without the field (old reports) still parse; a malformed fingerprint does not", () => {
|
|
const document = buildLongformCoverDocument({
|
|
report: {
|
|
id: "123e4567-e89b-12d3-a456-426614174000",
|
|
reportType: "personal_full",
|
|
presentationMode: "default",
|
|
depth: "standard",
|
|
requestedThemes: ["career"],
|
|
skillName: "jyotish-vedic-astrology",
|
|
skillVersion: "6.9.14",
|
|
skillSourceCommit: null,
|
|
skillSnapshotSha256: "ab".repeat(32),
|
|
},
|
|
subject: { displayName: "虚构甲", birthTimeStatus: "reported", birthPlaceLabel: "虚构城" },
|
|
markdown: "# 个人长报告\n\n## 摘要\n\n事业方向保持观察。\n",
|
|
generatedAt: "2026-09-28T00:00:00.000Z",
|
|
});
|
|
assert.equal(safeParseReportDocument(document).ok, true);
|
|
assert.equal(safeParseReportDocument(withReportBirthFingerprint(document, "a".repeat(32))).ok, true);
|
|
assert.equal(safeParseReportDocument({ ...document, subject: { ...document.subject, birthFingerprint: "1994-05-18" } }).ok, false);
|
|
assert.equal(withReportBirthFingerprint(document, undefined), document, "no fingerprint → untouched");
|
|
});
|
|
|
|
test("coverSubject: only a matching fingerprint yields the subject id, self when chart_profile_id is null", () => {
|
|
const fp = "a".repeat(32);
|
|
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: fp }), "self");
|
|
assert.equal(reportCoverSubject({ chartProfileId: "11111111-1111-4111-8111-111111111111", storedFingerprint: fp, currentFingerprint: fp }), "11111111-1111-4111-8111-111111111111");
|
|
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: "b".repeat(32) }), null);
|
|
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: null, currentFingerprint: fp }), null);
|
|
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: null }), null);
|
|
});
|
|
|
|
test("GET /api/reports/:id compares on the server through the chart page's subject loader", () => {
|
|
assert.match(itemRouteSource, /loadCurrentBirthFingerprint: async \(ownerId: string, subjectId: string\) => \(\s*subjectBirthFingerprint\(\(await loadSubjectBirth\(supabase, ownerId, subjectId\)\)\.row\)/);
|
|
assert.match(routeCoreSource, /reportDocumentBirthFingerprint\(row\.reportDocument\)/);
|
|
});
|