Files
Jyotisha/frontend/tests/profile-gender-ui-20260927.test.tsx
T
Jesse_ChenandClaude Opus 5.5 d79dada970 feat(ui): optional gender radio in the people form and 个人资料 (T2)
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
2026-09-27 18:30:58 +08:00

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\)/);
});