Files
Jyotisha/frontend/tests/chart-page-view.test.tsx
T
jesse-ux e0c6070bef feat: fold dasha periods and redraw the western wheel
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.
2026-09-25 02:59:18 +08:00

448 lines
25 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 { 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"/);
});