Cap planet captions at 30 degrees with a third ring, fall back only within the current person's sessions, and load self when the people catalog fails.
365 lines
16 KiB
TypeScript
365 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|白羊/);
|
|
});
|