feat(onboarding): pick the birth place by level instead of by search
The single search field asked people to type a place name and then judge which of several near-identical results was theirs, which is the one thing they cannot verify about their own birth record. Province, city and district are now chosen from the dataset the app already ships, so there is nothing to type and nothing to disambiguate. Levels that offer no choice collapse: municipalities show one level, and prefecture cities without districts stop at the city. A district can be left as the city centre, which is accurate enough because the chart only needs coordinates and a timezone. The timezone is resolved once for the chosen place rather than on every keystroke, through a dedicated route that both this picker and the Geoapify path share. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
birthPlaceDraftFromLevels,
|
||||
formatBirthPlaceCoordinate,
|
||||
formatBirthPlaceTimezone,
|
||||
hasVirtualCityLevel,
|
||||
findProvinceNode,
|
||||
resolveBirthPlaceNode,
|
||||
} from "../src/lib/china-birth-place.ts";
|
||||
|
||||
const pickerSource = readFileSync(new URL("../src/components/birth-place-picker.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("resolves a province, city and district to the district centre", () => {
|
||||
const resolved = resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "130402" });
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.label, "河北省 · 邯郸市 · 邯山区");
|
||||
assert.equal(resolved.placeType, "district");
|
||||
assert.equal(resolved.node.code, "130402");
|
||||
assert.equal(resolved.latitude, 36.603196);
|
||||
assert.equal(resolved.longitude, 114.484989);
|
||||
});
|
||||
|
||||
test("falls back to the city centre when the district is left unnamed", () => {
|
||||
const resolved = resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "" });
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.label, "河北省 · 邯郸市");
|
||||
assert.equal(resolved.placeType, "city");
|
||||
assert.equal(resolved.node.code, "130400");
|
||||
assert.equal(resolved.latitude, 36.612273);
|
||||
});
|
||||
|
||||
test("collapses the repeated city level of municipalities and special regions", () => {
|
||||
assert.equal(hasVirtualCityLevel(findProvinceNode("110000")!), true);
|
||||
assert.equal(hasVirtualCityLevel(findProvinceNode("130000")!), false);
|
||||
|
||||
// The city code may be absent because the picker never shows that level.
|
||||
const resolved = resolveBirthPlaceNode({ provinceCode: "110000", cityCode: "", districtCode: "110105" });
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.label, "北京市 · 朝阳区");
|
||||
assert.equal(resolved.placeType, "district");
|
||||
});
|
||||
|
||||
test("completes at the city for prefecture cities that have no districts", () => {
|
||||
const resolved = resolveBirthPlaceNode({ provinceCode: "440000", cityCode: "441900", districtCode: "" });
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.label, "广东省 · 东莞市");
|
||||
assert.equal(resolved.placeType, "city");
|
||||
assert.equal(resolved.node.code, "441900");
|
||||
});
|
||||
|
||||
test("completes at the province when neither lower level offers a choice", () => {
|
||||
const resolved = resolveBirthPlaceNode({ provinceCode: "710000", cityCode: "", districtCode: "" });
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.label, "台湾省");
|
||||
assert.equal(resolved.placeType, "province");
|
||||
});
|
||||
|
||||
test("rejects codes that do not belong together", () => {
|
||||
assert.equal(resolveBirthPlaceNode({ provinceCode: "999999", cityCode: "", districtCode: "" }), null);
|
||||
assert.equal(resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "440100", districtCode: "" }), null);
|
||||
assert.equal(resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "110105" }), null);
|
||||
});
|
||||
|
||||
test("re-syncing a saved profile keeps the same draft so the picker cannot clear itself", () => {
|
||||
const saved = { provinceCode: "130000", cityCode: "130400", districtCode: "" };
|
||||
const draft = birthPlaceDraftFromLevels(saved);
|
||||
assert.deepEqual(draft, { provinceCode: "130000", cityCode: "130400", districtCode: "", cityCentre: true });
|
||||
assert.deepEqual(birthPlaceDraftFromLevels(draft), draft);
|
||||
|
||||
// A city without districts is complete without being marked as an unsure centre.
|
||||
assert.deepEqual(birthPlaceDraftFromLevels({ provinceCode: "440000", cityCode: "441900", districtCode: "" }), {
|
||||
provinceCode: "440000",
|
||||
cityCode: "441900",
|
||||
districtCode: "",
|
||||
cityCentre: false,
|
||||
});
|
||||
|
||||
// A municipality resumes with its virtual city filled in.
|
||||
assert.deepEqual(birthPlaceDraftFromLevels({ provinceCode: "110000", cityCode: "", districtCode: "110105" }), {
|
||||
provinceCode: "110000",
|
||||
cityCode: "110000-city",
|
||||
districtCode: "110105",
|
||||
cityCentre: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("states coordinates and the resolved offset in plain Chinese", () => {
|
||||
assert.equal(formatBirthPlaceCoordinate(36.612273, 114.490686), "东经 114.49° · 北纬 36.61°");
|
||||
assert.equal(formatBirthPlaceTimezone("Asia/Shanghai", 8), "Asia/Shanghai · UTC+8");
|
||||
assert.equal(formatBirthPlaceTimezone("Asia/Kathmandu", 5.75), "Asia/Kathmandu · UTC+5:45");
|
||||
assert.equal(formatBirthPlaceTimezone("Asia/Shanghai", null), "Asia/Shanghai");
|
||||
});
|
||||
|
||||
test("the picker offers three cascading levels instead of a free-text search", () => {
|
||||
assert.equal(existsSync(new URL("../src/components/location-search-combobox.tsx", import.meta.url)), false);
|
||||
assert.doesNotMatch(pickerSource, /role="combobox"/);
|
||||
assert.match(pickerSource, /aria-label="出生省份"/);
|
||||
assert.match(pickerSource, /aria-label="出生城市"/);
|
||||
assert.match(pickerSource, /aria-label="出生区县"/);
|
||||
// The level is hidden rather than shown empty when it carries no choice.
|
||||
assert.match(pickerSource, /\{province && !virtualCity && \(/);
|
||||
assert.match(pickerSource, /\{city && districts\.length > 0 && \(/);
|
||||
assert.match(pickerSource, /不确定,用市区中心/);
|
||||
assert.match(pickerSource, /精确到区 \/ 县就足够/);
|
||||
});
|
||||
|
||||
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"\)/);
|
||||
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\);/);
|
||||
|
||||
const service = readFileSync(new URL("../src/lib/birth-location-timezone-service.ts", import.meta.url), "utf8");
|
||||
assert.match(service, /\/api\/location\/timezone/);
|
||||
assert.match(service, /timezoneSource: "iana_historical"/);
|
||||
});
|
||||
|
||||
test("mounting a saved birth place neither refetches nor clears the stored location", () => {
|
||||
// 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\.provinceCode \|\| saved\.timezoneId\) onChangeRef\.current\(null\);/);
|
||||
// A changed birth date invalidates the stored offset, so the zone is resolved again.
|
||||
assert.match(pickerSource, /mountedMoment === 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 \? "china_locations" : ""/);
|
||||
assert.match(pageSource, /timezoneSource: location \? "iana_historical" : ""/);
|
||||
assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/);
|
||||
assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/);
|
||||
});
|
||||
|
||||
test("profile accepts a selected IANA location before a numeric offset is resolved", () => {
|
||||
assert.match(pageSource, /profile\.timezoneId\.trim\(\)/);
|
||||
assert.match(pageSource, /profile\.timezoneOffset === null \|\| Number\.isFinite\(profile\.timezoneOffset\)/);
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
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 {
|
||||
LocationSearchCombobox,
|
||||
parseLocationSearchResults,
|
||||
type ResolvedBirthLocation,
|
||||
} from "../src/components/location-search-combobox.tsx";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const taipei: ResolvedBirthLocation = {
|
||||
id: "nominatim:1293250",
|
||||
label: "台北市, 台湾",
|
||||
placeType: "city",
|
||||
provider: "nominatim",
|
||||
providerPlaceId: "1293250",
|
||||
countryCode: "TW",
|
||||
countryName: "台湾",
|
||||
admin1: "台北市",
|
||||
admin2: "",
|
||||
locality: "台北市",
|
||||
latitude: 25.0375,
|
||||
longitude: 121.5637,
|
||||
timezoneId: "Asia/Taipei",
|
||||
timezoneOffset: 8,
|
||||
timezoneSource: "historical_tzdb",
|
||||
};
|
||||
|
||||
test("location search parser keeps only complete coordinate and timezone results", () => {
|
||||
assert.deepEqual(parseLocationSearchResults({
|
||||
locations: [
|
||||
{ ...taipei, id: undefined, regionName: taipei.admin1, localityName: taipei.locality },
|
||||
{ ...taipei, id: "", providerPlaceId: "", label: "invalid" },
|
||||
{ ...taipei, id: "mapbox:unknown-time", timezoneOffset: null },
|
||||
],
|
||||
}), [taipei, { ...taipei, id: "mapbox:unknown-time", timezoneOffset: null }]);
|
||||
assert.deepEqual(parseLocationSearchResults({ results: "not-an-array" }), []);
|
||||
});
|
||||
|
||||
test("location combobox exposes accessible search semantics and selected state", () => {
|
||||
const emptyMarkup = renderToStaticMarkup(React.createElement(LocationSearchCombobox, {
|
||||
value: null,
|
||||
birthDate: "1997-08-08",
|
||||
birthTime: "06:30",
|
||||
onChange: () => undefined,
|
||||
}));
|
||||
assert.match(emptyMarkup, /role="combobox"/);
|
||||
assert.match(emptyMarkup, /aria-autocomplete="list"/);
|
||||
assert.match(emptyMarkup, /aria-expanded="false"/);
|
||||
assert.match(emptyMarkup, /aria-controls="[^"]+-listbox"/);
|
||||
assert.match(emptyMarkup, /输入至少两个字开始搜索/);
|
||||
|
||||
const selectedMarkup = renderToStaticMarkup(React.createElement(LocationSearchCombobox, {
|
||||
value: taipei,
|
||||
onChange: () => undefined,
|
||||
}));
|
||||
assert.match(selectedMarkup, /value="台北市, 台湾"/);
|
||||
assert.match(selectedMarkup, /已选择 台北市, 台湾/);
|
||||
assert.match(selectedMarkup, /aria-label="清除出生地点"/);
|
||||
});
|
||||
|
||||
test("profile integrates global location persistence while retaining legacy China fallback", () => {
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(pageSource, /birthPlaceProviderId:\s*string/);
|
||||
assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/);
|
||||
assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/);
|
||||
assert.match(pageSource, /function selectedLocationValue/);
|
||||
assert.match(pageSource, /legacy-cn:/);
|
||||
assert.match(pageSource, /provinceCode:\s*""[\s\S]*cityCode:\s*""[\s\S]*districtCode:\s*""/);
|
||||
assert.doesNotMatch(pageSource, /目前先支持中国大陆地区/);
|
||||
});
|
||||
|
||||
test("selecting a location cancels stale searches and keeps the result list outside the onboarding card", () => {
|
||||
const componentSource = readFileSync(new URL("../src/components/location-search-combobox.tsx", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
assert.match(componentSource, /function choose\(location: ResolvedBirthLocation\) \{\s*requestSequence\.current \+= 1;/);
|
||||
assert.match(globalStyles, /\.birth-time-transition-card \{ position: relative; overflow: visible; \}/);
|
||||
});
|
||||
|
||||
test("profile accepts a selected IANA location before a numeric offset is resolved", () => {
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(pageSource, /profile\.timezoneId\.trim\(\)/);
|
||||
assert.match(pageSource, /profile\.timezoneOffset === null \|\| Number\.isFinite\(profile\.timezoneOffset\)/);
|
||||
assert.match(pageSource, /timezoneId: birthPlace\.timezoneId/);
|
||||
});
|
||||
@@ -13,8 +13,12 @@ test("mobile login can scroll and keeps the form reachable on a short screen", (
|
||||
assert.match(layout, /interactiveWidget:\s*"resizes-content"/);
|
||||
});
|
||||
|
||||
test("mobile onboarding does not clip location results or session history", () => {
|
||||
test("mobile onboarding does not clip the birth place levels or session history", () => {
|
||||
assert.match(css, /\.session-nav \{ overflow: visible; \}/);
|
||||
assert.doesNotMatch(css, /\.session-nav \{ overflow: hidden; \}/);
|
||||
assert.match(css, /\.location-combobox-results \{ position: static; top: auto;/);
|
||||
// The birth place levels portal their popup out of the onboarding card, so an
|
||||
// ancestor can no longer clip it and only the viewport height still constrains it.
|
||||
const select = readFileSync(new URL("../src/components/ui/select.tsx", import.meta.url), "utf8");
|
||||
assert.match(select, /<SelectPrimitive\.Portal>/);
|
||||
assert.match(css, /\.select-list \{ max-height: min\(288px, 48dvh\) !important; \}/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user