Files
Jyotisha/frontend/tests/chart-planet-glyphs.test.tsx
T
jesse-uxandClaude Code 64aa0ec20c
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 1m27s
feat(chart): polish chart presentation and symbol explanations
Hide engine branding, gate supported divisional columns, align house cells, and add accessible symbol help. Reduce desktop chart width and stabilize the waiting slot.

Validated with 78 browser checks and 87 focused frontend tests. Full suite retains 29 identical baseline loader failures; no new failures.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 19:03:28 +08:00

247 lines
11 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 { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx";
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import {
PLANET_GLYPH,
PLANET_GLYPH_KEYS,
parseChartOccupant,
} from "../src/lib/planet-glyphs.ts";
import { toNorthIndianChart } from "../src/lib/report-chart-block.ts";
Object.assign(globalThis, { React });
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
/** The two house-table shapes that reach the chart as display text only. */
function textChart(occupantsByHouse: Record<number, string[]>) {
return {
houses: Array.from({ length: 12 }, (_, index) => ({
houseNumber: index + 1,
sign: "白羊座",
occupants: occupantsByHouse[index + 1] ?? [],
})),
};
}
function draw(chart: Parameters<typeof VedicChartSvg>[0]["chart"], label = "测试盘"): string {
return renderToStaticMarkup(React.createElement(VedicChartSvg, { chart, ariaLabel: label }));
}
test("a house of display text renders symbols, and a retrograde body gets a bar instead of 逆", () => {
// The report chart block's own label form, which is what the markdown charts
// and the rectification board's engine house tables look like on the wire.
const markup = draw(textChart({ 5: ["水 19°", "罗逆 29°"] }));
assert.match(markup, /☿/, "水 renders as ☿");
assert.match(markup, /☊/, "罗 renders as ☊");
assert.match(markup, /class="personal-report-chart-retrograde is-node"/, "罗逆 gets a bar");
assert.doesNotMatch(markup, /class="personal-report-chart-retrograde is-mercury"/, "水 is direct");
assert.doesNotMatch(markup, /°/, "degrees are gone from the chart");
// 原值:全 markup 的「逆」只有一处;新值:只计 SVG 可见 text,另测说明节点。
// 原因:页面交互说明可包含逆行,但盘内不能重新塞回「逆」字标签。
const visibleText = [...markup.matchAll(/<text\b[^>]*>(.*?)<\/text>/g)].map((hit) => hit[1]).join("");
assert.deepEqual(visibleText.match(/逆/g), ["逆"]);
assert.doesNotMatch(markup, /data-chart-help|tabindex|<title>/, "shared default stays static");
});
test("the rectification board's full Chinese names map to the same symbols", () => {
// scripts/rectification/house_table.py emits 太阳 / 水星 / 罗睺, not 日 / 水 / 罗.
const markup = draw(textChart({ 4: ["太阳", "水星", "罗睺", "计都"] }));
for (const glyph of ["☉", "☿", "☊", "☋"]) assert.match(markup, new RegExp(glyph));
});
test("♀ and ♂ carry U+FE0E so iOS draws them as text, not emoji", () => {
assert.equal(PLANET_GLYPH.venus, "♀︎");
assert.equal(PLANET_GLYPH.mars, "♂︎");
const markup = draw(textChart({ 2: ["金 1°", "火 2°"] }));
assert.match(markup, /♀︎/);
assert.match(markup, /♂︎/);
});
test("house 1 carries the As mark, because the North Indian first house is the ascendant", () => {
const markup = draw(textChart({ 1: ["日 22°"], 3: ["月 12°"] }));
assert.match(markup, /class="personal-report-chart-asc is-ascendant">As</);
assert.equal((markup.match(/>As</g) ?? []).length, 1, "exactly one As, and only in house 1");
});
test("an explicit ascendant occupant folds into the same mark instead of doubling it", () => {
const markup = draw(textChart({ 1: ["上升点", "日 22°"] }));
assert.equal((markup.match(/>As</g) ?? []).length, 1);
assert.doesNotMatch(markup, /上升点/);
});
test("the legend only appears once the chart carries bodies", () => {
const empty = draw(textChart({}));
assert.doesNotMatch(empty, /横线=逆行/);
assert.doesNotMatch(empty, />As</, "an empty grid stays empty");
assert.match(empty, /viewBox="0 0 400 400"/, "no legend band without a legend");
const filled = draw(textChart({ 7: ["木 24°"] }));
assert.match(filled, /横线=逆行/);
assert.match(filled, /viewBox="0 0 400 452"/, "the legend sits under the square, not on it");
for (const key of PLANET_GLYPH_KEYS) {
assert.match(filled, new RegExp(`class="personal-report-chart-legend-glyph is-[a-z]+">${PLANET_GLYPH[key]}<`));
}
for (const name of ["日", "月", "水", "金", "火", "木", "土", "罗", "计"]) {
assert.match(filled, new RegExp(`class="personal-report-chart-legend">${name}<`));
}
});
test("six bodies in one house wrap onto two rows; a seventh becomes +N", () => {
const six = draw(textChart({ 4: ["日 1°", "月 2°", "火 3°", "水 4°", "木 5°", "金 6°"] }));
const ys = [...six.matchAll(/class="personal-report-chart-glyph[^"]*"/g)];
assert.equal(ys.length, 6, "all six are drawn, none summarised");
const rows = new Set(
[...six.matchAll(/<text x="[\d.]+" y="([\d.]+)"[^>]*class="personal-report-chart-glyph/g)]
.map((hit) => hit[1]),
);
assert.equal(rows.size, 2, "three per row, so six bodies occupy two rows");
const seven = draw(textChart({
4: ["日 1°", "月 2°", "火 3°", "水 4°", "木 5°", "金 6°", "土 7°"],
}));
assert.match(seven, />\+2</, "the overflow summary survives the symbol rewrite");
});
test("toNorthIndianChart hands the renderer structured bodies, not a string to re-read", () => {
const chart = toNorthIndianChart({
version: 1,
id: "D1",
title: "D1",
layout: "north",
ascendant: { sign: "Leo", degree: 12.3 },
planets: [{ name: "Saturn", sign: "Capricorn", degree: 3.4, retrograde: true }],
});
const house = chart.houses.find((item) => item.occupantGlyphs?.length);
assert.ok(house, "the occupied house carries occupantGlyphs");
assert.deepEqual(house.occupantGlyphs, [{ key: "saturn", retrograde: true, degree: 3.4 }]);
// The display string stays: aria-label, the house <details> table and screen
// readers still read it.
assert.deepEqual(house.occupants, ["土逆 3°"]);
});
test("an unknown occupant name is drawn as written rather than dropped", () => {
const markup = draw(textChart({ 6: ["占星体1"] }));
assert.match(markup, /class="personal-report-chart-occupant-text">占星体1</);
});
test("parseChartOccupant reads body and direction out of every label form in use", () => {
assert.deepEqual(parseChartOccupant("水 19°"), {
kind: "planet", key: "mercury", retrograde: false, label: "水 19°",
});
assert.deepEqual(parseChartOccupant("罗逆 29°"), {
kind: "planet", key: "rahu", retrograde: true, label: "罗逆 29°",
});
assert.equal(parseChartOccupant("太阳").kind, "planet");
assert.equal(parseChartOccupant("Mercury").kind, "planet");
assert.equal(parseChartOccupant("Mars").kind, "planet");
assert.equal(parseChartOccupant("Lagna").kind, "ascendant");
assert.equal(parseChartOccupant("上升").kind, "ascendant");
assert.equal(parseChartOccupant("占星体1").kind, "text");
});
test("every planet colour is defined once in :root and once in each dark block", () => {
const tokens = [
"sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "node", "ascendant",
].map((name) => `--color-planet-${name}`);
const block = (opener: string) => {
const start = globalStyles.indexOf(opener);
assert.notEqual(start, -1, `missing block: ${opener}`);
return globalStyles.slice(start, globalStyles.indexOf("\n}", start));
};
const blocks = {
light: block(":root {\n color-scheme: light;"),
preferred: block(':root:not([data-theme="light"]) {'),
pinned: block(':root[data-theme="dark"] {'),
};
for (const [name, body] of Object.entries(blocks)) {
for (const token of tokens) {
const hits = [...body.matchAll(new RegExp(`${token}:\\s*(#[0-9a-f]{6});`, "gi"))];
assert.equal(hits.length, 1, `${token} must be declared exactly once in the ${name} block`);
}
}
// A colour that only exists in one theme is unreadable in the other.
for (const token of tokens) {
assert.notEqual(
blocks.light.match(new RegExp(`${token}:\\s*(#[0-9a-f]{6});`, "i"))![1].toLowerCase(),
blocks.pinned.match(new RegExp(`${token}:\\s*(#[0-9a-f]{6});`, "i"))![1].toLowerCase(),
`${token} must be lifted for the dark ground, not reused`,
);
}
});
test("the symbol font is vendored, not fetched, and the stack falls through to system faces", () => {
assert.match(layout, /src: "\.\/fonts\/NotoSansSymbols-planets\.woff2"/);
assert.match(layout, /variable: "--font-planet-glyphs"/);
assert.doesNotMatch(layout, /from ["']next\/font\/google["']/);
const font = readFileSync(new URL("../src/app/fonts/NotoSansSymbols-planets.woff2", import.meta.url));
assert.equal(Buffer.from(font.subarray(0, 4)).toString(), "wOF2");
assert.match(
globalStyles,
/font-family: var\(--font-planet-glyphs\), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", serif;/,
);
// The licence and provenance live beside the file, as they do for Inter.
const source = readFileSync(new URL("../src/app/fonts/SOURCE.txt", import.meta.url), "utf8");
assert.match(source, /NotoSansSymbols-planets\.woff2/);
assert.match(source, /Open Font License/);
});
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",
};
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> };
async function okView(): 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 };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return result.body as ChartViewOk;
}
test("the chart page planet table carries the columns the chart stopped drawing", async () => {
const view = await okView();
const markup = renderToStaticMarkup(
React.createElement(ChartVedicTab, { view, vargaId: "D1", onSelectVarga: () => {} }),
);
const headers = [...markup.matchAll(/<th scope="col">([^<]*)<\/th>/g)].map((hit) => hit[1]);
assert.deepEqual(headers, ["行星", "黄道经度", "速度", "宫位", "星宿", "宿主", "pada", "状态"]);
// The ascendant is a row, and it is the first one.
assert.match(markup, /<tbody><tr><th scope="row"><span class="chart-page-planet-glyph is-ascendant">As<\/span>/);
assert.match(markup, /上升/);
// Degrees keep their arc-minutes: 29°59′ and 0°01′ are a sign apart.
assert.match(markup, /\d+°\d\d′/);
assert.match(markup, /class="chart-page-planet-glyph is-sun">☉<\/span> 太阳/);
assert.match(markup, /class="chart-page-planet-abbr">Su</);
assert.doesNotMatch(markup, /星座与度数/, "the old four-column header is gone");
});