Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
171 lines
11 KiB
TypeScript
171 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 { ChartSymbolHelp } from "../src/components/chart-page/chart-symbol-help.tsx";
|
||
import { ChartWesternTab } from "../src/components/chart-page/chart-western-tab.tsx";
|
||
import { VedicChartSvg, VedicChartSvgDrawing } from "../src/components/personal-report/vedic-chart-svg.tsx";
|
||
import { assembleChartView } from "../src/lib/chart-view-load.ts";
|
||
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||
import { reportChartExportSvg } from "../src/lib/report-chart-export.tsx";
|
||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||
|
||
Object.assign(globalThis, { React });
|
||
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
|
||
async function view(): Promise<ChartViewOk> {
|
||
const result = await assembleChartView({
|
||
userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1",
|
||
profile: { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" },
|
||
asOf: "2026-09-15", layers: ["varga", "western"],
|
||
postEngine: async (path) => ({ status: "ok", payload: path === "/api/chart" ? golden.chart : path === "/api/varga_full" ? golden.varga_full : golden.western }),
|
||
});
|
||
assert.equal(result.body.status, "ok");
|
||
return result.body as ChartViewOk;
|
||
}
|
||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||
function wrapper(h: Harness) { return h.elements().find((node) => node.getAttribute("class") === "chart-page-symbol-help")!; }
|
||
function note(h: Harness) { return h.elements().find((node) => node.getAttribute("role") === "status")!; }
|
||
function target(h: Harness, fragment: string) { return h.elements().find((node) => node.getAttribute("data-chart-help")?.includes(fragment))!; }
|
||
|
||
// These mount real React components and invoke their host handlers. Geometry,
|
||
// native pointer dispatch and touch target dimensions still need browser QA.
|
||
test("Vedic help supports hover, focus, touch click, leave, blur and Escape without changing glyphs", async () => {
|
||
const data = await view();
|
||
const chart = data.vedic.vargas.find((varga) => varga.id === "D1")!.chart;
|
||
const h = createClientLifecycleHarness();
|
||
try {
|
||
await h.render(<ChartSymbolHelp resetKey="D1"><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>);
|
||
const retro = target(h, "逆行");
|
||
assert.ok(retro, "golden contains a retrograde planet");
|
||
assert.equal(retro.getAttribute("role"), "button");
|
||
assert.equal(retro.childNodes.filter((node) => node.getAttribute("tabindex") !== null).length, 0, "bar and glyph share one focus stop");
|
||
await h.event(wrapper(h), "onPointerOver", { target: retro.childNodes.find((node) => node.tagName === "LINE") ?? retro, pointerType: "mouse" });
|
||
assert.match(note(h).text, /逆行(符号下的横线)/);
|
||
await h.event(wrapper(h), "onPointerOut", { target: retro, relatedTarget: null });
|
||
assert.doesNotMatch(note(h).text, /逆行/);
|
||
const asc = target(h, "As · 上升");
|
||
await h.event(wrapper(h), "onFocus", { target: asc });
|
||
assert.equal(note(h).text, "As · 上升");
|
||
await h.event(wrapper(h), "onBlur", { target: asc });
|
||
assert.doesNotMatch(note(h).text, /As/);
|
||
await h.event(wrapper(h), "onClick", { target: asc, pointerType: "touch" });
|
||
assert.equal(note(h).text, "As · 上升", "touch uses a visible page note, not native title");
|
||
await h.event(wrapper(h), "onKeyDown", { target: asc, key: "Escape" });
|
||
assert.doesNotMatch(note(h).text, /As/);
|
||
const sign = target(h, "星座编号");
|
||
await h.event(wrapper(h), "onKeyDown", { target: sign, key: " " });
|
||
assert.match(note(h).text, /座,星座编号,不是宫位编号/);
|
||
await h.event(wrapper(h), "onPointerLeave");
|
||
assert.doesNotMatch(note(h).text, /星座编号/);
|
||
assert.deepEqual(h.errors, []);
|
||
} finally { await h.close(); }
|
||
});
|
||
|
||
test("Vedic help resets on chart, person or data identity changes", async () => {
|
||
const data = await view();
|
||
const chart = data.vedic.vargas.find((varga) => varga.id === "D1")!.chart;
|
||
const h = createClientLifecycleHarness();
|
||
try {
|
||
for (const key of ["person-a:D1:v1", "person-a:D9:v1", "person-b:D9:v1", "person-b:D9:v2"]) {
|
||
await h.render(<ChartSymbolHelp resetKey={key}><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>);
|
||
assert.doesNotMatch(note(h).text, /As/);
|
||
await h.event(wrapper(h), "onClick", { target: target(h, "As · 上升") });
|
||
assert.equal(note(h).text, "As · 上升");
|
||
}
|
||
} finally { await h.close(); }
|
||
});
|
||
|
||
test("crowded Vedic help explains overflow and preserves static report/export boundaries", async () => {
|
||
const data = await view();
|
||
const chart = structuredClone(data.vedic.vargas.find((varga) => varga.id === "D1")!.chart);
|
||
// Directed crowding mutation of the engine golden, not a made-up response.
|
||
const occupants = chart.houses.flatMap((house) => house.occupants);
|
||
const occupantGlyphs = chart.houses.flatMap((house) => house.occupantGlyphs ?? []);
|
||
chart.houses.forEach((house, index) => {
|
||
house.occupants = index === 0 ? occupants : [];
|
||
house.occupantGlyphs = index === 0 ? occupantGlyphs : [];
|
||
});
|
||
const interactive = renderToStaticMarkup(<VedicChartSvg chart={chart} ariaLabel="测试盘" interactive />);
|
||
assert.match(interactive, /这一宫还有 \d+ 个标记未展开/);
|
||
assert.match(interactive, /role="group"/);
|
||
assert.match(interactive, /<title>[^<]*逆行/);
|
||
for (const title of interactive.matchAll(/<title>(.*?)<\/title>/g)) {
|
||
assert.doesNotMatch(title[1]!, /°|′/, "help does not reverse-engineer precise degrees from display strings");
|
||
}
|
||
const skeleton = renderToStaticMarkup(<VedicChartSvg chart={chart} ariaLabel="测试盘" interactive skeleton />);
|
||
assert.doesNotMatch(skeleton, /data-chart-help|tabindex|role="button"|<title>/);
|
||
const visibleText = [...interactive.matchAll(/<text\b[^>]*>(.*?)<\/text>/g)].map((hit) => hit[1]).join("");
|
||
assert.deepEqual(visibleText.match(/逆/g), ["逆"]);
|
||
const staticMarkup = renderToStaticMarkup(VedicChartSvgDrawing({ chart, ariaLabel: "测试盘", chartId: "static" }));
|
||
const exported = reportChartExportSvg(chart, "测试盘");
|
||
for (const markup of [staticMarkup, exported]) {
|
||
assert.doesNotMatch(markup, /data-chart-help|tabindex|role="button"|<title>|<script|\son\w+=|height="auto"/);
|
||
assert.match(markup, /横线=逆行/);
|
||
}
|
||
assert.match(exported, /height="452"/);
|
||
});
|
||
|
||
test("Western visible caption and hit target explain the same nearest planet as selection; Escape only closes help", async () => {
|
||
const data = await view();
|
||
assert.equal(data.western.status, "ok");
|
||
if (data.western.status !== "ok") return;
|
||
const planets = data.western.planets;
|
||
const h = createClientLifecycleHarness();
|
||
try {
|
||
await h.render(<ChartWesternTab view={data} />);
|
||
const hosts = h.elements();
|
||
const lastHit = Math.max(...hosts.map((node, index) => node.getAttribute("class") === "chart-page-western-hit" ? index : -1));
|
||
for (const className of ["chart-page-western-sign-label", "chart-page-western-house-label", "chart-page-western-angle"]) {
|
||
assert.ok(hosts.findIndex((node) => node.getAttribute("class") === className) > lastHit, `${className} stays above transparent hit circles`);
|
||
}
|
||
const caption = h.elements().find((node) => node.getAttribute("data-planet-caption") === planets[0]!.id)!;
|
||
const otherCaption = h.elements().find((node) => node.getAttribute("data-planet-caption") === planets[1]!.id)!;
|
||
const text = otherCaption.childNodes.find((node) => node.tagName === "TEXT")!;
|
||
const point = { x: Number(text.props.x), y: Number(text.props.y) };
|
||
const ownerSVGElement = { getScreenCTM: () => ({ inverse: () => ({}) }), createSVGPoint: () => ({ x: 0, y: 0, matrixTransform: () => point }) };
|
||
const event = { currentTarget: { ownerSVGElement }, clientX: point.x, clientY: point.y, pointerType: "mouse" };
|
||
await h.event(caption, "onPointerMove", event);
|
||
assert.match(note(h).text, new RegExp(planets[1]!.label));
|
||
await h.event(caption, "onClick", { ...event, pointerType: "touch" });
|
||
assert.match(note(h).text, new RegExp(planets[1]!.label));
|
||
const selected = () => h.elements().find((node) => node.getAttribute("role") === "button" && node.getAttribute("aria-pressed") === "true")!;
|
||
assert.match(selected().getAttribute("aria-label")!, new RegExp(planets[1]!.label));
|
||
await h.event(wrapper(h), "onKeyDown", { key: "Escape" });
|
||
assert.doesNotMatch(note(h).text, new RegExp(planets[1]!.label));
|
||
assert.ok(selected(), "Escape does not clear the aspect filter");
|
||
await h.event(caption, "onClick", event);
|
||
assert.equal(selected(), undefined, "a second click still cancels selection");
|
||
const hit = h.elements().find((node) => node.getAttribute("role") === "button" && node.getAttribute("aria-label")?.startsWith(planets[0]!.label))!;
|
||
await h.event(hit, "onPointerOver", event);
|
||
assert.match(note(h).text, new RegExp(planets[1]!.label));
|
||
await h.event(hit, "onFocus");
|
||
assert.match(note(h).text, new RegExp(planets[0]!.label));
|
||
await h.event(hit, "onKeyDown", { key: "Enter" });
|
||
assert.ok(selected());
|
||
await h.event(hit, "onKeyDown", { key: " " });
|
||
assert.equal(selected(), undefined);
|
||
await h.event(hit, "onPointerLeave");
|
||
assert.doesNotMatch(note(h).text, new RegExp(planets[0]!.label));
|
||
const asc = target(h, "ASC · 上升");
|
||
await h.event(wrapper(h), "onClick", { target: asc });
|
||
assert.equal(note(h).text, "ASC · 上升");
|
||
await h.render(<ChartWesternTab view={{ ...data, subjectId: "synthetic-other" }} />);
|
||
assert.doesNotMatch(note(h).text, /ASC/);
|
||
assert.deepEqual(h.errors, []);
|
||
} finally { await h.close(); }
|
||
});
|
||
|
||
test("the global tooltip-positioner rule does not switch off the chart stage's pointer events (BUG-1120)", () => {
|
||
// The bubble is an inline role="tooltip" child of .chart-page-symbol-help. A bare
|
||
// `:has(> [role="tooltip"]) { pointer-events: none }` matched the stage, so the
|
||
// drawing under the cursor stopped being hoverable while the bubble was up and
|
||
// the bubble flickered on every mouse move (reproduced in Chromium).
|
||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const rules = [...css.matchAll(/([^{}]*:has\(>\s*\[role="tooltip"\]\)[^{}]*)\{([^}]*)\}/g)];
|
||
assert.ok(rules.length > 0);
|
||
for (const [, selector, body] of rules) {
|
||
if (/pointer-events:\s*none/.test(body)) assert.match(selector, /:not\(\.chart-page-symbol-help\)/, selector.trim());
|
||
}
|
||
});
|