北印星盘(星盘页 / 校正右栏 / 我的报告共用 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
244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
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");
|
||
});
|