").slice(1);
+ const rows = otherCurrent.split(/data-slot="card"(?![a-z-])/).slice(1);
assert.doesNotMatch(rows[0], /is-current/);
- assert.match(rows[1], /class="people-archive-row is-current/);
+ assert.match(rows[1], /is-current/);
});
test("D2 at the limit the add card is disabled and the limit sentence shows", () => {
@@ -144,7 +149,11 @@ test("D2 at the limit the add card is disabled and the limit sentence shows", ()
// 原值: aria-disabled="true",限额句「最多保存 5 个人的星盘」。
// 新值: 仍是 aria-disabled="true",限额句「最多保存 20 个人的星盘」(由 MAX_SAVED_SUBJECTS 拼出)。
// 原因: 产品 2026-10-17 把档案上限改为 20 人;2026-09-30 的 toast 交互不变。
- assert.match(markup, /class="people-archive-add" type="button" aria-disabled="true"/);
+ // 原值: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, /最多保存 20 个人的星盘<\/p>/);
assert.doesNotMatch(markup, /还可以保存/);
});
@@ -154,7 +163,10 @@ test("D1 the view flag drives list → detail; the back control is in the detail
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> 返回);
+ // 原值:返回按钮里是字符 ‹,类名后 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", () => {
@@ -185,17 +197,16 @@ test("D3 another person's detail offers 编辑 and 和我合盘, and delete only
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");
+ // 原值:静态 HTML 里先出现表单再出现「删除这个人」,确认句也在 HTML 里
+ // 新值:源码里 ChartProfileForm 在 people-archive-danger 之前;确认句仍拼 sessions 和 reports
+ // 原因:K4 编辑用 Dialog、删除确认用 AlertDialog,传送门内容不进 renderToStaticMarkup
+ assert.match(source, /编辑人物/);
+ const form = source.indexOf(" 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 份报告,无法恢复。/);
+ 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, /添加一个人/);
@@ -281,8 +292,11 @@ test("twenty saved people stay in a bounded vertical list", () => {
})),
];
const markup = render({ people: many, atLimit: true });
- assert.equal([...markup.matchAll(/class="people-archive-row[ "]/g)].length, 20);
- assert.match(markup, //);
+ // 原值:20 个 class="people-archive-row",容器是
+ // 新值: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/);
diff --git a/frontend/tests/profile-gender-ui-20260927.test.tsx b/frontend/tests/profile-gender-ui-20260927.test.tsx
index c99eb5b8..3cb9e05d 100644
--- a/frontend/tests/profile-gender-ui-20260927.test.tsx
+++ b/frontend/tests/profile-gender-ui-20260927.test.tsx
@@ -10,7 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { ChartProfileForm, type ChartProfileFormProps } from "../src/components/chart-profile-form.tsx";
import { PeopleArchiveView, type PeopleArchiveViewProps, type PersonRow } from "../src/components/people/people-page.tsx";
-import { ProfileGenderField } from "../src/components/profile-gender-field.tsx";
+import { genderChoice, ProfileGenderField } from "../src/components/profile-gender-field.tsx";
import { ProfilePanel } from "../src/components/profile-panel.tsx";
import { emptyProfile, type Account, type Profile } from "../src/lib/home-types.ts";
import { PROFILE_GENDER_HINT, type ProfileGender } from "../src/lib/profile-gender.ts";
@@ -64,26 +64,36 @@ function elements(node: ReactNode): ReactElement>[] {
return out;
}
-function radios(markup: string) {
- return [...markup.matchAll(/]*role="radio"[^>]*aria-checked="(true|false)"[^>]*>([^<]*)<\/button>/g)]
- .map((match) => `${match[2]}:${match[1]}`);
+function pressed(markup: string) {
+ return [...markup.matchAll(/]*>[\s\S]*?<\/button>/g)].flatMap((match) => {
+ const tag = match[0];
+ const open = tag.slice(0, tag.indexOf(">") + 1);
+ const state = /aria-pressed="(true|false)"/.exec(open)?.[1];
+ if (!state) return [];
+ const text = tag.replace(/<[^>]+>/g, "").trim();
+ return [`${text}:${state}`];
+ });
}
-test("the field: 女 / 男 / 不填 as one radio group, 不填 selected when nothing is stored, one hint line", () => {
+test("the field: 女 / 男 / 不填 as one group, 不填 selected when nothing is stored, one hint line", () => {
const empty = renderToStaticMarkup( {}} />);
- assert.deepEqual(radios(empty), ["女:false", "男:false", "不填:true"]);
- assert.match(empty, /role="radiogroup" aria-labelledby="t-gender-title" aria-describedby="t-gender-hint"/);
+ // 原值:role="radio" + aria-checked,空值时 ["女:false", "男:false", "不填:true"]
+ // 新值:ToggleGroup 的 aria-pressed,空值时仍是 不填 按下
+ // 原因:K4 关系与性别改用标签式 ToggleGroup,选项、空值和再点已选项不改的行为还在
+ assert.deepEqual(pressed(empty), ["女:false", "男:false", "不填:true"]);
+ assert.match(empty, /aria-labelledby="t-gender-title"/);
+ assert.match(empty, /aria-describedby="t-gender-hint"/);
assert.match(empty, /性别(选填)/);
assert.ok(empty.includes(PROFILE_GENDER_HINT));
assert.equal(PROFILE_GENDER_HINT, "用于婚恋解读里判断夫星 / 妻星,不填也能用。");
const female = renderToStaticMarkup( {}} />);
- assert.deepEqual(radios(female), ["女:true", "男:false", "不填:false"]);
+ assert.deepEqual(pressed(female), ["女:true", "男:false", "不填:false"]);
- const chosen: Array = [];
- const tree = elements(ProfileGenderField({ idPrefix: "t", value: "female", onChange: (value) => chosen.push(value) }));
- const buttons = tree.filter((element) => element.type === "button");
- for (const button of buttons) (button.props.onClick as () => void)();
- assert.deepEqual(chosen, ["male", null], "clicking the selected choice again is a no-op; 不填 stores null");
+ assert.equal(genderChoice("female", ["female"]), undefined);
+ assert.equal(genderChoice("female", []), undefined);
+ assert.equal(genderChoice("female", ["male"]), "male");
+ assert.equal(genderChoice("female", ["unset"]), null);
+ assert.equal(genderChoice(null, ["unset"]), undefined, "clicking the selected choice again is a no-op; 不填 stores null");
});
test("people edit form: the gender field is part of the form and writes the draft (不填 → null)", () => {
@@ -94,7 +104,7 @@ test("people edit form: the gender field is part of the form and writes the draf
onChange: (next) => { draft = typeof next === "function" ? next(draft) : next; },
};
const markup = renderToStaticMarkup( );
- assert.deepEqual(radios(markup), ["女:false", "男:true", "不填:false"]);
+ assert.deepEqual(pressed(markup), ["女:false", "男:true", "不填:false"]);
const field = elements(ChartProfileForm(props)).find((element) => element.type === ProfileGenderField);
assert.ok(field);
(field.props.onChange as (value: ProfileGender | null) => void)(null);
@@ -105,16 +115,17 @@ test("people edit form: the gender field is part of the form and writes the draf
const without = renderToStaticMarkup( );
assert.equal(without.includes("性别(选填)"), false);
- const editing = renderToStaticMarkup( );
- // 原值: ["女:false", "男:true", "不填:false"]
- // 新值: 性别单选前面多一组「TA 是你的」(空关系显示为已选「其他」)
- // 原因: 档案编辑他人时要能补关系(#95 T2);性别单选本身没改
- assert.deepEqual(radios(editing), [
+ const editing = renderToStaticMarkup( );
+ // 原值: PeopleArchiveView 编辑态里的 role=radio,性别前多一组「TA 是你的」
+ // 新值: 同一张表直接渲染;页面源码把表单放进 Dialog,并传 showRelation={!editingSelf}
+ // 原因: K4 编辑用 Dialog,传送门不进静态 HTML;关系和性别仍是这一张表上的 ToggleGroup
+ assert.deepEqual(pressed(editing), [
"伴侣:false", "孩子:false", "父母:false", "朋友:false", "其他:true",
"女:false", "男:true", "不填:false",
]);
+ const peopleSource = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
+ assert.match(peopleSource, /showRelation=\{!editingSelf\}/);
+ assert.match(peopleSource, /showGender/);
});
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edits it in the same form as everyone", () => {
@@ -126,8 +137,17 @@ test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edit
// 原因: BUG-1081 / TASK-self-edit-avatar-menu-20260928 S1——本人在星盘档案可编辑,小字指向的「设置里改」不再是唯一入口
assert.match(self, /性别<\/dt> 未填<\/dd>/);
assert.equal(self.includes("role=\"radio\""), false, "the detail card itself has no radios");
- const editing = renderToStaticMarkup( );
- assert.equal(editing.includes("role=\"radio\""), true, "the owner's edit form carries gender like everyone's");
+ assert.equal(self.includes("aria-pressed"), false, "the detail card itself has no gender choices");
+ const peopleSource = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
+ // 原值:本人编辑视图的静态 HTML 里有 role="radio"
+ // 新值:编辑表单在 Dialog 里,源码仍传 showGender,表单本身仍有性别
+ // 原因:K4 编辑改用 Dialog,传送门内容不进 renderToStaticMarkup
+ assert.match(peopleSource, /showGender/);
+ const editing = renderToStaticMarkup( {}, onChange: () => {},
+ }} />);
+ assert.equal(editing.includes("性别(选填)"), true, "the owner's edit form carries gender like everyone's");
});
test("个人资料: the owner's gender saves on its own and birth-data saves never send it", () => {
@@ -140,7 +160,7 @@ test("个人资料: the owner's gender saves on its own and birth-data saves nev
{}} />,
);
- assert.deepEqual(radios(panel), []);
+ assert.deepEqual(pressed(panel), []);
assert.equal(panel.includes(PROFILE_GENDER_HINT), false);
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
assert.match(people, /await saveSelfGender\(gender\)/);
diff --git a/frontend/tests/self-edit-people-20260928.test.tsx b/frontend/tests/self-edit-people-20260928.test.tsx
index 6080cafe..eee748b8 100644
--- a/frontend/tests/self-edit-people-20260928.test.tsx
+++ b/frontend/tests/self-edit-people-20260928.test.tsx
@@ -66,18 +66,24 @@ function fakeFetch(status: number, body: unknown, calls: Call[]): typeof fetch {
test("本人 detail offers 编辑 and still says it cannot be deleted", () => {
const detail = render();
- assert.match(detail, /class="people-archive-foot">本人不能删除<\/p>编辑<\/button>/);
+ // 原值:脚注里的编辑按钮 class 只有 people-archive-secondary,500 字内出现「编辑」
+ // 新值:脚注仍先写「本人不能删除」,1600 字内出现「编辑」
+ // 原因:K4 编辑按钮改用 Button,组件库样式写在同一个 class 里,比原来长
+ assert.match(detail, /class="people-archive-foot">本人不能删除<\/p>[\s\S]{0,1600}编辑/);
assert.doesNotMatch(detail, /删除这个人|确认删除/);
});
test("本人 edit view: its own title, gender like everyone, and no delete area", () => {
const editing = render({ editing: true, draft: selfProfile });
- assert.match(editing, /编辑我的资料/);
+ // 原值:本人编辑的静态 HTML 含「编辑我的资料」和 role="radio"
+ // 新值:标题在源码里;静态 HTML 没有删除区。他人编辑的静态 HTML 仍有「删除这个人」
+ // 原因:K4 编辑表单放进 Dialog,传送门不进 renderToStaticMarkup;删除按钮留在编辑区下面
+ assert.match(peopleSource, /编辑我的资料/);
assert.doesNotMatch(editing, /编辑人物|填报的出生时间保存后不能再改/);
- assert.equal(editing.includes("role=\"radio\""), true);
+ assert.match(peopleSource, /showGender/);
assert.doesNotMatch(editing, /people-archive-danger|删除这个人/);
const other = render({ editing: true, selected: people[1]!, selectedId: people[1]!.id, draft: otherProfile });
- assert.match(other, /编辑人物/);
+ assert.match(peopleSource, /编辑人物/);
assert.match(other, /删除这个人/);
});
diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts
index fd7f098a..2721546a 100644
--- a/frontend/tests/synastry-full-report.test.ts
+++ b/frontend/tests/synastry-full-report.test.ts
@@ -166,12 +166,30 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
assert.match(picker, /合盘服务暂时连不上,请稍后再试/);
assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("fetchAccount()"));
const pending = renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SynastryPicker)));
- assert.match(pending, /看这段感情往后的走向,和两人之间的助力与考验。/);
+ // 原值:开关未返回时静态 HTML 有副标题「看这段感情往后的走向,和两人之间的助力与考验。」
+ // 新值:这段副标题不在页面上;未返回时仍不出现「开始合盘」和「第一个人」
+ // 原因:K4 去掉页内重复标题和副标题,顶栏已经有「婚恋合盘」(BUG-1403 的不闪选人区还在)
+ assert.doesNotMatch(pending, /看这段感情往后的走向/);
assert.doesNotMatch(pending, /开始合盘|第一个人/);
- assert.match(picker, /看这段感情往后的走向,和两人之间的助力与考验。/);
+ assert.doesNotMatch(picker, /看这段感情往后的走向/);
+ // 原值:两个选择框各自写 aria-label="第一个人" / aria-label="第二个人"
+ // 新值:调用处是 label="第一个人" 和 label="第二个人",触发器 aria-label={props.label}
+ // 原因:两个 Select 共用一个选人框,可访问名称仍是这两个词
+ assert.match(picker, /label="第一个人"/);
+ assert.match(picker, /label="第二个人"/);
+ assert.match(picker, /aria-label=\{props\.label\}/);
+ assert.match(picker, /HeartHandshake/);
+ assert.doesNotMatch(picker, /Sparkles/);
assert.match(picker, /开始合盘/);
- assert.match(picker, /+ 新增星盘/);
- assert.match(picker, /生成一份完整合盘报告,用掉/);
+ assert.match(picker, /新增星盘/);
+ assert.doesNotMatch(picker, /+/);
+ // 原值:按钮下方写「生成一份完整合盘报告,用掉 N 点。」
+ // 新值:按钮上不写价格;确认句是「会用掉 N 点,余额 M 点。」,不够时是「需要 N 点,余额只有 M 点。」
+ // 原因:K4 点开始先确认扣点,价格从计价表读,余额从账户读
+ assert.match(picker, /会用掉 \$\{creditCost\} 点,余额 \$\{balance\} 点。/);
+ assert.match(picker, /需要 \$\{creditCost\} 点,余额只有 \$\{balance\} 点。/);
+ assert.match(picker, /去补点数/);
+ assert.match(picker, /accountDialogHref\("billing"\)/);
const again = renderToStaticMarkup(createElement(SynastryGoNotes, {
creditCost: 19,
pricingNote: "",
@@ -179,7 +197,7 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
generating: false,
}));
assert.match(again, /这两人已经合过 2 次,再生成会另扣 19 点/);
- assert.match(again, /生成一份完整合盘报告,用掉 19 点/);
+ assert.doesNotMatch(again, /生成一份完整合盘报告/);
const quiet = renderToStaticMarkup(createElement(SynastryGoNotes, {
creditCost: 19,
pricingNote: "",
@@ -238,8 +256,12 @@ test("report view keeps analysis folded and offers a section ask entry", () => {
assert.doesNotMatch(html, /]*>共同练习<\/button>/);
assert.match(html, /所以:靠近时先确认对方听完了。/);
assert.match(html, /外部资料与真实案例核对:未进行/);
- assert.match(html, /· 婚恋合盘/);
- assert.match(html, /‹ 婚恋合盘/);
+ assert.match(html, /甲 × 乙 · 婚恋合盘/);
+ // 原值:返回链接文字是「‹ 婚恋合盘」
+ // 新值:链接仍指向 /synastry,文字是「婚恋合盘」,箭头用 ChevronLeft
+ // 原因:K4 去掉手写返回箭头。标题里的 × 还在,第一次打开时两个头像靠拢仍按这个标题拆
+ assert.match(html, /href="\/synastry"/);
+ assert.match(readFileSync(new URL("../src/components/synastry/synastry-report-view.tsx", import.meta.url), "utf8"), /ChevronLeft/);
assert.match(html, /已保存,再次打开不扣点/);
assert.match(html, /可以一起聊聊/);
});