Files
Jyotisha/frontend/tests/chart-page-view.test.tsx
T
Jesse_ChenandClaude Opus 5 50ce02c837
Independent Staging Quality Gate / validate (push) Failing after 9m57s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 星盘/星历/报告中心并入 app 外壳,侧栏不再消失
三个页面此前各是脱离外壳的独立全屏路由,各写了一套一样的
*-shell / *-topbar(只有一个「返回对话」链接)/ *-hero 骨架,
侧栏在打开它们的瞬间整个消失。

新增 AppNavRail(只读:两个 GET,零写操作)与 SecondaryShell。
会话行走 sessionHref → /?c=<uuid>,跳转复用现有侧栏的
persistLoginSessionReturn + location.assign,行为完全一致。

刻意不带重命名/收藏/归档/删除与账户菜单:那套连着 Home() 的乐观更新与
回滚层,为四个路由把它整体上提远超需要,且会撞 useState 增长门禁。

四个路由渲染标记完全不变:/ ○、/chart ○、/ephemeris ○、/reports ƒ。
CSS gzip 39,825→39,727(−0.25%);per-route JS 体积构建不输出,
已作为口径缺口记录。

两处自身问题被测试抓到并修复:usePathname() 在 app-router 上下文外
返回 null(类型说是 string)、fetch 在 jsdom 里可能不存在。
另差点随 hero 一起丢掉 BUG-717 的 eyebrow 文案(成本与速度承诺,
会印在失败页上),已放回 tab 行下方。

/reports/[reportId] 留给 R6 与目录一起做;根边界页保留「返回对话」,
它们不得 import globals.css,挂不了外壳。

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

189 lines
9.2 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 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("mobile width keeps two centre lines and moves the full parameter card below the chart", () => {
// 原值:三条断言都以 `@media (max-width: 767px) { .chart-page-hero h1` 起锚,
// 再加一条 `.chart-page-back` 的 44px 触摸目标。
// 新值:直接断那个断点块里的三个开关;`.chart-page-back` 一条删除。
// 原因:hero 与「返回对话」按钮随页面并入 app 外壳而删除(T4.2)。被断言的三个
// 开关本身一条没动,只是不再有 hero 那一行可以用来起锚。返回路径现在是
// 常驻侧栏,它的触摸目标由 sidebar-contract 管。
const mobile = globalStyles.slice(
globalStyles.indexOf("@media (max-width: 767px)", globalStyles.indexOf(".chart-page-center-compact")
- 2000),
);
assert.match(mobile, /\.chart-page-center-compact \{ display: block; \}/);
assert.match(mobile, /\.chart-page-center-extra \{ display: none; \}/);
assert.match(mobile, /\.chart-page-params-below \{ display: block; \}/);
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("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);
});