fix(profile): keep the saved birth place when the timezone lookup fails (BUG-1506)
The birth place picker reported null on a failed timezone lookup and on a half-chosen level; BirthLocationFields then cleared the place and the picker reset its draft. Editing a saved person's date, time or place during a timezone outage silently erased the birthplace with no error or retry. The same path reset the picker when changing province/city/country on edit and when opening an overseas person while the region list loaded. - The picker only reports a freshly resolved place; failures and unfinished choices keep the previous value and report "pending" instead. - BirthPlacePendingScope + BirthPlaceSubmitButton disable 保存 (edit dialogs) and 确定 (onboarding) while pending. - Failure copy for a person with a place: 「时区这会儿没查到,原来的出生地还在, 查到后才能保存。」 with 重试; new profiles keep the existing copy. - Track the moment the stored offset was resolved for, so going back to the original date after a successful lookup re-resolves instead of saving a stale offset; ignore late results from aborted lookups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-02 — 改出生资料时,时区没查到不再清掉出生地
|
||||
|
||||
- 修改已存人物的出生日期、时间或地点后,如果时区这会儿查不到,原来的出生地会留着,地点下面写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,旁边有「重试」。查到之前「保存」不能点,所以不会把旧日期的时区存进新日期;改回原来的日期和地点也可以直接保存。
|
||||
- 新建人物时查不到时区,仍是「时区服务暂时不可用,稍后重试即可。」加「重试」,「确定」不能点。
|
||||
- 顺带修了同一个原因的两处:编辑时改省份、城市或国家,选到一半不会再跳回空白要重选;打开海外人物的编辑,不会再被清空并跳回中国。
|
||||
- 改日期查到新时区后又改回原日期,会重新查一次,不再沿用刚才那个日期的时区。
|
||||
- 记为 BUG-1506。Skill 版本不变。
|
||||
|
||||
## 2026-11-01 — 年运报告:从「我的报告」直接打开,写好一节出一节
|
||||
|
||||
- 「我的报告」多了一张「年运」卡片,点进去就是年运报告页,没有单独的入口页。个人报告卡片位置不变。
|
||||
|
||||
+15
-10
@@ -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 夏令时出生差一小时)
|
||||
|
||||
|
||||
@@ -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 次的阈值。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<fieldset className="location-fieldset">
|
||||
<legend>出生地点</legend>
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@@ -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<BirthPlacePending | null>(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 <BirthPlacePendingContext.Provider value={value}>{children}</BirthPlacePendingContext.Provider>;
|
||||
}
|
||||
|
||||
/** 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<ComponentProps<typeof Button>, "type">) {
|
||||
const pending = useContext(BirthPlacePendingContext)?.pending ?? false;
|
||||
return <Button {...props} type="submit" disabled={Boolean(disabled) || pending} />;
|
||||
}
|
||||
@@ -50,7 +50,14 @@ type BirthPlacePickerProps = {
|
||||
birthDate?: string;
|
||||
birthTime?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (selection: BirthPlaceSelection | null) => void;
|
||||
/** Reports a freshly resolved place only. A failed or unfinished choice never clears the value. */
|
||||
onChange: (selection: BirthPlaceSelection) => void;
|
||||
/**
|
||||
* True while the shown choice or birth moment needs a timezone the value does not carry yet
|
||||
* (unfinished levels, a lookup in flight, or a failed lookup). The form must not save then:
|
||||
* the value still holds the previous place and offset (BUG-1506).
|
||||
*/
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
};
|
||||
|
||||
type TimezoneState =
|
||||
@@ -97,6 +104,10 @@ function draftFromValue(value: SavedBirthPlace): BirthPlaceDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function draftKey(draft: BirthPlaceDraft) {
|
||||
return `${draft.countryCode}/${draft.provinceCode}/${draft.cityCode}/${draft.districtCode}/${draft.cityCentre ? "centre" : ""}`;
|
||||
}
|
||||
|
||||
function countryLabel(countryCode: string) {
|
||||
return worldCountries.find((item) => item.code === countryCode)?.name ?? countryCode;
|
||||
}
|
||||
@@ -107,14 +118,15 @@ export function BirthPlacePicker({
|
||||
birthTime = "",
|
||||
disabled = false,
|
||||
onChange,
|
||||
onPendingChange,
|
||||
}: BirthPlacePickerProps) {
|
||||
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
|
||||
const fieldPrefix = useId();
|
||||
const onChangeRef = useRef(onChange);
|
||||
const savedRef = useRef(value);
|
||||
const onPendingChangeRef = useRef(onPendingChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
savedRef.current = value;
|
||||
onPendingChangeRef.current = onPendingChange;
|
||||
});
|
||||
|
||||
const externalKey = `${countryCode}/${value.provinceCode}/${value.cityCode}/${value.districtCode}`;
|
||||
@@ -124,11 +136,15 @@ export function BirthPlacePicker({
|
||||
const [globalLoadErrorCountry, setGlobalLoadErrorCountry] = useState("");
|
||||
const [outcome, setOutcome] = useState<TimezoneOutcome | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
// The birth moment the value's offset was resolved for. A historical offset depends on it.
|
||||
const birthMoment = `${birthDate}/${birthTime}`;
|
||||
const [valueMoment, setValueMoment] = useState(birthMoment);
|
||||
|
||||
// Adopt a profile that was loaded or replaced outside this picker.
|
||||
// Adopt a profile that was loaded or replaced outside this picker, or the place this picker just resolved.
|
||||
if (syncedKey !== externalKey) {
|
||||
setSyncedKey(externalKey);
|
||||
setDraft(draftFromValue(value));
|
||||
setValueMoment(birthMoment);
|
||||
}
|
||||
|
||||
const isChina = draft.countryCode === "CN";
|
||||
@@ -208,12 +224,15 @@ export function BirthPlacePicker({
|
||||
}
|
||||
: globalResolved;
|
||||
|
||||
// The offset the profile already carries is only authoritative while the birth moment is unchanged.
|
||||
const birthMoment = `${birthDate}/${birthTime}`;
|
||||
const [mountedMoment] = useState(birthMoment);
|
||||
// The offset the profile already carries is only authoritative for the birth moment it was resolved for.
|
||||
const valueHasZone = value.timezoneId.trim() !== "";
|
||||
const valueHasPlace = valueHasZone || countryCode !== "CN" || Boolean(value.provinceCode);
|
||||
const valueIsCurrent = valueHasZone
|
||||
&& valueMoment === birthMoment
|
||||
&& draftKey(draft) === draftKey(draftFromValue(value));
|
||||
const savedIsCurrent = resolved !== null
|
||||
&& mountedMoment === birthMoment
|
||||
&& value.timezoneId.trim() !== ""
|
||||
&& valueMoment === birthMoment
|
||||
&& valueHasZone
|
||||
&& countryCode === resolved.countryCode
|
||||
&& value.provinceCode === resolved.provinceCode
|
||||
&& value.cityCode === resolved.cityCode
|
||||
@@ -226,14 +245,11 @@ export function BirthPlacePicker({
|
||||
: "empty";
|
||||
|
||||
useEffect(() => {
|
||||
if (syncKey === "saved") return;
|
||||
if (syncKey === "empty") {
|
||||
const saved = savedRef.current;
|
||||
if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
|
||||
return;
|
||||
}
|
||||
// An unfinished choice keeps the previous value; the pending flag holds the save instead.
|
||||
if (syncKey === "saved" || syncKey === "empty") return;
|
||||
|
||||
const target = resolved as NonNullable<typeof resolved>;
|
||||
const lookupMoment = birthMoment;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
@@ -254,7 +270,9 @@ export function BirthPlacePicker({
|
||||
const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : "";
|
||||
if (!timezoneId) throw new Error("timezone_unavailable");
|
||||
const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null;
|
||||
if (controller.signal.aborted) return;
|
||||
setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } });
|
||||
setValueMoment(lookupMoment);
|
||||
onChangeRef.current({
|
||||
countryCode: target.countryCode,
|
||||
provinceCode: target.provinceCode,
|
||||
@@ -271,8 +289,8 @@ export function BirthPlacePicker({
|
||||
});
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
// Keep the value: a failed lookup must not erase the saved place (BUG-1506).
|
||||
setOutcome({ key: syncKey, state: { status: "error" } });
|
||||
onChangeRef.current(null);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
@@ -320,6 +338,14 @@ export function BirthPlacePicker({
|
||||
: outcome?.key === syncKey
|
||||
? outcome.state
|
||||
: { status: "loading" };
|
||||
const pending = syncKey === "saved"
|
||||
? false
|
||||
: syncKey === "empty"
|
||||
? valueHasPlace && !valueIsCurrent
|
||||
: settled.status !== "ready";
|
||||
useEffect(() => { onPendingChangeRef.current?.(pending); }, [pending]);
|
||||
useEffect(() => () => onPendingChangeRef.current?.(false), []);
|
||||
|
||||
const globalIsLoading = !isChina
|
||||
&& globalData?.code !== draft.countryCode
|
||||
&& globalLoadErrorCountry !== draft.countryCode;
|
||||
@@ -329,7 +355,9 @@ export function BirthPlacePicker({
|
||||
: settled.status === "ready" && resolved
|
||||
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
|
||||
: settled.status === "error"
|
||||
? "时区服务暂时不可用,稍后重试即可。"
|
||||
? valueHasPlace
|
||||
? "时区这会儿没查到,原来的出生地还在,查到后才能保存。"
|
||||
: "时区服务暂时不可用,稍后重试即可。"
|
||||
: globalIsLoading
|
||||
? "正在加载该国家的地区列表…"
|
||||
: globalHasError
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
|
||||
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
|
||||
import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProfileFields } from "@/components/profile-fields";
|
||||
import { PersonRelationField } from "@/components/person-relation-field";
|
||||
@@ -48,39 +49,41 @@ export function ChartProfileForm({
|
||||
notice,
|
||||
}: ChartProfileFormProps) {
|
||||
return (
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
nameInputId={nameInputId}
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
/>
|
||||
{showRelation ? (
|
||||
<PersonRelationField
|
||||
idPrefix={nameInputId}
|
||||
value={value.relation}
|
||||
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
|
||||
<BirthPlacePendingScope>
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
nameInputId={nameInputId}
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
/>
|
||||
) : null}
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
value={value.gender}
|
||||
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
|
||||
/>
|
||||
) : null}
|
||||
{showAyanamsa ? (
|
||||
<AyanamsaPreferenceField
|
||||
value={resolveAyanamsa(value)}
|
||||
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
|
||||
<Button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
{showRelation ? (
|
||||
<PersonRelationField
|
||||
idPrefix={nameInputId}
|
||||
value={value.relation}
|
||||
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
|
||||
/>
|
||||
) : null}
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
value={value.gender}
|
||||
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
|
||||
/>
|
||||
) : null}
|
||||
{showAyanamsa ? (
|
||||
<AyanamsaPreferenceField
|
||||
value={resolveAyanamsa(value)}
|
||||
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
|
||||
<BirthPlaceSubmitButton className="button-primary save-profile" disabled={submitDisabled}>{submitLabel}</BirthPlaceSubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</BirthPlacePendingScope>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="onboarding-card-reveal" data-onboarding-enter="place-card" data-onboarding-index={4}>
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<div className="onboarding-card-actions"><Button className="button-primary" type="submit" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</Button></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
<BirthPlacePendingScope>
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<div className="onboarding-card-actions"><BirthPlaceSubmitButton className="button-primary" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</BirthPlaceSubmitButton></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</BirthPlacePendingScope>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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, /<BirthPlacePicker/);
|
||||
assert.match(pageSource, /provinceCode: location\?\.provinceCode \|\| ""/);
|
||||
assert.match(pageSource, /cityCode: location\?\.cityCode \|\| ""/);
|
||||
assert.match(pageSource, /districtCode: location\?\.districtCode \|\| ""/);
|
||||
assert.match(pageSource, /birthPlaceProvider: location\?\.provider \|\| ""/);
|
||||
assert.match(pageSource, /timezoneSource: location \? "iana_historical" : ""/);
|
||||
// 原值:`provinceCode: location?.provinceCode || ""` 等可空写法,以及 `timezoneSource: location ? "iana_historical" : ""`。
|
||||
// 新值:`provinceCode: location.provinceCode` 等,`timezoneSource: "iana_historical"`。
|
||||
// 原因:BUG-1506,选择器不再上报 null,父组件不再有把地点清空的分支。
|
||||
assert.match(pageSource, /provinceCode: location\.provinceCode,/);
|
||||
assert.match(pageSource, /cityCode: location\.cityCode,/);
|
||||
assert.match(pageSource, /districtCode: location\.districtCode,/);
|
||||
assert.match(pageSource, /birthPlaceProvider: location\.provider,/);
|
||||
assert.match(pageSource, /timezoneSource: "iana_historical",/);
|
||||
assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/);
|
||||
assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
import { ChartProfileForm } from "../src/components/chart-profile-form";
|
||||
import { emptyProfile, type Profile } from "../src/lib/home-types";
|
||||
import { selectedBirthPlace } from "../src/lib/home-profile";
|
||||
import usRegions from "../public/data/world-locations/us.json";
|
||||
|
||||
// BUG-1506 (2026-11-02): editing the birth date, time or place of a saved person
|
||||
// while /api/locations/timezone fails used to erase the saved place silently.
|
||||
// These tests mount the real edit form with a fetch stub.
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const EDIT_FAILURE = "时区这会儿没查到,原来的出生地还在,查到后才能保存。";
|
||||
const NEW_FAILURE = "时区服务暂时不可用,稍后重试即可。";
|
||||
|
||||
const savedHandan: Profile = {
|
||||
...emptyProfile,
|
||||
name: "甲",
|
||||
date: "1990-05-01",
|
||||
time: "10:00",
|
||||
reportedTime: "10:00",
|
||||
birthTimeSource: "hospital_record",
|
||||
countryCode: "CN",
|
||||
provinceCode: "130000",
|
||||
cityCode: "130400",
|
||||
districtCode: "130402",
|
||||
birthPlaceLabel: "中国 · 河北省 · 邯郸市 · 邯山区",
|
||||
birthPlaceType: "district",
|
||||
birthPlaceProvider: "china_locations",
|
||||
birthPlaceProviderId: "130402",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneSource: "iana_historical",
|
||||
latitude: 36.603196,
|
||||
longitude: 114.484989,
|
||||
timezoneOffset: 8,
|
||||
};
|
||||
|
||||
type Reply = "fail" | { timezoneId: string; timezoneOffset: number };
|
||||
|
||||
async function mountForm(initial: Profile, options: { regions?: "ok" | "fail" } = {}) {
|
||||
const harness = createClientLifecycleHarness();
|
||||
const lookups: unknown[] = [];
|
||||
let reply: Reply = "fail";
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
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 (
|
||||
<ChartProfileForm
|
||||
title="编辑人物"
|
||||
description="称呼和出生资料。"
|
||||
value={profile}
|
||||
onChange={setProfile}
|
||||
nameInputId="bug-1506"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
submitLabel="保存"
|
||||
submitDisabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
await harness.render(<Host />);
|
||||
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<Profile>) {
|
||||
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("<BirthPlacePendingScope>"), source.indexOf("</BirthPlacePendingScope>"));
|
||||
assert.ok(scope.includes("<BirthPlaceSubmitButton"), path);
|
||||
assert.ok(scope.includes(path.includes("chart-profile-form") ? "<ProfileFields" : "<BirthLocationFields"), path);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user