Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
158 lines
8.0 KiB
TypeScript
158 lines
8.0 KiB
TypeScript
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, 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\(\)/);
|
||
// 原值: /setNotice\(caught instanceof Error/(失败写进页面 notice,且曾以绿色成功样式显示,BUG-1121)
|
||
// 新值: /notifyError\(caught instanceof Error/(失败是 error toast)
|
||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
||
assert.match(selfSave, /notifyError\(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/);
|
||
});
|
||
|
||
// TASK-mobile-chart-and-confirmed-edit-20260929 M2: once the server drops a
|
||
// confirmed minute, the saved profile carries the new truth, not the old one.
|
||
test("saving a new time over a confirmed minute takes the server's new status and minute", async () => {
|
||
const confirmedSelf: Profile = { ...selfProfile, time: "07:52", birthTimeStatus: "confirmed" };
|
||
const accepted = await saveSelfProfile({ ...confirmedSelf, reportedTime: "08:05" }, fakeFetch(200, {
|
||
birthTime: { status: "accepted", activeTime: "08:05:00", activeDate: null, activeTimezoneOffset: null },
|
||
}, []));
|
||
assert.equal(accepted.profile.birthTimeStatus, "accepted");
|
||
assert.equal(accepted.profile.time, "08:05");
|
||
const reported = await saveSelfProfile({ ...confirmedSelf, birthTimeSource: "period_only", reportedTime: "" }, fakeFetch(200, {
|
||
birthTime: { status: "reported", activeTime: null, activeDate: null, activeTimezoneOffset: null },
|
||
}, []));
|
||
assert.equal(reported.profile.birthTimeStatus, "reported");
|
||
assert.equal(reported.profile.time, "");
|
||
});
|