Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
370 lines
16 KiB
TypeScript
370 lines
16 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 {
|
|
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<string, EnginePoint>;
|
|
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[^>]*>升<\/text>/);
|
|
assert.match(markup, />ASC</);
|
|
assert.match(markup, />MC</);
|
|
const ascendant = fixture.ascendant.longitude;
|
|
for (let index = 0; index < fixture.houses.length; index += 1) {
|
|
const house = fixture.houses[index]!;
|
|
const next = fixture.houses[(index + 1) % fixture.houses.length]!;
|
|
const expected = houseMid(house.longitude, next.longitude);
|
|
const tag = markup.match(new RegExp(`<text x="([^"]+)" y="([^"]+)"[^>]*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 x="([^"]+)" y="([^"]+)"[^>]*>([^<]*)<\/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 x="([^"]+)" y="([^"]+)"([^>]*)>([^<]*)<\/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(/<line[^>]*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(/<circle cx="([^"]+)" cy="([^"]+)" r="([^"]+)" class="chart-page-western-hit"/g)]
|
|
.map((match) => ({ 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(/<circle /g) ?? []).length, 3);
|
|
assert.equal((markup.match(/<line /g) ?? []).length, 12);
|
|
// 原值: assert.doesNotMatch(markup, /<text|<path|data-tone|ASC|MC|白羊/) — no <path> at all
|
|
// 新值: exactly twelve <path class="chart-skeleton-sector"> (the waiting glow), and still no other path, text, aspect or label
|
|
// 原因: 产品 2026-09-30「星盘的加载动画太丑了」,选「宫位依次点亮」——西洋空盘沿圆周逐格点亮
|
|
assert.equal((markup.match(/<path /g) ?? []).length, 12);
|
|
assert.equal((markup.match(/<path [^>]*class="chart-skeleton-sector"/g) ?? []).length, 12);
|
|
assert.doesNotMatch(markup, /<text|data-tone|ASC|MC|白羊/);
|
|
});
|