Files
Jyotisha/frontend/tests/chart-planet-glyphs.test.tsx
T
Jesse_ChenandClaude Fable 5.1 5a1dcbd2a1
Independent Staging Quality Gate / validate (push) Failing after 8m58s
Independent Staging Quality Gate / publish (push) Skipped
feat(chart): 星盘改用行星符号与颜色,度数进行星表
北印星盘(星盘页 / 校正右栏 / 我的报告共用 VedicChartSvg)宫内不再画
「水 19°」「罗逆 29°」这类显示文本——一宫三颗星就叠三行、字号压到 8。
改成九个彩色符号横排,每行最多 3 个;逆行是符号下方一道同色横线,
第 1 宫加 As 小标,盘下方两行图例。度数、星宿、顺逆下沉到星盘页的
行星表,列从四列扩到八列,首行是上升,度数精确到分。

- 符号字体:vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB),
  经 next/font/local 挂成 --font-planet-glyphs;♀ ♂ 带 U+FE0E 防 iOS
  画成表情。构建镜像不能联网,所以必须自带,与 Inter 同一条理由。
- 颜色:--color-planet-* 九个 token,:root 与两个深色块各一份。只用于
  认星不表吉凶,罗计共用中性灰。挂 is-* 修饰类而非行内 style——报告
  文档的标记合同禁止渲染结果出现 style=,星盘不做例外。
- 数据来源:toNorthIndianChart() 加结构化 occupantGlyphs(星盘页与
  markdown fence 走这条);校正与报告两条路线的模型只有引擎 / golden
  显示文本,按首字解析,解析由测试锁住。
- 星宿沿用引擎返回名:仓库无中文宿名表,印度 27 宿与中国二十八宿不是
  同一套,不自造对照。

tsc 0 错,lint 0 error,npm test 3471→3484 条、36 红与基线逐条相同,
/ 仍 Static,首屏 JS gzip +0.29%。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-18 00:39:13 +00:00

244 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");
// 「逆」survives only in the legend line 横线=逆行.
assert.deepEqual(markup.match(/逆/g), ["逆"]);
});
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");
});