Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
159 lines
9.6 KiB
TypeScript
159 lines
9.6 KiB
TypeScript
// TASK-consult-gender-optional-20260927 T2: optional gender radio (女 / 男 / 不填)
|
|
// in the people-archive edit form (the only place since 2026-09-30), one explanatory
|
|
// line, and the detail card shows the stored value (未填 when empty).
|
|
// Fictional people only (AGENTS §8).
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import React, { type ReactElement, type ReactNode } from "react";
|
|
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 { 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";
|
|
|
|
Object.assign(globalThis, { React });
|
|
|
|
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
|
|
const selfProfile: Profile = {
|
|
...emptyProfile,
|
|
name: "示例甲",
|
|
date: "1990-01-02",
|
|
reportedTime: "05:00",
|
|
time: "05:00",
|
|
birthTimeSource: "family_exact",
|
|
birthTimeStatus: "reported",
|
|
birthPlaceLabel: "中国 · 浙江省 · 杭州市",
|
|
latitude: 30.25,
|
|
longitude: 120.13,
|
|
timezoneId: "Asia/Shanghai",
|
|
timezoneOffset: 8,
|
|
};
|
|
const otherProfile: Profile = { ...selfProfile, name: "示例乙", gender: "male" };
|
|
const people: PersonRow[] = [
|
|
{ id: "self", role: "self", profile: selfProfile, incomplete: false },
|
|
{ id: "11111111-1111-4111-8111-111111111111", role: "other", profile: otherProfile, incomplete: false },
|
|
];
|
|
|
|
function viewProps(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchiveViewProps {
|
|
const noop = () => {};
|
|
const asyncNoop = async () => {};
|
|
return {
|
|
people, subjectId: "self", selected: people[0]!, selectedId: "self", view: "detail", 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,
|
|
};
|
|
}
|
|
|
|
/** All elements in a hook-free component's rendered tree (function components are expanded). */
|
|
function elements(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
|
if (Array.isArray(node)) return node.flatMap(elements);
|
|
if (!React.isValidElement(node)) return [];
|
|
const element = node as ReactElement<Record<string, unknown>>;
|
|
const out = [element];
|
|
if (typeof element.type === "function" && (element.type === ProfileGenderField)) {
|
|
out.push(...elements((element.type as (props: unknown) => ReactNode)(element.props)));
|
|
}
|
|
out.push(...elements(element.props.children as ReactNode));
|
|
return out;
|
|
}
|
|
|
|
function radios(markup: string) {
|
|
return [...markup.matchAll(/<button[^>]*role="radio"[^>]*aria-checked="(true|false)"[^>]*>([^<]*)<\/button>/g)]
|
|
.map((match) => `${match[2]}:${match[1]}`);
|
|
}
|
|
|
|
test("the field: 女 / 男 / 不填 as one radio group, 不填 selected when nothing is stored, one hint line", () => {
|
|
const empty = renderToStaticMarkup(<ProfileGenderField idPrefix="t" value={undefined} onChange={() => {}} />);
|
|
assert.deepEqual(radios(empty), ["女:false", "男:false", "不填:true"]);
|
|
assert.match(empty, /role="radiogroup" aria-labelledby="t-gender-title" aria-describedby="t-gender-hint"/);
|
|
assert.match(empty, /性别(选填)/);
|
|
assert.ok(empty.includes(PROFILE_GENDER_HINT));
|
|
assert.equal(PROFILE_GENDER_HINT, "用于婚恋解读里判断夫星 / 妻星,不填也能用。");
|
|
const female = renderToStaticMarkup(<ProfileGenderField idPrefix="t" value="female" onChange={() => {}} />);
|
|
assert.deepEqual(radios(female), ["女:true", "男:false", "不填:false"]);
|
|
|
|
const chosen: Array<ProfileGender | null> = [];
|
|
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");
|
|
});
|
|
|
|
test("people edit form: the gender field is part of the form and writes the draft (不填 → null)", () => {
|
|
let draft: Profile = { ...otherProfile, gender: "male" };
|
|
const props: ChartProfileFormProps = {
|
|
title: "编辑人物", description: "", value: draft, nameInputId: "people-archive-name", showAyanamsa: true,
|
|
showGender: true, submitLabel: "保存", submitDisabled: false, onSubmit: () => {},
|
|
onChange: (next) => { draft = typeof next === "function" ? next(draft) : next; },
|
|
};
|
|
const markup = renderToStaticMarkup(<ChartProfileForm {...props} />);
|
|
assert.deepEqual(radios(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);
|
|
assert.equal(draft.gender, null, "explicit null so the PUT clears the stored value");
|
|
(field.props.onChange as (value: ProfileGender | null) => void)("female");
|
|
assert.equal(draft.gender, "female");
|
|
assert.equal(draft.name, "示例乙", "other fields untouched");
|
|
const without = renderToStaticMarkup(<ChartProfileForm {...props} showGender={false} />);
|
|
assert.equal(without.includes("性别(选填)"), false);
|
|
|
|
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({
|
|
selected: people[1]!, selectedId: people[1]!.id, editing: true, draft: otherProfile,
|
|
})} />);
|
|
assert.deepEqual(radios(editing), ["女:false", "男:true", "不填:false"]);
|
|
});
|
|
|
|
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edits it in the same form as everyone", () => {
|
|
const other = renderToStaticMarkup(<PeopleArchiveView {...viewProps({ selected: people[1]!, selectedId: people[1]!.id })} />);
|
|
assert.match(other, /<dt>性别<\/dt><dd>男<\/dd>/);
|
|
const self = renderToStaticMarkup(<PeopleArchiveView {...viewProps()} />);
|
|
// 原值: /<dd>未填<small class="people-archive-fact-note">在「设置 → 个人资料」里改<\/small><\/dd>/,且本页没有本人的性别单选
|
|
// 新值: /<dd>未填<\/dd>/;本人的编辑视图里有性别单选(与他人同一张表)
|
|
// 原因: BUG-1081 / TASK-self-edit-avatar-menu-20260928 S1——本人在星盘档案可编辑,小字指向的「设置里改」不再是唯一入口
|
|
assert.match(self, /<dt>性别<\/dt><dd>未填<\/dd>/);
|
|
assert.equal(self.includes("role=\"radio\""), false, "the detail card itself has no radios");
|
|
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({ editing: true, draft: selfProfile })} />);
|
|
assert.equal(editing.includes("role=\"radio\""), 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", () => {
|
|
// 原值: 个人资料 rendered the 女 / 男 / 不填 radios (["女:true", "男:false", "不填:false"]) plus the hint, and
|
|
// use-profile-onboarding's persistGender sent the one-field PATCH
|
|
// 新值: 个人资料 has no gender control; the one-field PATCH is saveSelfGender, called from the people archive's self edit
|
|
// 原因: 产品 2026-09-30「性别应该在档案里设置而不是在个人资料的账户里设置」——两个入口删一个(BUG-1119)
|
|
const account = { user: { id: "u", email: "fictional@example.invalid" }, avatar: null } as unknown as Account;
|
|
const panel = renderToStaticMarkup(
|
|
<ProfilePanel account={account} profile={{ ...selfProfile, gender: "female" }} avatarSaving={false}
|
|
persistAvatar={() => {}} />,
|
|
);
|
|
assert.deepEqual(radios(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\)/);
|
|
const selfSave = readFileSync(new URL("../src/lib/self-profile-save.ts", import.meta.url), "utf8");
|
|
const saveGender = selfSave.slice(selfSave.indexOf("export async function saveSelfGender"));
|
|
assert.match(saveGender, /body: JSON\.stringify\(\{ gender \}\)/);
|
|
const hook = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
|
|
assert.equal(/persistGender/.test(hook), false, "no second gender entry in the home hook");
|
|
const persistProfile = hook.slice(hook.indexOf("async function persistProfile"), hook.indexOf("async function assessSavedBirthTime"));
|
|
assert.equal(/gender/.test(persistProfile), false, "onboarding and birth-data saves do not carry gender");
|
|
const onboarding = readFileSync(new URL("../src/components/home-onboarding-shell.tsx", import.meta.url), "utf8");
|
|
assert.equal(/ProfileGenderField|性别/.test(onboarding), false, "not asked in onboarding (G1)");
|
|
});
|
|
|
|
test("CSS: the choices are 44px touch targets and every new class has a rule", () => {
|
|
for (const name of ["profile-gender-field", "profile-gender-title", "profile-gender-options", "profile-gender-option", "profile-gender-hint"]) {
|
|
assert.match(css, new RegExp(`\\.${name}(?![a-z-])[^{]*\\{`), name);
|
|
}
|
|
assert.match(css, /\.profile-gender-option \{[^}]*min-height: 44px/);
|
|
assert.match(css, /\.profile-gender-option\.is-selected \{[^}]*var\(--color-action-soft\)/);
|
|
});
|