Files
Jyotisha/frontend/tests/people-archive-view.test.tsx
T
jesse-uxandGrok (agent loop) eee8564b7f
Independent Staging Quality Gate / validate (push) Failing after 9m16s
Independent Staging Quality Gate / publish (push) Skipped
星盘档案可以拖动排序,合盘开始前先确认扣点。
人物卡、菜单、编辑和删除换成组件库。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)
2026-10-25 05:05:00 +08:00

328 lines
17 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();
// 原值:两行 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}\\)`));
}
});