import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import React from "react"; import { BirthSkyEntry } from "../src/components/birth-sky/birth-sky-entry"; import { ChartSymbolHelp, TOUCH_BUBBLE_MS, placeBubble } from "../src/components/chart-page/chart-symbol-help"; import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg"; import { assembleChartView } from "../src/lib/chart-view-load"; import type { ChartViewOk } from "../src/lib/chart-view-contract"; import { goldenSky } from "./birth-sky-test-support"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support"; // Product feedback 2026-09-29 evening (BUG-1100..1103). Object.assign(globalThis, { React }); const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); const golden = JSON.parse(read("./fixtures/chart-view-golden.json")); async function d1() { 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"], postEngine: async (path) => ({ status: "ok", payload: path === "/api/chart" ? golden.chart : golden.varga_full }), }); return (result.body as ChartViewOk).vedic.vargas.find((varga) => varga.id === "D1")!.chart; } test("the bubble sits over the mark, flips below near the top and never leaves the stage (BUG-1101)", () => { const stage = { left: 100, top: 50, width: 440 }; const middle = placeBubble({ left: 300, top: 250, width: 20, height: 20 }, stage); assert.deepEqual(middle, { x: 210, y: 200, below: false }); const top = placeBubble({ left: 300, top: 60, width: 20, height: 20 }, stage); assert.equal(top.below, true); assert.equal(top.y, 30, "below the mark: its bottom edge"); assert.equal(placeBubble({ left: 102, top: 250, width: 10, height: 10 }, stage).x, 118, "clamped from the left edge"); assert.equal(placeBubble({ left: 530, top: 250, width: 10, height: 10 }, stage).x, 322, "clamped from the right edge"); }); test("hover shows a bubble at the mark; a tap keeps it past pointer-out and closes after 3s; no hint line (BUG-1101)", async () => { const chart = await d1(); const h = createClientLifecycleHarness(); try { await h.render(); const stage = h.elements().find((node) => node.getAttribute("class") === "chart-page-symbol-help")!; const bubble = () => h.elements().find((node) => node.getAttribute("role") === "tooltip"); const asc = h.elements().find((node) => node.getAttribute("data-chart-help") === "As · 上升")!; assert.equal(bubble()?.text, undefined); assert.ok(!h.container.text.includes("停在或轻点"), "the hint line is gone"); await h.event(stage, "onPointerOver", { target: asc, pointerType: "mouse" }); assert.equal(bubble()?.text, "As · 上升"); await h.event(stage, "onPointerOut", { target: asc, relatedTarget: null, pointerType: "mouse" }); assert.equal(bubble()?.text, undefined); // Touch: pointer-over is ignored, the tap (pointer-up) opens, pointer-out and leave do not close. await h.event(stage, "onPointerOver", { target: asc, pointerType: "touch" }); assert.equal(bubble()?.text, undefined); await h.event(stage, "onPointerUp", { target: asc, pointerType: "touch" }); assert.equal(bubble()?.text, "As · 上升"); await h.event(stage, "onPointerOut", { target: asc, relatedTarget: null, pointerType: "touch" }); await h.event(stage, "onPointerLeave", { pointerType: "touch" }); assert.equal(bubble()?.text, "As · 上升"); await new Promise((resolve) => setTimeout(resolve, TOUCH_BUBBLE_MS + 50)); await h.idle(); assert.equal(bubble()?.text, undefined, "closes by itself"); assert.deepEqual(h.errors, []); } finally { await h.close(); } }); test("the chart card hugs the drawing on desktop (BUG-1100)", () => { const css = read("../src/app/globals.css"); assert.match(css, /\.chart-page-vedic-stage \{ width: 100%; max-width: calc\(440px \+ 2 \* var\(--space-3\) \+ 2px\); \}/); assert.doesNotMatch(read("../src/components/chart-page/chart-symbol-help.tsx"), /停在或轻点盘内标记可查看说明/); }); test("the sky button shows while the cover paints and opens the moment it lands (BUG-1102)", async () => { const h = createClientLifecycleHarness(); try { await h.render(); const button = h.elements().find((node) => node.tagName === "BUTTON")!; assert.match(button.text, /那一刻的天空/); await h.event(button); // Remembered, nothing opened yet and nothing spinning. assert.ok(!h.container.text.includes("正在加载")); const cover = { key: "self:fp", sky: goldenSky("day"), blob: new Blob([], { type: "image/png" }), url: "blob:birth-sky", serif: false }; await h.render(); await h.idle(); assert.deepEqual(h.errors, []); } finally { await h.close(); } const view = read("../src/components/chart-page/chart-page-view.tsx"); assert.match(view, /\(birthSky \|\| birthSkyPending\) \? /); const hook = read("../src/hooks/use-birth-sky-cover.ts"); assert.match(hook, /pending: key !== null && current === null && settledKey !== key/); }); test("report centre card: spaced from the header, title says what, button says do (BUG-1103)", () => { const centre = read("../src/components/personal-report/personal-report-center.tsx"); assert.match(centre, /

完整本命报告<\/h2>/); assert.doesNotMatch(centre, /]*>生成个人报告/); assert.match(read("../src/app/globals.css"), /\.report-center-create \{\n width: min\(900px, 100%\);\n(?: \/\*[\s\S]*?\*\/\n)? margin: var\(--space-10\) auto var\(--space-8\);/); }); // Phone check 2026-09-30 (BUG-1106..1107). test("opening a report shows one waiting screen, the same from the route and from the reader (BUG-1106)", () => { const route = read("../src/app/(app)/reports/[reportId]/loading.tsx"); assert.match(route, /return ;/); assert.doesNotMatch(route, /InlineSpinner|
;/); const opening = read("../src/components/personal-report/report-opening.tsx"); assert.match(opening, //); }); test("on phones the reader bar is one row and the export sheet leaves room for the list (BUG-1107)", () => { const css = read("../src/app/globals.css"); const phone = css.slice(css.indexOf(" .personal-report-back-label { display: none; }") - 800, css.indexOf(" .personal-report-back-label { display: none; }") + 400); assert.match(phone, /\.personal-report-action-end \{ flex-wrap: nowrap;/); assert.match(read("../src/components/personal-report/report-actions.tsx"), /aria-label="返回报告中心"[\s\S]*返回报告中心<\/span>/); // The export sheet was redesigned the same night (two steps, no presets); its own tests are in report-export-drawer.test.tsx. const drawer = read("../src/components/personal-report/report-export-drawer.tsx"); assert.match(drawer, /aria-label="关闭导出" title="关闭">