- /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
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
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);
|
||
});
|