The dasha tab leads with a now card and a duration bar, and keeps sub-periods inside the current details element. A zero-year Chara row stays grey, shows 0 年, and cannot expand. The tropical wheel uses a 520 view box, element bands, separated planet labels, and aspect lines that stay visible while a selected body is highlighted.
448 lines
25 KiB
TypeScript
448 lines
25 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 { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
|
||
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
|
||
import { ChartWesternTab } from "../src/components/chart-page/chart-western-tab.tsx";
|
||
import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx";
|
||
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 { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
|
||
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
|
||
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
|
||
import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.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 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 = 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/);
|
||
// 原值:`.chart-page-vedic-stage .personal-report-chart-svg text { font-size: 16px; }`
|
||
// —— 一条 `text` 通选,把盘内所有文字统一提到 16 viewBox 单位。
|
||
// 新值:只提小字(宫位序号 16、As 15、图例 14/16),并新增一条反向断言:这个
|
||
// stage 下不得再出现 `text {` 通选规则。
|
||
// 原因:盘内文字现在不只有一种字号——行星符号本身已经按窄盘宽度定在 22 单位。
|
||
// 通选规则会把符号一起压回 16,正是本轮要避免的(DESIGN §15 的原意是
|
||
// 「小字在 ~318px 盘宽下不低于 12px」,不是「所有字一样大」)。断言主语
|
||
// (宫位序号仍是 16)一字未改,新增的 doesNotMatch 比原断言更强。
|
||
assert.match(
|
||
globalStyles,
|
||
/\.chart-page-vedic-stage \.personal-report-chart-svg \.personal-report-chart-muted \{ font-size: 16px; \}/,
|
||
);
|
||
assert.doesNotMatch(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{/);
|
||
});
|
||
|
||
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, /这一张盘还没拿到/);
|
||
// 原值:等待页不得出现骨架;新值:必须有空盘骨架、禁用 Tab,仍无 spinner。
|
||
// 原因:2026-09-24 D1 仅授权星盘盘位骨架(BUG-1016)。
|
||
assert.match(markup, /data-skeleton="true"/);
|
||
assert.match(markup, /aria-busy="true"/);
|
||
assert.equal((markup.match(/disabled=""/g) ?? []).length, 5);
|
||
assert.doesNotMatch(markup, /正在加载|InlineSpinner|spinner|打开即有/i);
|
||
assert.doesNotMatch(markup, /chart-page-eyebrow/);
|
||
});
|
||
|
||
test("profile data failures offer the chart settings entry and other failures do not", () => {
|
||
const reasons = [
|
||
"profile_incomplete",
|
||
"profile_query_error",
|
||
"adopted_calculation_incomplete",
|
||
"timezone_resolver_failure",
|
||
] as const;
|
||
for (const reason of reasons) {
|
||
const view = chartViewFailureResponse(reason);
|
||
const markup = withSidebarProvider(React.createElement(ChartPageView, { view }));
|
||
assert.match(markup, new RegExp(view.message));
|
||
assert.ok(markup.includes(`href="${CHART_SETTINGS_HREF}"`));
|
||
assert.match(markup, new RegExp(CHART_VIEW_COPY.profileEntryAction));
|
||
if (reason === "profile_incomplete") {
|
||
assert.match(markup, new RegExp(CHART_VIEW_COPY.profileEntryTitle));
|
||
assert.match(globalStyles, /\.chart-page-message h2 \{[^}]*font-size:\s*var\(--type-title-md\)/);
|
||
} else {
|
||
assert.doesNotMatch(markup, new RegExp(CHART_VIEW_COPY.profileEntryTitle));
|
||
}
|
||
assert.doesNotMatch(markup, /正在加载|skeleton|spinner/i);
|
||
}
|
||
const engine = withSidebarProvider(React.createElement(ChartPageView, {
|
||
view: chartViewFailureResponse("engine_busy"),
|
||
}));
|
||
assert.match(engine, new RegExp(CHART_VIEW_COPY.busy));
|
||
assert.equal(engine.includes(CHART_VIEW_COPY.profileEntryAction), false);
|
||
assert.equal(engine.includes(CHART_SETTINGS_HREF), false);
|
||
});
|
||
|
||
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, /这一栏还没拿到/);
|
||
// 原值:禁止 skeleton;新值:必须 data-skeleton;原因:D1/D2 授权复用西洋空盘。
|
||
assert.match(markup, /data-skeleton="true"/);
|
||
assert.doesNotMatch(markup, /正在加载|InlineSpinner/i);
|
||
});
|
||
|
||
test("varga pending shows the empty shared chart and failure replaces it", async () => {
|
||
const all = await okView();
|
||
const view = { ...all, vedic: { ...all.vedic, vargas: all.vedic.vargas.filter((v) => v.id === "D1") } };
|
||
const render = (pending: boolean, failure?: "engine_busy" | "client_timeout") => renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "D9", pending, failure, onSelectVarga: () => {} }));
|
||
assert.match(render(true), /data-skeleton="true"/);
|
||
assert.match(render(true), /这一分盘还没拿到/);
|
||
for (const [failure, copy] of [["engine_busy", CHART_VIEW_COPY.busy], ["client_timeout", CHART_VIEW_COPY.timedOut]] as const) {
|
||
const markup = render(false, failure);
|
||
assert.ok(markup.includes(copy)); assert.doesNotMatch(markup, /data-skeleton|这一分盘还没拿到/);
|
||
}
|
||
assert.match(render(true, "engine_busy"), /data-skeleton="true"/);
|
||
});
|
||
|
||
test("western qizheng and chara failures are terminal copy rather than waiting", async () => {
|
||
const view = await okView();
|
||
for (const [tab, layer] of [["western", "western"], ["qizheng", "qizheng"], ["dasha", "chara"]] as const) {
|
||
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab, layerFailures: new Map([[layer, "engine_busy" as const]]) }));
|
||
assert.ok(markup.includes(CHART_VIEW_COPY.busy)); assert.doesNotMatch(markup, /data-skeleton|这一栏还没拿到/);
|
||
if (tab === "dasha") assert.match(markup, /Vimshottari/);
|
||
}
|
||
});
|
||
|
||
test("qizheng and chara pending remain static and only western has a wheel skeleton", async () => {
|
||
const view = await okView();
|
||
for (const [tab, layer] of [["qizheng", "qizheng"], ["dasha", "chara"]] as const) {
|
||
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab, pendingLayers: new Set([layer]) }));
|
||
assert.match(markup, /这一栏还没拿到/); assert.doesNotMatch(markup, /data-skeleton|InlineSpinner/);
|
||
}
|
||
});
|
||
|
||
test("skeleton shares north Indian geometry without planets degrees or signs", async () => {
|
||
const view = await okView();
|
||
const markup = renderToStaticMarkup(React.createElement(VedicChartSvg, { chart: view.vedic.vargas[0].chart, ariaLabel: "test", skeleton: true }));
|
||
const empty = renderToStaticMarkup(React.createElement(VedicChartSvg, { chart: EMPTY_NORTH_INDIAN_CHART, ariaLabel: "test" }));
|
||
assert.deepEqual([...markup.matchAll(/<polygon points="([^"]+)"/g)].map((m) => m[1]), [...empty.matchAll(/<polygon points="([^"]+)"/g)].map((m) => m[1]));
|
||
assert.doesNotMatch(markup, /<text|personal-report-chart-glyph|°/);
|
||
});
|
||
|
||
test("chart skeleton has one scoped breath rule and reduced motion is static", () => {
|
||
assert.match(cssDeclarations(".chart-page-skeleton", globalStyles), /animation: chart-skeleton-breathe 2\.4s ease-in-out infinite/);
|
||
assert.equal((globalStyles.match(/@keyframes chart-skeleton-breathe/g) ?? []).length, 1);
|
||
assert.match(globalStyles, /@keyframes chart-skeleton-breathe\s*\{\s*0%, 100% \{ opacity: 0\.35; \}\s*50% \{ opacity: 0\.6; \}/);
|
||
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.chart-page-skeleton \{ animation: none; opacity: 0\.5; \}/);
|
||
});
|
||
|
||
test("chart svgs omit the invalid height attribute and western height stays in css", () => {
|
||
const westernSvg = readFileSync(new URL("../src/components/chart-page/western-wheel-svg.tsx", import.meta.url), "utf8");
|
||
assert.doesNotMatch(vedicSvg, /height="auto"/);
|
||
assert.doesNotMatch(westernSvg, /height="auto"/);
|
||
assert.match(westernSvg, /width="100%"/);
|
||
assert.match(vedicSvg, /width="100%"/);
|
||
assert.match(globalStyles, /\.chart-page-western-svg \{[^}]*height: auto/);
|
||
});
|
||
|
||
test("the dasha tab folds every track to the current period and keeps a now card", async () => {
|
||
const view = await okView(golden.western);
|
||
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
|
||
assert.equal((markup.match(/chart-page-dasha-now/g) ?? []).length, 2);
|
||
assert.match(markup, /现在/);
|
||
assert.match(markup, /<dt>小运剩余<\/dt><dd>\d+ 个月<\/dd>/);
|
||
const periods = [...view.dasha.vimshottari.periods, ...view.dasha.chara.periods];
|
||
assert.equal(periods.some((period) => period.zeroYear), false);
|
||
assert.equal((markup.match(/data-dasha-segment=/g) ?? []).length, periods.length);
|
||
const details = markup.match(/<details/g) ?? [];
|
||
const open = markup.match(/<details open/g) ?? [];
|
||
assert.equal(open.length, periods.filter((period) => period.current).length);
|
||
assert.ok(details.length > open.length);
|
||
assert.match(cssDeclarations(".chart-page-dasha-period summary", globalStyles), /min-height:\s*44px/);
|
||
assert.match(globalStyles, /\.chart-page-dasha-period details:not\(\[open\]\) > ul \{ display: none; \}/);
|
||
});
|
||
|
||
test("the western tab lists every planet and filters aspects for the selected body", async () => {
|
||
const view = await okView(golden.western);
|
||
assert.equal(view.western.status, "ok");
|
||
if (view.western.status !== "ok") return;
|
||
const markup = renderToStaticMarkup(React.createElement(ChartWesternTab, { view }));
|
||
assert.equal((markup.match(/data-planet-row=/g) ?? []).length, view.western.planets.length);
|
||
assert.equal((markup.match(/data-aspect-row=/g) ?? []).length, view.western.aspects.length);
|
||
assert.match(markup, /viewBox="0 0 520 520"/);
|
||
assert.match(markup, /data-tone=/);
|
||
assert.doesNotMatch(markup, /<text[^>]*>升<\/text>/);
|
||
assert.match(markup, /上升 /);
|
||
assert.match(markup, /太阳 /);
|
||
assert.match(markup, /月亮 /);
|
||
assert.match(markup, /星群 摩羯/);
|
||
assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*520px/);
|
||
assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /overflow-x:\s*auto/);
|
||
assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /min-width:\s*0/);
|
||
assert.doesNotMatch(cssDeclarations(".chart-page-western-table", globalStyles), /min-width/);
|
||
const sun = view.western.planets.find((planet) => planet.id === "sun");
|
||
assert.ok(sun);
|
||
const filtered = renderToStaticMarkup(React.createElement(ChartWesternTab, { view, initialSelectedId: sun.id }));
|
||
const expected = view.western.aspects.filter((aspect) => aspect.left === sun.label || aspect.right === sun.label);
|
||
assert.equal((filtered.match(/data-aspect-row=/g) ?? []).length, expected.length);
|
||
assert.ok(expected.length < view.western.aspects.length);
|
||
assert.match(filtered, /data-filtering="true"/);
|
||
assert.match(filtered, /is-selected/);
|
||
const svg = markup.slice(markup.indexOf("<svg"), markup.indexOf("</svg>"));
|
||
const labels = [...svg.matchAll(/<text x="([^"]+)" y="([^"]+)"[^>]*>([^<]*)<\/text>/g)].map((match) => ({
|
||
text: match[3] ?? "",
|
||
box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""),
|
||
}));
|
||
for (let left = 0; left < labels.length; left += 1) {
|
||
for (let right = left + 1; right < labels.length; right += 1) {
|
||
const a = labels[left]!.box;
|
||
const b = labels[right]!.box;
|
||
const overlap = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||
assert.equal(overlap, false, `${labels[left]!.text} overlaps ${labels[right]!.text}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
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"/);
|
||
});
|