- /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
237 lines
11 KiB
TypeScript
237 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 { 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}\\)`));
|
||
}
|
||
});
|