feat(chart): 星盘改用行星符号与颜色,度数进行星表
Independent Staging Quality Gate / validate (push) Failing after 8m58s
Independent Staging Quality Gate / publish (push) Skipped

北印星盘(星盘页 / 校正右栏 / 我的报告共用 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
This commit is contained in:
Jesse_Chen
2026-09-18 00:39:13 +00:00
co-authored by Claude Fable 5.1
parent 84b293fb47
commit 5a1dcbd2a1
20 changed files with 1239 additions and 73 deletions
+13 -1
View File
@@ -203,7 +203,19 @@ test("the parameters are one key/value table, not a per-breakpoint duplicate", (
}
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; \}/);
// 原值:`.chart-page-vedic-stage .personal-report-chart-svg text { font-size: 16px; }`
// —— 一条 `text` 通选,把盘内所有文字统一提到 16 viewBox 单位。
// 新值:只提小字(宫位序号 16、As 15、图例 14/16),并新增一条反向断言:这个
// stage 下不得再出现 `text {` 通选规则。
// 原因:盘内文字现在不只有一种字号——行星符号本身已经按窄盘宽度定在 22 单位。
// 通选规则会把符号一起压回 16,正是本轮要避免的(DESIGN §15 的原意是
// 「小字在 ~318px 盘宽下不低于 12px」,不是「所有字一样大」)。断言主语
// (宫位序号仍是 16)一字未改,新增的 doesNotMatch 比原断言更强。
assert.match(
globalStyles,
/\.chart-page-vedic-stage \.personal-report-chart-svg \.personal-report-chart-muted \{ font-size: 16px; \}/,
);
assert.doesNotMatch(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{/);
});
test("the planet table scrolls inside its own box instead of widening the page", () => {
+243
View File
@@ -0,0 +1,243 @@
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");
});
@@ -69,7 +69,22 @@ test("markdown view draws the North Indian component and skips engine SVG HTML",
assert.doesNotMatch(markup, /viewBox="0 0 420 480"/);
assert.match(markup, /图盘数据无效/);
assert.doesNotMatch(markup, /<script/i);
assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
// 原值:assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)))
// —— 盘里必须出现「土逆 3°」这串显示文本。
// 新值:盘里必须出现 ♄ 符号并带一条 .personal-report-chart-retrograde 横线,
// 且不得再出现「土逆 3°」或任何「逆」字与度数符号。
// 原因:本轮产品拍板把盘内文字换成彩色行星符号,度数 / 星宿 / 逆行下沉到星盘页
// 的行星表。原断言守的正是本轮要去掉的东西,无法原样保留。断言主语(这颗
// 逆行土星画在盘里、且是从 fence 数据来的)没变,新增的 doesNotMatch 比
// 原断言更强:它同时锁住「不再写字」这一半。
// planetDisplayLabel 本身没删——occupants 字符串仍然喂 aria-label 与宫位表。
assert.match(markup, /♄/);
assert.match(markup, /class="personal-report-chart-retrograde is-saturn"/);
assert.doesNotMatch(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
assert.doesNotMatch(markup, /°/, "degrees moved to the chart page planet table");
// 「逆」只剩图例里那一句「横线=逆行」,宫内不再写这个字。
assert.deepEqual(markup.match(/逆/g), ["逆"]);
assert.match(markup, /横线=逆行/);
});
test("consecutive chart heading+fence pairs become one two-column grid of cards", () => {
@@ -17,7 +17,13 @@ test("Inter is loaded through next/font and applied as a CSS variable", () => {
assert.match(layout, /src: "\.\/fonts\/InterVariable-latin\.woff2"/);
assert.match(layout, /display: "swap"/);
assert.match(layout, /variable: "--font-inter"/);
assert.match(layout, /className=\{inter\.variable\}/);
// 原值:/className=\{inter\.variable\}/ —— <html> 上只挂一个字体变量。
// 新值:/className=\{`\$\{inter\.variable\}[^`]*`\}/ —— 允许同一个模板串里再挂别的
// 字体变量,但 inter.variable 必须仍是第一个。
// 原因:本轮新增了 vendored 的 9 字形行星符号子集(--font-planet-glyphs),两个
// next/font 变量必须挂在同一个 <html> 上。断言主语(Inter 以 CSS 变量形式
// 挂在根元素)没变,只是不再要求 className 只有它一个。
assert.match(layout, /className=\{`\$\{inter\.variable\}[^`]*`\}/);
assert.match(readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"), /var\(--font-inter, Inter\)/);
assert.match(
readFileSync(new URL("../src/components/admin/admin-app.tsx", import.meta.url), "utf8"),