Files
Jyotisha/frontend/tests/personal-report-entry.test.ts
T
Jesse_ChenandClaude Fable 5.1 fb77c86585 test(ui): 侧栏只读模式、共享外壳与列表缓存的合同回归
新增 8 条:`sidebar-data-cache.test.ts`(命中不重拉 / 过期重拉一次 / 写操作后
拿到新标题并逐条锁住五个写路径 / 双账户不串 / 只存内存 / 401 清空)、
`sidebar-state.test.ts` +2(收起后重挂仍收起,含三种降级;移动端不读不写)、
`sidebar-contract.test.ts` +1(只读模式只少三样)、`chart-page-view.test.tsx` +1
(`(secondary)` layout 恰好挂一份只读侧栏,数据 hook 无写方法)。

改写 9 处既有断言,每处带「原值 / 新值 / 原因」三栏注释,均未削弱:
`window.location.assign(path)` → `<SidebarMenuLink href>` 并追加反向断言;
`onOpenReports` / `useRouter` 改成 doesNotMatch;`SecondaryShell` → `SecondaryHeader`;
导航顺序改在 `NAV_PAGES` 常量里量;两个 render 辅助改为裹 `SidebarProvider`
(provider 上移到 layout);三处源码路径跟随路由组移动。

测试总数 3391 → 3399,失败清单与基线逐条一致(47 条均为无 Docker 的既有缺口)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 11:23:11 +00:00

364 lines
18 KiB
TypeScript

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 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/);
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", () => {
// 原值:`.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, /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}生成个人报告/);
});
test("ready reports expose Markdown export only in the ready branch", () => {
assert.match(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL/);
assert.match(reportCenterSource, /downloadPersonalReportLongformAppendix/);
assert.match(reportCenterSource, /cardSummary/);
assert.match(longformDownloadSource, /professional-reference/);
assert.match(longformDownloadSource, /personalReportMarkdownFilename/);
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, /PERSONAL_REPORT_EXPORT_LABEL/);
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /PERSONAL_REPORT_EXPORT_LABEL/);
});
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, /className="report-center-export-error" role="alert"/);
// 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, /张盘|节,共/);
});