Files
Jyotisha/frontend/tests/chart-page-view.test.tsx
T
Jesse_ChenandClaude Opus 5 1e3570b462
Independent Staging Quality Gate / validate (push) Failing after 9m13s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 星盘 tab 下划线化、参数收成键值表,星历日期条改 ‹ 日期 ›
星盘页(T5.1):
- 五个体系 tab 从五枚描边药丸改成顶栏下方一条下划线式 tab 行,
  flex-wrap: nowrap + 容器自身横滚,375px 下仍是一行;底线画在滚动
  容器的 border 上,不随内容滚走。眉标(BUG-717 的成本与速度承诺)
  原文保留,位置从 tab 行上方移到下方,让 tab 行直接接住 46px 顶栏。
- 分盘 chips 保留但降一级:去描边去卡面、caption 字号、三级色,
  当前项只留浅底。它是一个 tab 内部的筛选器,不是第二层导航。
- 参数从「中宫叠卡(手机两行/桌面六行)+ 盘下重复卡 + media query
  三选一」收成一张八行键值表。同一组事实原本画三遍,改一处要改三处。
  .chart-page-center-card / -compact / -extra / .chart-page-params-below
  / .chart-page-boundary-desktop 五个类连同 767/768 开关块一并删除。
  view.vedic.boundary 那句准确性边界句原文未动,紧跟在表下方,且
  DOM 里从出现两次变成一次。
- 三张行星表(印度宿度 / 西洋相位 / 七政十一曜)全部包上横向滚动
  容器,wrap 补 min-width: 0 —— 少了它 grid item 按内容定宽,表格会
  撑宽页面而不是在自己盒子里滚动。原先只有第一张有 wrap。

星历页(T5.2):
- 日期导航从三个并排等宽按钮改成居中的 ‹ 日期 ›:日期是主语,两枚
  44px 箭头是把手。箭头字形 aria-hidden,可访问名仍是「前一天」
  「后一天」,读屏不丢方向。
- 「今天」只在非今日时以文字按钮出现在日期下方;在今日时整个不渲染,
  而不是渲染成禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。
- 三段统一成同一套卡片:框从内容上移到段落,.ephemeris-panchanga 的
  dl 网格原样保留。收尾句原文不变,但从底部第四张卡降为页尾一行小字。

两条既有断言改动,测试文件内就地写了原值/新值/原因,进度记录成表:
参数断点开关的三条断言换成「参数只有一张键值表且那五个类不再有规则」
(更强,且避开了原写法 indexOf 未命中后静默锚错断点块的陷阱);
「今天」在场的单状态断言拆成今日/非今日两状态并加断不出现 disabled。
测试总数 3350 → 3352,失败清单与基线逐条一致(31 条无 Docker 缺口)。

tsc 0 错;lint 0 error / 118 warning(与基线同数);/ /chart /ephemeris
渲染标记均保持 ○ Static,/reports 仍 ƒ;主 CSS gzip 36,745 → 36,788
(+0.12%)。行星表 375px 横滚、tab 行横滚、读屏播报无真机验收,已在
进度记录里如实写成环境缺口。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 06:47:50 +00:00

228 lines
11 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 { 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;
}
test("the five tabs render and can each be selected", async () => {
const view = await okView();
for (const tab of CHART_VIEW_TABS) {
const markup = renderToStaticMarkup(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 = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: "western" }));
const qizheng = renderToStaticMarkup(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 = renderToStaticMarkup(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 我的报告", () => {
const header = sidebar.slice(sidebar.indexOf("<SidebarHeader"), sidebar.indexOf("</SidebarHeader>"));
const newChat = header.indexOf("新建对话");
const chart = header.indexOf(">星盘<");
const ephemeris = header.indexOf(">星历<");
const reports = header.indexOf("我的报告");
assert.ok(newChat >= 0 && chart > newChat && ephemeris > chart && reports > ephemeris);
assert.match(sidebar, /leaveChat\("\/chart"\)/);
assert.match(sidebar, /leaveChat\("\/ephemeris"\)/);
assert.match(sidebar, /tooltip="星盘"/);
assert.match(sidebar, /tooltip="星历"/);
});
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 = renderToStaticMarkup(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 = renderToStaticMarkup(React.createElement(ChartPageView, { view: null }));
assert.match(markup, />星盘</);
// 原值:断言页面上有「返回对话」链接
// 新值:断言常驻侧栏在场(「新建对话」是它的第一项)
// 原因:星盘页并入 app 外壳后侧栏常驻,回对话不再依赖一个一次性链接;
// 这比原断言更强——它要求的是整条导航都在,而不只是一个返回按钮。
assert.match(markup, /新建对话/);
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 = renderToStaticMarkup(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 = renderToStaticMarkup(React.createElement(ChartPageView, {
view,
initialTab: "western",
pendingLayers: new Set<typeof CHART_VIEW_LAYERS[number]>(["western"]),
}));
assert.match(markup, /这一栏还没拿到/);
assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i);
});