人物卡、菜单、编辑和删除换成组件库。chart_profiles 增加可空的 archive_order,本人始终排在第一。合盘选人页两个名字并排,开始按钮居中,点了先说出点数和余额。 自测:tsc 0;lint 0 error(122 警告,与上一张相同);定向测试通过;next build 后 / 仍是 Static;首页 JS gzip-9 639609,相对 640131 少 0.08%。test:db 79 通过、13 失败,失败名单与 windows-known-failures.txt 一致,排序列断言在通过的那一组。没有浏览器登录态。 Co-Authored-By: Grok (agent loop)
328 lines
17 KiB
TypeScript
328 lines
17 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,
|
||
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();
|
||
// 原值:两行 class="people-archive-row",行尾是字符 ›,添加按钮以「+ 添加一个人」开头
|
||
// 新值:两张 data-slot="card"(不含 card-action),行尾是 people-archive-chevron,添加按钮仍写「添加一个人」
|
||
// 原因:K4 人物卡改用 Card,箭头和加号改用 Lucide,名字、标签和日期行不变
|
||
assert.equal([...markup.matchAll(/data-slot="card"(?![a-z-])/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(/people-archive-chevron/g)].length, 2);
|
||
assert.match(markup, /people-archive-add[\s\S]{0,400}添加一个人/);
|
||
// 原值: 还可以保存 3 个人(上限 5,列表里已有本人和 1 个他人)。
|
||
// 新值: 还可以保存 18 个人(上限 20,列表里已有本人和 1 个他人)。
|
||
// 原因: 产品 2026-10-17 把档案上限改为 20 人。
|
||
assert.match(markup, /class="people-archive-hint">还可以保存 18 个人</);
|
||
});
|
||
|
||
test("someone else shows 其他 until a relation is saved; the form asks TA 是你的", () => {
|
||
assert.match(render(), /<b>示例乙<\/b><span class="people-archive-tag">其他<\/span>/);
|
||
const childPeople = [people[0]!, { ...people[1]!, profile: { ...otherProfile, relation: "child" as const } }];
|
||
assert.match(render({ people: childPeople }), /<b>示例乙<\/b><span class="people-archive-tag">孩子<\/span>/);
|
||
const detail = render({
|
||
view: "detail",
|
||
people: childPeople,
|
||
selected: childPeople[1],
|
||
selectedId: childPeople[1]!.id,
|
||
});
|
||
assert.match(detail, /<h2>示例乙<span class="people-archive-tag">孩子<\/span><\/h2>/);
|
||
// 原值:编辑态静态 HTML 里有「TA 是你的」和「孩子」
|
||
// 新值:关系字段写「TA 是你的」,选项文案「孩子」在关系词表里,字段按这张表渲染
|
||
// 原因:K4 编辑放进 Dialog,传送门内容不进 renderToStaticMarkup;选项不再在字段里写死
|
||
assert.match(source, /showRelation=\{!editingSelf\}/);
|
||
const relationField = readFileSync(new URL("../src/components/person-relation-field.tsx", import.meta.url), "utf8");
|
||
assert.match(relationField, /TA 是你的/);
|
||
assert.match(relationField, /PERSON_RELATION_LABELS\[option\]/);
|
||
assert.match(readFileSync(new URL("../src/lib/person-relation.ts", import.meta.url), "utf8"), /child: "孩子"/);
|
||
const selfEdit = render({ view: "detail", editing: true, draft: selfProfile });
|
||
assert.doesNotMatch(selfEdit, /TA 是你的/);
|
||
});
|
||
|
||
test("D2 the current person carries is-current, whoever it is", () => {
|
||
const selfCurrent = render();
|
||
// 原值:class="people-archive-row is-current" 且按 <li> 切开
|
||
// 新值:卡片的 class 里有 is-current,aria-current="true",只按完整 data-slot="card" 切开
|
||
// 原因:K4 列表从按钮行改成 Card,本人仍是第一张且不能被标成当前以外的人
|
||
assert.match(selfCurrent, /data-slot="card"(?![a-z-])[^>]*is-current[^>]*aria-current="true"[\s\S]*示例甲/);
|
||
const otherCurrent = render({ subjectId: people[1].id });
|
||
const rows = otherCurrent.split(/data-slot="card"(?![a-z-])/).slice(1);
|
||
assert.doesNotMatch(rows[0], /is-current/);
|
||
assert.match(rows[1], /is-current/);
|
||
});
|
||
|
||
test("D2 at the limit the add card is disabled and the limit sentence shows", () => {
|
||
const markup = render({ atLimit: true });
|
||
// 原值: aria-disabled="true",限额句「最多保存 5 个人的星盘」。
|
||
// 新值: 仍是 aria-disabled="true",限额句「最多保存 20 个人的星盘」(由 MAX_SAVED_SUBJECTS 拼出)。
|
||
// 原因: 产品 2026-10-17 把档案上限改为 20 人;2026-09-30 的 toast 交互不变。
|
||
// 原值:class="people-archive-add" type="button" aria-disabled="true" 写在同一个起始位置
|
||
// 新值:按钮仍带 people-archive-add 和 aria-disabled="true",没有 disabled 属性
|
||
// 原因:K4 添加按钮改用 Button,到顶时仍可点,用来弹出原来的限额提示
|
||
assert.match(markup, /people-archive-add[^>]*aria-disabled="true"|aria-disabled="true"[^>]*people-archive-add/);
|
||
assert.doesNotMatch(markup, /people-archive-add[^>]*\sdisabled=/);
|
||
assert.match(markup, /<p class="people-archive-hint">最多保存 20 个人的星盘<\/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"));
|
||
// 原值:返回按钮里是字符 ‹,类名后 240 字内出现「返回」
|
||
// 新值:返回按钮仍有 people-archive-back,其后 800 字内出现「返回」
|
||
// 原因:K4 返回箭头改用 Lucide,图标 SVG 比原来的字符长,返回这条路不变
|
||
assert.match(detailColumn, /people-archive-back[\s\S]{0,800}返回/);
|
||
});
|
||
|
||
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.doesNotMatch(detail, /加上你自己后可合盘/);
|
||
assert.match(detail, />编辑</);
|
||
assert.doesNotMatch(detail, /删除这个人/);
|
||
|
||
const editing = render({ view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile });
|
||
// 原值:静态 HTML 里先出现表单再出现「删除这个人」,确认句也在 HTML 里
|
||
// 新值:源码里 ChartProfileForm 在 people-archive-danger 之前;确认句仍拼 sessions 和 reports
|
||
// 原因:K4 编辑用 Dialog、删除确认用 AlertDialog,传送门内容不进 renderToStaticMarkup
|
||
assert.match(source, /编辑人物/);
|
||
const form = source.indexOf("<ChartProfileForm");
|
||
const danger = source.indexOf("people-archive-danger");
|
||
assert.ok(form > 0 && danger > form, "delete sits under the edit form");
|
||
assert.match(editing, /删除这个人/);
|
||
assert.match(source, /将同时删除 \{pendingDelete\.sessions\} 个对话和 \{pendingDelete\.reports\} 份报告/);
|
||
assert.match(source, /无法恢复。/);
|
||
|
||
const adding = render({ view: "detail", selected: people[0], selectedId: "", editing: true });
|
||
assert.match(adding, /添加一个人/);
|
||
assert.doesNotMatch(adding, /删除这个人/);
|
||
});
|
||
|
||
test("D3 本人资料不完整时,他人详情把「和我合盘」换成说明", () => {
|
||
// 原值: 他人详情始终是可点的「和我合盘」(上一则,本人 incomplete: false)。
|
||
// 新值: 本人 incomplete 时该位置是文字「加上你自己后可合盘」,没有「和我合盘」。
|
||
// 原因: 先帮别人看时本人出生资料可以后补,合盘要等本人资料齐。
|
||
const self = { ...people[0], incomplete: true };
|
||
const other = people[1];
|
||
const detail = render({
|
||
people: [self, other],
|
||
view: "detail",
|
||
selected: other,
|
||
selectedId: other.id,
|
||
subjectId: other.id,
|
||
});
|
||
assert.match(detail, /加上你自己后可合盘/);
|
||
assert.doesNotMatch(detail, /和我合盘/);
|
||
});
|
||
|
||
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("twenty saved people stay in a bounded vertical list", () => {
|
||
const many: PersonRow[] = [
|
||
people[0],
|
||
...Array.from({ length: 19 }, (_, index) => ({
|
||
id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||
role: "other" as const,
|
||
profile: { ...otherProfile, name: `示例${index}` },
|
||
incomplete: false,
|
||
})),
|
||
];
|
||
const markup = render({ people: many, atLimit: true });
|
||
// 原值:20 个 class="people-archive-row",容器是 <ul class="people-archive-rows">
|
||
// 新值:20 张完整 data-slot="card"(不含 card-action),容器是 div.people-archive-rows,仍限高并只纵向滚动
|
||
// 原因:K4 改成可换行的网格,方便拖动排序;二十人的滚动边界不变
|
||
assert.equal([...markup.matchAll(/data-slot="card"(?![a-z-])/g)].length, 20);
|
||
assert.match(markup, /class="people-archive-rows"/);
|
||
const rows = cssDeclarations(".people-archive-rows", css);
|
||
assert.match(rows, /max-height:\s*min\(70vh, 640px\)/);
|
||
assert.match(rows, /overflow-x:\s*hidden/);
|
||
assert.match(rows, /overflow-y:\s*auto/);
|
||
const menu = cssDeclarations(".chat-profile-picker-options", css);
|
||
assert.match(menu, /max-height:\s*min\(50vh, 320px\)/);
|
||
assert.match(menu, /overflow-x:\s*hidden/);
|
||
assert.match(menu, /overflow-y:\s*auto/);
|
||
const switcher = readFileSync(new URL("../src/components/subject-switcher.tsx", import.meta.url), "utf8");
|
||
assert.match(switcher, /slice\(0, MAX_SAVED_SUBJECTS\)/);
|
||
assert.doesNotMatch(switcher, /slice\(0,\s*5\)/);
|
||
});
|
||
|
||
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}\\)`));
|
||
}
|
||
});
|