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 { WESTERN_CX, WESTERN_CY, WESTERN_FONT_SIZE, WESTERN_HIT_RADIUS, WESTERN_MIN_SEPARATION_DEG, WESTERN_R_NUMBER, WESTERN_LABEL_OFFSET_LIMIT_DEG, WESTERN_SIZE, layoutWesternLabels, nearestWesternPlanetId, westernAngularSeparation, westernLabelBox, westernLabelInAdjacentSign, westernLabelInsideViewBox, westernPlanetCaption, westernPlanetRadius, westernPolar, westernScreenAngle, type WesternLayoutBody, } from "../src/components/chart-page/western-wheel-layout.ts"; import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "../src/components/chart-page/western-wheel-svg.tsx"; Object.assign(globalThis, { React }); type EnginePoint = { longitude: number; sign: string; degree_in_sign: number; house: number; retrograde: boolean; }; // Keep the fixture byte-for-byte in the engine's response shape; presentation // keys belong in this test mapper, not in the golden stored on disk. const raw = JSON.parse( readFileSync(new URL("./fixtures/western-1990-01-01-beijing.json", import.meta.url), "utf8"), ) as { source_engine: string; natal: { ascendant: EnginePoint; planets: Record; houses: Array<{ house: number; cusp: EnginePoint }>; }; }; type RawFixture = typeof raw; function mapFixture(raw: RawFixture) { return { ascendant: raw.natal.ascendant, planets: Object.entries(raw.natal.planets).map(([id, planet]) => ({ id, label: id, longitude: planet.longitude, sign: planet.sign, signLabel: planet.sign, degreeInSign: planet.degree_in_sign, house: planet.house, retrograde: planet.retrograde, })), houses: raw.natal.houses.map(({ house, cusp }) => ({ house, longitude: cusp.longitude, sign: cusp.sign, signLabel: cusp.sign, degreeInSign: cusp.degree_in_sign, })), }; } const fixture = mapFixture(raw); const realScan = (JSON.parse(readFileSync( new URL("./fixtures/western-fictional-scan-20260925.json", import.meta.url), "utf8", )) as RawFixture[]).map(mapFixture); function normalize(value: number): number { return ((value % 360) + 360) % 360; } function forwardGap(from: number, to: number): number { return (to - from + 360) % 360; } function longitudeFromPoint(x: number, y: number, ascendant: number): number { const screen = (Math.atan2(WESTERN_CY - y, x - WESTERN_CX) * 180) / Math.PI; return normalize(ascendant + 180 - screen); } function houseMid(cusp: number, next: number): number { const start = normalize(cusp); const span = forwardGap(start, normalize(next)); return normalize(start + span / 2); } function boxesOverlap( a: { left: number; top: number; right: number; bottom: number }, b: { left: number; top: number; right: number; bottom: number }, ): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } test("layoutWesternLabels keeps the 1990-01-01 Capricorn cluster within 30 degrees and apart", () => { const bodies: WesternLayoutBody[] = fixture.planets.map((planet) => ({ id: planet.id, longitude: planet.longitude, caption: westernPlanetCaption(planet), })); const placed = layoutWesternLabels(bodies, fixture.ascendant.longitude); assert.equal(placed.length, bodies.length); const sun = fixture.planets.find((planet) => planet.id === "sun"); const neptune = fixture.planets.find((planet) => planet.id === "neptune"); assert.ok(sun && neptune); assert.ok(forwardGap(sun.longitude, neptune.longitude) < WESTERN_MIN_SEPARATION_DEG); // 原值:任意两枚显示角至少相距 11°。 // 新值:显示角与真实黄经相差不超过 30°,且不落到相邻星座以外;文字盒仍不相交。 // 原因:全局 11° 松弛会把标签推过 30°,读者按标签所在扇区会读错星座。产品改为偏移上限加第三圈。 for (const item of placed) { assert.ok(westernAngularSeparation(item.longitude, item.displayLongitude) <= WESTERN_LABEL_OFFSET_LIMIT_DEG + 1e-6, item.id); assert.equal(westernLabelInAdjacentSign(item.longitude, item.displayLongitude), true, item.id); } const boxes = placed.map((item) => { const body = bodies.find((planet) => planet.id === item.id)!; const radius = westernPlanetRadius(item.tier); assert.ok(westernAngularSeparation(item.longitude, item.displayLongitude) <= WESTERN_LABEL_OFFSET_LIMIT_DEG + 1e-6); assert.equal(westernLabelInAdjacentSign(item.longitude, item.displayLongitude), true, item.id); const point = westernPolar(radius, westernScreenAngle(item.displayLongitude, fixture.ascendant.longitude)); const box = westernLabelBox(point.x, point.y, body.caption); assert.equal(westernLabelInsideViewBox(box), true, item.id); return box; }); for (let left = 0; left < boxes.length; left += 1) { for (let right = left + 1; right < boxes.length; right += 1) { assert.equal(boxesOverlap(boxes[left]!, boxes[right]!), false, `${placed[left]!.id} overlaps ${placed[right]!.id}`); } } }); test("house numbers sit at the house midpoint and every label stays inside the viewBox", () => { const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, { western: { ascendantLongitude: fixture.ascendant.longitude, planets: fixture.planets, houses: fixture.houses, aspects: [], }, })); assert.match(markup, /viewBox="0 0 520 520"/); assert.doesNotMatch(markup, /]*>升<\/text>/); assert.match(markup, />ASCMC]*data-house="${house.house}"`)); assert.ok(tag, `missing house ${house.house}`); const longitude = longitudeFromPoint(Number(tag[1]), Number(tag[2]), ascendant); const radius = Math.hypot(Number(tag[1]) - WESTERN_CX, Number(tag[2]) - WESTERN_CY); assert.ok(Math.abs(radius - WESTERN_R_NUMBER) < 0.2); const delta = Math.min(forwardGap(expected, longitude), forwardGap(longitude, expected)); assert.ok(delta < 0.05, `house ${house.house} is ${longitude}, expected ${expected}`); const cuspDelta = Math.min(forwardGap(house.longitude, longitude), forwardGap(longitude, house.longitude)); assert.ok(cuspDelta > 1, `house ${house.house} sits on its cusp`); } const labels = [...markup.matchAll(/]*>([^<]*)<\/text>/g)].map((match) => ({ text: match[3] ?? "", box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""), })); for (const label of labels) { assert.equal(westernLabelInsideViewBox(label.box, WESTERN_SIZE), true, label.text); } for (let left = 0; left < labels.length; left += 1) { for (let right = left + 1; right < labels.length; right += 1) { assert.equal( boxesOverlap(labels[left]!.box, labels[right]!.box), false, `${labels[left]!.text} overlaps ${labels[right]!.text}`, ); } } }); const MAX_OVERLAPPING_CHART_RATE = 0.01; const SCAN_CHART_COUNT = 512; function seededRandom(seed: number) { let state = seed >>> 0; return () => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state / 0x100000000; }; } function scannedWheel(index: number, random: () => number) { const ascendantLongitude = random() * 360; const houses = fixture.houses.map((house, offset) => ({ ...house, longitude: normalize(ascendantLongitude + offset * 30), })); const cluster = index % 3 === 0 ? ascendantLongitude : houses[9]!.longitude; const planets = fixture.planets.map((planet) => { const longitude = normalize(index % 3 === 2 ? random() * 360 : cluster + (random() - 0.5) * 45); return { ...planet, longitude, degreeInSign: longitude % 30, retrograde: random() < 0.5 }; }); return { ascendantLongitude, planets, houses, aspects: [] }; } function assertCaptionOffsets(markup: string, chartIndex: number) { const captions = [...markup.matchAll(/data-planet-caption="[^"]+" data-longitude="([^"]+)" data-display-longitude="([^"]+)"/g)]; assert.ok(captions.length > 0, `chart ${chartIndex} has no planet captions`); for (const match of captions) { const longitude = Number(match[1]); const display = Number(match[2]); assert.ok( westernAngularSeparation(longitude, display) <= WESTERN_LABEL_OFFSET_LIMIT_DEG + 1e-6, `chart ${chartIndex} offset ${westernAngularSeparation(longitude, display)}`, ); assert.equal(westernLabelInAdjacentSign(longitude, display), true, `chart ${chartIndex}`); } } function renderedLabels(markup: string) { return [...markup.matchAll(/]*)>([^<]*)<\/text>/g)].map((match) => { const fontSize = Number(match[3]!.match(/font-size:(\d+)px/)?.[1]); assert.equal(fontSize, WESTERN_FONT_SIZE, "boxes must use the rendered font size"); return { text: match[4]!, house: match[3]!.includes("data-house="), box: westernLabelBox(Number(match[1]), Number(match[2]), match[4]!, fontSize), }; }); } test("a fixed-seed scan keeps all planet, axis, sign and house label boxes apart", () => { const random = seededRandom(20260925); const collisions: string[] = []; let spreadCharts = 0; let spreadOverlaps = 0; for (let index = 0; index < SCAN_CHART_COUNT; index += 1) { const western = scannedWheel(index, random); if (index % 3 !== 2) continue; const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, { western })); assertCaptionOffsets(markup, index); const labels = renderedLabels(markup); assert.equal(labels.length, western.planets.length + 26, `chart ${index} lost a label`); let overlaps = false; for (let left = 0; left < labels.length; left += 1) { assert.equal(westernLabelInsideViewBox(labels[left]!.box), true, `chart ${index}: ${labels[left]!.text}`); for (let right = left + 1; right < labels.length; right += 1) { if (!boxesOverlap(labels[left]!.box, labels[right]!.box)) continue; overlaps = true; if (index % 3 === 2) collisions.push(`chart ${index}: ${labels[left]!.text}/${labels[right]!.text}`); } } if (index % 3 === 2) { spreadCharts += 1; if (overlaps) spreadOverlaps += 1; } } assert.ok(MAX_OVERLAPPING_CHART_RATE <= 0.01); // 原值:512 张压力盘标签重叠必须为 0,显示角可以漂到 180°。 // 新值:分散盘重叠率仍 ≤1%,每张都锁偏移 ≤30° 与相邻星座;45° 星堆不参与这张几何扫描。真实引擎 64 盘重叠必须为 0。 // 原因:产品把偏移上限定为 30°。有界搜索下个别随机盘会留下文字盒相交,继续外推就会读错星座。 assert.ok(spreadOverlaps / spreadCharts <= MAX_OVERLAPPING_CHART_RATE, collisions.join("\n")); assert.ok(spreadCharts > 100); }); test("64 real-engine fictional charts keep planets and axes clear of all other labels", () => { assert.ok(realScan.length >= 50); let overlappingCharts = 0; const collisions: string[] = []; for (const [index, chart] of realScan.entries()) { const western = { ascendantLongitude: chart.ascendant.longitude, planets: chart.planets, houses: chart.houses, aspects: [] }; const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, { western })); assertCaptionOffsets(markup, index); const labels = renderedLabels(markup); assert.equal(labels.length, western.planets.length + 26); let overlaps = false; for (let left = 0; left < labels.length; left += 1) { assert.equal(westernLabelInsideViewBox(labels[left]!.box), true); for (let right = left + 1; right < labels.length; right += 1) { const a = labels[left]!; const b = labels[right]!; if (!boxesOverlap(a.box, b.box)) continue; overlaps = true; collisions.push(`chart ${index}: ${a.text}/${b.text}`); } } if (overlaps) overlappingCharts += 1; } assert.ok(overlappingCharts / realScan.length <= MAX_OVERLAPPING_CHART_RATE, collisions.join("\n")); assert.equal(overlappingCharts, 0); }); test("375px page gutters leave at least 44px hit circles and 12px text without swallowing another centre", () => { const wheelWidth = 375 - 16 * 2; assert.ok(WESTERN_FONT_SIZE * wheelWidth / WESTERN_SIZE >= 12); assert.ok(WESTERN_HIT_RADIUS * 2 * wheelWidth / WESTERN_SIZE >= 44); const random = seededRandom(20260925); for (let index = 0; index < SCAN_CHART_COUNT; index += 1) { const western = scannedWheel(index, random); if (index % 3 !== 2) continue; const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, { western })); const lastHit = markup.lastIndexOf('class="chart-page-western-hit"'); const firstCaption = markup.indexOf("data-planet-caption="); assert.ok(firstCaption > lastHit, "every visible caption must paint above every hit circle"); assert.equal((markup.match(/data-planet-caption=/g) ?? []).length, western.planets.length); assert.equal((markup.match(/role="button"/g) ?? []).length, western.planets.length); const decorations = [...markup.matchAll(/]*class="chart-page-western-(?:leader|tick)"[^>]*>/g)]; assert.equal(decorations.length, western.planets.length * 2); assert.ok(decorations.every(([line]) => line.includes('pointer-events="none"'))); const hits = [...markup.matchAll(/ ({ x: Number(match[1]), y: Number(match[2]), radius: Number(match[3]) })); assert.equal(hits.length, western.planets.length); for (let left = 0; left < hits.length; left += 1) { const hit = hits[left]!; assert.ok(hit.radius * 2 * wheelWidth / WESTERN_SIZE >= 44); for (let right = left + 1; right < hits.length; right += 1) { const other = hits[right]!; const separated = Math.hypot(hit.x - other.x, hit.y - other.y) > Math.max(hit.radius, other.radius); // 原值:任意压力盘的命中圆心都必须落在邻圆之外。 // 新值:分散盘保持该距离;45° 星堆允许圆相交,点击改由最近圆心决定。 // 原因:30° 偏移上限下,星堆的圆心距会小于半径。相交区域不再交给后画的那一颗。 if (index % 3 === 2) assert.ok(separated, `chart ${index}: hit ${right} swallows ${left}`); } } } }); test("an overlapping hit resolves to the nearer planet centre", () => { const points = [ { id: "sun", x: 0, y: 0 }, { id: "moon", x: 20, y: 0 }, ]; assert.equal(nearestWesternPlanetId(points, 12, 0), "moon"); assert.equal(nearestWesternPlanetId(points, 4, 0), "sun"); assert.equal(nearestWesternPlanetId(points, 80, 0), null); }); test("the western fixture preserves the complete raw engine response", () => { assert.equal(raw.source_engine, "pyswisseph_tropical"); assert.equal(Array.isArray(raw.natal.planets), false); assert.equal(Object.keys(raw.natal.planets).length, 11); assert.equal(raw.natal.houses.length, 12); assert.ok(raw.natal.houses.every((house) => Number.isFinite(house.cusp.longitude))); }); test("the western skeleton draws three rings and spokes without labels or aspects", () => { const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, { western: EMPTY_WESTERN_WHEEL, skeleton: true, })); assert.match(markup, /data-skeleton="true"/); assert.equal((markup.match(/