Files
Jyotisha/frontend/tests/personal-report-entry.test.ts
T

390 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/(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 longformDownloadSource = readFileSync(
new URL("../src/lib/personal-report-longform-download.ts", 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", "health"]);
assert.deepEqual(DEFAULT_REPORT_THEMES, ["career", "marriage", "wealth", "timing", "health"]);
// 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/)`(按钮在 46px 标题栏里不被挤压)
// 新值:`assert.doesNotMatch(componentSource, /shrink-0/)`;`whitespace-nowrap` 保留
// 原因:TASK-report-reader-polish-20260929 §3-1——按钮从标题栏挪进页面主体的生成卡片居中,
// 不再与标题争宽;「一行不折」这条要求没变
assert.doesNotMatch(componentSource, /shrink-0/);
assert.match(componentSource, /whitespace-nowrap/);
// 原值: /toast\.error\(outcome\.message\)/ 与 /toast\.error\("网络异常,请检查连接后重试。"\)/(直接调 sonner)
// 新值: 同两处改走 notifyError(同一个 toast,带 6 秒与关闭按钮的全站口径)
// 原因: 2026-09-30 全站提示统一走 src/lib/notify.ts
assert.match(componentSource, /notifyError\(outcome\.message\)/);
assert.match(componentSource, /notifyError\("网络异常,请检查连接后重试。"\)/);
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/);
// 原值: 报告中心顶部说明段必须含「有填报到分钟的出生时间即可生成」「完整本命报告需要具体分钟」「与某一次对话无关」「后台继续处理」四句(四条 match)
// 新值: 四句都不再出现(doesNotMatch),顶部说明区与统计行整块删除;生成按钮的 title 仍说明分钟要求(下方断言不动)
// 原因: TASK-self-edit-avatar-menu-20260928 S3——产品要求「我的报告」去掉顶部提示,页面从「报告记录」列表开始
assert.doesNotMatch(reportCenterSource, /有填报到分钟的出生时间即可生成/);
assert.doesNotMatch(reportCenterSource, /完整本命报告需要具体分钟/);
assert.doesNotMatch(reportCenterSource, /与某一次对话无关/);
assert.doesNotMatch(reportCenterSource, /后台继续处理/);
assert.doesNotMatch(reportCenterSource, /report-center-hero|report-center-meta|报告概览/);
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", () => {
// 原值:`.report-center-shell`(报告中心自己的全屏滚动容器)
// 新值:`.secondary-panel > :not(.chat-header)`——报告中心并入 app 外壳后,
// 滚动所有权归外壳的内容区,三个次级页共用一条规则。
// 原因:T4.2。「页面在外壳的高度锁内部滚动、不自己撑高」这条要求没变。
const centre = cssDeclarations(".secondary-panel > :not(.chat-header)", globalStyles);
const reader = cssDeclarations(".personal-report-reader", globalStyles);
assert.match(globalStyles, /html, body \{ width: 100%; height: 100%; overflow: hidden; \}/);
assert.match(centre, /overflow-y:\s*auto/);
// 高度由 .chat-panel 的 grid 行给定,内容区自己不再声明 height。
assert.match(cssDeclarations(".chat-panel", globalStyles), /grid-template-rows:\s*46px minmax\(0, 1fr\) 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"\)\}/)`
// 新值:断言侧栏自己用 `<SidebarMenuLink href="/reports">` 走这一步,且 `page.tsx`
// 不再传 `onOpenReports`、也不再持有 router
// 原因:TASK-sidebar-unify T4。`onOpenReports` 从 D9 起就是死 prop——侧栏把它解构成
// `_onOpenReports` 从未调用过,真正的跳转走的是 `leaveChat("/reports")` 的整页
// 刷新。删掉死 prop,跳转改客户端导航。入口仍然只在侧栏一处,断言主语没变;
// 新增的两条 doesNotMatch 比原断言更强。
assert.match(sidebarSource, /\{ href: "\/reports", label: "我的报告"/);
assert.doesNotMatch(sidebarSource, /onOpenReports/);
assert.doesNotMatch(pageSource, /onOpenReports/);
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
assert.doesNotMatch(pageSource, /useRouter/);
assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/);
// Was `justify-content: center`, which is what put 我的报告 in the middle of
// the mobile drawer (BUG-438). The base layer is left-aligned now; centering
// moved into the >=768px collapsed rail, locked by sidebar-contract.
assert.match(
globalStyles,
/\.report-nav-button \{[^}]*display: flex;[^}]*align-items: center;[^}]*justify-content: flex-start;/,
);
});
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,
declaredWindowStart: "",
declaredWindowEnd: "",
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, /先看阶段/);
assert.match(markdown, /这次说不到具体哪一天/);
assert.doesNotMatch(markdown, /workflow_route/);
assert.doesNotMatch(markdown, /precise_timing/);
assert.doesNotMatch(markdown, /technique_truth/);
assert.doesNotMatch(markdown, /MEVG/);
assert.equal(typeof downloadMarkdownReport, "function");
assert.match(pageSource, /consultation-report-export/);
assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/);
});
test("ready reports expose only the reader action in the ready branch", () => {
assert.doesNotMatch(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL|#export/);
assert.doesNotMatch(reportCenterSource, /downloadPersonalReportLongformAppendix|exportingReportId|exportError/);
assert.match(reportCenterSource, /查看报告/);
assert.match(reportCenterSource, /cardSummary/);
assert.doesNotMatch(longformDownloadSource, /professional-reference/);
assert.match(longformDownloadSource, /projectOrdinaryReportMarkdown/);
assert.doesNotMatch(longformDownloadSource, /fetch\(/);
assert.match(longformDownloadSource, /ordinaryReportDownloadMarkdown/);
assert.doesNotMatch(reportCenterSource, /全量数据附录/);
assert.doesNotMatch(reportCenterSource, /章节/);
const readyActionStart = reportCenterSource.indexOf(
'{report.status === "ready" ? (',
);
const generatingActionStart = reportCenterSource.indexOf(
') : report.status === "generating" ? (',
readyActionStart,
);
assert.ok(readyActionStart >= 0 && generatingActionStart > readyActionStart);
const readyBranch = reportCenterSource.slice(readyActionStart, generatingActionStart);
assert.match(readyBranch, /查看报告/);
assert.equal((readyBranch.match(/<Button\b/g) ?? []).length, 1, "ready row has exactly one document action");
assert.doesNotMatch(readyBranch, /#export|分块导出/);
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /分块导出/);
});
test("report centre is a single-column row list with a dotted status chip", () => {
// D12. The card grid is gone; a row puts every status chip on the same x, so
// a list of a dozen reports costs one scan instead of one per card.
assert.doesNotMatch(globalStyles, /\.report-center-card/);
assert.doesNotMatch(reportCenterSource, /report-center-card/);
assert.match(reportCenterSource, /className=\{`report-center-row is-\$\{report\.status\}`\}/);
const list = cssDeclarations(".report-center-list", globalStyles);
assert.match(list, /display: grid/);
assert.doesNotMatch(list, /grid-template-columns/, "one column, always");
const row = cssDeclarations(".report-center-row", globalStyles);
assert.match(row, /grid-template-columns: minmax\(0, 1fr\) auto/);
// The chip's colour dot — the three states used to differ only by fill.
const dot = cssDeclarations(".report-center-status::before", globalStyles);
assert.match(dot, /border-radius: 50%/);
assert.match(dot, /background: currentColor/);
for (const status of ["ready", "generating", "failed"]) {
assert.match(globalStyles, new RegExp(`\\.report-center-status\\.is-${status}`));
}
});
test("every report state keeps the affordance its row carried before", () => {
// The generating row still announces itself to assistive tech, the failed row
// still says why, and a failed export is still an alert.
assert.match(
reportCenterSource,
/role=\{report\.status === "generating" \? "status" : undefined\}/,
);
assert.match(reportCenterSource, /report\.failureSummary \?\? report\.failureCode \?\? "生成失败"/);
assert.match(reportCenterSource, /<ReportRowDelete reportId=\{report.id\} onDeleted=\{removeReport\}/);
const deletionSource = readFileSync(new URL("../src/components/personal-report/report-row-delete.tsx", import.meta.url), "utf8");
// 原值: /className="report-center-delete-error" role="alert"/(删除失败写在行内确认条里)
// 新值: 删除失败是 error toast(notifyError),行内不再有这一行
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
assert.match(deletionSource, /notifyError\("报告没能删除,请稍后重试。"\)/);
assert.doesNotMatch(deletionSource, /report-center-delete-error/);
// Empty and read-failure states are untouched by the row rewrite.
assert.match(reportCenterSource, /还没有个人报告/);
assert.match(reportCenterSource, /列表读取失败,请检查网络后重试。/);
// Row meta is only what GET /api/reports returns: no invented section or
// chart counts (VOICE.md 第 2 条).
const meta = reportCenterSource.slice(reportCenterSource.indexOf("function rowMeta"));
assert.match(meta, /formatDate\(report\.createdAt\)/);
assert.doesNotMatch(meta, /张盘|节,共/);
});