fix(people): 本人 can edit their own birth data in 星盘档案 (S1, BUG-1081)

The owner had no edit entry anywhere once /people replaced the settings
chart pane. 本人 now gets 编辑 with the same form; the save goes through
PATCH /api/account via a shared lib/self-profile-save.ts that the home
onboarding also uses. The dead saveProfile is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 00:43:43 +08:00
co-authored by Claude Opus 5.5
parent adbb205440
commit 8415fdca72
18 changed files with 337 additions and 106 deletions
@@ -231,7 +231,10 @@ test("homepage and profile result copy use the source-aware consultation options
const page = homeSurface;
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.match(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/);
// 原值: assert.match(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/)——首页 hook 的 `saveProfile` 保存后提示用这句
// 新值: 首页不再有这条保存提示(doesNotMatch);来源感知的选项文案仍由录入表单 `birth-time-intake` 给出(下方断言不动)
// 原因: BUG-1081——`saveProfile` 自 `ab6c55f8` 起已无 UI 调用,随 TASK-self-edit-avatar-menu-20260928 S1 删除;星盘档案本人保存后直接回到已更新的详情
assert.doesNotMatch(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/);
assert.doesNotMatch(page, /birthTimeConsultationOptionsCopy\(profile\)/);
assert.match(intake, /birthTimeConsultationOptionsCopy\(value\)/);
});
@@ -151,7 +151,10 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme
const pageSource = homeSurface;
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/);
// 原值: assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/)
// 新值: 首页不再有这条保存提示(doesNotMatch);本条要守的「候选未确认时不写成当前排盘时间」由其余断言继续锁住
// 原因: BUG-1081——这句只在无 UI 调用的 `saveProfile` 里,随 TASK-self-edit-avatar-menu-20260928 S1 删除
assert.doesNotMatch(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/);
assert.match(pageSource, /<ConversationalBirthTimeRectification/);
assert.doesNotMatch(pageSource, /当前使用候选时间排盘/);
});
@@ -341,10 +341,16 @@ test("session changes contain no birth-time notice state", () => {
test("profile and place saves do not auto-start the retired assessment flow", () => {
const source = homeSurface;
const normalSave = source.slice(source.indexOf("async function saveProfile"), source.indexOf("async function saveOnboardingName"));
const placeSave = source.slice(source.indexOf("async function saveOnboardingPlace"), source.indexOf("function completeGuidedBirthTime"));
assert.doesNotMatch(normalSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
// 原值: 从首页 hook 切出 `saveProfile` 断言它不自动评估
// 新值: 首页 hook 不再有 `saveProfile`;本人资料编辑的唯一入口 `saveSelf`(星盘档案)同样不评估
// 原因: BUG-1081——`saveProfile` 早已无 UI 调用,随 S1 删除;编辑入口搬到 /people
assert.doesNotMatch(source, /async function saveProfile/);
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
const selfSave = people.slice(people.indexOf("async function saveSelf"), people.indexOf("async function askDelete"));
assert.ok(selfSave.length > 0);
assert.doesNotMatch(selfSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
assert.doesNotMatch(placeSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
});
+1
View File
@@ -28,6 +28,7 @@ const optionalHomeHookFiles = [
"../src/lib/session-list-filter.ts",
"../src/lib/home-bootstrap-run.ts",
"../src/lib/consultation-recovery-poll.ts",
"../src/lib/self-profile-save.ts",
] as const;
export const homeSurface = [
@@ -81,7 +81,10 @@ test("people delete requires known usage and synastry never switches the global
assert.match(people, /暂时查不到这个人的对话数量,稍后再试/);
assert.match(people, /Number\.isInteger\(usage\.sessions\)/);
assert.match(people, /pendingDelete\?\.id !== selected\.id/);
assert.match(people, /title=\{selectedId \? "编辑人物" : "添加一个人"\}/);
// 原值: /title=\{selectedId \? "编辑人物" : "添加一个人"\}/
// 新值: 同一张表多一种标题:本人「编辑我的资料」,其余不变
// 原因: BUG-1081——本人在星盘档案可编辑(TASK-self-edit-avatar-menu-20260928 S1)
assert.match(people, /title=\{editingSelf \? "编辑我的资料" : selectedId \? "编辑人物" : "添加一个人"\}/);
assert.doesNotMatch(people, /openPerson\(selected\.id,.*synastry/);
assert.match(source("hooks/use-synastry.ts"), /startNewChat\(\{ subjectId: "self" \}\)/);
});
@@ -80,6 +80,7 @@ function props(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchiveVi
onCancelEdit: noop,
onDraftChange: noop,
saveOther: asyncNoop,
saveSelf: asyncNoop,
askDelete: asyncNoop,
onCancelDelete: noop,
confirmDelete: asyncNoop,
@@ -46,7 +46,7 @@ function viewProps(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchi
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,
saveOther: asyncNoop, saveSelf: asyncNoop, askDelete: asyncNoop, onCancelDelete: noop, confirmDelete: asyncNoop, openPerson: noop,
...overrides,
};
}
@@ -111,12 +111,17 @@ test("people edit form: the gender field is part of the form and writes the draf
assert.deepEqual(radios(editing), ["女:false", "男:true", "不填:false"]);
});
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 points to 设置 → 个人资料", () => {
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()} />);
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");
// 原值: /<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", () => {
@@ -137,7 +142,7 @@ test("个人资料: the owner's gender saves on its own and birth-data saves nev
});
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"]) {
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/);
@@ -118,9 +118,11 @@ test("only the reader's own onboarding place save triggers it, after the flow ha
assert.ok(persisted > 0 && nextStep > persisted && trigger > nextStep, "trigger runs after the save succeeded and the next step is set");
assert.ok(trigger < place.indexOf("catch (caught)"), "a failed save never triggers it");
assert.equal(onboarding.match(/onOwnBirthPlaceSaved\?\.\(\)/g)?.length, 1);
// Profile edits (saveProfile) and the birth-time step do not trigger it.
const saveProfile = onboarding.slice(onboarding.indexOf("async function saveProfile"), onboarding.indexOf("async function saveOnboardingName"));
assert.doesNotMatch(saveProfile, /onOwnBirthPlaceSaved/);
// Profile edits and the birth-time step do not trigger it.
// 原值: 切出首页 hook 的 `saveProfile` 断言不含 onOwnBirthPlaceSaved
// 新值: 首页 hook 不再有 `saveProfile`;本人编辑在 people-page(下方循环已断言它不触发)
// 原因: BUG-1081——无 UI 调用的 `saveProfile` 删除,本人编辑入口在星盘档案
assert.doesNotMatch(onboarding, /async function saveProfile/);
// New family members in the people archive never reach this hook.
for (const path of [
"../src/components/chart-profile-form.tsx",
@@ -0,0 +1,138 @@
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 { saveSelfGender, saveSelfProfile, selfProfilePatchBody } from "../src/lib/self-profile-save.ts";
Object.assign(globalThis, { React });
/*
* BUG-1081 / TASK-self-edit-avatar-menu-20260928 S1: the owner's birth data
* had no edit entry anywhere after /people replaced the settings chart pane.
* Fictional people only (AGENTS §8).
*/
const peopleSource = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
const onboardingSource = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
const selfProfile: Profile = {
...emptyProfile,
name: "示例甲",
date: "1994-05-18",
time: "07:40",
reportedTime: "07:40",
birthTimeSource: "family_exact",
birthTimeStatus: "accepted",
countryCode: "CN",
birthPlaceLabel: "中国 · 浙江省 · 杭州市 · 西湖区",
latitude: 30.27,
longitude: 120.15,
timezoneId: "Asia/Shanghai",
timezoneOffset: 8,
timezoneSource: "iana",
ayanamsa: "raman",
gender: "female",
};
const otherProfile: Profile = { ...selfProfile, name: "示例乙", gender: null };
const people: PersonRow[] = [
{ id: "self", role: "self", profile: selfProfile, incomplete: false },
{ id: "11111111-1111-4111-8111-111111111111", role: "other", profile: otherProfile, incomplete: false },
];
function render(overrides: Partial<PeopleArchiveViewProps> = {}) {
const noop = () => {};
const asyncNoop = async () => {};
const props: PeopleArchiveViewProps = {
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, saveSelf: asyncNoop, askDelete: asyncNoop, onCancelDelete: noop, confirmDelete: asyncNoop, openPerson: noop,
...overrides,
};
return renderToStaticMarkup(React.createElement(PeopleArchiveView, props));
}
type Call = { url: string; init: RequestInit };
function fakeFetch(status: number, body: unknown, calls: Call[]): typeof fetch {
return (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}) as typeof fetch;
}
test("本人 detail offers 编辑 and still says it cannot be deleted", () => {
const detail = render();
assert.match(detail, /class="people-archive-foot"><p>本人不能删除<\/p><button type="button" class="people-archive-secondary">编辑<\/button>/);
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, /编辑我的资料/);
assert.doesNotMatch(editing, /编辑人物|填报的出生时间保存后不能再改/);
assert.equal(editing.includes("role=\"radio\""), true);
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(other, /删除这个人/);
});
test("the owner's birth data goes to PATCH /api/account with its time source and never with gender", async () => {
const body = selfProfilePatchBody(selfProfile);
assert.equal(body.birth_time_source, "family_exact");
assert.equal(body.reported_birth_time, "07:40");
assert.equal(body.birth_place_label, "中国 · 浙江省 · 杭州市 · 西湖区");
assert.equal("gender" in body, false, "birth-data saves never carry gender");
const calls: Call[] = [];
const fingerprint = "0123456789abcdef0123456789abcdef";
const saved = await saveSelfProfile(selfProfile, fakeFetch(200, {
birthTime: { status: "accepted", activeTime: "07:40" },
chartBirth: { fingerprint },
}, calls));
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, "/api/account");
assert.equal(calls[0]!.init.method, "PATCH");
assert.equal(JSON.parse(String(calls[0]!.init.body)).birth_time_source, "family_exact");
assert.equal(saved.fingerprint, fingerprint);
assert.equal(saved.profile.name, "示例甲");
});
test("a refused save surfaces the route's message; gender is its own one-field PATCH", async () => {
await assert.rejects(
saveSelfProfile(selfProfile, fakeFetch(400, { error: "修改出生资料时必须同时说明时间来源" }, [])),
/修改出生资料时必须同时说明时间来源/,
);
await assert.rejects(saveSelfProfile(selfProfile, fakeFetch(500, null, [])), /账户资料暂时无法保存。/);
const calls: Call[] = [];
await saveSelfGender("male", fakeFetch(200, {}, calls));
assert.deepEqual(JSON.parse(String(calls[0]!.init.body)), { gender: "male" });
});
test("星盘档案 saves 本人 through the account write, not chart_profiles, and drops every stale copy", () => {
const selfSave = peopleSource.slice(peopleSource.indexOf("async function saveSelf"), peopleSource.indexOf("async function askDelete"));
assert.ok(selfSave.length > 0);
assert.match(selfSave, /await saveSelfProfile\(draft\)/);
assert.match(selfSave, /saveSelfGender\(gender\)/);
assert.doesNotMatch(selfSave, /chart-profiles|role: "self"/);
assert.match(selfSave, /pinChartSnapshotIdentity\(/);
assert.match(selfSave, /clearHomeWarmSnapshot\(\)/);
assert.match(selfSave, /invalidateEphemerisPage\(\)/);
assert.match(selfSave, /setAccount\(/);
assert.match(selfSave, /invalidateSubjectCatalog\(\)/);
assert.match(selfSave, /setNotice\(caught instanceof Error/);
});
test("guard: the product keeps at least one edit entry for the owner's birth data (BUG-1081)", () => {
// Home's `saveProfile` had no caller left once /people replaced the settings
// pane, and nothing noticed. The entry now lives on /people, on the same
// shared write the home onboarding uses.
assert.match(peopleSource, /onClick=\{\(\) => onEdit\(selected\.profile\)\}>编辑</);
assert.doesNotMatch(peopleSource, /selected\.role === "self" \? <p>本人不能删除<\/p> : \(/);
assert.match(onboardingSource, /await saveSelfProfile\(nextProfile\)/);
assert.doesNotMatch(onboardingSource, /async function saveProfile/);
});
+5 -1
View File
@@ -31,7 +31,11 @@ test("the chart library owns self-profile editing and keeps other-chart save sep
// 原值 / 新值 / 原因:已退役面板编辑本人 / 本人账户来源、独立他人保存 / /people 是唯一入口,不伪造已移除的本人编辑。
assert.match(charts, /readProfile\(account\.profile\)/);
assert.match(charts, /selectedId && selected\?\.role === "other"/);
assert.match(charts, /onSubmit=\{\(event\) => void saveOther\(event\)\}/);
// 原值: /onSubmit=\{\(event\) => void saveOther\(event\)\}/
// 新值: 同一张表按 editingSelf 分给 saveSelf / saveOther
// 原因: BUG-1081——本人在星盘档案可编辑,保存走 PATCH /api/account,他人仍走 chart-profiles
assert.match(charts, /onSubmit=\{\(event\) => void \(editingSelf \? saveSelf\(event\) : saveOther\(event\)\)\}/);
assert.match(charts, /await saveSelfProfile\(draft\)/);
assert.doesNotMatch(charts, /setEditingSelfChart/);
assert.match(form, /AyanamsaPreferenceField/);
assert.doesNotMatch(personalProfile, /AyanamsaPreferenceField/);