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) { return { houses: Array.from({ length: 12 }, (_, index) => ({ houseNumber: index + 1, sign: "白羊座", occupants: occupantsByHouse[index + 1] ?? [], })), }; } function draw(chart: Parameters[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">AsAs { const markup = draw(textChart({ 1: ["上升点", "日 22°"] })); assert.equal((markup.match(/>As { const empty = draw(textChart({})); assert.doesNotMatch(empty, /横线=逆行/); assert.doesNotMatch(empty, />As${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(/]*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 { 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
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 { 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; varga_full: Record; chara: Record }; async function okView(): Promise { 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>/g)].map((hit) => hit[1]); assert.deepEqual(headers, ["行星", "黄道经度", "速度", "宫位", "星宿", "宿主", "pada", "状态"]); // The ascendant is a row, and it is the first one. assert.match(markup, /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