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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
13c70a162a
commit
2981da6fbc
@@ -10,6 +10,7 @@ import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/e
|
||||
import {
|
||||
EPHEMERIS_COPY,
|
||||
ephemerisAskDraft,
|
||||
ephemerisAyanamsaNote,
|
||||
panchangaFactorsFromEngine,
|
||||
shiftEphemerisDate,
|
||||
transitsFromEngine,
|
||||
@@ -120,12 +121,16 @@ test("unavailable events render a static note, not a blank page", () => {
|
||||
test("the Lahiri note sits under panchanga and not under transits", () => {
|
||||
const markup = render(payloadFor("2026-09-15"));
|
||||
const panchanga = markup.indexOf(EPHEMERIS_COPY.panchangaHeading);
|
||||
const note = markup.indexOf(EPHEMERIS_COPY.panchangaLahiriNote);
|
||||
// 原值:`EPHEMERIS_COPY.panchangaLahiriNote`(固定句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」)
|
||||
// 新值:`ephemerisAyanamsaNote("raman")`(「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」,fixture 的 transits.ayanamsa 是 raman)
|
||||
// 原因:TASK-people-ephemeris-ui-20260926 D10 改句式,并要求按资料实际岁差填名、不写死;位置断言(五要素之下、行运之上)一字未改。
|
||||
const lahiriNote = ephemerisAyanamsaNote("raman");
|
||||
const note = markup.indexOf(lahiriNote);
|
||||
const transits = markup.indexOf(EPHEMERIS_COPY.transitsHeading);
|
||||
assert.ok(panchanga >= 0 && note >= 0 && transits >= 0);
|
||||
assert.ok(panchanga < note && note < transits);
|
||||
const transitsBlock = markup.slice(transits);
|
||||
assert.doesNotMatch(transitsBlock, new RegExp(EPHEMERIS_COPY.panchangaLahiriNote));
|
||||
assert.doesNotMatch(transitsBlock, new RegExp(lahiriNote));
|
||||
});
|
||||
|
||||
test("今天 is absent on today and present off it, never disabled", () => {
|
||||
@@ -163,7 +168,13 @@ test("copy stays factual: no fortune-telling, no spinner, ask prefills the date"
|
||||
assert.match(markup, new RegExp(EPHEMERIS_COPY.ask));
|
||||
assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay));
|
||||
assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay));
|
||||
assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/);
|
||||
// 原值:`assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/)`,对整页 markup。
|
||||
// 新值:先去掉底部提示卡那一句问句(EPHEMERIS_COPY.askPrompt),再对其余 markup 断同一组词;另断问句只出现一次。
|
||||
// 原因:D8 产品定稿的底部提示卡原文是「想问这一天适不适合做某件事?」——这是邀请用户提问的问句,不是页面给出的
|
||||
// 宜忌结论;五要素、总评、行运、九十天事件里仍不得出现这组词,约束范围没有放宽。
|
||||
const withoutAskPrompt = markup.replace(EPHEMERIS_COPY.askPrompt, "");
|
||||
assert.equal(markup.split(EPHEMERIS_COPY.askPrompt).length - 1, 1);
|
||||
assert.doesNotMatch(withoutAskPrompt, /运势|适合|不宜|建议|吉祥日/);
|
||||
assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i);
|
||||
assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。");
|
||||
assert.match(pageSource, /setComposerDraft\(ephemerisAskDraft/);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
KARANA_ZH,
|
||||
NAKSHATRA_ZH,
|
||||
TITHI_ZH,
|
||||
YOGA_ZH,
|
||||
normalizePanchangaName,
|
||||
panchangaNameZh,
|
||||
splitTithiName,
|
||||
} from "../src/lib/panchanga-names-zh.ts";
|
||||
|
||||
/* The brief's table (TASK-people-ephemeris-ui-20260926 「中文名对照表」),
|
||||
typed a second time here on purpose: the module and this list must agree
|
||||
entry for entry, so an edit to either one alone fails. */
|
||||
const BRIEF_TITHI: ReadonlyArray<readonly [string, string]> = [
|
||||
["Pratipada", "初一"], ["Dwitiya", "初二"], ["Tritiya", "初三"], ["Chaturthi", "初四"],
|
||||
["Panchami", "初五"], ["Shashthi", "初六"], ["Saptami", "初七"], ["Ashtami", "初八"],
|
||||
["Navami", "初九"], ["Dashami", "初十"], ["Ekadashi", "十一"], ["Dwadashi", "十二"],
|
||||
["Trayodashi", "十三"], ["Chaturdashi", "十四"],
|
||||
];
|
||||
const BRIEF_NAKSHATRA: ReadonlyArray<readonly [string, string]> = [
|
||||
["Ashwini", "娄宿"], ["Bharani", "胃宿"], ["Krittika", "昴宿"], ["Rohini", "毕宿"],
|
||||
["Mrigashira", "觜宿"], ["Ardra", "参宿"], ["Punarvasu", "井宿"], ["Pushya", "鬼宿"],
|
||||
["Ashlesha", "柳宿"], ["Magha", "星宿"], ["Purva Phalguni", "张宿"], ["Uttara Phalguni", "翼宿"],
|
||||
["Hasta", "轸宿"], ["Chitra", "角宿"], ["Swati", "亢宿"], ["Vishakha", "氐宿"],
|
||||
["Anuradha", "房宿"], ["Jyeshtha", "心宿"], ["Mula", "尾宿"], ["Purva Ashadha", "箕宿"],
|
||||
["Uttara Ashadha", "斗宿"], ["Shravana", "女宿"], ["Dhanishta", "虚宿"], ["Shatabhisha", "危宿"],
|
||||
["Purva Bhadrapada", "室宿"], ["Uttara Bhadrapada", "壁宿"], ["Revati", "奎宿"],
|
||||
];
|
||||
const BRIEF_YOGA: ReadonlyArray<readonly [string, string]> = [
|
||||
["Vishkambha", "支柱"], ["Priti", "喜爱"], ["Ayushman", "长寿"], ["Saubhagya", "好运"],
|
||||
["Shobhana", "光彩"], ["Atiganda", "大险"], ["Sukarma", "善业"], ["Dhriti", "坚持"],
|
||||
["Shula", "刺痛"], ["Ganda", "险结"], ["Vriddhi", "增长"], ["Dhruva", "恒定"],
|
||||
["Vyaghata", "打击"], ["Harshana", "欢喜"], ["Vajra", "金刚"], ["Siddhi", "成就"],
|
||||
["Vyatipata", "灾变"], ["Variyan", "安逸"], ["Parigha", "阻碍"], ["Shiva", "吉祥"],
|
||||
["Siddha", "圆满"], ["Sadhya", "可成"], ["Shubha", "吉善"], ["Shukla", "清白"],
|
||||
["Brahma", "梵天"], ["Indra", "因陀罗"], ["Vaidhriti", "分离"],
|
||||
];
|
||||
const BRIEF_KARANA: ReadonlyArray<readonly [string, string]> = [
|
||||
["Bava", "巴瓦"], ["Balava", "巴拉瓦"], ["Kaulava", "考拉瓦"], ["Taitila", "泰提拉"],
|
||||
["Garija", "加拉"], ["Vanija", "瓦尼贾"], ["Vishti", "毗湿提"], ["Shakuni", "沙昆尼"],
|
||||
["Chatushpada", "四足"], ["Naga", "蛇"], ["Kimstughna", "金斯图格纳"],
|
||||
];
|
||||
|
||||
test("the module tables are exactly the brief's tables, nothing added or dropped", () => {
|
||||
assert.deepEqual(
|
||||
Object.entries(TITHI_ZH).sort(),
|
||||
[...BRIEF_TITHI, ["Purnima", "望日(满月)"], ["Amavasya", "朔日(新月)"]].sort(),
|
||||
);
|
||||
assert.deepEqual(Object.entries(NAKSHATRA_ZH).sort(), [...BRIEF_NAKSHATRA].sort());
|
||||
assert.deepEqual(Object.entries(YOGA_ZH).sort(), [...BRIEF_YOGA].sort());
|
||||
assert.deepEqual(Object.entries(KARANA_ZH).sort(), [...BRIEF_KARANA].sort());
|
||||
assert.equal(Object.keys(NAKSHATRA_ZH).length, 27);
|
||||
assert.equal(Object.keys(YOGA_ZH).length, 27);
|
||||
assert.equal(Object.keys(KARANA_ZH).length, 11);
|
||||
});
|
||||
|
||||
test("every tithi reads 白分 / 黑分 + day, full and new moon keep their own label", () => {
|
||||
for (const [name, day] of BRIEF_TITHI) {
|
||||
assert.equal(panchangaNameZh("tithi", `Shukla ${name}`), `白分${day}`, name);
|
||||
assert.equal(panchangaNameZh("tithi", `Krishna ${name}`), `黑分${day}`, name);
|
||||
}
|
||||
assert.equal(panchangaNameZh("tithi", "Shukla Purnima"), "望日(满月)");
|
||||
assert.equal(panchangaNameZh("tithi", "Krishna Amavasya"), "朔日(新月)");
|
||||
assert.equal(panchangaNameZh("tithi", "Purnima"), "望日(满月)");
|
||||
assert.equal(panchangaNameZh("tithi", "Amavasya"), "朔日(新月)");
|
||||
// A bare day name has no fortnight; it is not guessed into one.
|
||||
assert.equal(panchangaNameZh("tithi", "Dashami"), null);
|
||||
});
|
||||
|
||||
test("every nakshatra, yoga and karana in the brief resolves", () => {
|
||||
for (const [name, zh] of BRIEF_NAKSHATRA) assert.equal(panchangaNameZh("nakshatra", name), zh, name);
|
||||
for (const [name, zh] of BRIEF_YOGA) assert.equal(panchangaNameZh("yoga", name), zh, name);
|
||||
for (const [name, zh] of BRIEF_KARANA) assert.equal(panchangaNameZh("karana", name), zh, name);
|
||||
});
|
||||
|
||||
test("the three listed aliases resolve to the same entry", () => {
|
||||
assert.equal(panchangaNameZh("karana", "Gara"), "加拉");
|
||||
assert.equal(panchangaNameZh("karana", "Garija"), "加拉");
|
||||
assert.equal(panchangaNameZh("tithi", "Shukla Dvitiya"), "白分初二");
|
||||
assert.equal(panchangaNameZh("tithi", "Krishna Dwitiya"), "黑分初二");
|
||||
assert.equal(panchangaNameZh("tithi", "Shukla Shashti"), "白分初六");
|
||||
assert.equal(panchangaNameZh("tithi", "Krishna Shashthi"), "黑分初六");
|
||||
});
|
||||
|
||||
test("case, spaces and hyphens are ignored", () => {
|
||||
assert.equal(normalizePanchangaName("Uttara-Bhadra pada"), "uttarabhadrapada");
|
||||
assert.equal(panchangaNameZh("nakshatra", "uttara bhadrapada"), "壁宿");
|
||||
assert.equal(panchangaNameZh("nakshatra", "UTTARA-BHADRAPADA"), "壁宿");
|
||||
assert.equal(panchangaNameZh("nakshatra", "PurvaPhalguni"), "张宿");
|
||||
assert.equal(panchangaNameZh("tithi", "shukla-purnima"), "望日(满月)");
|
||||
assert.equal(panchangaNameZh("karana", " vishti "), "毗湿提");
|
||||
});
|
||||
|
||||
test("a name the table does not know returns null so the caller keeps the engine name", () => {
|
||||
assert.equal(panchangaNameZh("nakshatra", "Abhijit"), null);
|
||||
assert.equal(panchangaNameZh("yoga", ""), null);
|
||||
assert.equal(panchangaNameZh("karana", "Unknown"), null);
|
||||
// A yoga name is not looked up in another limb's table.
|
||||
assert.equal(panchangaNameZh("nakshatra", "Vriddhi"), null);
|
||||
/* Engine spellings (`scripts/muhurta.py`) that the brief's table spells
|
||||
differently. Not aliased here: the brief allows only the three aliases
|
||||
above, so these stay in Sanskrit until the table is ruled on — see
|
||||
PROGRESS-people-ephemeris-ui-20260926 「待裁决」. */
|
||||
assert.equal(panchangaNameZh("yoga", "Aindra"), null);
|
||||
assert.equal(panchangaNameZh("yoga", "Variyana"), null);
|
||||
assert.equal(panchangaNameZh("nakshatra", "Dhanishtha"), null);
|
||||
});
|
||||
|
||||
test("tithi split separates the fortnight from the day", () => {
|
||||
assert.deepEqual(splitTithiName("Shukla Purnima"), { paksha: "shukla", name: "purnima" });
|
||||
assert.deepEqual(splitTithiName("Krishna Amavasya"), { paksha: "krishna", name: "amavasya" });
|
||||
assert.deepEqual(splitTithiName("Navami"), { paksha: null, name: "navami" });
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
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 { PeopleArchiveView, type PeopleArchiveViewProps, type PersonRow } from "../src/components/people/people-page.tsx";
|
||||
import { emptyProfile, type Profile } from "../src/lib/home-types.ts";
|
||||
import {
|
||||
PEOPLE_AVATAR_TONES,
|
||||
peopleAvatarInitial,
|
||||
peopleAvatarTone,
|
||||
peopleAyanamsaLabel,
|
||||
peopleCityLabel,
|
||||
peopleRowMeta,
|
||||
peopleTimeRectified,
|
||||
} from "../src/lib/people-archive-view.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
|
||||
|
||||
/* Fictional people only (AGENTS §8). */
|
||||
const selfProfile: Profile = {
|
||||
...emptyProfile,
|
||||
name: "示例甲",
|
||||
date: "1990-01-02",
|
||||
time: "04:53",
|
||||
reportedTime: "05:00",
|
||||
birthTimeStatus: "accepted",
|
||||
rectificationCaseId: "fictional-case-1",
|
||||
activeDate: "1990-01-02",
|
||||
birthPlaceLabel: "中国 · 浙江省 · 杭州市 · 西湖区",
|
||||
latitude: 30.25,
|
||||
longitude: 120.13,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneOffset: 8,
|
||||
ayanamsa: "raman",
|
||||
};
|
||||
const otherProfile: Profile = {
|
||||
...emptyProfile,
|
||||
name: "示例乙",
|
||||
date: "1992-03-12",
|
||||
time: "10:01",
|
||||
reportedTime: "10:01",
|
||||
birthTimeStatus: "reported",
|
||||
birthPlaceLabel: "Lisbon, Portugal",
|
||||
latitude: 38.72,
|
||||
longitude: -9.14,
|
||||
timezoneId: "Europe/Lisbon",
|
||||
timezoneOffset: 0,
|
||||
ayanamsa: "lahiri",
|
||||
};
|
||||
const people: PersonRow[] = [
|
||||
{ id: "self", role: "self", profile: selfProfile, incomplete: false },
|
||||
{ id: "11111111-1111-4111-8111-111111111111", role: "other", profile: otherProfile, incomplete: false },
|
||||
];
|
||||
|
||||
function props(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchiveViewProps {
|
||||
const noop = () => {};
|
||||
const asyncNoop = async () => {};
|
||||
return {
|
||||
people,
|
||||
subjectId: "self",
|
||||
selected: people[0],
|
||||
selectedId: "self",
|
||||
view: "list",
|
||||
editing: false,
|
||||
draft: emptyProfile,
|
||||
notice: "",
|
||||
pendingDelete: null,
|
||||
saving: false,
|
||||
atLimit: false,
|
||||
onSelect: noop,
|
||||
onBack: noop,
|
||||
onAdd: noop,
|
||||
onEdit: noop,
|
||||
onCancelEdit: noop,
|
||||
onDraftChange: noop,
|
||||
saveOther: asyncNoop,
|
||||
askDelete: asyncNoop,
|
||||
onCancelDelete: noop,
|
||||
confirmDelete: asyncNoop,
|
||||
openPerson: noop,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const render = (overrides: Partial<PeopleArchiveViewProps> = {}) =>
|
||||
renderToStaticMarkup(React.createElement(PeopleArchiveView, props(overrides)));
|
||||
|
||||
test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron", () => {
|
||||
const markup = render();
|
||||
assert.equal([...markup.matchAll(/class="people-archive-row[ "]/g)].length, 2);
|
||||
assert.match(markup, /<span class="people-archive-avatar is-self" aria-hidden="true">我<\/span>/);
|
||||
assert.match(markup, /<b>示例甲<\/b><span class="people-archive-tag">本人<\/span>/);
|
||||
assert.match(markup, /<small class="people-archive-row-meta">1990-01-02 · 04:53 · 杭州<\/small>/);
|
||||
assert.match(markup, /<small class="people-archive-row-meta">1992-03-12 · 10:01 · Lisbon<\/small>/);
|
||||
assert.equal([...markup.matchAll(/class="people-archive-chevron" aria-hidden="true">›</g)].length, 2);
|
||||
assert.match(markup, /class="people-archive-add"[^>]*>+ 添加一个人</);
|
||||
assert.match(markup, /class="people-archive-hint">还可以保存 3 个人</);
|
||||
});
|
||||
|
||||
test("D2 the current person carries is-current, whoever it is", () => {
|
||||
const selfCurrent = render();
|
||||
assert.match(selfCurrent, /class="people-archive-row is-current[^"]*" aria-current="true"[\s\S]*示例甲/);
|
||||
const otherCurrent = render({ subjectId: people[1].id });
|
||||
const rows = otherCurrent.split("<li>").slice(1);
|
||||
assert.doesNotMatch(rows[0], /is-current/);
|
||||
assert.match(rows[1], /class="people-archive-row is-current/);
|
||||
});
|
||||
|
||||
test("D2 at the limit the add card is disabled and the limit sentence shows", () => {
|
||||
const markup = render({ atLimit: true });
|
||||
assert.match(markup, /class="people-archive-add" type="button" disabled=""/);
|
||||
assert.match(markup, /最多保存 5 个人的星盘/);
|
||||
assert.doesNotMatch(markup, /还可以保存/);
|
||||
});
|
||||
|
||||
test("D1 the view flag drives list → detail; the back control is in the detail column", () => {
|
||||
assert.match(render(), /class="people-archive" data-view="list"/);
|
||||
const detail = render({ view: "detail" });
|
||||
assert.match(detail, /class="people-archive" data-view="detail"/);
|
||||
const detailColumn = detail.slice(detail.indexOf("people-archive-detail"));
|
||||
assert.match(detailColumn, /class="people-archive-back"[^>]*><span aria-hidden="true">‹<\/span> 返回</);
|
||||
});
|
||||
|
||||
test("D3 detail: header, two time cards (rectified in the success family), date, readable ayanamsa, three actions", () => {
|
||||
const markup = render({ view: "detail" });
|
||||
assert.match(markup, /<h2>示例甲<\/h2><p>本人 · 中国 · 浙江省 · 杭州市 · 西湖区<\/p>/);
|
||||
assert.match(markup, /class="people-archive-time"><small>填报时间<\/small><b>05:00<\/b>/);
|
||||
assert.match(markup, /class="people-archive-time is-rectified"><small>排盘时间 · 已校正<\/small><b>04:53<\/b>/);
|
||||
assert.match(markup, /<dt>出生日期<\/dt><dd>1990-01-02<\/dd><dt>岁差<\/dt><dd>Raman(默认)<\/dd>/);
|
||||
assert.doesNotMatch(markup, /<dd>raman<\/dd>/);
|
||||
const actions = markup.slice(markup.indexOf("people-archive-actions"));
|
||||
for (const label of ["和 TA 对话", "看星盘", "生成报告"]) assert.match(actions, new RegExp(label));
|
||||
});
|
||||
|
||||
test("D3 本人 has no delete anywhere; the grey foot says so", () => {
|
||||
const markup = render({ view: "detail" });
|
||||
assert.match(markup, /class="people-archive-foot"><p>本人不能删除<\/p>/);
|
||||
assert.doesNotMatch(markup, /删除这个人|确认删除/);
|
||||
});
|
||||
|
||||
test("D3 another person's detail offers 编辑 and 和我合盘, and delete only in the edit view", () => {
|
||||
const other = people[1];
|
||||
const detail = render({ view: "detail", selected: other, selectedId: other.id });
|
||||
assert.match(detail, /class="people-archive-time"><small>排盘时间<\/small><b>10:01<\/b>/);
|
||||
assert.match(detail, /<dd>Lahiri<\/dd>/);
|
||||
assert.match(detail, /和我合盘/);
|
||||
assert.match(detail, />编辑</);
|
||||
assert.doesNotMatch(detail, /删除这个人/);
|
||||
|
||||
const editing = render({ view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile });
|
||||
assert.match(editing, /编辑人物/);
|
||||
const form = editing.indexOf("chart-library-form");
|
||||
const danger = editing.indexOf("people-archive-danger");
|
||||
assert.ok(form > 0 && danger > form, "delete sits under the edit form");
|
||||
assert.match(editing.slice(danger), /删除这个人/);
|
||||
|
||||
const confirm = render({
|
||||
view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile,
|
||||
pendingDelete: { id: other.id, sessions: 2, reports: 1 },
|
||||
});
|
||||
assert.match(confirm, /将同时删除 2 个对话和 1 份报告,无法恢复。/);
|
||||
|
||||
const adding = render({ view: "detail", selected: people[0], selectedId: "", editing: true });
|
||||
assert.match(adding, /添加一个人/);
|
||||
assert.doesNotMatch(adding, /删除这个人/);
|
||||
});
|
||||
|
||||
test("D3 the delete-count failure shows under the delete button, not as a form success", () => {
|
||||
const other = people[1];
|
||||
const markup = render({
|
||||
view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile,
|
||||
notice: "暂时查不到这个人的对话数量,稍后再试",
|
||||
});
|
||||
const danger = markup.slice(markup.indexOf("people-archive-danger"));
|
||||
assert.match(danger, /class="form-error" role="alert">暂时查不到这个人的对话数量,稍后再试/);
|
||||
assert.doesNotMatch(markup.slice(0, markup.indexOf("people-archive-danger")), /暂时查不到/);
|
||||
});
|
||||
|
||||
test("D4 avatar tone is stable per id and only uses element tones", () => {
|
||||
const id = people[1].id;
|
||||
assert.equal(peopleAvatarTone(id, "other"), peopleAvatarTone(id, "other"));
|
||||
assert.ok((PEOPLE_AVATAR_TONES as readonly string[]).includes(peopleAvatarTone(id, "other")));
|
||||
assert.equal(peopleAvatarTone("self", "self"), "self");
|
||||
const tones = new Set(["a", "b", "c", "d", "e", "f", "g", "h"].map((value) => peopleAvatarTone(value, "other")));
|
||||
assert.ok(tones.size > 1, "different ids spread over the tones");
|
||||
assert.equal(peopleAvatarInitial(otherProfile, "other"), "示");
|
||||
assert.equal(peopleAvatarInitial(selfProfile, "self"), "我");
|
||||
assert.equal(peopleAvatarInitial({ ...emptyProfile }, "other"), "未");
|
||||
});
|
||||
|
||||
test("helpers: city, meta, rectified and ayanamsa label", () => {
|
||||
assert.equal(peopleCityLabel(selfProfile), "杭州");
|
||||
assert.equal(peopleCityLabel(otherProfile), "Lisbon");
|
||||
assert.equal(peopleCityLabel(emptyProfile), "");
|
||||
assert.equal(peopleRowMeta({ ...otherProfile, birthPlaceLabel: "" }), "1992-03-12 · 10:01");
|
||||
assert.equal(peopleTimeRectified(selfProfile), true);
|
||||
assert.equal(peopleTimeRectified({ ...selfProfile, birthTimeStatus: "confirmed" }), true);
|
||||
assert.equal(peopleTimeRectified(otherProfile), false);
|
||||
// A legacy row with a time reads back as `confirmed` but has no rectification case.
|
||||
assert.equal(peopleTimeRectified({ ...otherProfile, birthTimeStatus: "confirmed", rectificationCaseId: "" }), false);
|
||||
assert.equal(peopleAyanamsaLabel(selfProfile), "Raman(默认)");
|
||||
assert.equal(peopleAyanamsaLabel({ ...selfProfile, ayanamsa: "kp" }), "KP");
|
||||
});
|
||||
|
||||
test("CSS: every people-archive class the component uses has a rule (BUG-1039)", () => {
|
||||
// Class names only: every `className=` string / template in the component (the
|
||||
// import path and the form's input id also contain "people-archive").
|
||||
const classStrings = [...source.matchAll(/className=\{?(?:`([^`]+)`|"([^"]+)"|[^}]*?\? "([^"]+)" : "([^"]+)")/g)]
|
||||
.flatMap((hit) => hit.slice(1).filter(Boolean));
|
||||
const used = new Set(classStrings.flatMap((value) => value.match(/people-archive[a-z-]*/g) ?? []));
|
||||
const missing = [...used].filter((name) => !new RegExp(`\\.${name}(?![a-z-])`).test(css));
|
||||
assert.deepEqual(missing, []);
|
||||
assert.ok(used.size >= 20);
|
||||
});
|
||||
|
||||
test("CSS: touch targets, list/detail switch below 1024, two columns at 1024", () => {
|
||||
const minHeight = (selector: string) => Number(cssDeclarations(selector, css).match(/min-height:\s*(\d+)px/)?.[1] ?? 0);
|
||||
assert.ok(minHeight(".people-archive-row") >= 44);
|
||||
assert.ok(minHeight(".people-archive-add") >= 44);
|
||||
assert.ok(minHeight(".people-archive-back") >= 44);
|
||||
assert.ok(minHeight(".people-archive-actions button") >= 44);
|
||||
assert.ok(minHeight(".people-archive-secondary") >= 44);
|
||||
assert.match(css, /@media \(max-width: 1023px\) \{[\s\S]*?\.people-archive\[data-view="list"\] \.people-archive-detail,\s*\.people-archive\[data-view="detail"\] \.people-archive-list \{ display: none; \}/);
|
||||
assert.match(css, /@media \(min-width: 1024px\) \{\s*\.people-archive \{ grid-template-columns:/);
|
||||
assert.match(cssDeclarations(".people-archive-row.is-current", css), /var\(--color-action-strong\)[\s\S]*var\(--color-action-soft\)/);
|
||||
assert.match(cssDeclarations(".people-archive-time.is-rectified", css), /var\(--color-success\)/);
|
||||
for (const tone of PEOPLE_AVATAR_TONES) {
|
||||
assert.match(cssDeclarations(`.people-archive-avatar.is-${tone}`, css), new RegExp(`var\\(--color-element-${tone}\\)`));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user