import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { buildPersonalReportCreateRequest, classifyCreateResponse, createReportRequestId, DEFAULT_REPORT_THEMES, } from "../src/components/personal-report/generate-personal-report-button.tsx"; import { isBirthTimeReadyForConsultation } from "../src/lib/birth-time-intake-model.ts"; import { consultationReportMarkdown, downloadMarkdownReport, } from "../src/lib/consultation-report-export.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; const componentSource = readFileSync( new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url), "utf8", ); const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const sidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"); const reportCenterSource = readFileSync( new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url), "utf8", ); const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); test("request shape: POST /api/reports body carries only report identity fields", () => { const body = buildPersonalReportCreateRequest("request-id-1"); assert.deepEqual(Object.keys(body).sort(), ["depth", "presentationMode", "reportType", "requestId", "themes"]); assert.equal(body.requestId, "request-id-1"); assert.equal(body.reportType, "personal_full"); assert.equal(body.presentationMode, "default"); assert.equal(body.depth, "standard"); assert.deepEqual(body.themes, ["career", "marriage", "wealth", "timing"]); assert.deepEqual(DEFAULT_REPORT_THEMES, ["career", "marriage", "wealth", "timing"]); // No birth data, no chat text, no profile payload. assert.doesNotMatch(JSON.stringify(body), /birth|birthDate|latitude|longitude|timezone|profile|message|text/i); }); test("request shape: global reports never carry a session identifier", () => { const body = buildPersonalReportCreateRequest("request-id-2"); assert.equal("sessionId" in body, false); }); test("requestId is a uuid v4", () => { assert.match(createReportRequestId() ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); const cryptoAny = globalThis.crypto as unknown as { randomUUID: () => string }; const original = cryptoAny.randomUUID; cryptoAny.randomUUID = () => "fixed-uuid-value"; assert.equal(createReportRequestId(), "fixed-uuid-value"); cryptoAny.randomUUID = original; }); test("without crypto.randomUUID the request id is null and no request is sent", () => { // Simulate an environment without a secure request identifier. const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto"); Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true }); try { assert.equal(createReportRequestId(), null); } finally { if (descriptor) { Object.defineProperty(globalThis, "crypto", descriptor); } } // The handler must abort before fetch and show a browser hint instead. assert.match(componentSource, /if \(requestId === null\)/); assert.match(componentSource, /请使用支持安全请求标识的现代浏览器/); const abortCheck = componentSource.indexOf("if (requestId === null)"); const fetchCall = componentSource.indexOf('fetch("/api/reports"'); assert.ok(abortCheck >= 0 && fetchCall > abortCheck, "null requestId must abort before fetch"); // No Math.random fallback may ever mint a weak request id. assert.doesNotMatch(componentSource, /Math\.random/); assert.doesNotMatch(componentSource, /xxxxxxxx-xxxx-4xxx/); }); test("202 accepted, 201 created and 200 ready replay navigate to the report reader", () => { const report = { id: "report-id-1", requestId: "req", reportType: "personal_full", presentationMode: "default", status: "ready", failureCode: null, createdAt: "2026-08-06T00:00:00Z", completedAt: null, }; const created = classifyCreateResponse(201, { report }); assert.deepEqual(created, { kind: "navigate", reportId: "report-id-1" }); const accepted = classifyCreateResponse(202, { report: { ...report, status: "generating" } }); assert.deepEqual(accepted, { kind: "navigate", reportId: "report-id-1" }); const replayed = classifyCreateResponse(200, { report, reportDocument: { schemaVersion: "report_document.v1" } }); assert.deepEqual(replayed, { kind: "navigate", reportId: "report-id-1" }); }); test("failed replay never navigates", () => { const outcome = classifyCreateResponse(200, { report: { id: "report-id-2", status: "failed", failureCode: "model_unavailable", }, }); assert.equal(outcome.kind, "hint"); assert.ok(outcome.kind === "hint" && /未成功|重试/.test(outcome.message)); }); test("409 generating/conflict, 422 profile, 403, 429, 400, 5xx all become friendly hints", () => { const cases: Array<[number, unknown, RegExp]> = [ [409, { error: "已有报告正在生成中", code: "report_generation_in_progress" }, /生成中/], [409, { error: "请求内容与已有记录不一致", code: "report_request_conflict" }, /不一致/], [409, {}, /生成中|不一致/], [422, { error: "出生时间尚未达到可用状态", code: "birth_time_not_usable" }, /出生|资料/], [422, {}, /出生资料/], [403, { error: "个人报告功能暂未开放", code: "report_export_disabled" }, /暂未开放/], [403, {}, /会话校验|无法生成/], [429, { error: "今日报告生成次数已达上限", code: "report_rate_limited" }, /上限/], [400, {}, /格式/], [502, { error: "报告模型暂不可用", code: "model_unavailable" }, /不可用/], [503, {}, /稍后重试/], [401, {}, /登录/], ]; for (const [status, json, pattern] of cases) { const outcome = classifyCreateResponse(status, json); assert.equal(outcome.kind, "hint", `status ${status}`); assert.ok(outcome.kind === "hint" && pattern.test(outcome.message), `status ${status}: ${outcome.message}`); } }); test("409/403 branches key on the server's real stable codes", () => { // Code-only bodies (no server error text) prove the branch is code-driven. const conflict = classifyCreateResponse(409, { code: "report_request_conflict" }); assert.ok(conflict.kind === "hint" && /不一致/.test(conflict.message)); const inProgress = classifyCreateResponse(409, { code: "report_generation_in_progress" }); assert.ok(inProgress.kind === "hint" && /生成中/.test(inProgress.message)); const disabled = classifyCreateResponse(403, { code: "report_export_disabled" }); assert.ok(disabled.kind === "hint" && /暂未开放/.test(disabled.message)); const notOwned = classifyCreateResponse(403, { code: "report_resource_forbidden" }); assert.ok(notOwned.kind === "hint" && /会话校验|无法生成/.test(notOwned.message)); // The comparisons use the stable codes from REPORT_STABLE_CODES, not short aliases. assert.match(componentSource, /code === "report_request_conflict"/); assert.match(componentSource, /code === "report_export_disabled"/); assert.doesNotMatch(componentSource, /code === "request_conflict"/); assert.doesNotMatch(componentSource, /code === "export_disabled"/); }); test("repeat clicks are guarded while a request is in flight", () => { assert.match(componentSource, /if \(inFlight\.current \|\| submitting\)/); assert.match(componentSource, /inFlight\.current = true/); assert.match(componentSource, /disabled=\{submitting\}/); assert.match(componentSource, /正在生成/); }); test("entry stays on one line and sends failures to the existing toast", () => { assert.match(componentSource, /shrink-0/); assert.match(componentSource, /whitespace-nowrap/); assert.match(componentSource, /toast\.error\(outcome\.message\)/); assert.match(componentSource, /toast\.error\("网络异常,请检查连接后重试。"\)/); assert.doesNotMatch(componentSource, /personal-report-entry-note/); assert.doesNotMatch(componentSource, /flex-wrap/); assert.match(globalStyles, /\.chat-header-actions \{[^}]*min-width: max-content;[^}]*display: flex;[^}]*white-space: nowrap;/); }); test("entry never requests a server PDF and never prints directly", () => { assert.doesNotMatch(componentSource, /api\/report_artifact/); assert.doesNotMatch(componentSource, /window\.print/); assert.doesNotMatch(componentSource, /html2canvas|jsPDF|jspdf|playwright|puppeteer|chromium/i); assert.doesNotMatch(componentSource, /getContext\s*\(|toDataURL|base64/i); }); test("no birth data or chat text is sent from the entry component", () => { // The request body is built only by buildPersonalReportCreateRequest, whose // keys are exactly the report identity fields (covered above). The component // posts that body as-is. assert.match(componentSource, /const body = buildPersonalReportCreateRequest\(requestId\)/); assert.match(componentSource, /body: JSON\.stringify\(body\)/); assert.doesNotMatch(componentSource, /const body = \{[\s\S]*?latitude/); }); test("global report copy uses the canonical profile, allows an unrectified minute, and makes background work explicit", () => { assert.match(componentSource, /authenticated user's canonical profile/); assert.match(reportCenterSource, /有填报到分钟的出生时间即可生成/); assert.match(reportCenterSource, /完整本命报告需要具体分钟/); assert.match(reportCenterSource, /与某一次对话无关/); assert.match(reportCenterSource, /后台继续处理/); assert.match(componentSource, /根据已保存的具体出生分钟生成完整报告/); assert.doesNotMatch(reportCenterSource, /基于你已确认的出生资料生成/); assert.doesNotMatch(componentSource, /根据已确认的个人出生资料生成完整报告/); assert.doesNotMatch(componentSource, /evidenceState|workflowReceipt/); }); test("report centre and ready reader scroll inside the chat shell lock", () => { const centre = cssDeclarations(".report-center-shell", globalStyles); const reader = cssDeclarations(".personal-report-reader", globalStyles); assert.match(globalStyles, /html, body \{ width: 100%; height: 100%; overflow: hidden; \}/); assert.match(centre, /height:\s*100%/); assert.match(centre, /overflow-y:\s*auto/); assert.match(centre, /-webkit-overflow-scrolling:\s*touch/); assert.doesNotMatch(centre, /min-height:\s*100dvh/); assert.match(reader, /height:\s*100%/); assert.match(reader, /overflow-y:\s*auto/); assert.match(reader, /-webkit-overflow-scrolling:\s*touch/); assert.doesNotMatch(reader, /height:\s*100dvh/); }); test("entry is global in the sidebar and absent from the active session header", () => { assert.match(sidebarSource, /我的报告/); assert.match(sidebarSource, /onOpenReports/); assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/); assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/); assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/); assert.match( globalStyles, /\.report-nav-button \{[^}]*display: flex;[^}]*align-items: center;[^}]*justify-content: center;/, ); }); test("accepted/confirmed remain the only statuses ready for verified natal consultation", () => { const draftBase = { date: "1990-01-01", time: "12:00", reportedTime: "12:00", birthTimeSource: "family_exact" as const, birthTimePeriod: "morning" as const, birthTimeClue: "", uncertaintyBeforeMinutes: 10, uncertaintyAfterMinutes: 10, }; // reported/candidate must never be treated as usable (no fake candidate boundary). assert.equal( isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "reported" }), false, "reported time must not show the entry", ); assert.equal( isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "candidate" }), false, "candidate time must not show the entry", ); assert.equal( isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "accepted" }), true, ); assert.equal( isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "confirmed" }), true, ); // A missing active time is not usable even when the status is accepted. assert.equal( isBirthTimeReadyForConsultation({ ...draftBase, time: "", birthTimeStatus: "accepted" }), false, ); }); test("legacy consultation Markdown export is untouched and still works", () => { const markdown = consultationReportMarkdown({ title: "事业咨询", messages: [ { role: "user", text: "未来一年事业如何?" }, { role: "assistant", text: "先看阶段,不承诺具体日期。", techniqueTruth: "partial", workflowReceipt: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: ["MEVG"] }, }, ], }); assert.match(markdown, /# 事业咨询/); assert.match(markdown, /workflow_route: career/); assert.match(markdown, /precise_timing: blocked/); assert.equal(typeof downloadMarkdownReport, "function"); assert.match(pageSource, /consultation-report-export/); assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/); });