From 85e017ff85b6145c3ed34b4d7d3ac5d7152bc4a0 Mon Sep 17 00:00:00 2001 From: gfrcsd <23112101+gfrcsd@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:47:10 +0100 Subject: [PATCH 1/2] fix(atlas): add A3 fallback when ISO_A2 is invalid --- client/src/pages/AtlasPage.test.tsx | 44 +++++++++++++++++++++++++++++ client/src/pages/AtlasPage.tsx | 11 ++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx index b18d2563..7bcf4be4 100644 --- a/client/src/pages/AtlasPage.test.tsx +++ b/client/src/pages/AtlasPage.test.tsx @@ -127,6 +127,23 @@ const geoJsonWithFR = { ], }; +const geoJsonWithFranceA3Fallback = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + ISO_A2: '-99', + ADM0_A3: 'FRA', + ISO_A3: 'FRA', + NAME: 'France', + ADMIN: 'France', + }, + 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,33 @@ describe('AtlasPage', () => { }); }); + describe('FE-PAGE-ATLAS-041: country search falls back from A3 when ISO_A2 is invalid', () => { + it('returns France in search results when GeoJSON provides ADM0_A3 but ISO_A2 is -99', async () => { + 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(geoJsonWithFranceA3Fallback) } as Response); + } + return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`)); + }); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/search a country/i)).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText(/search a country/i); + await user.type(searchInput, 'france'); + + await waitFor(() => { + const franceButton = screen.getAllByRole('button').find((button) => button.textContent?.includes('France')); + expect(franceButton).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( diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 76f40690..41fd6443 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -189,8 +189,15 @@ export default function AtlasPage(): React.ReactElement { const opts: { code: string; label: string }[] = [] const seen = new Set() 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 + let a2 = f?.properties?.ISO_A2 + if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) { + const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null + if (a3 && a3 !== '-99') { + const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3) + a2 = a3ToA2Entry ? a3ToA2Entry[0] : null + } + } + if (!a2 || 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) From 33bb2c686308a649c792f6572f7bedec758bff07 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 15 Apr 2026 03:31:19 +0200 Subject: [PATCH 2/2] fix(atlas): clean up A2_TO_A3 table and add A3-fallback Norway test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse A2_TO_A3_BASE + let A2_TO_A3 into a single const declaration; the _BASE copy was vestigial (never read after the clone) - Add a comment explaining the table's two sources and the load-bearing invariant: countries whose Natural Earth record has ISO_A2='-99' (France, Norway) must be listed here since the runtime augmentation loop skips those features - Refactor the France-only A3-fallback test fixture into a factory helper and extend FE-PAGE-ATLAS-041 with a Norway (NOR) case via it.each - Improve atlas_country_options useMemo: rename a2 → resolvedA2 for clarity, precompute the A3→A2 reverse-lookup Map once per geoData change instead of O(n) Object.entries().find() per feature --- client/src/pages/AtlasPage.test.tsx | 25 +++++++++++--------- client/src/pages/AtlasPage.tsx | 36 +++++++++++++++++------------ 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx index 7bcf4be4..72cc0756 100644 --- a/client/src/pages/AtlasPage.test.tsx +++ b/client/src/pages/AtlasPage.test.tsx @@ -127,22 +127,22 @@ const geoJsonWithFR = { ], }; -const geoJsonWithFranceA3Fallback = { +const makeGeoJsonWithA3Fallback = (a3: string, name: string) => ({ type: 'FeatureCollection', features: [ { type: 'Feature', properties: { ISO_A2: '-99', - ADM0_A3: 'FRA', - ISO_A3: 'FRA', - NAME: 'France', - ADMIN: 'France', + ADM0_A3: a3, + ISO_A3: a3, + NAME: name, + ADMIN: name, }, geometry: null, }, ], -}; +}); // ── Atlas API response fixture ──────────────────────────────────────────────── const atlasStatsResponse = { @@ -1341,11 +1341,14 @@ describe('AtlasPage', () => { }); describe('FE-PAGE-ATLAS-041: country search falls back from A3 when ISO_A2 is invalid', () => { - it('returns France in search results when GeoJSON provides ADM0_A3 but ISO_A2 is -99', async () => { + 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(geoJsonWithFranceA3Fallback) } as Response); + return Promise.resolve({ ok: true, json: () => Promise.resolve(makeGeoJsonWithA3Fallback(a3, name)) } as Response); } return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`)); }); @@ -1358,11 +1361,11 @@ describe('AtlasPage', () => { }); const searchInput = screen.getByPlaceholderText(/search a country/i); - await user.type(searchInput, 'france'); + await user.type(searchInput, query); await waitFor(() => { - const franceButton = screen.getAllByRole('button').find((button) => button.textContent?.includes('France')); - expect(franceButton).toBeTruthy(); + const countryButton = screen.getAllByRole('button').find((button) => button.textContent?.includes(name)); + expect(countryButton).toBeTruthy(); }); }); }); diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 85459b43..955a4110 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -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 = {"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 = { ...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 = {"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,22 +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() + for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key) + const opts: { code: string; label: string }[] = [] const seen = new Set() for (const f of (geoData as any).features || []) { - let a2 = f?.properties?.ISO_A2 - if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) { + 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') { - const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3) - a2 = a3ToA2Entry ? a3ToA2Entry[0] : null - } + if (a3 && a3 !== '-99') resolvedA2 = a3ToA2.get(a3) ?? null } - if (!a2 || 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 }) + 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