One ProfileGenderField (女 / 男 / 不填, 44px, one hint line) in the people-archive edit form (saved with the form, 不填 → null) and in 设置 → 个人资料 for the owner (saves on its own PATCH; birth-data saves never send gender). The detail card shows 性别 (未填 when empty). Not asked in onboarding. DESIGN/VOICE updated; 390/1280 screenshots with fictional data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
146 lines
8.4 KiB
TypeScript
146 lines
8.4 KiB
TypeScript
// TASK-consult-gender-optional-20260927 T2: optional gender radio (女 / 男 / 不填)
|
|
// in the people-archive edit form and the owner's 个人资料, 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, notice: "", pendingDelete: null, saving: false, atLimit: false,
|
|
onSelect: noop, onBack: noop, onAdd: noop, onEdit: noop, onCancelEdit: noop, onDraftChange: noop,
|
|
saveOther: 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; 本人 points to 设置 → 个人资料", () => {
|
|
const other = renderToStaticMarkup(<PeopleArchiveView {...viewProps({ selected: people[1]!, selectedId: people[1]!.id })} />);
|
|
assert.match(other, /<dt>性别<\/dt><dd>男<\/dd>/);
|
|
const self = renderToStaticMarkup(<PeopleArchiveView {...viewProps()} />);
|
|
assert.match(self, /<dt>性别<\/dt><dd>未填<small class="people-archive-fact-note">在「设置 → 个人资料」里改<\/small><\/dd>/);
|
|
assert.equal(self.includes("role=\"radio\""), false, "no second entry for the owner on this page");
|
|
});
|
|
|
|
test("个人资料: the owner's gender saves on its own and birth-data saves never send it", () => {
|
|
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} avatarNotice=""
|
|
accountError="" persistAvatar={() => {}} persistGender={async () => {}} />,
|
|
);
|
|
assert.deepEqual(radios(panel), ["女:true", "男:false", "不填:false"]);
|
|
assert.ok(panel.includes(PROFILE_GENDER_HINT));
|
|
const hook = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
|
|
const persistGender = hook.slice(hook.indexOf("async function persistGender"), hook.indexOf("async function persistProfile"));
|
|
assert.match(persistGender, /body: JSON\.stringify\(\{ gender \}\)/);
|
|
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", "people-archive-fact-note"]) {
|
|
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\)/);
|
|
});
|