Merge pull request #660 from mauriceboe/fix/atlas-country-matching-france-norway-review

fix(atlas): add A3 fallback when ISO_A2 is invalid + cleanup
This commit is contained in:
Julien G.
2026-04-15 03:37:55 +02:00
committed by GitHub
2 changed files with 70 additions and 10 deletions
+47
View File
@@ -127,6 +127,23 @@ const geoJsonWithFR = {
],
};
const makeGeoJsonWithA3Fallback = (a3: string, name: string) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
ISO_A2: '-99',
ADM0_A3: a3,
ISO_A3: a3,
NAME: name,
ADMIN: name,
},
geometry: null,
},
],
});
// ── Atlas API response fixture ────────────────────────────────────────────────
const atlasStatsResponse = {
countries: [{ code: 'FR', tripCount: 2, placeCount: 5, firstVisit: '2023-01-01', lastVisit: '2024-06-01' }],
@@ -1323,6 +1340,36 @@ describe('AtlasPage', () => {
});
});
describe('FE-PAGE-ATLAS-041: country search falls back from A3 when ISO_A2 is invalid', () => {
it.each([
{ a3: 'FRA', name: 'France', query: 'france' },
{ a3: 'NOR', name: 'Norway', query: 'norway' },
])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(makeGeoJsonWithA3Fallback(a3, name)) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
const user = userEvent.setup();
render(<AtlasPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(/search a country/i)).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(/search a country/i);
await user.type(searchInput, query);
await waitFor(() => {
const countryButton = screen.getAllByRole('button').find((button) => button.textContent?.includes(name));
expect(countryButton).toBeTruthy();
});
});
});
describe('FE-PAGE-ATLAS-042: bucket form submit with actual name value', () => {
it('submitting bucket form with a non-empty name covers handleAddBucketItem', async () => {
server.use(
+23 -10
View File
@@ -109,10 +109,14 @@ function useCountryNames(language: string): (code: string) => string {
return resolver
}
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
// Built dynamically from GeoJSON + hardcoded fallbacks
const A2_TO_A3_BASE: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
let A2_TO_A3: Record<string, string> = { ...A2_TO_A3_BASE }
// ISO-3166-1 alpha-2 → alpha-3 mapping. Two sources feed this table:
// 1. Hardcoded entries below — REQUIRED for any country whose Natural Earth GeoJSON record
// has ISO_A2='-99' (e.g. France=FRA, Norway=NOR). The runtime augmentation loop
// (see geoData useEffect below) skips '-99' features, so those countries MUST be
// listed here or the atlas_country_options A3-fallback will silently fail.
// 2. Runtime augmentation — the geoData load effect adds entries for every feature
// that has a valid ISO_A2, covering territories not present below.
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
@@ -186,15 +190,24 @@ export default function AtlasPage(): React.ReactElement {
const atlas_country_options = useMemo(() => {
if (!geoData) return []
// Precompute A3 → A2 reverse lookup once per geoData change instead of
// scanning A2_TO_A3 for every feature that needs the fallback.
const a3ToA2 = new Map<string, string>()
for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key)
const opts: { code: string; label: string }[] = []
const seen = new Set<string>()
for (const f of (geoData as any).features || []) {
const a2 = f?.properties?.ISO_A2
if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue
if (seen.has(a2)) continue
seen.add(a2)
const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2)
opts.push({ code: a2, label })
const rawA2 = f?.properties?.ISO_A2
let resolvedA2: string | null = (typeof rawA2 === 'string' && rawA2.length === 2 && rawA2 !== '-99') ? rawA2 : null
if (!resolvedA2) {
const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null
if (a3 && a3 !== '-99') resolvedA2 = a3ToA2.get(a3) ?? null
}
if (!resolvedA2 || seen.has(resolvedA2)) continue
seen.add(resolvedA2)
const label = String(resolveName(resolvedA2) || f?.properties?.NAME || f?.properties?.ADMIN || resolvedA2)
opts.push({ code: resolvedA2, label })
}
opts.sort((a, b) => a.label.localeCompare(b.label))
return opts