Files
Jyotisha/frontend/tests/chart-page-view.test.tsx
T
jesse-ux e4e73f56c0
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m33s
fix(web): 四个页面共用一份会话列表,空会话不入列
对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
2026-09-17 21:27:40 +08:00

281 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/(app)/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 }));
// 原值: 成功页必须含「主盘直接算 · 分盘按需 · 不消耗点数」;基础信息含「词条式释义」
// 新值: 成功页与失败页都不写这两句;基础信息只留行星卡
// 原因: 产品 2026-09-17 判定为过度提示,计费/速度说明和「不是对你个人的判断」都不印在页上
assert.doesNotMatch(markup, /chart-page-eyebrow|主盘直接算|分盘按需|不消耗点数|打开即有/);
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, /本命/);
assert.match(markup, /D9/);
}
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",
// 原值: 这一组不含 .chart-page-eyebrow
// 新值: 成功页眉标已删,该类不得再有 CSS 规则
// 原因: 产品 2026-09-17 去掉「主盘直接算 · 分盘按需 · 不消耗点数」
".chart-page-eyebrow",
]) {
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/(app)/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 (app) layout mounts one sidebar for home and the four secondary routes", () => {
// 原值:`(secondary)/layout.tsx` 挂只读侧栏,`useSidebarData` 另拉一份列表
// 新值:`(app)/layout.tsx` 一份 SessionListProvider + SidebarProvider,首页也在里面
// 原因:TASK-session-list-single-source T2,列表只拉一次、侧栏不卸载
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
assert.match(layout, /<SessionListProvider>/);
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
assert.match(layout, /<AppSidebar\b/);
assert.equal(layout.match(/<SidebarProvider/g)?.length, 1);
assert.match(layout, /controls=\{registration\?\.controls\}/);
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
assert.match(provider, /fetch\("\/api\/account"/);
});