Files
Jyotisha/frontend/tests/chart-type-picker.test.tsx
T

132 lines
7.1 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 { ChartTypePicker } from "../src/components/chart-page/chart-type-picker.tsx";
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_TYPE_ORDER, CHART_VIEW_COPY, VARGA_CHIP_ORDER, type ChartTypeId } from "../src/lib/chart-view-labels.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
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"));
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "confirmed",
};
async function vargaView(birthTimeStatus = "confirmed"): Promise<{ view: ChartViewOk; divisions: unknown }> {
let divisions: unknown = null;
const result = await assembleChartView({
userId: "user-1",
profile: { ...profile, birthTimeStatus },
asOf: "2026-09-15",
layers: ["varga"],
postEngine: async (path, body) => {
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/varga_full") { divisions = body.divisions; return { status: "ok", payload: golden.varga_full }; }
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return { view: result.body as ChartViewOk, divisions };
}
test("the strip lists all 23 chart types in the decided order with a pinned 全部盘型 button", () => {
assert.deepEqual([...CHART_TYPE_ORDER], [
"D1", "D9", "MOON", "BHAVA", "TRANSIT",
"D2", "D3", "D4", "D5", "D6", "D7", "D8", "D10", "D11", "D12",
"D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60",
]);
const markup = renderToStaticMarkup(React.createElement(ChartTypePicker, { current: "D1", onSelect: () => {} }));
const strip = markup.match(/<div class="chart-page-type-strip" role="tablist" aria-label="盘型">([\s\S]*?)<\/div>/)?.[1] ?? "";
const labels = [...strip.matchAll(/<button[^>]*>([^<]*)<\/button>/g)].map((match) => match[1]);
assert.deepEqual(labels, ["本命", "D9", "月亮", "Bhava", "行运", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60"]);
assert.match(strip, /aria-selected="true"[^>]*>本命</);
// The menu button sits outside the scrolling tablist, so it never scrolls away.
assert.match(markup, /<\/div><button type="button" class="chart-page-type-menu" aria-label="全部盘型" aria-haspopup="dialog" aria-expanded="false">/);
});
test("the strip is one sideways-scrolling line and the sheet is a three-column grid", () => {
const strip = cssDeclarations(".chart-page-type-strip", globalStyles);
assert.match(strip, /flex-wrap:\s*nowrap/);
assert.match(strip, /overflow-x:\s*auto/);
assert.match(strip, /min-width:\s*0/);
assert.match(cssDeclarations(".chart-page-type-grid", globalStyles), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
assert.match(cssDeclarations(".chart-page-type-menu", globalStyles), /width:\s*44px/);
// 原值:min-height 56px;新值:64px;原因:2026-09-30 真机截图说明文字被截底,说明行改为最多两行、行高 1.4,格子加高以容纳
assert.match(cssDeclarations(".chart-page-type-cell", globalStyles), /min-height:\s*64px/);
assert.match(cssDeclarations(".chart-page-type-cell small", globalStyles), /line-clamp:\s*2/);
assert.doesNotMatch(globalStyles, /\.chart-page-varga-chips/);
});
test("「≡」 opens the sheet, a cell selects that type and closes it", async () => {
const h = createClientLifecycleHarness();
const picked: ChartTypeId[] = [];
try {
await h.render(React.createElement(ChartTypePicker, { current: "D1", onSelect: (id: ChartTypeId) => picked.push(id) }));
const menu = h.elements().find((node) => node.getAttribute("aria-label") === CHART_VIEW_COPY.chartTypeMenu);
assert.ok(menu);
assert.equal(h.elements().some((node) => node.tagName === "DIALOG"), false);
await h.event(menu);
const dialog = h.elements().find((node) => node.tagName === "DIALOG");
assert.ok(dialog);
assert.equal(dialog.open, true, "opened with showModal");
assert.match(dialog.text, /星盘类型/);
const cells = h.elements().filter((node) => node.getAttribute("class")?.startsWith("chart-page-type-cell"));
assert.equal(cells.length, 23);
assert.match(cells.find((cell) => cell.text.startsWith("D60"))!.text, /前世业力/);
assert.match(cells.find((cell) => cell.text.startsWith("月亮盘"))!.text, /以月亮为第 1 宫/);
assert.equal(cells.find((cell) => cell.text.startsWith("本命"))!.getAttribute("aria-pressed"), "true");
await h.event(cells.find((cell) => cell.text.startsWith("D60"))!);
assert.deepEqual(picked, ["D60"]);
assert.equal(h.elements().some((node) => node.tagName === "DIALOG"), false, "closed after choosing");
// Esc goes through the dialog's cancel event.
await h.event(menu);
await h.event(h.elements().find((node) => node.tagName === "DIALOG")!, "onCancel");
assert.equal(h.elements().some((node) => node.tagName === "DIALOG"), false);
assert.deepEqual(h.errors, []);
} finally {
await h.close();
}
});
test("the varga layer asks the engine for D40, D45 and D60 and the page draws them", async () => {
const { view, divisions } = await vargaView();
assert.deepEqual(divisions, VARGA_CHIP_ORDER.map((division) => `D${division}`));
for (const id of ["D40", "D45", "D60"]) {
const varga = view.vedic.vargas.find((item) => item.id === id);
assert.ok(varga, id);
assert.equal(varga.rows?.length, 9);
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: id, onSelectVarga: () => {} }));
assert.match(markup, new RegExp(`<caption>${id} · `));
assert.match(markup, new RegExp(`aria-label="${id} 北印度盘`));
}
});
test("the precision hint shows on D40/D45/D60 only while the birth time is not confirmed", async () => {
const reported = (await vargaView("reported")).view;
const confirmed = (await vargaView("confirmed")).view;
const render = (view: ChartViewOk, id: string) => renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: id, onSelectVarga: () => {} }));
for (const id of ["D40", "D45", "D60"]) {
assert.ok(render(reported, id).includes(CHART_VIEW_COPY.precisionHint), id);
assert.ok(!render(confirmed, id).includes(CHART_VIEW_COPY.precisionHint), id);
}
for (const id of ["D1", "D9", "D30"]) assert.ok(!render(reported, id).includes(CHART_VIEW_COPY.precisionHint), id);
});