feat(chart): hide qizheng from the chart page; backend endpoint kept (T1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:17:34 +08:00
co-authored by Claude Opus 5.5
parent 5dc977ef2c
commit af27709de4
16 changed files with 38 additions and 318 deletions
-27
View File
@@ -4959,7 +4959,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-page-basics,
.chart-page-dasha,
.chart-page-western,
.chart-page-qizheng,
.chart-page-western-side {
min-width: 0;
}
@@ -5350,32 +5349,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-page-western-table tbody tr[tabindex] { cursor: pointer; }
.chart-page-western-table tbody tr[tabindex]:hover { background: var(--color-canvas-muted); }
.chart-page-western-table tr.is-selected { background: var(--color-selected); }
.chart-page-qizheng-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
margin: var(--space-4) 0;
}
.chart-page-qizheng-cell {
min-height: 88px;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
font-size: var(--type-body-sm);
}
.chart-page-qizheng-cell strong { display: block; font-size: var(--type-title-sm); font-weight: 500; }
.chart-page-qizheng-cell span,
.chart-page-qizheng-cell small,
.chart-page-qizheng-cell p { display: block; margin: var(--space-1) 0 0; color: var(--color-ink-secondary); }
.chart-page-qizheng-cell small { font-size: var(--type-caption); }
.chart-page-qizheng-hole { min-height: 0; }
.chart-page-qizheng-rules {
margin: var(--space-3) 0;
padding-left: 1.2em;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.chart-page-unavailable > p { margin: 0 0 var(--space-3); font-size: var(--type-body-md); }
@media (min-width: 768px) {
@@ -17,7 +17,6 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import { ChartPageSkeleton } from "./chart-page-skeleton";
import { ChartBasicsTab } from "./chart-basics-tab";
import { ChartDashaTab } from "./chart-dasha-tab";
import { ChartQizhengTab } from "./chart-qizheng-tab";
import { ChartVedicTab } from "./chart-vedic-tab";
import { ChartWesternTab } from "./chart-western-tab";
@@ -70,7 +69,6 @@ export function ChartPageView({
setTab(id);
if (id === "dasha") onNeedLayer?.("chara");
if (id === "western") onNeedLayer?.("western");
if (id === "qizheng") onNeedLayer?.("qizheng");
}
function selectVarga(id: string) {
@@ -136,15 +134,6 @@ export function ChartPageView({
<ChartWesternTab view={view} />
)
) : null}
{tab === "qizheng" ? (
pendingLayers?.has("qizheng") ? (
<WaitingNote copy={CHART_VIEW_COPY.waitingLayer} />
) : layerFailures?.has("qizheng") ? (
<WaitingNote copy={chartLayerFailureCopy(layerFailures.get("qizheng")!)} />
) : (
<ChartQizhengTab view={view} />
)
) : null}
</>}
</section>
</div>
@@ -1,57 +0,0 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY, QIZHENG_RULES } from "@/lib/chart-view-labels";
import { QizhengPalaceGrid } from "./qizheng-palace-grid";
function Rules() {
return (
<ul className="chart-page-qizheng-rules">
{QIZHENG_RULES.map((rule) => <li key={rule}>{rule}</li>)}
</ul>
);
}
export function ChartQizhengTab({ view }: { view: ChartViewOk }) {
const qizheng = view.qizheng;
if (qizheng.status === "unavailable") {
return (
<div className="chart-page-unavailable">
<p className="chart-page-boundary">{COORDINATE_BOUNDARY.qizheng}</p>
<p>{qizheng.copy}</p>
<Rules />
</div>
);
}
return (
<div className="chart-page-qizheng">
<p className="chart-page-boundary">{qizheng.boundary}</p>
<p className="chart-page-note">计都派别:{qizheng.ketuMode} · 坐标系 {qizheng.coordinateSystem}</p>
<Rules />
<QizhengPalaceGrid qizheng={qizheng} />
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>十一曜宿度</caption>
<thead>
<tr>
<th scope="col">曜</th>
<th scope="col">宿</th>
<th scope="col">宿度</th>
<th scope="col">宫</th>
<th scope="col">庙旺</th>
</tr>
</thead>
<tbody>
{qizheng.bodies.map((body) => (
<tr key={body.name}>
<th scope="row">{body.name}</th>
<td>{body.mansion}</td>
<td>{body.mansionDegree.toFixed(2)}</td>
<td>{body.palace}</td>
<td>{body.dignityClosed ? body.dignity : `${body.dignity}(未闭合)`}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -1,24 +0,0 @@
import type { ChartViewQizhengOk } from "@/lib/chart-view-contract";
import { QIZHENG_BRANCH_GRID } from "@/lib/chart-view-labels";
export function QizhengPalaceGrid({ qizheng }: { qizheng: ChartViewQizhengOk }) {
const byBranch = new Map(qizheng.palaces.map((palace) => [palace.branch, palace]));
return (
<div className="chart-page-qizheng-grid" role="img" aria-label="七政四余地支十二宫">
{QIZHENG_BRANCH_GRID.flatMap((row, rowIndex) => row.map((branch, columnIndex) => {
if (!branch) {
return <div key={`empty-${rowIndex}-${columnIndex}`} className="chart-page-qizheng-hole" />;
}
const palace = byBranch.get(branch);
return (
<article key={branch} className="chart-page-qizheng-cell">
<strong>{branch}</strong>
<span>{palace?.lifePalace ?? "—"}</span>
<small>{palace?.mansions.length ? palace.mansions.join(" ") : "所辖宿未返回"}</small>
<p>{palace?.occupants.length ? palace.occupants.join(" ") : "空"}</p>
</article>
);
}))}
</div>
);
}
-1
View File
@@ -48,7 +48,6 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie
...(layer === "varga" ? { vedic: incoming.vedic } : {}),
...(layer === "chara" ? { dasha: incoming.dasha } : {}),
...(layer === "western" ? { western: incoming.western } : {}),
...(layer === "qizheng" ? { qizheng: incoming.qizheng } : {}),
};
}
-25
View File
@@ -145,27 +145,6 @@ export const chartViewWesternOkSchema = z.object({
})),
});
export const chartViewQizhengOkSchema = z.object({
status: z.literal("ok"),
coordinateSystem: z.string().min(1),
ketuMode: z.string().min(1),
boundary: z.string().min(1),
palaces: z.array(z.object({
branch: z.string().min(1),
lifePalace: z.string().min(1),
mansions: z.array(z.string()),
occupants: z.array(z.string()),
})),
bodies: z.array(z.object({
name: z.string().min(1),
mansion: z.string().min(1),
mansionDegree: z.number(),
palace: z.string().min(1),
dignity: z.string().min(1),
dignityClosed: z.boolean(),
})),
});
export const chartViewProfileSchema = z.object({
name: z.string().min(1),
date: isoDate,
@@ -200,7 +179,6 @@ export const chartViewOkSchema = z.object({
varga: chartViewFailureStatusSchema.optional(),
chara: chartViewFailureStatusSchema.optional(),
western: chartViewFailureStatusSchema.optional(),
qizheng: chartViewFailureStatusSchema.optional(),
}).optional(),
status: z.literal("ok"),
billed: z.literal(false),
@@ -221,7 +199,6 @@ export const chartViewOkSchema = z.object({
dualTrackNote: z.string().min(1),
}),
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
qizheng: z.union([chartViewQizhengOkSchema, chartViewUnavailableLayerSchema]),
accountId: z.string().min(1).optional(),
profileFingerprint: z.string().min(1).optional(),
subjectId: z.string().min(1).optional(),
@@ -248,7 +225,6 @@ export type ChartViewMessage = z.infer<typeof chartViewMessageSchema>;
export type ChartViewResponse = z.infer<typeof chartViewResponseSchema>;
export type ChartViewUnavailableLayer = z.infer<typeof chartViewUnavailableLayerSchema>;
export type ChartViewWesternOk = z.infer<typeof chartViewWesternOkSchema>;
export type ChartViewQizhengOk = z.infer<typeof chartViewQizhengOkSchema>;
export type ChartViewVarga = z.infer<typeof chartViewVargaSchema>;
export type ChartViewPlanetRow = z.infer<typeof chartViewPlanetRowSchema>;
export type NorthIndianChart = z.infer<typeof northIndianChartSchema>;
@@ -259,7 +235,6 @@ export const CHART_VIEW_TABS = [
{ id: "basics", label: "基础信息" },
{ id: "dasha", label: "大运" },
{ id: "western", label: "西洋盘" },
{ id: "qizheng", label: "七政四余" },
] as const;
export type ChartViewTabId = (typeof CHART_VIEW_TABS)[number]["id"];
+1 -1
View File
@@ -2,7 +2,7 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000;
export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000;
const ENGINE_CACHE_MAX_ENTRIES = 200;
export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "qizheng"] as const;
export const CHART_VIEW_LAYERS = ["varga", "chara", "western"] as const;
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
export type EngineCallResult =
+2 -18
View File
@@ -121,17 +121,9 @@ export const MODE_ZH: Record<string, string> = {
mutable: "变动",
};
export const QIZHENG_BRANCH_GRID: ReadonlyArray<readonly (string | null)[]> = [
["巳", "午", "未", "申"],
["辰", null, null, "酉"],
["卯", null, null, "戌"],
["寅", "丑", "子", "亥"],
];
export const COORDINATE_BOUNDARY = {
vedic: "这是恒星黄道,岁差用你资料里选的那一套。它和西洋回归黄道、七政宿度不是同一套坐标,不能换算,也不能叠成双重印证。",
western: "这是回归黄道,宫位用 Placidus。它和印度盘的恒星黄经、七政宿度不是同一套,不能换算,也不能叠成双重印证。",
qizheng: "宿度从角宿初度起算,不是恒星黄经,也不是回归黄道。三套不能换算,也不能叠成双重印证。",
vedic: "这是恒星黄道,岁差用你资料里选的那一套。它和西洋回归黄道不是同一套坐标,不能换算,也不能叠成双重印证。",
western: "这是回归黄道,宫位用 Placidus。它和印度盘的恒星黄经不是同一套,不能换算,也不能叠成双重印证。",
} as const;
export const DASHA_DUAL_TRACK_NOTE = "只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。";
@@ -146,14 +138,6 @@ export const CHARA_ZERO_YEAR_NOTE = "本段时长为 0,不展开";
export const WESTERN_UNAVAILABLE_COPY = "西洋盘这一栏要等回归黄道端点上线。现在还没有,先空着——不会拿印度盘的度数改写成回归黄道。";
export const QIZHENG_UNAVAILABLE_COPY = "七政四余这一栏要等宿度端点上线。现在还没有,先空着。";
export const QIZHENG_RULES = [
"宿度从角宿初度起算,不是恒星黄经。",
"计都派别(是否取罗睺对点)会改整张盘,必须写明,不能静默默认。",
"庙旺表还没闭合,这一列只能当占位,不能拿来下判断。",
] as const;
export const CHART_VIEW_CLIENT_TIMEOUT_MS = 25_000;
export const CHART_VIEW_COPY = {
+1 -5
View File
@@ -157,7 +157,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
if (result.status !== "ok") layerFailures[layer] = engineFailureReason(result.status);
return enginePayload(result);
};
const [varga, chara, western, qizheng] = await Promise.all([
const [varga, chara, western] = await Promise.all([
layers.has("varga")
? callEngine(input.postEngine, "/api/varga_full", {
...followUp,
@@ -174,9 +174,6 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
layers.has("western")
? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western"))
: Promise.resolve(null),
layers.has("qizheng")
? callEngine(input.postEngine, "/api/qizheng", payload).then(layerPayload("qizheng"))
: Promise.resolve(null),
]);
try {
@@ -185,7 +182,6 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
varga,
chara,
western,
qizheng,
profile,
asOf: input.asOf,
});
+1 -97
View File
@@ -8,7 +8,6 @@ import {
COORDINATE_BOUNDARY,
DASHA_DUAL_TRACK_NOTE,
PLANET_ORDER,
QIZHENG_UNAVAILABLE_COPY,
SIGN_ORDER,
VARGA_CHIP_ORDER,
VIMSHOTTARI_ORDER,
@@ -26,7 +25,6 @@ import { traditionalSymbol } from "./chart-view-symbols.ts";
import {
chartViewOkSchema,
type ChartViewOk,
type ChartViewQizhengOk,
type ChartViewUnavailableLayer,
type ChartViewVarga,
type ChartViewWesternOk,
@@ -51,7 +49,6 @@ export type ChartViewEngineBundle = {
varga: Record<string, unknown> | null;
chara: Record<string, unknown> | null;
western: Record<string, unknown> | null;
qizheng: Record<string, unknown> | null;
profile: ChartViewProfileInput;
asOf: string;
};
@@ -467,10 +464,6 @@ function unavailableWestern(reason: string): ChartViewUnavailableLayer {
return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY };
}
function unavailableQizheng(reason: string): ChartViewUnavailableLayer {
return { status: "unavailable", reason, copy: QIZHENG_UNAVAILABLE_COPY };
}
function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string): ChartViewWesternOk | ChartViewUnavailableLayer {
if (!raw) return unavailableWestern("endpoint_missing");
const natal = record(raw.natal) ?? raw;
@@ -559,96 +552,8 @@ function palaceBranch(row: Record<string, unknown>): string | null {
return text(row.branch) ?? text(row.zhi) ?? text(row.earthly_branch) ?? text(row.name);
}
function mapQizheng(raw: Record<string, unknown> | null): ChartViewQizhengOk | ChartViewUnavailableLayer {
if (!raw) return unavailableQizheng("endpoint_missing");
const palacesRaw = raw.palaces;
const bodiesRaw = raw.bodies ?? raw.planets;
const palaces: ChartViewQizhengOk["palaces"] = [];
if (Array.isArray(palacesRaw)) {
for (const entry of palacesRaw) {
const row = record(entry);
if (!row) continue;
const branch = palaceBranch(row);
if (!branch) continue;
palaces.push({
branch,
lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? text(row.human_palace) ?? "—",
mansions: Array.isArray(row.mansions)
? row.mansions.filter((item): item is string => typeof item === "string")
: text(row.mansions)
? [String(row.mansions)]
: (text(row.xiu) ? [String(row.xiu)] : []),
occupants: Array.isArray(row.occupants)
? row.occupants.filter((item): item is string => typeof item === "string")
: [],
});
}
} else {
const asRecord = record(palacesRaw);
if (asRecord) {
for (const [branch, value] of Object.entries(asRecord)) {
const row = record(value) ?? {};
palaces.push({
branch,
lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? "—",
mansions: Array.isArray(row.mansions)
? row.mansions.filter((item): item is string => typeof item === "string")
: [],
occupants: Array.isArray(row.occupants)
? row.occupants.filter((item): item is string => typeof item === "string")
: [],
});
}
}
}
const bodies: ChartViewQizhengOk["bodies"] = [];
if (Array.isArray(bodiesRaw)) {
for (const entry of bodiesRaw) {
const row = record(entry);
if (!row) continue;
const name = text(row.name) ?? text(row.body) ?? text(row.planet);
if (!name) continue;
bodies.push({
name,
mansion: text(row.mansion) ?? text(row.xiu) ?? "—",
mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0,
palace: text(row.palace) ?? text(row.branch) ?? "—",
dignity: text(row.dignity) ?? text(row.status) ?? "未闭合",
dignityClosed: false,
});
}
} else {
const asRecord = record(bodiesRaw);
if (asRecord) {
for (const [name, value] of Object.entries(asRecord)) {
const row = record(value) ?? {};
bodies.push({
name,
mansion: text(row.mansion) ?? text(row.xiu) ?? "—",
mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0,
palace: text(row.palace) ?? text(row.branch) ?? "—",
dignity: text(row.dignity) ?? "未闭合",
dignityClosed: false,
});
}
}
}
if (palaces.length === 0 || bodies.length === 0) return unavailableQizheng("payload_incomplete");
return {
status: "ok",
coordinateSystem: text(raw.coordinate_system) ?? "qizheng_mansion_degrees_from_jiao",
ketuMode: text(record(raw.calculation)?.engine_ketu_mode)
?? text(record(raw.chart)?.ketu_mode)
?? text(raw.ketuMode)
?? "unspecified",
boundary: COORDINATE_BOUNDARY.qizheng,
palaces,
bodies,
};
}
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
const { chart, varga, chara, western, qizheng, profile, asOf } = bundle;
const { chart, varga, chara, western, profile, asOf } = bundle;
const planets = asPlanetMap(chart.planets);
const moon = planets.Moon ?? {};
const ascendant = record(chart.ascendant);
@@ -701,7 +606,6 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
},
western: mapWestern(western, ayanamsaDisplay),
qizheng: mapQizheng(qizheng),
};
return chartViewOkSchema.parse(view);
}
-1
View File
@@ -214,7 +214,6 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<
...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}),
dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) },
...(prior.western.status === "ok" ? { western: prior.western } : {}),
...(prior.qizheng.status === "ok" ? { qizheng: prior.qizheng } : {}),
} };
}
return rememberChartSnapshot(snapshot);
+2 -1
View File
@@ -81,7 +81,8 @@ for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "respon
assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]);
}));
for (const layer of ["varga", "chara", "western", "qizheng"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
// 原值: 含 "qizheng";新值: 去掉;原因: 七政懒加载层已删(TASK-chart-types D1)
for (const layer of ["varga", "chara", "western"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
const initial = await packet();
const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 });
globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial);
+18 -19
View File
@@ -33,7 +33,6 @@ const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.
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> {
@@ -86,7 +85,7 @@ function withSidebarProvider(element: React.ReactElement) {
return renderToStaticMarkup(React.createElement(SidebarProvider, null, element));
}
test("the five tabs render and can each be selected", async () => {
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 }));
@@ -99,7 +98,10 @@ test("the five tabs render and can each be selected", async () => {
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") {
@@ -112,23 +114,15 @@ test("the five tabs render and can each be selected", async () => {
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 () => {
// 原值: 西洋与七政两栏各断言一次;新值: 只断言西洋;原因: 七政 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" }));
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 () => {
@@ -232,7 +226,8 @@ test("the planet table scrolls inside its own box instead of widening the page",
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]) {
// 原值: [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);
@@ -264,7 +259,9 @@ test("the chart page shell is visible before the natal chart arrives", () => {
// 原因: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);
// 原值: 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/);
});
@@ -332,18 +329,20 @@ test("varga pending shows the empty shared chart and failure replaces it", async
assert.match(render(true, "engine_busy"), /data-skeleton="true"/);
});
test("western qizheng and chara failures are terminal copy rather than waiting", async () => {
// 原值: 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"], ["qizheng", "qizheng"], ["dasha", "chara"]] as const) {
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/);
}
});
test("qizheng and chara pending remain static and only western has a wheel skeleton", async () => {
// 原值: 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 [["qizheng", "qizheng"], ["dasha", "chara"]] as const) {
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/);
}
+2 -1
View File
@@ -121,5 +121,6 @@ test("engine cache keys include ayanamsa and node mode and expire", () => {
test("layer query parsing keeps only known layers in stable order", () => {
assert.deepEqual(parseChartViewLayers(null), []);
assert.deepEqual(parseChartViewLayers("western,varga,nope,qizheng"), ["varga", "western", "qizheng"]);
// 原值: "qizheng" 被保留为已知层;新值: 与 "nope" 一样被丢弃;原因: 七政层已删(TASK-chart-types D1)
assert.deepEqual(parseChartViewLayers("western,varga,nope,qizheng"), ["varga", "western"]);
});
+11 -29
View File
@@ -65,7 +65,6 @@ function engine(overrides: Record<string, EngineCallResult | Record<string, unkn
if (path === "/api/varga_full") return asResult(path, golden.varga_full);
if (path === "/api/dasha/chara") return asResult(path, golden.chara);
if (path === "/api/western") return asResult(path, null);
if (path === "/api/qizheng") return asResult(path, null);
return asResult(path, null);
};
return { calls, bodies, postEngine };
@@ -162,11 +161,12 @@ test("opening the natal chart does not call follow-up engine paths", async () =>
assert.equal(body.billed, false);
assert.ok(body.vedic.vargas.some((item) => item.id === "D1"));
assert.equal(body.western.status, "unavailable");
assert.equal(body.qizheng.status, "unavailable");
// 原值: body.qizheng.status === "unavailable";新值: 响应不再带 qizheng;原因: TASK-chart-types D1
assert.equal("qizheng" in body, false);
assert.deepEqual(calls, ["/api/chart"]);
});
test("western and qizheng stay unavailable without failing the Indian tabs", async () => {
test("western stays unavailable without failing the Indian tabs", async () => {
const { calls, postEngine } = engine();
const result = await assembleChartView({
userId: "user-1",
@@ -183,18 +183,18 @@ test("western and qizheng stay unavailable without failing the Indian tabs", asy
assert.equal(body.dasha.vimshottari.method, "Vimshottari");
assert.equal(body.dasha.chara.title, "Chara Dasha(kn_rao 变体)");
assert.equal(body.western.status, "unavailable");
assert.equal(body.qizheng.status, "unavailable");
assert.ok(body.vedic.vargas.some((item) => item.id === "D9"));
// 原值: 无 layers 时也会打 /api/western 与 /api/qizheng
// 新值: 只有请求对应 layer 才打
// 原因: BUG-717 开页不得占满重计算配额;西洋/七政改按需
assert.equal(calls.includes("/api/western"), true);
assert.equal(calls.includes("/api/qizheng"), true);
// 原值: 全部 layer 时会打 /api/qizheng;新值: 任何情况都不打;原因: 七政层已删(TASK-chart-types D1)
assert.equal(calls.includes("/api/qizheng"), false);
assert.doesNotMatch(calls.join(" "), /billing|mastra|consult/);
});
test("a real western engine packet lights the tropical tab without mixing sidereal longitudes", async () => {
const { postEngine } = engine({ "/api/western": golden.western, "/api/qizheng": null });
const { postEngine } = engine({ "/api/western": golden.western });
const result = await assembleChartView({
userId: "user-1",
profile,
@@ -209,30 +209,10 @@ test("a real western engine packet lights the tropical tab without mixing sidere
assert.equal(body.western.houses.length, 12);
assert.match(body.western.boundary, /回归黄道/);
assert.notEqual(body.western.ascendantLongitude, body.vedic.planets[0]?.degreeInSign);
assert.equal(body.qizheng.status, "unavailable");
});
test("qizheng ketuMode follows the engine field, not a request echo", async () => {
const qizheng = {
coordinate_system: "qizheng_mansion_degrees_from_jiao",
calculation: { ketu_mode: "descending-node", engine_ketu_mode: "apogee" },
palaces: [{ name: "午", branch: "午", lifePalace: "命宫", mansions: ["星"], occupants: [] }],
bodies: { sun: { mansion: "奎", siderealLon: 177.94, palace: "亥宮", dignity: "陷" } },
};
const { postEngine } = engine({ "/api/qizheng": qizheng });
const result = await assembleChartView({
userId: "user-1",
profile,
postEngine,
asOf: "2026-09-15",
layers: ["qizheng"],
});
const body = chartViewOkSchema.parse(result.body);
assert.equal(body.qizheng.status, "ok");
if (body.qizheng.status !== "ok") throw new Error("expected qizheng ok");
assert.equal(body.qizheng.ketuMode, "apogee");
});
// 原值: 断言七政 ketuMode 取引擎字段;新值: 整条删除;原因: 七政层已从星盘页删除(TASK-chart-types D1),
// ketu_mode 的引擎侧合同仍由 tests/test_qizheng_api_productization.py 守住
test("busy vs other natal engine failures split copy and each leave a log line", async () => {
const stubs: Array<{ status: EngineCallResult["status"]; httpStatus?: number; errorName?: string }> = [
{ status: "busy", httpStatus: 429 },
@@ -298,7 +278,9 @@ test("the golden chart-view envelope stays inside the page contract", () => {
});
assert.match(COORDINATE_BOUNDARY.vedic, /不能换算/);
assert.match(COORDINATE_BOUNDARY.western, /不能换算/);
assert.match(COORDINATE_BOUNDARY.qizheng, /角宿/);
// 原值: COORDINATE_BOUNDARY.qizheng 含「角宿」;新值: 该条已删,印度/西洋两句不再提七政;原因: TASK-chart-types D1
assert.equal("qizheng" in COORDINATE_BOUNDARY, false);
assert.doesNotMatch(COORDINATE_BOUNDARY.vedic + COORDINATE_BOUNDARY.western, /七政/);
});
test("page.tsx does not host the chart page", () => {
@@ -44,7 +44,6 @@ function viewFrom(chara: Record<string, unknown>): ChartViewOk {
varga: null,
chara,
western: null,
qizheng: null,
profile,
asOf: "2026-09-25",
});