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; varga_full: Record; chara: Record; western: Record; }; 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 { const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " "); const found = new Set(); 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 | null = null): Promise { 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()` 新值:外面裹一层 `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, />星盘基础信息大运西洋盘七政四余 { 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 我的报告", () => { // 原值:三个页面项的中文标签直接写在 `` 的 JSX 里,顺序用 // `header.indexOf(">星盘<")` 之类量。 // 新值:标签收进模块常量 `NAV_PAGES`,头部只剩 `NAV_PAGES.map(...)`;顺序改在 // 常量里量,并另外断言这一段仍排在「新建对话」之后。 // 原因:TASK-sidebar-unify T1 把次级页那份侧栏收编进同一个组件,三项在两种模式下 // 必须逐字一致,写两遍就会再次分叉。断言主语(新建对话 → 星盘 → 星历 → // 我的报告,且「我的报告」未改名)一字未改。 const header = sidebar.slice(sidebar.indexOf("")); 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` 渲染成 ``,并追加 // 反向断言:侧栏里不得再出现 `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(//g)].length; const wraps = [...source.matchAll(/
/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, />星盘 { 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(["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, //); assert.match(layout, //); assert.equal(layout.match(//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"/); });