Files
Jyotisha/frontend/tests/chart-symbol-help.test.tsx
T
jesse-uxandClaude Code 64aa0ec20c
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 1m27s
feat(chart): polish chart presentation and symbol explanations
Hide engine branding, gate supported divisional columns, align house cells, and add accessible symbol help. Reduce desktop chart width and stabilize the waiting slot.

Validated with 78 browser checks and 87 focused frontend tests. Full suite retains 29 identical baseline loader failures; no new failures.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 19:03:28 +08:00

158 lines
9.9 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(); }
});