Files
Jyotisha/frontend/tests/people-archive-view.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

237 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 { 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}\\)`));
}
});