Files
Jyotisha/frontend/tests/chart-page-view.test.tsx
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

280 lines
15 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 React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
Object.assign(globalThis, { React });
const golden = JSON.parse(
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
) as {
chart: Record<string, unknown>;
varga_full: Record<string, unknown>;
chara: Record<string, unknown>;
western: Record<string, unknown>;
};
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const vedicTab = readFileSync(new URL("../src/components/chart-page/chart-vedic-tab.tsx", import.meta.url), "utf8");
const westernTab = readFileSync(new URL("../src/components/chart-page/chart-western-tab.tsx", import.meta.url), "utf8");
const qizhengTab = readFileSync(new URL("../src/components/chart-page/chart-qizheng-tab.tsx", import.meta.url), "utf8");
/** Every selector that has at least one rule, comments stripped first. */
function declaredSelectors(source: string): Set<string> {
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " ");
const found = new Set<string>();
for (const [, selectorList] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
for (const target of selectorList.split(",")) found.add(target.trim().replace(/\s+/g, " "));
}
return found;
}
const vedicSvg = readFileSync(new URL("../src/components/personal-report/vedic-chart-svg.tsx", import.meta.url), "utf8");
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "confirmed",
};
async function okView(western: Record<string, unknown> | null = null): Promise<ChartViewOk> {
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers: CHART_VIEW_LAYERS,
postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara };
if (path === "/api/western" && western) return { status: "ok", payload: western };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return result.body as ChartViewOk;
}
/* 原值:直接 `renderToStaticMarkup(<ChartPageView … />)`
新值:外面裹一层 `SidebarProvider`
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx`
TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的
`SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */
function withSidebarProvider(element: React.ReactElement) {
return renderToStaticMarkup(React.createElement(SidebarProvider, null, element));
}
test("the five tabs render and can each be selected", async () => {
const view = await okView();
for (const tab of CHART_VIEW_TABS) {
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab.id }));
// 原值: 直接计算 · 打开即有 · 不消耗点数
// 新值: 主盘直接算 · 分盘按需 · 不消耗点数
// 原因: BUG-717 「打开即有」是速度承诺,且印在失败页上
assert.equal(markup.includes(CHART_VIEW_COPY.eyebrow), true);
assert.doesNotMatch(markup, /打开即有/);
assert.match(markup, />星盘</);
assert.match(markup, />基础信息</);
assert.match(markup, />大运</);
assert.match(markup, />西洋盘</);
assert.match(markup, />七政四余</);
assert.doesNotMatch(markup, /正在加载|骨架|spinner/i);
if (tab.id === "vedic") assert.match(markup, /D1/);
if (tab.id === "basics") assert.match(markup, /词条式释义/);
if (tab.id === "dasha") {
assert.match(markup, /Vimshottari/);
assert.match(markup, /Chara Dashakn_rao 变体)/);
assert.match(markup, /只有两条同时指向同一段时间才算证据/);
}
if (tab.id === "western") assert.match(markup, /西洋盘这一栏要等回归黄道端点上线/);
if (tab.id === "qizheng") {
assert.match(markup, /七政四余这一栏要等宿度端点上线/);
assert.match(markup, /角宿/);
assert.match(markup, /计都派别/);
assert.match(markup, /庙旺/);
}
}
});
test("unavailable western and qizheng tabs stay explanatory", async () => {
const view = await okView();
const western = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western" }));
const qizheng = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "qizheng" }));
assert.doesNotMatch(western, /报错|出错了|500/);
assert.doesNotMatch(qizheng, /报错|出错了|500/);
assert.match(western, /不能换算/);
assert.match(qizheng, /不能换算/);
});
test("a tropical packet draws the western wheel instead of the unavailable copy", async () => {
const view = await okView(golden.western);
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western" }));
assert.match(markup, /回归黄道本命盘/);
assert.doesNotMatch(markup, /西洋盘这一栏要等回归黄道端点上线/);
});
test("the North Indian svg is imported in place and its export signature is unchanged", () => {
assert.match(vedicTab, /from "@\/components\/personal-report\/vedic-chart-svg"/);
assert.match(vedicSvg, /export function VedicChartSvg/);
assert.match(vedicSvg, /export const NorthIndianChartSvg = VedicChartSvg/);
assert.doesNotMatch(vedicTab, /chart-page\/vedic-chart-svg/);
});
test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的报告", () => {
// 原值:三个页面项的中文标签直接写在 `<SidebarHeader>` 的 JSX 里,顺序用
// `header.indexOf(">星盘<")` 之类量。
// 新值:标签收进模块常量 `NAV_PAGES`,头部只剩 `NAV_PAGES.map(...)`;顺序改在
// 常量里量,并另外断言这一段仍排在「新建对话」之后。
// 原因:TASK-sidebar-unify T1 把次级页那份侧栏收编进同一个组件,三项在两种模式下
// 必须逐字一致,写两遍就会再次分叉。断言主语(新建对话 → 星盘 → 星历 →
// 我的报告,且「我的报告」未改名)一字未改。
const header = sidebar.slice(sidebar.indexOf("<SidebarHeader"), sidebar.indexOf("</SidebarHeader>"));
const newChat = header.indexOf("新建对话");
const navPages = header.indexOf("NAV_PAGES.map");
assert.ok(newChat >= 0 && navPages > newChat);
const pages = sidebar.slice(sidebar.indexOf("const NAV_PAGES"), sidebar.indexOf("] as const;"));
const chart = pages.indexOf('"星盘"');
const ephemeris = pages.indexOf('"星历"');
const reports = pages.indexOf('"我的报告"');
assert.ok(chart >= 0 && ephemeris > chart && reports > ephemeris);
// 原值:`leaveChat("/chart")` / `leaveChat("/ephemeris")` —— 两个会走
// `window.location.assign(path)` 的按钮回调。
// 新值:三个页面项统一由 `NAV_PAGES` 渲染成 `<SidebarMenuLink href=…>`,并追加
// 反向断言:侧栏里不得再出现 `window.location.assign("/chart"…)` 这类整页跳转。
// 原因:TASK-sidebar-unify D3/T4——首页进次级页曾是整页刷新,React 树、会话列表、
// 账户全部清零。断言主语(三项都在、顺序正确、有 tooltip)没变,
// 新增的 doesNotMatch 比原断言更强。
assert.match(sidebar, /\{ href: "\/chart", label: "星盘"/);
assert.match(sidebar, /\{ href: "\/ephemeris", label: "星历"/);
assert.match(sidebar, /\{ href: "\/reports", label: "我的报告"/);
assert.match(sidebar, /tooltip=\{label\}/);
assert.doesNotMatch(sidebar, /window\.location\.assign/);
});
test("the parameters are one key/value table, not a per-breakpoint duplicate", () => {
// 原值:断言 `@media (max-width: 767px)` 块里的三个开关
// `.chart-page-center-compact { display: block }` /
// `.chart-page-center-extra { display: none }` /
// `.chart-page-params-below { display: block }`。
// 新值:断言参数只有一张 `.chart-page-params-table` 键值表,且那三个类名在
// 整份 CSS 里都不再作为规则出现。
// 原因:T5.1 要求参数从中宫卡与盘下卡收进一张统一键值表(决策记录 D8 一系)。
// 原断言守的是「同一组参数按断点切换三种画法」,那正是本轮要去掉的东西,
// 断言无法原样保留。新断言比原来更强:它不只要求当前画法正确,还禁止
// 任何一种按断点复制参数的写法回来。
// 注:原写法本身也是个陷阱——`indexOf` 未命中时返回 -1`-2000` 之后
// 会从文件开头重新搜 `@media`,静默锚到无关的断点块上。
assert.match(cssDeclarations(".chart-page-param-row", globalStyles), /grid-template-columns/);
for (const dead of [
".chart-page-center-card",
".chart-page-center-compact",
".chart-page-center-extra",
".chart-page-params-below",
".chart-page-boundary-desktop",
]) {
assert.equal(
declaredSelectors(globalStyles).has(dead),
false,
`${dead} still has a CSS rule`,
);
}
assert.match(cssDeclarations(".chart-page-tab", globalStyles), /min-height:\s*44px/);
assert.match(cssDeclarations(".chart-page-chip", globalStyles), /min-height:\s*44px/);
assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/);
});
test("the planet table scrolls inside its own box instead of widening the page", () => {
assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /overflow-x:\s*auto/);
// Without `min-width: 0` a grid item is sized by its content, so the table
// pushes the page sideways and the overflow never engages.
assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /min-width:\s*0/);
assert.match(cssDeclarations(".chart-page-planet-table", globalStyles), /min-width:\s*\d/);
// Every planet table is wrapped, not just the one on the 星盘 tab.
for (const source of [vedicTab, westernTab, qizhengTab]) {
const tables = [...source.matchAll(/<table className="chart-page-planet-table">/g)].length;
const wraps = [...source.matchAll(/<div className="chart-page-planet-table-wrap">/g)].length;
assert.equal(wraps, tables);
}
});
test("an incomplete profile page has no loading animation", () => {
const markup = withSidebarProvider(React.createElement(ChartPageView, {
view: { status: "birth_profile_incomplete", billed: false, message: "还没有可用来排盘的出生资料。" },
}));
assert.match(markup, /还没有可用来排盘的出生资料/);
assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i);
assert.doesNotMatch(markup, /chart-page-eyebrow|打开即有/);
});
test("the chart page shell is visible before the natal chart arrives", () => {
const markup = withSidebarProvider(React.createElement(ChartPageView, { view: null }));
assert.match(markup, />星盘</);
// 原值:`assert.match(markup, /新建对话/)` —— 在页面组件的产物里断言侧栏第一项
// 新值:断言 46px 顶栏的侧栏触发器在场;侧栏本体改由本文件下一条
// 「(secondary) layout 挂一份只读侧栏」断言,读的是 layout 源码
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏从每页各一份上移到
// `app/(secondary)/layout.tsx`,页面组件的产物里本就不该再有侧栏。
// 断言主语(这一页不靠一次性「返回对话」链接回去)没变,覆盖没有减少。
assert.match(markup, /data-sidebar="trigger"/);
assert.doesNotMatch(markup, /返回对话/);
assert.match(markup, /这一张盘还没拿到/);
assert.doesNotMatch(markup, /正在加载|骨架|spinner|打开即有/i);
assert.doesNotMatch(markup, /chart-page-eyebrow/);
});
test("failure copy has no eyebrow and does not promise a later retry", () => {
const markup = withSidebarProvider(React.createElement(ChartPageView, {
view: { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable },
}));
assert.match(markup, new RegExp(CHART_VIEW_COPY.unavailable));
assert.doesNotMatch(markup, /chart-page-eyebrow|打开即有|过一会儿再打开|不消耗点数/);
});
test("a pending western tab uses inline waiting copy instead of a spinner", async () => {
const view = await okView();
const markup = withSidebarProvider(React.createElement(ChartPageView, {
view,
initialTab: "western",
pendingLayers: new Set<typeof CHART_VIEW_LAYERS[number]>(["western"]),
}));
assert.match(markup, /这一栏还没拿到/);
assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i);
});
test("the (secondary) layout mounts one read-only sidebar for all four routes", () => {
const layout = readFileSync(new URL("../src/app/(secondary)/layout.tsx", import.meta.url), "utf8");
// One provider, one sidebar, one inset — for /chart, /ephemeris, /reports and
// /reports/[reportId] together. Each page used to mount its own.
assert.match(layout, /<SidebarProvider>/);
assert.match(layout, /<AppSidebar\b/);
assert.match(layout, /<SidebarInset className="chat-panel secondary-panel">/);
assert.equal(layout.match(/<SidebarProvider>/g)?.length, 1);
// Read-only: no `controls`, so no rename / archive / delete / account menu.
assert.doesNotMatch(layout, /controls=/);
assert.match(layout, /useSidebarData\(\)/);
// The data hook never writes, and the pages never mount a second shell.
const hook = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8");
assert.doesNotMatch(hook, /method:\s*"(?:POST|PATCH|PUT|DELETE)"/);
assert.match(hook, /fetch\("\/api\/sessions\?limit=40"/);
assert.match(hook, /fetch\("\/api\/account"/);
});