An inline classic script on / starts account, people list, sessions and models; the boot takes each once from one global, falls back to the normal request on failure, drops another person's or another account's list, and trusts nothing older than 30s. Paths and init come from one shared module. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
535 lines
32 KiB
TypeScript
535 lines
32 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";
|
||
import { goldenSky } from "./birth-sky-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");
|
||
|
||
/** 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("all chart types omit the engine display without removing calculation parameters", async () => {
|
||
const view = await okView(golden.western);
|
||
for (const varga of view.vedic.vargas) {
|
||
const markup = renderToStaticMarkup(<ChartVedicTab view={view} vargaId={varga.id} onSelectVarga={() => {}} />);
|
||
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
|
||
assert.match(markup, />岁差</);
|
||
assert.match(markup, />交点</);
|
||
}
|
||
for (const id of ["MOON", "BHAVA", "TRANSIT"] as const) {
|
||
const markup = renderToStaticMarkup(<ChartVedicTab view={view} vargaId={id} onSelectVarga={() => {}} />);
|
||
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
|
||
}
|
||
assert.doesNotMatch(renderToStaticMarkup(<ChartWesternTab view={view} />), /Swiss Ephemeris|>引擎</);
|
||
});
|
||
|
||
test("the four 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, />西洋盘</);
|
||
// 原值: 必须含「七政四余」Tab
|
||
// 新值: 不得含「七政四余」
|
||
// 原因: 产品 2026-09-28 决定七政四余先不在星盘页展示(TASK-chart-types D1),后端保留
|
||
assert.doesNotMatch(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, /西洋盘这一栏要等回归黄道端点上线/);
|
||
}
|
||
});
|
||
|
||
// 原值: 西洋与七政两栏各断言一次;新值: 只断言西洋;原因: 七政 Tab 已删(TASK-chart-types D1)
|
||
test("unavailable western tab stays explanatory", async () => {
|
||
const view = await okView();
|
||
const western = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western" }));
|
||
assert.doesNotMatch(western, /报错|出错了|500/);
|
||
assert.match(western, /不能换算/);
|
||
});
|
||
|
||
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.
|
||
// 原值: [vedicTab, westernTab, qizhengTab];新值: 去掉 qizhengTab;原因: 文件已删(TASK-chart-types D1)
|
||
for (const source of [vedicTab, westernTab]) {
|
||
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"/);
|
||
// 原值: 5 个禁用 Tab;新值: 4 个(CHART_VIEW_TABS.length);原因: 七政 Tab 已删(TASK-chart-types D1)
|
||
assert.equal((markup.match(/disabled=""/g) ?? []).length, CHART_VIEW_TABS.length);
|
||
assert.equal(CHART_VIEW_TABS.length, 4);
|
||
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"/);
|
||
});
|
||
|
||
// 原值: western / qizheng / chara 三栏;新值: 去掉 qizheng;原因: 七政 Tab 已删(TASK-chart-types D1)
|
||
test("western and chara failures are terminal copy rather than waiting", async () => {
|
||
const view = await okView();
|
||
for (const [tab, layer] of [["western", "western"], ["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/);
|
||
}
|
||
});
|
||
|
||
// 原值: qizheng 与 chara 两栏;新值: 只剩 chara;原因: 七政 Tab 已删(TASK-chart-types D1)
|
||
test("chara pending remains static and only western has a wheel skeleton", async () => {
|
||
const view = await okView();
|
||
for (const [tab, layer] of [["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", () => {
|
||
// 原值: `.chart-page-skeleton { animation: chart-skeleton-breathe 2.4s … }` (the whole empty drawing
|
||
// breathed 0.35 ↔ 0.6), one @keyframes chart-skeleton-breathe, reduced motion `animation: none; opacity: 0.5`
|
||
// 新值: the lines stay still (stroke-opacity .55); only the house fills move — one scoped
|
||
// @keyframes chart-skeleton-house, staggered by --house-index × 150ms; reduced motion switches it off
|
||
// 原因: 产品 2026-09-30「星盘的加载动画太丑了」,选「宫位依次点亮」
|
||
assert.doesNotMatch(globalStyles, /\.chart-page-skeleton \{[^}]*animation/, "the drawing as a whole no longer breathes");
|
||
assert.equal((globalStyles.match(/@keyframes chart-skeleton-breathe/g) ?? []).length, 0);
|
||
assert.equal((globalStyles.match(/@keyframes chart-skeleton-house/g) ?? []).length, 1);
|
||
const house = cssDeclarations(".chart-page-skeleton .personal-report-chart-cell,\n.chart-page-skeleton .chart-skeleton-sector", globalStyles);
|
||
assert.match(house, /animation: chart-skeleton-house 2\.4s ease-in-out infinite/);
|
||
assert.match(house, /animation-delay: calc\(var\(--house-index, 0\) \* 150ms\)/);
|
||
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.chart-page-skeleton \.personal-report-chart-cell,\s*\.chart-page-skeleton \.chart-skeleton-sector \{ animation: none; fill-opacity: 0; \}/);
|
||
});
|
||
|
||
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, /星群 摩羯/);
|
||
// 原值:所有宽度均为 520px 上限;新值:桌面 480px、窄屏仍 520px。
|
||
// 原因:TASK-chart-surface-polish 只收紧桌面盘宽,不修改 viewBox 或手机布局。
|
||
assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*520px/);
|
||
assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*480px/);
|
||
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");
|
||
const accountRead = readFileSync(new URL("../src/lib/current-subject.ts", import.meta.url), "utf8");
|
||
const bootReads = readFileSync(new URL("../src/lib/boot-early-reads.ts", import.meta.url), "utf8");
|
||
// 原值:provider 里内联 fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`)。
|
||
// 新值:provider 用 fetchSessionPage → fetch(bootSessionsPath(subjectId), …),路径由 boot-early-reads.ts 生成。
|
||
// 原因:首页 HTML 的早发请求(TASK-home-first-load T8)与正常请求必须同一路径,共用一个常量防漂移。
|
||
assert.match(provider, /fetch\(bootSessionsPath\(subjectId\), \{ \.\.\.BOOT_READ_INIT, signal \}\)/);
|
||
assert.match(bootReads, /`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}&subject=\$\{encodeURIComponent\(subjectId\)\}`/);
|
||
// 原值:会话列表文件里直接 fetch("/api/account")。
|
||
// 新值:同一句 fetch 在 readBootAccount,会话列表调用它,避免和人物目录各打一次。
|
||
// 原因:人物目录和会话列表要共享这一次账户结果。
|
||
assert.match(provider, /readBootAccount\(\)/);
|
||
// 原值:accountRead 里 fetch("/api/account"。
|
||
// 新值:fetch(BOOT_ACCOUNT_PATH, BOOT_READ_INIT),常量 BOOT_ACCOUNT_PATH = "/api/account" 在 boot-early-reads.ts。
|
||
// 原因:同上,早发请求与正常请求共用常量;仍只在 readBootAccount 一处发。
|
||
assert.match(accountRead, /fetch\(BOOT_ACCOUNT_PATH, BOOT_READ_INIT\)/);
|
||
assert.match(bootReads, /BOOT_ACCOUNT_PATH = "\/api\/account"/);
|
||
});
|
||
|
||
// 「那一刻的天空」 header entry (TASK-birth-sky-cover-20260928 T3).
|
||
test("the birth-sky button appears only once the chart is ok and the cover is painted", async () => {
|
||
const view = await okView();
|
||
const cover = {
|
||
key: "self:fp",
|
||
sky: goldenSky("day"),
|
||
blob: new Blob([], { type: "image/png" }),
|
||
url: "blob:birth-sky",
|
||
serif: false,
|
||
};
|
||
const ready = withSidebarProvider(React.createElement(ChartPageView, { view, birthSky: cover }));
|
||
assert.match(ready, /chat-header-actions/);
|
||
assert.match(ready, />那一刻的天空</);
|
||
|
||
// Prefetch not finished, or failed: no button, no placeholder, no error copy.
|
||
for (const birthSky of [null, undefined]) {
|
||
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, birthSky }));
|
||
assert.doesNotMatch(markup, /那一刻的天空|chat-header-actions/);
|
||
assert.doesNotMatch(markup, /正在加载|失败|出错/);
|
||
}
|
||
|
||
// Incomplete birth profile: never a button, even with a stale cover in hand.
|
||
const incomplete = withSidebarProvider(React.createElement(ChartPageView, {
|
||
view: { status: "birth_profile_incomplete", billed: false, message: "先补全出生资料。" },
|
||
birthSky: cover,
|
||
}));
|
||
assert.doesNotMatch(incomplete, /那一刻的天空/);
|
||
const loading = withSidebarProvider(React.createElement(ChartPageView, { view: null, birthSky: cover }));
|
||
assert.doesNotMatch(loading, /那一刻的天空/);
|
||
});
|
||
|
||
test("birth-sky prefetch follows the current subject and the dialog is a lazy chunk", () => {
|
||
const chartView = readFileSync(new URL("../src/components/chart-page/chart-page-view.tsx", import.meta.url), "utf8");
|
||
const hook = readFileSync(new URL("../src/hooks/use-birth-sky-cover.ts", import.meta.url), "utf8");
|
||
const entry = readFileSync(new URL("../src/components/birth-sky/birth-sky-entry.tsx", import.meta.url), "utf8");
|
||
const cover = readFileSync(new URL("../src/lib/birth-sky/cover.ts", import.meta.url), "utf8");
|
||
assert.match(chartView, /useBirthSkyCover\(ok \? ok\.subjectId \?\? "self" : null, ok\?\.profileFingerprint \?\? null\)/);
|
||
assert.match(hook, /\$\{subjectId\}:\$\{fingerprint \?\? ""\}/);
|
||
assert.match(hook, /controller\.abort\(\)/);
|
||
// 原值:入口懒加载 `./birth-sky-dialog`(静态海报弹层)
|
||
// 新值:入口懒加载 `./birth-sky-replay`(重放汇聚 → 定格 → 右上角分享)
|
||
// 原因:TASK-mobile-chart-and-confirmed-edit-20260929 M4,产品要求点开重放动画、定格即海报
|
||
assert.match(entry, /dynamic\(\s*\(\) => import\("\.\/birth-sky-replay"\)/);
|
||
assert.match(cover, /await import\("\.\/render\.ts"\)/);
|
||
assert.doesNotMatch(chartView, /birth-sky-dialog|lib\/birth-sky\/(draw|render)/);
|
||
});
|