diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 94ef5272..60adc3d7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3932,6 +3932,7 @@ - 相关记录:BUG-018(首次保存未写入档案状态,同一类真值不一致)、BUG-017(`mode_changed` 的另一入口) - 复发自:无 - 修复版本:本地未提交候选 +- 2026-09-29 产品修订(`TASK-mobile-chart-and-confirmed-edit-20260929` M2,不是本 Bug 复发):原约定「已确认与 legacy 分钟不被普通资料修改覆盖」改为——**只有声明字段(出生日期、时间与来源、时段 / 范围、地点代码与坐标、时区)真的变了**,已确认或 legacy 分钟才被丢掉,之后按新声明派生,与未确认状态完全一致(零误差家人时间 → `accepted` + 新分钟,否则 `reported`,`rectification_case_id` 清空);同时清掉图表会回落读取的遗留 `birth_time`,以及跨午夜采用三列 `active_birth_date` / `active_birth_timezone_offset` / `active_birth_provenance`(数据库触发器 `zz_guard_adopted_birth_date` 也会清)。只改称呼、岁差、性别、头像不动已确认分钟。影响:用户改出生资料会让已采用的生时校正结果作废,这是产品要的(「用户改生日肯定是得到信息了」)。同时把声明比较改成按值比较(库里的 `HH:MM:SS` 与表单提交的 `HH:MM`、数字与数字字符串、空串与 null 视为相同),否则 `/people` 每次整表重提都会被误判为新声明。`account-api.test.ts` 与 `chart-profile-update-consistency.test.ts` 里锁旧约定的断言按三栏改写,新增一条「confirmed 分钟只跟随新声明」测试。数据库侧:`guard_birth_time_journey`(最新版 20260804020000)只在状态为空且有分钟时补 `confirmed`,本修订总是写明确状态,不会被改回;`zz_guard_adopted_birth_date`、`agentic_rectification_profiles_rebaseline_guard`、`invalidate_agentic_rectification_results_on_profile_change` 在声明变化时照旧触发。本机无 Docker,未跑 `npm run test:db`,staging 实测见真机清单。 ## BUG-265 | 首页“今日星语”号称个人化,实际是 4 张写死卡片轮换 diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index 50ce0fce..c2096e2c 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -269,6 +269,12 @@ export type AccountBirthTimeApplicationPatch = Readonly<{ active_birth_time?: string | null; birth_time_status?: "accepted" | "reported"; rectification_case_id?: null; + /** Only when a confirmed or legacy minute is dropped: the legacy column the chart falls back to. */ + birth_time?: null; + /** Only when a confirmed adoption is dropped; `guard_adopted_birth_date` clears them too. */ + active_birth_date?: null; + active_birth_timezone_offset?: null; + active_birth_provenance?: null; }>; function normalizeApplicableBirthClock(value: string | null | undefined): string | null { @@ -299,6 +305,29 @@ function resolveExactFamilyBirthTime( return normalizeApplicableBirthClock(reportedTime); } +/** + * Stored and submitted declarations differ only in shape: Postgres returns a + * `time` as `HH:MM:SS`, the form sends `HH:MM`; numbers may arrive as strings; + * an empty string and null both mean "not given". The profile form resubmits + * every birth field on each save, so a raw `!==` would read a name-only save + * as a new declaration and drop a confirmed minute. + */ +function sameDeclarationValue(submitted: unknown, stored: unknown): boolean { + const empty = (value: unknown) => value === null || value === undefined || value === ""; + if (empty(submitted) || empty(stored)) return empty(submitted) && empty(stored); + if (typeof submitted === "string" && typeof stored === "string") { + const clock = /^(\d{2}:\d{2})(?::00(?:\.0+)?)?$/; + const a = clock.exec(submitted); + const b = clock.exec(stored); + if (a && b) return a[1] === b[1]; + return submitted === stored; + } + const a = Number(submitted); + const b = Number(stored); + if (Number.isFinite(a) && Number.isFinite(b)) return a === b; + return submitted === stored; +} + export function resolveAccountBirthTimeApplicationPatch( current: AccountBirthTimeState | null, patch: AccountProfilePatch, @@ -315,11 +344,28 @@ export function resolveAccountBirthTimeApplicationPatch( const confirmed = current.birth_time_status === "confirmed" || (current.birth_time_status === null && normalizeApplicableBirthClock(current.birth_time) !== null); - if (confirmed) return {}; - const declarationChanged = declarationFields.some((field) => ( - patch[field] !== undefined && patch[field] !== current[field] + patch[field] !== undefined && !sameDeclarationValue(patch[field], current[field]) )); + // Product 2026-09-29 (TASK-mobile-chart-and-confirmed-edit M2, revising + // BUG-264): a confirmed or adopted minute survives every edit except a new + // declaration. Once the user changes the birth date, time or place, the chart + // follows the new declaration exactly as an unconfirmed one would, and the + // old minute is dropped everywhere the chart could still read it. + if (confirmed) { + if (!declarationChanged) return {}; + return { + ...(exactFamilyBirthTime + ? { active_birth_time: exactFamilyBirthTime, birth_time_status: "accepted" as const } + : { active_birth_time: null, birth_time_status: "reported" as const }), + rectification_case_id: null, + ...(current.birth_time != null ? { birth_time: null } : {}), + ...(current.active_birth_date != null || current.active_birth_timezone_offset != null + ? { active_birth_date: null, active_birth_timezone_offset: null, active_birth_provenance: null } + : {}), + }; + } + const repairsMissingStatus = current.birth_time_status === null && current.active_birth_time === null && current.birth_time === null diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 1115863d..b4854df7 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -156,7 +156,10 @@ test("profile patch schema validates calendar, clock, source requirements, and l }).success, false); }); -test("ordinary declaration edits clear stale candidate application but never overwrite confirmed active time", () => { +test("ordinary declaration edits clear stale candidate application, and a new declaration drops a confirmed minute too", () => { + // 原值(测试名):…but never overwrite confirmed active time + // 新值:…and a new declaration drops a confirmed minute too + // 原因:产品 2026-09-29 推翻 BUG-264 约定:改了出生日期 / 时间 / 地点就按新资料排盘,即使原来已确认(TASK-mobile-chart-and-confirmed-edit M2) const candidate = { birth_date: "1997-08-08", reported_birth_time: "05:30", @@ -214,10 +217,18 @@ test("ordinary declaration edits clear stale candidate application but never ove birth_time_status: "reported", rectification_case_id: null, }); + // 原值:confirmed + 新声明 → {}(已确认分钟不动) + // 新值:confirmed + 新声明 → 回到 reported,并清掉遗留 birth_time(图表会回落读它) + // 原因:产品 2026-09-29 M2;只改岁差仍然不动(下一条断言原样保留) assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...candidate, birth_time_status: "confirmed", - }, edited), {}); + }, edited), { + active_birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + birth_time: null, + }); assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...candidate, birth_time_status: "confirmed", @@ -267,10 +278,16 @@ test("ordinary declaration edits clear stale candidate application but never ove birth_time_status: "reported", rectification_case_id: null, }); + // 原值 / 新值 / 原因:同上一处 confirmed 断言(产品 2026-09-29 M2) assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...candidate, birth_time_status: "confirmed", - }, edited), {}); + }, edited), { + active_birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + birth_time: null, + }); assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...candidate, active_birth_time: null, @@ -320,6 +337,9 @@ test("zero-uncertainty family exact time becomes an accepted usable chart time", birth_time_status: "accepted", rectification_case_id: null, }); + // 原值:confirmed / legacy 分钟 + 新的精确家人时间 → {} + // 新值:→ accepted + 新分钟;legacy 行另清遗留 birth_time + // 原因:产品 2026-09-29 M2,已确认分钟也跟随新声明 assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...reportedExactProfile, active_birth_time: "05:00:00", @@ -327,7 +347,11 @@ test("zero-uncertainty family exact time becomes an accepted usable chart time", }, { ...exactDeclaration, reported_birth_time: "05:40", - }), {}); + }), { + active_birth_time: "05:40", + birth_time_status: "accepted", + rectification_case_id: null, + }); assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...reportedExactProfile, active_birth_time: "05:00:00", @@ -336,7 +360,12 @@ test("zero-uncertainty family exact time becomes an accepted usable chart time", }, { ...exactDeclaration, reported_birth_time: "05:40", - }), {}); + }), { + active_birth_time: "05:40", + birth_time_status: "accepted", + rectification_case_id: null, + birth_time: null, + }); }); test("account PATCH answers with the birth-time truth it derived server-side", () => { @@ -391,6 +420,9 @@ test("account PATCH answers with the birth-time truth it derived server-side", ( ); // An untouched application keeps the stored truth, including a legacy confirmed minute. + // 原值:confirmed / legacy 分钟 + 改 district_code → 仍 confirmed 05:18 + // 新值:改地点属于新声明 → 按声明走(本夹具声明是零误差家人时间 05:00,于是 accepted 05:00);只改称呼 / 岁差仍保留 confirmed 05:18 + // 原因:产品 2026-09-29 M2(改日期、时间或地点就按新资料排盘) const confirmedProfile = { ...reportedExactProfile, active_birth_time: "05:18:00", @@ -401,6 +433,13 @@ test("account PATCH answers with the birth-time truth it derived server-side", ( confirmedProfile, resolveAccountBirthTimeApplicationPatch(confirmedProfile, { district_code: "130407" }), ), + { status: "accepted", activeTime: "05:00" }, + ); + assert.deepEqual( + resolveAppliedAccountBirthTime( + confirmedProfile, + resolveAccountBirthTimeApplicationPatch(confirmedProfile, { name: "示例", ayanamsa: "lahiri" }), + ), { status: "confirmed", activeTime: "05:18" }, ); const legacyProfile = { @@ -413,6 +452,13 @@ test("account PATCH answers with the birth-time truth it derived server-side", ( legacyProfile, resolveAccountBirthTimeApplicationPatch(legacyProfile, { district_code: "130407" }), ), + { status: "accepted", activeTime: "05:00" }, + ); + assert.deepEqual( + resolveAppliedAccountBirthTime( + legacyProfile, + resolveAccountBirthTimeApplicationPatch(legacyProfile, { name: "示例" }), + ), { status: "confirmed", activeTime: "05:18" }, ); assert.deepEqual( @@ -576,3 +622,86 @@ test("candidate invalidation compares coordinates and timezone in the conditiona assert.deepEqual(calls.find((call) => call[1] === "active_birth_time"), ["eq", "active_birth_time", "05:18"]); assert.deepEqual(calls.find((call) => call[1] === "birth_time_status"), ["eq", "birth_time_status", "candidate"]); }); + +// TASK-mobile-chart-and-confirmed-edit-20260929 M2 (product 2026-09-29): a new +// declaration drops a confirmed or adopted minute; nothing else does. +test("confirmed minute follows a new declaration and survives everything else", () => { + const confirmed = { + birth_date: "1994-05-18", + reported_birth_time: "07:40:00", + birth_time_source: "approximate", + birth_time_period: null, + birth_time_clue: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + active_birth_time: "07:52:00", + active_birth_date: "1994-05-18", + active_birth_timezone_offset: 8, + birth_time: null, + birth_time_status: "confirmed", + rectification_case_id: "11111111-1111-4111-8111-111111111111", + country_code: "CN", + province_code: "330000", + city_code: "330100", + district_code: "330106", + latitude: 30.27, + longitude: 120.15, + timezone_offset: 8, + } as const; + // The form resubmits every birth field; stored `HH:MM:SS` vs submitted `HH:MM` is not a change. + const resubmitted = { + birth_date: "1994-05-18", + reported_birth_time: "07:40", + birth_time_source: "approximate", + birth_time_period: null, + birth_time_clue: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + country_code: "CN", + province_code: "330000", + city_code: "330100", + district_code: "330106", + latitude: 30.27, + longitude: 120.15, + timezone_offset: 8, + } as const; + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ...resubmitted, name: "示例" }), {}); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ayanamsa: "lahiri" }), {}); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { gender: "female" }), {}); + + const cleared = { active_birth_date: null, active_birth_timezone_offset: null, active_birth_provenance: null }; + // New exact family time → accepted at the new minute. + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { + ...resubmitted, + reported_birth_time: "08:05", + birth_time_source: "family_exact", + uncertainty_before_minutes: 0, + uncertainty_after_minutes: 0, + }), { active_birth_time: "08:05", birth_time_status: "accepted", rectification_case_id: null, ...cleared }); + // Period only → reported, no minute. + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { + ...resubmitted, + reported_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "morning", + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, + }), { active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, ...cleared }); + // A new date drops the adopted cross-midnight date, offset and provenance. + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ...resubmitted, birth_date: "1994-05-19" }), + { active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, ...cleared }); + // A new place (coordinates) is a new declaration too. + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ...resubmitted, latitude: 31.23, longitude: 121.47 }), + { active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, ...cleared }); + // Without adoption columns, nothing extra is written. + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...confirmed, active_birth_date: null, active_birth_timezone_offset: null, + }, { ...resubmitted, birth_date: "1994-05-19" }), + { active_birth_time: null, birth_time_status: "reported", rectification_case_id: null }); + + // Legacy minute (status null, birth_time set): same rule, and the legacy column the chart falls back to is cleared. + const legacy = { ...confirmed, active_birth_time: null, active_birth_date: null, active_birth_timezone_offset: null, birth_time: "07:52:00", birth_time_status: null }; + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(legacy, { ...resubmitted, name: "示例" }), {}); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(legacy, { ...resubmitted, birth_date: "1994-05-19" }), + { active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, birth_time: null }); +}); diff --git a/frontend/tests/chart-profile-update-consistency.test.ts b/frontend/tests/chart-profile-update-consistency.test.ts index 4b971b86..eed121e0 100644 --- a/frontend/tests/chart-profile-update-consistency.test.ts +++ b/frontend/tests/chart-profile-update-consistency.test.ts @@ -101,7 +101,10 @@ test("account and chart-view share server-owned date, time, offset, and provenan assert.equal(adoptedBirth.failure, null); }); -test("confirmed ordinary edits keep the active minute and clear a complete adopted tuple together", () => { +// 原值(测试名与断言):confirmed ordinary edits keep the active minute…;application = {},排盘仍 confirmed 23:55(legacy) +// 新值:confirmed declaration edits drop the minute…;application 回到 reported 并显式清空跨午夜三列,排盘按新声明 00:20(declared) +// 原因:产品 2026-09-29 推翻 BUG-264 约定——改了出生日期 / 时间 / 地点就按新资料排盘(TASK-mobile-chart-and-confirmed-edit M2) +test("confirmed declaration edits drop the minute and clear a complete adopted tuple together", () => { const confirmed = { ...crossMidnight, birth_time_status: "confirmed" as const }; const patch = { birth_date: "2000-06-16", @@ -112,22 +115,31 @@ test("confirmed ordinary edits keep the active minute and clear a complete adopt uncertainty_before_minutes: 30, uncertainty_after_minutes: 30, }; - assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, patch), {}); - const stored = applyAdoptedBirthDateGuard(confirmed, { ...confirmed, ...patch }); - assert.equal(stored.active_birth_time, "23:55"); + const application = resolveAccountBirthTimeApplicationPatch(confirmed, patch); + assert.deepEqual(application, { + active_birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + active_birth_date: null, + active_birth_timezone_offset: null, + active_birth_provenance: null, + }); + const current = { ...confirmed } as Record; + const stored = applyAdoptedBirthDateGuard(current, { ...current, ...patch, ...application }); + assert.equal(stored.active_birth_time, null); assert.equal(stored.active_birth_date, null); assert.equal(stored.active_birth_timezone_offset, null); assert.equal(stored.active_birth_provenance, null); const birth = resolveServerOwnedChartBirth(stored); - assert.equal(birth.status, "confirmed"); - assert.equal(birth.time, "23:55"); + assert.equal(birth.status, "reported"); + assert.equal(birth.time, "00:20"); assert.equal(birth.date, "2000-06-16"); assert.equal(birth.timezoneOffset, -4); - assert.equal(birth.adoption, "legacy"); + assert.equal(birth.adoption, "declared"); assert.equal(birth.failure, null); const echoed = chartBirthAfterAccountWrite({ current: confirmed, - written: { ...confirmed, ...patch }, + written: { ...confirmed, ...patch, ...application }, returned: stored, }); assert.equal(echoed.fingerprint, birth.fingerprint); diff --git a/frontend/tests/self-edit-people-20260928.test.tsx b/frontend/tests/self-edit-people-20260928.test.tsx index bbe018d2..b1ff1fa1 100644 --- a/frontend/tests/self-edit-people-20260928.test.tsx +++ b/frontend/tests/self-edit-people-20260928.test.tsx @@ -136,3 +136,19 @@ test("guard: the product keeps at least one edit entry for the owner's birth dat 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, ""); +});