Files
Jyotisha/frontend/tests/ephemeris-readable-panchanga.test.tsx
T
Jesse_ChenandClaude Opus 5.5 2981da6fbc fix(people,ephemeris): style /people and make the ephemeris readable (BUG-1039)
- /people had zero CSS rules for its people-archive classes. Add the
  approved layout: list -> detail below 1024px (data-view, ?person= history
  entry so Back returns to the list), two columns at 1024px and above;
  avatar rows with the current person highlighted, time cards with a
  rectified state, readable ayanamsa name, three quick actions; delete moves
  to the bottom of the edit view with unchanged confirm/count logic.
- Ephemeris: Chinese-first panchanga names from the brief's table only
  (panchanga-names-zh.ts, three listed aliases, unknown names stay
  Sanskrit), coloured 吉/中/凶 tags, N吉/M凶 counts and a fixed-rule day
  summary (pure function, no model call); the header ask button becomes a
  bottom hint card; transit rows show glyph, 「行星 在 星座」, house line and
  degree; the Lahiri note names the subject's own ayanamsa.
- API, engine and database unchanged; /people and /ephemeris stay Static.
- DESIGN, VOICE, BUG_HISTORY, CHANGELOG, BLOCKED, PROGRESS and a manual
  checklist updated. Two existing assertions changed, with 原值/新值/原因.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 06:58:41 +08:00

223 lines
11 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 test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { EphemerisView } from "../src/components/ephemeris/ephemeris-page.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { parseEphemerisOkResponse, type EphemerisOkResponse, type EphemerisPanchangaFactor, type EphemerisQuality } from "../src/lib/ephemeris-contract.ts";
import {
EPHEMERIS_COPY,
ephemerisAyanamsaNote,
panchangaComplete,
panchangaDaySummary,
panchangaDisplayRows,
panchangaFactorsFromEngine,
panchangaQualityCounts,
transitHouseLine,
transitsFromEngine,
} from "../src/lib/ephemeris-view.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
Object.assign(globalThis, { React });
const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8"));
const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8"));
const transit15 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
type Q = EphemerisQuality | null;
/** Five limbs with chosen names and qualities; labels match the engine mapper. */
function factors(input: {
tithi?: string;
karana?: string;
qualities?: readonly [Q, Q, Q, Q, Q];
}): EphemerisPanchangaFactor[] {
const q = input.qualities ?? ["subha", "subha", "subha", "asubha", "asubha"];
return [
{ key: "vara", label: "星期", value: "星期六", quality: q[0], detail: null },
{ key: "tithi", label: "月相日", value: input.tithi ?? "Shukla Purnima", quality: q[1], detail: null },
{ key: "nakshatra", label: "月宿", value: "Uttara Bhadrapada", quality: q[2], detail: "第 1 足" },
{ key: "yoga", label: "瑜伽", value: "Vriddhi", quality: q[3], detail: null },
{ key: "karana", label: "迦那", value: input.karana ?? "Bava", quality: q[4], detail: null },
];
}
function withGood(good: number): readonly [Q, Q, Q, Q, Q] {
return Array.from({ length: 5 }, (_, index) => (index < good ? "subha" : "asubha")) as unknown as readonly [Q, Q, Q, Q, Q];
}
test("D7 moon phrase: Purnima, Amavasya, waxing and waning fortnights", () => {
assert.equal(panchangaDaySummary(factors({ tithi: "Shukla Purnima", qualities: withGood(3) })), "满月之日,整体偏吉。");
assert.equal(panchangaDaySummary(factors({ tithi: "Krishna Amavasya", qualities: withGood(3) })), "新月之日,整体偏吉。");
assert.equal(panchangaDaySummary(factors({ tithi: "Shukla Dashami", qualities: withGood(3) })), "月亮渐盈,整体偏吉。");
assert.equal(panchangaDaySummary(factors({ tithi: "Krishna Dashami", qualities: withGood(3) })), "月亮渐亏,整体偏吉。");
});
test("D7 overall phrase for 吉 = 0 … 5", () => {
const expected = ["整体偏凶", "整体偏凶", "吉凶参半", "整体偏吉", "整体吉", "整体吉"];
for (let good = 0; good <= 5; good += 1) {
assert.equal(
panchangaDaySummary(factors({ tithi: "Shukla Tritiya", qualities: withGood(good) })),
`月亮渐盈,${expected[good]}。`,
`吉 = ${good}`,
);
}
});
test("D7 中 counts as neither 吉 nor 凶", () => {
const mixed = factors({ qualities: ["subha", "subha", "mixed", "mixed", "asubha"] });
assert.deepEqual(panchangaQualityCounts(mixed), { good: 2, bad: 1 });
assert.equal(panchangaDaySummary(mixed), "满月之日,吉凶参半。");
});
test("D7 Vishti appends the avoid clause, in the brief's own example sentence", () => {
assert.equal(
panchangaDaySummary(factors({ tithi: "Shukla Purnima", karana: "Vishti", qualities: ["asubha", "subha", "subha", "subha", "asubha"] })),
"满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。",
);
});
test("D7 no summary when a limb or its quality is missing", () => {
const full = factors({});
assert.equal(panchangaComplete(full), true);
assert.equal(panchangaDaySummary(full.filter((factor) => factor.key !== "yoga")), null);
assert.equal(panchangaDaySummary(full.filter((factor) => factor.key !== "tithi")), null);
assert.equal(panchangaDaySummary(factors({ qualities: ["subha", "subha", null, "subha", "subha"] })), null);
assert.equal(panchangaComplete([]), false);
assert.equal(panchangaDaySummary([]), null);
});
test("D5 rows: Chinese first, engine name second; 星期 has no second line; unknown stays Sanskrit", () => {
const warn = console.warn;
const logged: unknown[][] = [];
console.warn = (...args: unknown[]) => { logged.push(args); };
try {
const rows = panchangaDisplayRows(factors({ karana: "Vishti" }));
assert.deepEqual(rows.map((row) => [row.key, row.primary, row.secondary]), [
["vara", "星期六", null],
["tithi", "望日(满月)", "Shukla Purnima"],
["nakshatra", "壁宿 · 第 1 足", "Uttara Bhadrapada"],
["yoga", "增长", "Vriddhi"],
["karana", "毗湿提", "Vishti"],
]);
assert.equal(logged.length, 0);
const unknown = panchangaDisplayRows([{ key: "yoga", label: "瑜伽", value: "Fictionalyoga", quality: "subha", detail: null }]);
assert.equal(unknown[0].primary, "Fictionalyoga");
assert.equal(unknown[0].secondary, null);
assert.equal(logged.length, 1);
assert.equal(logged[0][0], "panchanga_name_zh_missing");
// Logged once per name, not on every render.
panchangaDisplayRows([{ key: "yoga", label: "瑜伽", value: "Fictionalyoga", quality: "subha", detail: null }]);
assert.equal(logged.length, 1);
} finally {
console.warn = warn;
}
});
test("D5 over the real engine golden: 2026-09-15 reads in Chinese", () => {
const rows = panchangaDisplayRows(panchangaFactorsFromEngine(panchangaGolden, "2026-09-15"));
assert.deepEqual(rows.map((row) => row.primary), ["星期二", "白分初五", "亢宿 · 第 4 足", "分离", "巴瓦"]);
assert.deepEqual(rows.map((row) => row.secondary), [null, "Shukla Panchami", "Swati", "Vaidhriti", "Bava"]);
});
test("D10 names the subject's own ayanamsa, never a hard-coded Raman", () => {
assert.equal(ephemerisAyanamsaNote("raman"), "五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。");
assert.equal(ephemerisAyanamsaNote("kp"), "五要素按 Lahiri 岁差算,和星盘页的 KP 不同。");
assert.equal(ephemerisAyanamsaNote("true_pushya"), "五要素按 Lahiri 岁差算,和星盘页的 True Pushya 不同。");
assert.equal(ephemerisAyanamsaNote("lahiri"), "五要素按 Lahiri 岁差算,和星盘页相同。");
assert.equal(ephemerisAyanamsaNote(null), "五要素按 Lahiri 岁差算。");
});
test("D9 house line only when the natal ascendant is known", () => {
assert.equal(transitHouseLine(2, true), "落你的第 2 宫");
assert.equal(transitHouseLine(2, false), null);
assert.equal(transitHouseLine(null, true), null);
});
function payload(overrides: Partial<{ natal: boolean; factors: EphemerisPanchangaFactor[] }> = {}): EphemerisOkResponse {
const natal = overrides.natal ?? true;
const transits = transitsFromEngine({ transitChart: transit15, natalChart: natal ? natalGolden : null });
const parsed = parseEphemerisOkResponse({
status: "ok",
date: "2026-09-15",
today: "2026-09-15",
timezoneId: "Asia/Shanghai",
panchanga: {
available: true,
ayanamsaNote: "lahiri_not_account",
factors: overrides.factors ?? panchangaFactorsFromEngine(panchangaGolden, "2026-09-15"),
},
transits: {
available: true,
natalRelativeAvailable: transits.natalRelativeAvailable,
natalRelativeNote: transits.natalRelativeAvailable ? "shown" : "missing_natal",
ayanamsa: "raman",
bodies: transits.bodies,
},
events: { status: "unavailable" },
});
assert.ok(parsed);
return parsed;
}
function render(data: EphemerisOkResponse) {
return renderToStaticMarkup(React.createElement(SidebarProvider, null, React.createElement(EphemerisView, {
date: data.date,
today: data.today,
payload: data,
onPrevious() {},
onNext() {},
onToday() {},
onSelectDate() {},
onAsk() {},
})));
}
test("D6/D7 card: summary, counts and coloured tags per row", () => {
const markup = render(payload({ factors: factors({ karana: "Vishti", qualities: ["asubha", "subha", "subha", "subha", "asubha"] }) }));
assert.match(markup, /class="ephemeris-summary">满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。</);
assert.match(markup, /class="ephemeris-quality is-subha">3 吉</);
assert.match(markup, /class="ephemeris-quality is-asubha">2 凶</);
assert.equal([...markup.matchAll(/class="ephemeris-quality is-(subha|asubha|mixed)">(吉|中|凶)</g)].length, 5);
assert.match(markup, /<strong>望日(满月)<\/strong><small lang="sa-Latn">Shukla Purnima<\/small>/);
const summary = markup.indexOf("ephemeris-summary");
const firstRow = markup.indexOf("ephemeris-panchanga-item");
assert.ok(summary > 0 && summary < firstRow);
});
test("D8 the ask button left the header for a bottom hint card", () => {
const markup = render(payload());
const header = markup.slice(0, markup.indexOf("secondary-page\""));
assert.doesNotMatch(header, /ephemeris-ask|带这天去提问/);
const card = markup.indexOf("ephemeris-ask-card");
assert.ok(card > markup.indexOf(EPHEMERIS_COPY.eventsHeading), "hint card comes after the last section");
assert.match(markup.slice(card), new RegExp(`<p>${EPHEMERIS_COPY.askPrompt}</p>[\\s\\S]*${EPHEMERIS_COPY.ask}`));
assert.doesNotMatch(pageSource, /actions=\{\(\s*<Button type="button" className="ephemeris-ask"/);
});
test("D9 transit rows: coloured glyph, 「行星 在 星座」, house line, monospace degree", () => {
const markup = render(payload());
assert.match(markup, /class="ephemeris-transit-glyph is-sun" aria-hidden="true">☉</);
assert.match(markup, /太阳 在 [^<]+座/);
assert.match(markup, /<small>落你的第 \d+ 宫/);
assert.match(markup, /class="ephemeris-transit-degree">\d+°\d{2}′</);
const noNatal = render(payload({ natal: false }));
assert.doesNotMatch(noNatal, /落你的第/);
assert.match(noNatal, new RegExp(EPHEMERIS_COPY.natalMissing));
});
test("CSS: tags use semantic tokens at 12%, label loses its tracking, no raw colours", () => {
assert.match(cssDeclarations(".ephemeris-quality.is-subha", css), /color-mix\(in srgb, var\(--color-success\) 12%, transparent\)/);
assert.match(cssDeclarations(".ephemeris-quality.is-asubha", css), /color-mix\(in srgb, var\(--color-danger\) 12%, transparent\)/);
assert.match(cssDeclarations(".ephemeris-quality.is-mixed", css), /color-mix\(in srgb, var\(--color-warning\) 12%, transparent\)/);
assert.doesNotMatch(cssDeclarations(".ephemeris-panchanga-item dt", css), /letter-spacing/);
assert.match(cssDeclarations(".ephemeris-ask", css), /min-height:\s*44px/);
const block = css.slice(css.indexOf(".ephemeris-panchanga-item dt {"), css.indexOf("/* Dynamic viewport units"));
assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|rgba?\(/i);
assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i);
});