Files
Jyotisha/frontend/tests/chart-symbol-help.test.tsx
T
Jesse_ChenandClaude Opus 5.5 90b1b6fba9
Independent Staging Quality Gate / validate (push) Successful in 13m17s
Independent Staging Quality Gate / publish (push) Successful in 3m41s
fix(chart): symbol bubble no longer flickers (global tooltip rule switched the stage's pointer events off); rounded chart tables (BUG-1120)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-30 11:19:38 +08:00

171 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
});