星盘页(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
228 lines
11 KiB
TypeScript
228 lines
11 KiB
TypeScript
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 Dasha(kn_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);
|
||
});
|