diff --git a/CHANGELOG.md b/CHANGELOG.md index b667875b..d5d46fa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-11-02 — 改出生资料时,时区没查到不再清掉出生地 + +- 修改已存人物的出生日期、时间或地点后,如果时区这会儿查不到,原来的出生地会留着,地点下面写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,旁边有「重试」。查到之前「保存」不能点,所以不会把旧日期的时区存进新日期;改回原来的日期和地点也可以直接保存。 +- 新建人物时查不到时区,仍是「时区服务暂时不可用,稍后重试即可。」加「重试」,「确定」不能点。 +- 顺带修了同一个原因的两处:编辑时改省份、城市或国家,选到一半不会再跳回空白要重选;打开海外人物的编辑,不会再被清空并跳回中国。 +- 改日期查到新时区后又改回原日期,会重新查一次,不再沿用刚才那个日期的时区。 +- 记为 BUG-1506。Skill 版本不变。 + ## 2026-11-01 — 年运报告:从「我的报告」直接打开,写好一节出一节 - 「我的报告」多了一张「年运」卡片,点进去就是年运报告页,没有单独的入口页。个人报告卡片位置不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b7104882..552c1b94 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20605,19 +20605,24 @@ ## BUG-1506 | 修改已存人物的出生资料时,时区服务失败会默默清空出生地 -- 状态:investigating +- 状态:resolved - 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计) -- 最近更新:2026-11-01 -- 影响面:用 `BirthPlacePicker` 编辑已存人物的地方(本人资料、人物列表)。新建人物不受影响:新建时会显示错误和「重试」。 +- 最近更新:2026-11-02 +- 影响面:所有用 `BirthPlacePicker` 的表单:星盘档案和星盘页的「编辑人物 / 编辑我的资料 / 添加一个人」(共用 `ChartProfileForm`),以及建档的出生地点卡。修复时又确认了同一根因的两条触发路径:编辑时改省份、改市或改国家(还没选完那一刻就清空并跳回空选择器,要再选一遍),以及打开海外人物的编辑(地区列表还在加载时就被清空、跳回中国)。 - 用户现象:修改出生日期、时间或地点后,如果 `/api/locations/timezone` 返回 503、连不上或报错,原来显示的「省 · 市 · 区县 … UTC+8」变回「按省、市、区县逐级选择即可。」。没有错误提示,也没有「重试」按钮,用户要重新选地点,而且可能以为已经存好了。 -- 触发条件:编辑已有地点的人物时,时区解析失败。 -- 根因(读码 + 本机渲染确认):失败时 `frontend/src/components/birth-place-picker.tsx` 调 `onChange(null)`(约 274 行)。`frontend/src/components/birth-location-fields.tsx:21-35` 把地点代码全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(`birth-place-picker.tsx:129-132`),刚设置的错误状态也一起被丢掉。 -- 修复:未修。本轮只审计,待产品决定排期。 -- 验证:本机用真实 React 渲染 `BirthPlacePicker`,把 fetch 换成返回 503 的 stub,复现上述现象。脚本是临时的,没有入库。 -- 防复发:待修复单补「编辑时时区失败保留原地点并显示重试」的行为测试;现有 `frontend/tests/birth-place-picker.test.ts` 只断言源码文本。 -- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节。 +- 触发条件:编辑已有地点的人物时,时区解析失败;或改到一半的省 / 市 / 国家;或打开海外人物。 +- 根因(读码 + 真实渲染确认):选择器在两处向上报 `null`:时区查询失败(`frontend/src/components/birth-place-picker.tsx` 原 274 行)和选项没选完(原 228-233 行)。`frontend/src/components/birth-location-fields.tsx` 收到 `null` 就把地点代码、坐标和时区全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(原 129-132 行),刚设置的错误状态也一起被丢掉。另外改日期查到新时区后再改回原日期,旧代码按「挂载时的出生时刻」判断已存时区有效,会把新日期的时区当原日期的存下去。 +- 修复: + - 选择器不再上报 `null`,`onChange` 只在查到新时区时调用;失败和没选完都保留资料里原来的地点和时区。 + - 新增 `onPendingChange`:已选地点或出生时刻需要新时区、但资料里还没有(没选完、正在查、查失败)时为 true。`frontend/src/components/birth-place-pending.tsx` 的 `BirthPlacePendingScope` 收这个状态,`BirthPlaceSubmitButton` 据此禁用保存。`ChartProfileForm` 和建档出生地点卡都包了这一层,所以「保存」/「确定」在时区查到或用户改回原值之前不能点。 + - 查失败时,有原地点的写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,后面是「重试」;新建(没有原地点)仍是「时区服务暂时不可用,稍后重试即可。」加「重试」。不加别的提示行,状态行位置和高度不变。 + - 资料里时区对应的出生时刻单独记(`valueMoment`),查到新时区时更新;改回原日期时会重新查,不再把别的日期的时区存下去。查询被取消后迟到的成功结果不再写回。 +- 验证:新增 `frontend/tests/birth-place-timezone-failure-20261102.test.tsx`,用真实 React 渲染 `ChartProfileForm` + fetch stub:改日期后时区 503 → 地点、坐标、时区原样保留,显示上面的错误句和「重试」,保存按钮禁用;重试成功 → 错误消失、时区改为新值、保存可点;改回原时间 → 不再请求,恢复原时区;改省份没选完 → 草稿留在新省份、资料不变、保存禁用;新建人物查失败 → 新建文案 + 重试,资料里没有可用地点;海外人物打开时(地区列表成功或失败)资料都不被清空。把 `frontend/src` 换回修复前版本,这 6 条行为测试全部失败。`birth-place-picker.test.ts` 的源码断言按新写法改,带原值 / 新值 / 原因注释。 +- 防复发:上面的行为测试。表单里放出生地选择器时要包 `BirthPlacePendingScope` 并用 `BirthPlaceSubmitButton`,同一测试文件检查两处表单的接线。选择器的 `onChange` 类型不再接受 `null`,以后谁再想用「上报空值」来表示失败会过不了类型检查。 +- 遗留:海外人物地区列表加载失败时,再改日期会保存不了,但状态行只说「该国家的地区列表暂时不可用」,没有重试(`docs/operations/outage-behaviour-2026-11-01.md` 第二节另一行,未改)。时区接口卡住时仍没有超时(同节另一行,未改)。 +- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节;BUG-1507。 - 复发自:无 -- 修复版本:未修。 +- 修复版本:分支 `codex/profile-birthplace-1506`(基于 `origin/staging` ba1b4be7)。 ## BUG-1507 | 早期中国出生资料没有时区 ID,被默默按 +8 计算(1986–1991 夏令时出生差一小时) diff --git a/docs/operations/outage-behaviour-2026-11-01.md b/docs/operations/outage-behaviour-2026-11-01.md index 3ef80ae3..feabebf5 100644 --- a/docs/operations/outage-behaviour-2026-11-01.md +++ b/docs/operations/outage-behaviour-2026-11-01.md @@ -57,7 +57,7 @@ Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网 | --- | --- | --- | --- | --- | | 新建人物,时区接口 503 或连不上 | 「时区服务暂时不可用,稍后重试即可。」加「重试」按钮,草稿保留,「确定」按钮不可点。不能手填经纬度或时区 | 不涉及 | 路由和服务都不写日志 | `birth-place-picker.tsx:272-276`、`:490-493`;`home-onboarding-shell.tsx:137` [读码];[实测] stub 503 / 连接拒绝 / 坏 JSON 都返回 `unavailable` | | 新建人物,时区接口卡住 | 「正在确认该地点的时区…」一直转,我们的代码没有超时,要等 Node 默认约 300 秒 | 不涉及 | 无 | 选择器到 Next 再到 Python 三段都没有 AbortSignal 或超时 [读码];[实测] stub 卡住时转圈不结束 | -| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506 | +| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506。**2026-11-02 已修**:保留原地点,显示「时区这会儿没查到,原来的出生地还在,查到后才能保存。」加「重试」,保存按钮在查到前不能点 | | 海外国家地区列表加载失败 | 「该国家的地区列表暂时不可用,请稍后重试。」没有重试按钮,要换国家再换回来 | 不涉及 | 无 | `birth-place-picker.tsx:149-171`、`:336` [读码] | | 星盘页:本人资料没存时区,查询时时区失败 | 「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」引导去改资料。这是临时故障,文案却让用户以为资料有错;「已经记录下来」实际只是一行 `console.warn`。卡住时 25 秒后显示「这张盘等了太久没有回来…」 | `billed:false` | `console.warn("chart_view_failure", {reason:"timezone_resolver_failure"})`,不告警 | `frontend/src/lib/chart-view-service.ts:190-199`、`chart-view-labels.ts:184`、`:205` [读码];测试 `chart-profile-update-consistency.test.ts`「classifies query, timezone…」 | | 对话:本人资料没存时区,时区失败 | 503「暂时无法核对完整出生资料…请重新保存后再试,本次不会扣点。」同样把临时故障说成资料问题 | **不扣点**:时区在 `reserve()` 之前解析 | 不写日志 | `consultation-route-service.ts:567-574` 对比 `:622` [读码];测试 `consultation-route-service.test.ts`「historical timezone failure remains pre-billing」[实测通过] | @@ -116,7 +116,7 @@ Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网 1. **报告任务租约过期不退点(BUG-1505)。** 进程在最后一次尝试时重启就会触发,发版时尤其容易遇到。点数扣掉了、报告失败,用户要么删报告,要么重新付一次。退点 RPC 失败也被忽略。 2. **报告生成同步等 VedAstro,没有总时限。** VedAstro 卡住时约 128 秒,超过前端 90 秒,报告失败;同时占着两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。这条路径的失败也不写失败缓存,下一份报告会重来一遍。可选做法:报告也走 `defer_optional_external_evidence`,或者跳过主入口概览,或者复用对话的前台预算和失败缓存。 3. **VedAstro 连接被截断会变成 500(BUG-1504)。** 报告失败(会退点),不带 skip 的 `/api/chart` 也会 500。 -4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。** +4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。**2026-11-02 已修。 5. **早期中国资料默默按 +8 计算(BUG-1507)。** 这是正确性问题,与故障无关:1986–1991 年夏季出生的差一小时。新疆 +6 要产品决定。 6. **模型调用不重试、没有备用供应商。** 一次 429 或 503 就让付费用户这一轮失败。会退点,但体验差;目录里配了多个模型,失败时也不切换。 7. **外部依赖挂了没有任何告警或日志。** 健康检查不探测,VedAstro 模块不写日志,报告侧模型失败不写 `errorCode`。凌晨低流量时,对话也达不到 5 分钟 10 次的阈值。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 3a723c41..3e861204 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -386,6 +386,7 @@ The birth-time rectification session is the consultation transcript plus a house - **Value:** administrative codes, the node centre coordinate, and the IANA zone are all persisted. Coordinates come from the dataset; the zone is resolved once per chosen place through the chart engine rather than per keystroke. - **States:** nothing chosen, partially chosen, resolving the zone, resolved, and zone service unavailable with an explicit retry. An unresolved zone is never reported upward as a usable birth place. - **Saved profiles:** mounting an already-saved place neither refetches nor reports a change, so opening the profile dialog cannot look like a location edit. Changing the birth date does invalidate the stored offset and resolves the zone again. +- **Never clears (2026-11-02, BUG-1506):** the picker reports a place only when a fresh zone comes back. A half-chosen level or a failed lookup keeps the profile's previous place and offset; the picker reports *pending* instead, and the form's submit (`BirthPlaceSubmitButton` inside `BirthPlacePendingScope`: the edit dialogs' 保存, onboarding's 确定) stays disabled until the lookup succeeds or the reader goes back to the saved place and moment. A failed lookup on a person who already has a place reads 「时区这会儿没查到,原来的出生地还在,查到后才能保存。」 with 重试; with no place yet it stays 「时区服务暂时不可用,稍后重试即可。」 with 重试. The error replaces the status line in place: no extra hint row, no height change. An offset resolved for an edited date is not reused when the date goes back. - **Accessibility:** every level keeps a visible label, an explicit trigger name, and the 44px target from the select recipe; status text uses a polite live region. ### Birth date picker diff --git a/frontend/src/components/birth-location-fields.tsx b/frontend/src/components/birth-location-fields.tsx index b8c936c6..7ebc5e45 100644 --- a/frontend/src/components/birth-location-fields.tsx +++ b/frontend/src/components/birth-location-fields.tsx @@ -1,9 +1,12 @@ "use client"; +import { useBirthPlacePendingReporter } from "@/components/birth-place-pending"; import { BirthPlacePicker } from "@/components/birth-place-picker"; import type { Profile } from "@/lib/home-types"; export function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { + // The enclosing form holds its submit while the picker still needs a fresh timezone (BUG-1506). + const reportPending = useBirthPlacePendingReporter(); return (
出生地点 @@ -18,21 +21,22 @@ export function BirthLocationFields({ value, onChange }: { value: Profile; onCha }} birthDate={value.date} birthTime={value.reportedTime || value.time} + onPendingChange={reportPending} onChange={(location) => onChange({ ...value, - countryCode: location?.countryCode || "CN", - provinceCode: location?.provinceCode || "", - cityCode: location?.cityCode || "", - districtCode: location?.districtCode || "", - birthPlaceLabel: location?.label || "", - birthPlaceType: location?.placeType || "", - birthPlaceProvider: location?.provider || "", - birthPlaceProviderId: location?.providerPlaceId || "", - timezoneId: location?.timezoneId || "", - timezoneSource: location ? "iana_historical" : "", - latitude: location?.latitude ?? null, - longitude: location?.longitude ?? null, - timezoneOffset: location?.timezoneOffset ?? null, + countryCode: location.countryCode, + provinceCode: location.provinceCode, + cityCode: location.cityCode, + districtCode: location.districtCode, + birthPlaceLabel: location.label, + birthPlaceType: location.placeType, + birthPlaceProvider: location.provider, + birthPlaceProviderId: location.providerPlaceId, + timezoneId: location.timezoneId, + timezoneSource: "iana_historical", + latitude: location.latitude, + longitude: location.longitude, + timezoneOffset: location.timezoneOffset, })} />
diff --git a/frontend/src/components/birth-place-pending.tsx b/frontend/src/components/birth-place-pending.tsx new file mode 100644 index 00000000..0fb7ccaf --- /dev/null +++ b/frontend/src/components/birth-place-pending.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { createContext, useContext, useMemo, useState, type ComponentProps, type ReactNode } from "react"; +import { Button } from "@/components/ui/button"; + +type BirthPlacePending = { readonly pending: boolean; readonly report: (pending: boolean) => void }; + +const BirthPlacePendingContext = createContext(null); + +/** + * Wraps a form that holds a birth place picker. While the picker still needs a fresh + * timezone (unfinished levels, a lookup in flight or a failed lookup), the profile keeps + * the previous place and offset, so the form's submit button stays disabled (BUG-1506). + */ +export function BirthPlacePendingScope({ children }: { readonly children: ReactNode }) { + const [pending, report] = useState(false); + const value = useMemo(() => ({ pending, report }), [pending]); + return {children}; +} + +/** The picker inside a scope reports here; outside a scope there is nothing to hold. */ +export function useBirthPlacePendingReporter() { + return useContext(BirthPlacePendingContext)?.report; +} + +/** The form's submit button, disabled while the scope's picker is pending. */ +export function BirthPlaceSubmitButton({ disabled, ...props }: Omit, "type">) { + const pending = useContext(BirthPlacePendingContext)?.pending ?? false; + return : null} - - - + {showRelation ? ( + onChange((current) => ({ ...current, relation }))} + /> + ) : null} + {showGender ? ( + onChange((current) => ({ ...current, gender }))} + /> + ) : null} + {showAyanamsa ? ( + onChange((current) => ({ ...current, ayanamsa }))} + /> + ) : null} + {notice ?

{notice}

: null} +
+ {onCancel ? : null} + {submitLabel} +
+ + ); } diff --git a/frontend/src/components/home-onboarding-shell.tsx b/frontend/src/components/home-onboarding-shell.tsx index b3767d59..c04b7b73 100644 --- a/frontend/src/components/home-onboarding-shell.tsx +++ b/frontend/src/components/home-onboarding-shell.tsx @@ -3,6 +3,7 @@ import type { FormEvent } from "react"; import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; import { BirthLocationFields } from "@/components/birth-location-fields"; +import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending"; import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { rememberSkyOrigin } from "@/components/birth-sky/sky-origin"; import { OnboardingChatMessage } from "@/components/onboarding-chat-message"; @@ -130,14 +131,16 @@ export function HomeOnboardingShell({ {!profileComplete && onboardingStep === "place" && onboardingCardReady && (
-
-
出生地点搜索全球城市或区县,系统会匹配出生当日的时区。
-
- -
-
- - + +
+
出生地点搜索全球城市或区县,系统会匹配出生当日的时区。
+
+ +
rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}
+
+ + +
)} diff --git a/frontend/tests/birth-place-picker.test.ts b/frontend/tests/birth-place-picker.test.ts index a9bec3b3..4d099a58 100644 --- a/frontend/tests/birth-place-picker.test.ts +++ b/frontend/tests/birth-place-picker.test.ts @@ -111,10 +111,18 @@ test("timezone is resolved once for the chosen place rather than during browsing assert.equal(existsSync(new URL("../src/app/api/locations/timezone/route.ts", import.meta.url)), true); assert.match(pickerSource, /fetch\("\/api\/locations\/timezone"/); // Browsing the levels cannot spend a lookup: only a resolved place gets a sync key. - assert.match(pickerSource, /if \(syncKey === "empty"\)/); + // 原值:`if (syncKey === "empty")`(单独一个分支,里面上报 null)。 + // 新值:`if (syncKey === "saved" || syncKey === "empty") return;`,没选完不发请求也不上报。 + // 原因:BUG-1506,见下一条测试。 + assert.match(pickerSource, /syncKey === "empty"\) return;/); assert.match(pickerSource, /: \{ status: "loading" \};/); // An unresolved zone must not be reported upward as a usable birth place. - assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*onChangeRef\.current\(null\);/); + // 原值:断言失败分支 `setOutcome(error)` 之后紧跟 `onChangeRef.current(null)`。 + // 新值:失败分支只记错误,不再上报 null;选择器的 onChange 也不再接受 null。 + // 原因:BUG-1506,上报 null 会把已存的出生地清空。现在靠 onPendingChange 拦住保存,行为测试在 birth-place-timezone-failure-20261102.test.tsx。 + assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*\}/); + assert.doesNotMatch(pickerSource, /onChangeRef\.current\(null\)/); + assert.match(pickerSource, /onChange: \(selection: BirthPlaceSelection\) => void;/); const service = readFileSync(new URL("../src/lib/birth-location-timezone-service.ts", import.meta.url), "utf8"); assert.match(service, /\/api\/location\/timezone/); @@ -125,19 +133,27 @@ test("mounting a saved birth place neither refetches nor clears the stored locat // Reporting null on mount would look like a location edit and would drop a // rectified birth-time candidate just because the profile dialog was opened. assert.match(pickerSource, /const savedIsCurrent = resolved !== null/); - assert.match(pickerSource, /if \(syncKey === "saved"\) return;/); - assert.match(pickerSource, /if \(saved\.countryCode !== "CN" \|\| saved\.provinceCode \|\| saved\.timezoneId\) onChangeRef\.current\(null\);/); + // 原值:断言 `if (syncKey === "saved") return;`,以及选空时 `if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);`。 + // 新值:已存和没选完都直接返回,不上报。 + // 原因:BUG-1506,没选完时上报 null 会清空已存地点,草稿也跟着按空值重置(改省份、改国家、打开海外人物都会中招)。 + assert.match(pickerSource, /if \(syncKey === "saved" \|\| syncKey === "empty"\) return;/); // A changed birth date invalidates the stored offset, so the zone is resolved again. - assert.match(pickerSource, /mountedMoment === birthMoment/); + // 原值:`mountedMoment === birthMoment`。 + // 新值:`valueMoment === birthMoment`,valueMoment 是资料里这份时区对应的出生时刻。 + // 原因:BUG-1506,改日期查到新时区后再改回原日期,旧写法会把新日期的时区当成原日期的存下去。 + assert.match(pickerSource, /valueMoment === birthMoment/); }); test("the profile stores administrative codes alongside coordinates and the IANA zone", () => { assert.match(pageSource, / { + const url = String(input); + if (url.includes("/data/world-locations/")) { + return options.regions === "fail" + ? new Response("", { status: 503 }) + : new Response(JSON.stringify(usRegions), { status: 200 }); + } + lookups.push(JSON.parse(String(init?.body ?? "{}"))); + if (reply === "fail") return new Response("{}", { status: 503 }); + return new Response(JSON.stringify(reply), { status: 200 }); + }) as typeof fetch; + + const state: { profile: Profile; set: (next: Profile) => void } = { profile: initial, set: () => {} }; + function Host() { + const [profile, setProfile] = useState(initial); + state.profile = profile; + state.set = setProfile; + return ( + event.preventDefault()} + submitLabel="保存" + submitDisabled={false} + /> + ); + } + + await harness.render(); + await harness.idle(); + + const byClass = (name: string) => harness.elements().find((node) => (node.getAttribute("class") ?? "").split(/\s+/).includes(name)); + return { + lookups, + state, + reply(next: Reply) { reply = next; }, + status: () => byClass("birth-place-status")?.text ?? "", + retry: () => byClass("birth-place-retry"), + saveDisabled: () => Boolean(byClass("save-profile")?.hasAttribute("disabled")), + async edit(patch: Partial) { + await harness.update(() => state.set({ ...state.profile, ...patch })); + await harness.idle(); + }, + async choose(levelLabel: string, code: string) { + const nodes = harness.elements(); + const trigger = nodes.findIndex((node) => node.getAttribute("aria-label") === levelLabel); + assert.notEqual(trigger, -1, `${levelLabel} is rendered`); + const input = nodes.slice(trigger).find((node) => node.tagName === "INPUT"); + assert.ok(input, `${levelLabel} has its hidden input`); + await harness.event(input, "onChange", { target: Object.assign(input, { value: code }) }); + await harness.idle(); + }, + async clickRetry() { + await harness.event(byClass("birth-place-retry")!); + await harness.idle(); + }, + async close() { + try { await harness.close(); } finally { globalThis.fetch = originalFetch; } + }, + }; +} + +function assertStillHandan(profile: Profile) { + assert.equal(profile.provinceCode, "130000"); + assert.equal(profile.cityCode, "130400"); + assert.equal(profile.districtCode, "130402"); + assert.equal(profile.birthPlaceLabel, "中国 · 河北省 · 邯郸市 · 邯山区"); + assert.equal(profile.latitude, 36.603196); + assert.equal(profile.timezoneId, "Asia/Shanghai"); + assert.ok(selectedBirthPlace(profile), "the saved place is still a usable birth place"); +} + +test("a failed lookup after a date edit keeps the saved place, shows the error with a retry and holds the save", async () => { + const form = await mountForm(savedHandan); + try { + // Opening the saved person neither looks the zone up nor holds the save. + assert.equal(form.lookups.length, 0); + assert.match(form.status(), /邯山区.*Asia\/Shanghai · UTC\+8/); + assert.equal(form.saveDisabled(), false); + + await form.edit({ date: "1990-05-02" }); + assert.equal(form.lookups.length, 1); + assert.deepEqual(form.lookups[0], { latitude: 36.603196, longitude: 114.484989, birthDate: "1990-05-02", birthTime: "10:00" }); + assert.ok(form.status().includes(EDIT_FAILURE), form.status()); + assert.doesNotMatch(form.status(), /按省、市、区县逐级选择即可/); + assert.ok(form.retry(), "the error carries a retry"); + assertStillHandan(form.state.profile); + // The stored offset belongs to the old date: it must not be saved for the new one. + assert.equal(form.state.profile.timezoneOffset, 8); + assert.equal(form.saveDisabled(), true); + + form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 }); + await form.clickRetry(); + assert.equal(form.lookups.length, 2); + assert.ok(!form.status().includes(EDIT_FAILURE)); + assert.match(form.status(), /Asia\/Shanghai · UTC\+9/); + assert.equal(form.retry(), undefined); + assert.equal(form.state.profile.timezoneOffset, 9); + assert.equal(form.state.profile.date, "1990-05-02"); + assert.equal(form.saveDisabled(), false); + } finally { + await form.close(); + } +}); + +test("reverting the edit after a failed lookup restores the saved offset without another lookup", async () => { + const form = await mountForm(savedHandan); + try { + await form.edit({ reportedTime: "11:00", time: "11:00" }); + assert.equal(form.lookups.length, 1); + assert.equal(form.saveDisabled(), true); + + await form.edit({ reportedTime: "10:00", time: "10:00" }); + assert.equal(form.lookups.length, 1); + assert.match(form.status(), /Asia\/Shanghai · UTC\+8/); + assert.equal(form.saveDisabled(), false); + } finally { + await form.close(); + } +}); + +test("an offset resolved for an edited date is looked up again when the date goes back", async () => { + const form = await mountForm(savedHandan); + try { + form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 }); + await form.edit({ date: "1990-05-02" }); + assert.equal(form.state.profile.timezoneOffset, 9); + + form.reply("fail"); + await form.edit({ date: "1990-05-01" }); + // 9 was resolved for 05-02; saving it silently for 05-01 would be the stale offset. + assert.equal(form.lookups.length, 2); + assert.ok(form.status().includes(EDIT_FAILURE)); + assert.equal(form.saveDisabled(), true); + } finally { + await form.close(); + } +}); + +test("changing the place keeps the saved place while choosing and after a failed lookup", async () => { + const form = await mountForm(savedHandan); + try { + await form.choose("出生省份", "440000"); + // The half-chosen province stays on screen instead of snapping back to an empty picker. + assert.match(form.status(), /按省、市、区县逐级选择即可/); + assert.ok(form.state.profile.provinceCode === "130000"); + assertStillHandan(form.state.profile); + assert.equal(form.saveDisabled(), true); + assert.equal(form.lookups.length, 0); + + await form.choose("出生城市", "441900"); + assert.equal(form.lookups.length, 1); + assert.ok(form.status().includes(EDIT_FAILURE)); + assertStillHandan(form.state.profile); + assert.equal(form.saveDisabled(), true); + + form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 }); + await form.clickRetry(); + assert.equal(form.state.profile.provinceCode, "440000"); + assert.equal(form.state.profile.cityCode, "441900"); + assert.match(form.state.profile.birthPlaceLabel, /东莞市/); + assert.match(form.status(), /东莞市/); + assert.equal(form.saveDisabled(), false); + } finally { + await form.close(); + } +}); + +test("a new person with no saved place gets the retry and no usable place on failure", async () => { + const form = await mountForm({ ...emptyProfile, name: "乙", date: "1992-03-04", reportedTime: "08:00", time: "08:00" }); + try { + assert.equal(form.lookups.length, 0); + assert.match(form.status(), /按省、市、区县逐级选择即可/); + assert.equal(form.saveDisabled(), false, "the picker alone holds nothing while nothing is chosen"); + + await form.choose("出生省份", "440000"); + await form.choose("出生城市", "441900"); + assert.equal(form.lookups.length, 1); + assert.ok(form.status().includes(NEW_FAILURE), form.status()); + assert.ok(form.retry()); + assert.equal(form.state.profile.provinceCode, ""); + assert.equal(selectedBirthPlace(form.state.profile), null); + assert.equal(form.saveDisabled(), true); + + form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 }); + await form.clickRetry(); + assert.equal(form.state.profile.cityCode, "441900"); + assert.equal(form.state.profile.timezoneOffset, 8); + assert.ok(selectedBirthPlace(form.state.profile)); + assert.equal(form.saveDisabled(), false); + } finally { + await form.close(); + } +}); + +test("opening a saved overseas person keeps the place while the region list loads or fails", async () => { + const savedAbbeville: Profile = { + ...savedHandan, + countryCode: "US", + provinceCode: "US-AL", + cityCode: "110968", + districtCode: "", + birthPlaceLabel: "美国 · Alabama · Abbeville", + birthPlaceType: "city", + birthPlaceProvider: "world_location_table", + birthPlaceProviderId: "world:US:US-AL:110968", + timezoneId: "America/Chicago", + latitude: 31.57184, + longitude: -85.25049, + timezoneOffset: -5, + }; + for (const regions of ["ok", "fail"] as const) { + const form = await mountForm(savedAbbeville, { regions }); + try { + // Before the fix the picker reported null while the list loaded: the place was cleared and the picker fell back to China. + assert.equal(form.lookups.length, 0, regions); + assert.equal(form.state.profile.countryCode, "US", regions); + assert.equal(form.state.profile.cityCode, "110968", regions); + assert.equal(form.state.profile.timezoneId, "America/Chicago", regions); + assert.equal(form.saveDisabled(), false, regions); + if (regions === "ok") assert.match(form.status(), /Abbeville.*America\/Chicago · UTC[−-]5/); + } finally { + await form.close(); + } + } +}); + +test("every form that holds the picker holds its submit through the pending scope", () => { + const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); + const fields = read("../src/components/birth-location-fields.tsx"); + assert.match(fields, /onPendingChange=\{reportPending\}/); + // The edit dialogs (people page, chart page) share ChartProfileForm; the onboarding place card has its own form. + for (const path of ["../src/components/chart-profile-form.tsx", "../src/components/home-onboarding-shell.tsx"]) { + const source = read(path); + const scope = source.slice(source.indexOf(""), source.indexOf("")); + assert.ok(scope.includes("