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 { 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; 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(); 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(); 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(); assert.match(interactive, /这一宫还有 \d+ 个标记未展开/); assert.match(interactive, /role="group"/); assert.match(interactive, /[^<]*逆行/); 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()); } });