Files
Jyotisha/frontend/tests/chart-varga-table.test.tsx
T
jesse-uxandGrok (agent loop) b7ec8e78ed
Independent Staging Quality Gate / validate (push) Failing after 9m20s
Independent Staging Quality Gate / publish (push) Skipped
星盘页换成组件库:页签、盘型条、可拖分栏和换盘动效(#107)。
四个页签用下划线,盘型条用分段并可以横滑,全部盘型从底部打开。桌面上盘和参数可以拖宽,窄屏上下排。行星表仍跨满整行。换盘型时星体移到新位置;减少动态效果只淡入。第一次进场不重播。

自测:tsc 退出码 0;frontend 下 npm run lint 0 error(122 警告);定向测试 188 通过、0 失败、0 取消;npm run build 后 / 仍是 Static;首屏 JS gzip-9 为 640131,相对 d25dfdbe 记下的 639127 多 0.16%。没有跑全量 npm test、Python 快速门和 test:db。

Co-Authored-By: Grok (agent loop)
2026-10-25 04:02:14 +08:00

342 lines
18 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 { Badge } from "../src/components/ui/badge.tsx";
import { ChartVedicTab, VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { chartViewOkSchema, chartViewVargaSchema, type ChartViewOk, type ChartViewVarga } from "../src/lib/chart-view-contract.ts";
import { buildChartView } from "../src/lib/chart-view-mapper.ts";
Object.assign(globalThis, { React });
// Real engine output (see the fixture's sourceCommand); nothing here is hand-shaped.
const golden = JSON.parse(
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
) as {
chart: Record<string, unknown>;
varga_full: { result: Record<string, { ascendant: Record<string, unknown>; planets: Record<string, Record<string, unknown>> }> };
chara: Record<string, unknown>;
};
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 vargaView(): Promise<ChartViewOk> {
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers: ["varga"],
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 as unknown as Record<string, unknown> };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return result.body as ChartViewOk;
}
function render(view: ChartViewOk, vargaId: string): string {
return renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId, onSelectVarga: () => {} }));
}
/** The table row whose header cell names this body. */
function row(markup: string, label: string): string {
const tbody = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "";
const hit = [...tbody.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].map((match) => match[1]!).find((cells) => {
const header = cells.match(/<th scope="row">([\s\S]*?)<\/th>/)?.[1] ?? "";
return header.includes(label);
});
assert.ok(hit, `no row for ${label}`);
return hit.replace(/<!-- -->/g, "");
}
function degreeMinutes(degree: number): string {
const whole = Math.floor(degree);
const minutes = Math.floor((degree - whole) * 60);
return `${whole}°${String(minutes).padStart(2, "0")}′`;
}
// BUG-1076: the D9 caption changed but every row stayed D1.
test("a divisional chart's table carries that chart's signs, degrees and houses", async () => {
const view = await vargaView();
const d9 = golden.varga_full.result.D9_Navamsa!;
const d1Markup = render(view, "D1");
const d9Markup = render(view, "D9");
const sunD9 = row(d9Markup, "太阳");
const moonD9 = row(d9Markup, "月亮");
assert.match(sunD9, /天秤/, "D9 Sun is in Libra");
assert.ok(sunD9.includes(degreeMinutes(Number(d9.planets.Sun!.degree))), "D9 Sun degree is the in-varga degree");
assert.match(moonD9, /双鱼/, "D9 Moon is in Pisces");
assert.match(moonD9, />4宫</, "D9 Moon house counts from the D9 ascendant");
assert.notEqual(sunD9, row(d1Markup, "太阳"));
assert.notEqual(moonD9, row(d1Markup, "月亮"));
const ascD9 = row(d9Markup, "上升");
assert.match(ascD9, /射手/, "D9 ascendant is Sagittarius");
});
test("the D1 table is byte-for-byte the table the page drew before BUG-1076, ascendant nakshatra aside", async () => {
const view = await vargaView();
const markup = render(view, "D1");
const table = markup.match(/<div class="chart-page-planet-table-wrap">[\s\S]*?<\/table><\/div>/)?.[0];
// Rendered from the baseline commit 5dc977ef with the same golden. The only
// change since is BUG-1201: the engine now gives the ascendant its nakshatra
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
// 原值:<td>不适用</td>
// 新值:chart-page-asc-status 里可见「—」,说明句在 data-chart-help 与 aria-label
// 原因:K3 状态列没有状态就写「—」,不再用「/」按钮。基线 fixture 文件不改。
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td>'
+ '<td class="chart-page-asc-status"><span data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷" aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</span></td>';
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
let expected = baseline.replace(dashes, filled);
// 原值:状态格是纯文本
// 新值:同一列的状态换成 Badge;落陷、敌是 destructive,其余是 secondary
// 原因:K3。fixture 不改,断言时按渲染出的 Badge 替换已知状态句。
expected = expected.replace(/<td>([^<]+)<\/td>(?=<\/tr>)/g, (full, text: string) => {
if (text === "—") return full;
const variant = text.includes("落陷") || text.includes("敌") ? "destructive" : "secondary";
const badge = renderToStaticMarkup(React.createElement(Badge, { variant }, text));
return `<td>${badge}</td>`;
});
assert.equal(table, expected);
});
// BUG-1201: the ascendant row's nakshatra cells were always dashes.
test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async () => {
const view = await vargaView();
assert.equal(view.vedic.ascendant?.nakshatra, "Uttara Phalguni");
assert.equal(view.vedic.ascendant?.pada, 1);
const ascendant = row(render(view, "D1"), "上升");
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
assert.match(ascendant, />1</, "pada 1");
// 原值:aria-hidden 的「/」
// 新值:可见「—」,说明句仍在 data-chart-help 与 aria-label
// 原因:K3 没有状态就写「—」,同一列不再混第二种标记。
assert.match(ascendant, /chart-page-asc-status/);
assert.match(ascendant, /data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
assert.match(ascendant, /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
// An older cached chart without the fields still renders, with dashes.
const chart = structuredClone(golden.chart);
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
const old = buildChartView({ chart, varga: null, chara: null, western: null, profile, asOf: "2026-09-15" });
assert.equal(old.vedic.ascendant?.nakshatra, null);
});
test("divisional rows carry engine dignity and vargottama, and drop nakshatra columns", async () => {
const view = await vargaView();
const d9 = view.vedic.vargas.find((item) => item.id === "D9");
assert.ok(d9?.rows);
const saturn = d9.rows.find((item) => item.name === "Saturn");
// D1 Saturn is in Capricorn and D9 Saturn is in Capricorn: own sign, vargottama.
assert.equal(saturn?.sign, "Capricorn");
assert.equal(saturn?.dignity, "OWN_SIGN");
assert.equal(saturn?.vargottama, true);
const sun = d9.rows.find((item) => item.name === "Sun");
assert.equal(sun?.sign, "Libra");
assert.equal(sun?.dignity, "DEBILITATED");
assert.equal(sun?.vargottama, false);
const markup = render(view, "D9");
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
assert.match(header, /<th scope="col" class="chart-page-planet-mid">庙旺<\/th>/);
assert.match(header, /<th scope="col" class="chart-page-planet-mid">同位<\/th>/);
assert.doesNotMatch(header, /星宿|宿主|pada/);
// Same wording as the D1 status column (BUG-1202), friendship levels included.
// 原值:同位行星单元格是「同位」,非同位是「—」
// 新值:同位写「是」,非同位写「否」
// 原因:BUG-1315。横杠和「没数据」长得一样,表头仍叫「同位」
assert.match(row(markup, "土星"), />入庙\(Own Sign\)<[\s\S]*>是</);
assert.match(row(markup, "太阳"), />否</);
assert.match(row(markup, "太阳"), />落陷\(Debilitated\)</);
assert.match(row(markup, "月亮"), />入友\(Friendly Sign\)</);
assert.match(row(markup, "火星"), />中性\(Neutral\)</);
assert.match(row(markup, "水星"), />入敌\(Enemy Sign\)</);
assert.match(row(markup, "木星"), />极敌\(Great Enemy\)</);
// 原值:chart-page-asc-status,可见「/」
// 新值:同一格可见「—」,说明句仍在
// 原因:K3 没有状态就写「—」,不再用斜杠。
assert.match(row(markup, "上升"), /chart-page-asc-status/, "the ascendant has no sign dignity");
assert.match(row(markup, "上升"), /data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
assert.match(row(markup, "上升"), /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
// Every divisional chart the engine returned has its own rows, D1 has none.
for (const varga of view.vedic.vargas) {
if (varga.id === "D1") assert.equal(varga.rows, undefined);
else assert.equal(varga.rows?.length, 9, varga.id);
}
});
function tableMarkup(varga: ChartViewVarga): string {
return renderToStaticMarkup(React.createElement(VargaTable, { varga }));
}
function assertColumns(markup: string, dignity: boolean, vargottama: boolean) {
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
assert.equal(header.includes(">庙旺<"), dignity);
assert.equal(header.includes(">同位<"), vargottama);
const count = 4 + Number(dignity) + Number(vargottama);
assert.equal((header.match(/<th /g) ?? []).length, count);
const body = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "";
const rows = [...body.matchAll(/<tr>([\s\S]*?)<\/tr>/g)];
assert.equal(rows.length, 10, "the ascendant and all nine planets remain");
for (const [, cells] of rows) assert.equal((cells!.match(/<(?:th|td)\b/g) ?? []).length, count);
}
test("a non-vargottama row says 否 and a non-D9 chart has no same-sign column", async () => {
const view = await vargaView();
const d9 = render(view, "D9");
assert.match(row(d9, "太阳"), />否</);
const d10 = view.vedic.vargas.find((item) => item.id === "D10")!;
assert.equal(d10.supportsVargottama, false);
const markup = tableMarkup(d10);
assert.doesNotMatch(markup, />同位</);
assert.doesNotMatch(markup, />否</);
});
test("server capabilities retain dignity for every Dn and vargottama only for D9", async () => {
const view = await vargaView();
for (const varga of view.vedic.vargas) {
if (varga.id === "D1") {
assert.equal(varga.supportsDignity, undefined);
assert.equal(varga.supportsVargottama, undefined);
continue;
}
assert.equal(varga.supportsDignity, true, varga.id);
assert.equal(varga.supportsVargottama, varga.id === "D9", varga.id);
assertColumns(tableMarkup(varga), true, varga.id === "D9");
}
});
test("capability columns are independent and absent declarations hide whole columns", async () => {
const view = await vargaView();
const source = view.vedic.vargas.find((varga) => varga.id === "D9")!;
for (const dignity of [undefined, false, true]) {
for (const vargottama of [undefined, false, true]) {
const copy = structuredClone(source);
delete copy.supportsDignity;
delete copy.supportsVargottama;
if (dignity !== undefined) copy.supportsDignity = dignity;
if (vargottama !== undefined) copy.supportsVargottama = vargottama;
assertColumns(tableMarkup(chartViewVargaSchema.parse(copy)), dignity === true, vargottama === true);
}
}
});
test("valid false and null results never turn supported columns off", async () => {
const view = await vargaView();
const copy = structuredClone(view.vedic.vargas.find((varga) => varga.id === "D9")!);
for (const item of copy.rows!) {
item.dignity = null;
item.vargottama = false;
}
copy.ascendant!.vargottama = false;
const markup = tableMarkup(chartViewVargaSchema.parse(copy));
assertColumns(markup, true, true);
assert.doesNotMatch(markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|\((?:Exalted|Own Sign|Debilitated|Neutral)\)</);
// 原值:chart-page-asc-status,内容是「/」
// 新值:同一格可见「—」
// 原因:K3 没有状态就写「—」。庙旺列仍在,只是空状态不再用斜杠。
assert.match(row(markup, "上升"), /chart-page-asc-status/);
assert.match(row(markup, "上升"), /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
});
test("capability schema rejects wrong types and direct cache rendering requires strict true", async () => {
const view = await vargaView();
const source = view.vedic.vargas.find((varga) => varga.id === "D9")!;
for (const field of ["supportsDignity", "supportsVargottama"] as const) {
for (const value of [null, "true", "false", 1, 0, [], {}]) {
const copy = structuredClone(source);
Object.assign(copy, { [field]: value });
const parsed = chartViewVargaSchema.safeParse(copy);
assert.equal(parsed.success, false, `${field} must not coerce ${JSON.stringify(value)}`);
if (!parsed.success) assert.ok(parsed.error.issues.some((issue) => issue.path[0] === field && issue.code === "invalid_type"));
assertColumns(tableMarkup(copy), field !== "supportsDignity", field !== "supportsVargottama");
}
}
});
// BUG-1202: the dignity column is the engine's; it is never recomputed here.
test("a divisional payload without engine dignity hides the column instead of deriving it", () => {
const varga = structuredClone(golden.varga_full);
for (const item of Object.values(varga.result)) {
for (const planet of Object.values(item.planets)) delete planet.dignity_level;
}
const view = buildChartView({ chart: structuredClone(golden.chart), varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
const d9 = view.vedic.vargas.find((item) => item.id === "D9")!;
assert.equal(d9.supportsDignity, false);
assert.equal(d9.supportsVargottama, true);
assert.ok(d9.rows!.every((item) => item.dignity === null));
assertColumns(tableMarkup(d9), false, true);
const unknown = structuredClone(golden.varga_full);
unknown.result.D9_Navamsa!.planets.Sun!.dignity_level = "SOMETHING_NEW";
const mixed = buildChartView({ chart: structuredClone(golden.chart), varga: unknown as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
assert.equal(mixed.vedic.vargas.find((item) => item.id === "D9")!.supportsDignity, false);
});
test("missing or invalid source positions are not manufactured as negative same-sign results", () => {
const chart = structuredClone(golden.chart);
const varga = structuredClone(golden.varga_full);
const d9 = varga.result.D9_Navamsa!;
delete d9.planets.Sun!.sign;
d9.planets.Moon!.sign = "not-a-sign";
const view = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
const projected = view.vedic.vargas.find((item) => item.id === "D9")!;
assert.equal(projected.supportsDignity, true);
assert.equal(projected.supportsVargottama, true);
assert.equal(projected.rows!.some((item) => item.name === "Sun" || item.name === "Moon"), false);
// Destroy only the source positions on a golden copy, rather than making a
// response shape. With no valid result source no capability is advertised.
for (const planet of Object.values(d9.planets)) delete planet.sign;
delete (chart.ascendant as Record<string, unknown>).sign;
const empty = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
const emptyD9 = empty.vedic.vargas.find((item) => item.id === "D9")!;
assert.deepEqual(emptyD9.rows, []);
assert.equal(emptyD9.ascendant, undefined);
assert.equal(emptyD9.supportsDignity, false);
assert.equal(emptyD9.supportsVargottama, false);
});
test("view projection omits engine branding but old profile fields still parse and raw metadata stays intact", async () => {
const before = structuredClone(golden);
const view = await vargaView();
assert.equal("engineName" in view.profile, false);
const legacy = structuredClone(view);
Object.assign(legacy.profile, { engineName: "Swiss Ephemeris(本站)" });
assert.equal("engineName" in chartViewOkSchema.parse(legacy).profile, false);
assert.deepEqual(golden, before, "the mapper never strips or changes the raw engine packet");
});
test("divisional house cells do not override their header's left alignment", async () => {
const view = await vargaView();
for (const varga of view.vedic.vargas.filter((item) => item.id !== "D1")) {
const markup = tableMarkup(varga);
const rows = [...markup.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].slice(1);
assert.equal(rows.length, 10);
for (const [, cells] of rows) {
const tds = [...cells!.matchAll(/<td([^>]*)>([\s\S]*?)<\/td>/g)];
assert.equal(tds[1]![1], "", "house cells inherit the same alignment and padding as the header");
assert.match(tds[1]![2]!, /^\d+宫$/);
assert.match(tds[3]![1]!, /chart-page-planet-mid/, "the dignity column's deliberate centering remains");
}
}
});