Files
Jyotisha/frontend/tests/people-archive-view.test.tsx
T

243 lines
12 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,
pendingDelete: null,
saving: false,
atLimit: false,
onSelect: noop,
onBack: noop,
onAdd: noop,
onEdit: noop,
onCancelEdit: noop,
onDraftChange: noop,
saveOther: asyncNoop,
saveSelf: 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 });
// 原值: /class="people-archive-add" type="button" disabled=""/,限额句是红色 .form-error
// 新值: aria-disabled="true"(灰显但仍可点,点了由 startAdd 弹 toast「最多保存 5 个人的星盘」),限额句是灰色 .people-archive-hint
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」——提示改 toast,页面只留中性说明
assert.match(markup, /class="people-archive-add" type="button" aria-disabled="true"/);
assert.match(markup, /<p class="people-archive-hint">最多保存 5 个人的星盘<\/p>/);
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「暂时查不到…」,断言它以 .form-error 出现在删除按钮下面、且不出现在表单里
// 新值: 视图不再有 notice;失败走 notifyError toast(askDelete / confirmDelete),页面上两处都不出现
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」;「不能显示成成功样式」这一主语由 toast 的 error 类型保证
assert.doesNotMatch(markup, /暂时查不到|form-error|form-success/);
const source = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
assert.match(source, /notifyError\(DELETE_USAGE_UNAVAILABLE\)/);
assert.match(source, /notifyError\("删除失败,请重试"\)/);
});
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}\\)`));
}
});