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:
co-authored by
Claude Opus 5.5
parent
adbb205440
commit
8415fdca72
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user