Files
Jyotisha/frontend/tests/chart-moon-transit.test.tsx
T
jesse-uxandGrok (agent loop) b7ec8e78ed
Independent Staging Quality Gate / validate (push) Failing after 9m20s
Independent Staging Quality Gate / publish (push) Skipped
星盘页换成组件库:页签、盘型条、可拖分栏和换盘动效(#107)。
四个页签用下划线,盘型条用分段并可以横滑,全部盘型从底部打开。桌面上盘和参数可以拖宽,窄屏上下排。行星表仍跨满整行。换盘型时星体移到新位置;减少动态效果只淡入。第一次进场不重播。

自测:tsc 退出码 0;frontend 下 npm run lint 0 error(122 警告);定向测试 188 通过、0 失败、0 取消;npm run build 后 / 仍是 Static;首屏 JS gzip-9 为 640131,相对 d25dfdbe 记下的 639127 多 0.16%。没有跑全量 npm test、Python 快速门和 test:db。

Co-Authored-By: Grok (agent loop)
2026-10-25 04:02:14 +08:00

181 lines
9.8 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 { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { chartViewEngineCacheKey, chartViewEngineCachePath } from "../src/lib/chart-view-engine.ts";
import { CHART_VIEW_COPY, SIGN_ORDER } from "../src/lib/chart-view-labels.ts";
Object.assign(globalThis, { React });
// Real engine output only: the chart-page golden and the ephemeris page's
// `/api/chart` packet for 2026-09-15 noon (same engine route, same Raman mean).
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const transitPacket = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", 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",
};
type Call = { path: string; body: Record<string, unknown>; cacheScope?: string };
async function assemble(layers: ("transit" | "varga")[]): Promise<{ view: ChartViewOk; calls: Call[] }> {
const calls: Call[] = [];
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers,
postEngine: async (path, body, cacheScope) => {
calls.push({ path, body, cacheScope });
if (path === "/api/chart" && cacheScope?.startsWith("transit:")) return { status: "ok", payload: transitPacket };
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return { view: chartViewOkSchema.parse(result.body), calls };
}
function signIndex(sign: string): number {
return (SIGN_ORDER as readonly string[]).indexOf(sign);
}
/** House whose clip group holds the lagna mark. */
function ascendantMarkHouses(svg: string): number[] {
return svg.split('clip-path="url(#').slice(1).flatMap((chunk) => {
const house = Number(chunk.match(/-house-(\d+)\)/)?.[1]);
const body = chunk.split("</g></g>")[0] ?? "";
return body.includes("personal-report-chart-asc") ? [house] : [];
});
}
test("the Moon chart is the D1 counted from the Moon's sign, projected without a request", async () => {
const { view, calls } = await assemble([]);
assert.deepEqual(calls.map((call) => call.path), ["/api/chart"], "no extra engine call");
const moon = view.vedic.moon;
assert.ok(moon);
const natalMoonSign = golden.chart.planets.Moon.sign as string;
const natalAsc = golden.chart.ascendant.sign as string;
assert.equal(moon.moonSign, natalMoonSign);
assert.equal(moon.chart.houses[0]!.sign, natalMoonSign, "house 1 is the Moon's sign");
assert.equal(moon.ascendantHouse, ((signIndex(natalAsc) - signIndex(natalMoonSign) + 12) % 12) + 1);
for (const row of moon.planets) {
const natal = view.vedic.planets.find((item) => item.name === row.name)!;
assert.equal(row.sign, natal.sign, "signs are the D1's");
assert.equal(row.degreeInSign, natal.degreeInSign);
assert.equal(row.house, ((signIndex(row.sign) - signIndex(natalMoonSign) + 12) % 12) + 1);
}
assert.equal(moon.planets.find((row) => row.name === "Moon")?.house, 1);
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} }));
assert.match(markup, /<caption>月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/);
assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house");
const table = markup.slice(markup.indexOf("月亮盘"));
const ascendantRow = table.match(/<th scope="row">[\s\S]*?上升[\s\S]*?<\/tr>/)?.[0] ?? "";
assert.match(ascendantRow, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendantRow, /is-sun/);
assert.match(ascendantRow, />1</);
// 原值:chart-page-asc-status,可见「/」
// 新值:同一格可见「—」,说明句仍在
// 原因:K3 没有状态就写「—」。月亮盘与本命共用上升行。
assert.match(ascendantRow, /chart-page-asc-status/);
assert.match(ascendantRow, /data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
assert.match(ascendantRow, /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
});
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
const { view } = await assemble([]);
const svg = renderToStaticMarkup(React.createElement(VedicChartSvg, { chart: view.vedic.vargas[0]!.chart, ariaLabel: "D1" }));
assert.deepEqual(ascendantMarkHouses(svg), [1]);
});
test("the transit layer posts today's noon with the natal ayanamsa and node mode, under its own cache scope", async () => {
const { view, calls } = await assemble(["transit"]);
const natal = calls.find((call) => call.path === "/api/chart" && !call.cacheScope)!;
const transit = calls.find((call) => call.cacheScope?.startsWith("transit:"))!;
assert.ok(transit, "transit call made");
assert.equal(transit.path, "/api/chart");
assert.equal(transit.cacheScope, "transit:2026-09-15");
assert.deepEqual([transit.body.year, transit.body.month, transit.body.day, transit.body.hour, transit.body.minute], [2026, 9, 15, 12, 0]);
assert.equal(transit.body.ayanamsa, natal.body.ayanamsa);
assert.equal(transit.body.node_mode, natal.body.node_mode);
assert.equal(transit.body.tz, natal.body.tz);
assert.equal(transit.body.skip_vedastro_main_entry_overview, true);
assert.equal(view.transit.status, "ok");
if (view.transit.status !== "ok") throw new Error("expected transit ok");
assert.equal(view.transit.date, "2026-09-15");
assert.equal(view.transit.time, "12:00");
assert.equal(view.transit.timezoneLabel, "Asia/Shanghai");
const natalAsc = golden.chart.ascendant.sign as string;
assert.equal(view.transit.rows.length, 9);
for (const row of view.transit.rows) {
const engine = transitPacket.planets[row.name];
assert.equal(row.sign, engine.sign);
assert.equal(row.natalHouse, ((signIndex(row.sign) - signIndex(natalAsc) + 12) % 12) + 1);
assert.equal(row.natalPlanetHouse, golden.chart.planets[row.name].house);
}
assert.equal(view.transit.chart.houses[0]!.sign, natalAsc, "house 1 is the natal ascendant");
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} }));
assert.match(markup, /<caption>行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
for (const column of ["行星", "今天的位置", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
assert.match(markup, /<th scope="col" class="chart-page-planet-mid">速度<\/th>/);
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
assert.match(markup, /<dl class="chart-page-params-table">/);
assert.match(markup, /<dt>岁差<\/dt>/);
assert.match(markup, /<dt>交点<\/dt>/);
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
const body = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)![1]!;
const rows = [...body.matchAll(/<tr>([\s\S]*?)<\/tr>/g)];
assert.equal(rows.length, 9);
for (const [, cells] of rows) {
const tds = [...cells!.matchAll(/<td([^>]*)>([\s\S]*?)<\/td>/g)];
for (const index of [2, 3]) {
assert.equal(tds[index]![1], "", "both house columns inherit their header's left alignment");
assert.match(tds[index]![2]!, /^\d+宫$/);
}
assert.match(tds[1]![1]!, /chart-page-planet-mid/, "direction remains centered");
}
});
test("the transit tab waits, then fails with the layer copy, and is never requested for D1", async () => {
const { view, calls } = await assemble([]);
assert.equal(calls.some((call) => call.cacheScope), false);
assert.equal(view.transit.status, "unavailable");
const render = (props: Record<string, unknown>) => renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {}, ...props }));
assert.match(render({ pending: true }), /data-skeleton="true"/);
assert.ok(render({ pending: true }).includes(CHART_VIEW_COPY.waitingChart));
const failed = render({ pending: false, failure: "engine_busy" });
assert.ok(failed.includes(CHART_VIEW_COPY.busy));
assert.doesNotMatch(failed, /data-skeleton/);
});
test("a scoped engine call can never share the natal chart's cache entry", () => {
const base = { userId: "u", subjectId: "s", date: "1990-06-15", time: "12:00", latitude: 1, longitude: 2, timezoneOffset: 8, ayanamsa: "raman", nodeMode: "mean" };
const natal = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart") });
const today = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart", "transit:2026-09-15") });
const tomorrow = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart", "transit:2026-09-16") });
assert.notEqual(natal, today);
assert.notEqual(today, tomorrow);
assert.equal(chartViewEngineCachePath("/api/chart"), "/api/chart", "unscoped keys are unchanged");
assert.match(serviceSource, /path: chartViewEngineCachePath\(path, cacheScope\)/);
});