From 451be8e3b7e865f851ac15e8bee4ff5daf29d463 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 17:27:26 +0530 Subject: [PATCH] fix(voice): treat a Zone as retired only in Nepal Every name ending in "Zone" was kept without a level and refused as a retired administrative identity. That is right for Nepal, which replaced its zones in 2015, but zones are current units elsewhere: Ethiopia's "Gamo Zone" was refused. The selector now keeps the verified level unless display_name places the zone in Nepal or names no country, and the client no longer reads "Zone" as a retired level when the source verified one. --- server/providers/regional/outlineSelect.js | 18 ++++++++++++- src/tooling/nominatimGuardLifecycle.test.mjs | 28 ++++++++++++++++++++ src/voice/areaActions.js | 6 +++++ src/voice/areaActions.test.mjs | 23 +++++++++++++++- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/server/providers/regional/outlineSelect.js b/server/providers/regional/outlineSelect.js index 3ad4b46d3..b781e92f3 100644 --- a/server/providers/regional/outlineSelect.js +++ b/server/providers/regional/outlineSelect.js @@ -274,11 +274,27 @@ function selectedAdminIdentity(kind, row) { ); if (contradictory) return null; const adminArea = authoritativeAdminArea.slice(0, 160); - if (nameLevel === 'retired') return { adminArea }; + if (nameLevel === 'retired') { + // Nepal replaced its zones in 2015, but zones are current units elsewhere + // (Ethiopia). Without a country the name stays retired. + if (!zoneIsCurrentIn(row)) return { adminArea }; + return { adminArea, adminLevel }; + } if (nameLevel && nameLevel !== adminLevel) return null; return { adminArea, adminLevel }; } +/** Whether the row names a country (last display_name part) other than Nepal. */ +function zoneIsCurrentIn(row) { + const country = String(row?.display_name || '') + .split(',') + .slice(1) + .pop() + ?.trim() + .toLocaleLowerCase(); + return Boolean(country) && country !== 'nepal'; +} + /** Whether supplied administrative evidence contradicts the accepted identity. */ function adminLevelFits(kind, row) { const evidence = ADMIN_EVIDENCE[kind]; diff --git a/src/tooling/nominatimGuardLifecycle.test.mjs b/src/tooling/nominatimGuardLifecycle.test.mjs index 79f6141a3..3889ac95e 100644 --- a/src/tooling/nominatimGuardLifecycle.test.mjs +++ b/src/tooling/nominatimGuardLifecycle.test.mjs @@ -853,6 +853,34 @@ test('admin identity comes only from unambiguous evidence on the selected row', assert.equal('adminLevel' in retiredPrefix, false); }); +test('a Zone is retired only in Nepal, where zones were replaced', () => { + const current = selectOutlineResult( + [ + boundary({ + name: 'Gamo Zone', + addresstype: 'county', + display_name: 'Gamo Zone, South Ethiopia Regional State, Ethiopia', + }), + ], + { kind: 'admin', query: 'Gamo Zone' }, + ).outline; + assert.equal(current.adminArea, 'Gamo Zone'); + assert.equal(current.adminLevel, 'admin2'); + + const retired = selectOutlineResult( + [ + boundary({ + name: 'Bagmati Zone', + addresstype: 'state', + display_name: 'Bagmati Zone, Nepal', + }), + ], + { kind: 'admin', query: 'Bagmati Zone' }, + ).outline; + assert.equal(retired.adminArea, 'Bagmati Zone'); + assert.equal('adminLevel' in retired, false); +}); + test('admin identity validates the full name before display truncation', () => { const longValidName = `Province of ${'Example '.repeat(24)}`.trim(); const valid = selectOutlineResult( diff --git a/src/voice/areaActions.js b/src/voice/areaActions.js index d5e7f2f55..2032adcd4 100644 --- a/src/voice/areaActions.js +++ b/src/voice/areaActions.js @@ -270,6 +270,12 @@ export function createVoiceAreas({ const sourceAdmin = administrativeIdentity(sourceIdentity); const primaryAdmin = administrativeIdentity(primaryLabel); const requestedAdmin = administrativeIdentity(requestedLabel); + // The server leaves a Zone without a level only where zones were retired + // (Nepal). A verified level means a current zone, so "Zone" claims no level. + if (sourceLevel && sourceAdmin.level === 'retired') { + for (const admin of [sourceAdmin, primaryAdmin, requestedAdmin]) + if (admin.level === 'retired') admin.level = null; + } const sourceClaimsAdministrative = Boolean( administrativeRequest || administrativeLabel || diff --git a/src/voice/areaActions.test.mjs b/src/voice/areaActions.test.mjs index f827877ab..4894a2296 100644 --- a/src/voice/areaActions.test.mjs +++ b/src/voice/areaActions.test.mjs @@ -30,12 +30,13 @@ function fakeLadder({ calls = [], delay = null, label = 'Bagmati Province', + match = /bagmati/i, } = {}) { return { calls, async resolveAnnotationTarget(request) { calls.push(request); - if (!/bagmati/i.test(request.target)) return null; + if (!match.test(request.target)) return null; return { lat: 27.7, lon: 85.3, @@ -391,6 +392,26 @@ test('a verified province accepts a bare query with a separate level', async () assert.equal(retired.areas.store.size, 0); }); +test('a current Zone the source verified is not refused as retired', async () => { + const gamo = makeAreas({ + annotationResolver: fakeLadder({ + label: 'Gamo Zone', + match: /gamo/i, + outline: { + ring: BAGMATI_POLYGONS[0][0], + polygons: BAGMATI_POLYGONS, + adminArea: 'Gamo Zone', + adminLevel: 'admin2', + synthesized: false, + outlineSource: 'nominatim', + }, + }), + }); + const result = await gamo.areas.resolveAreaAction({ query: 'Gamo Zone' }); + assert.equal(result.ok, true, result.error); + assert.equal(gamo.areas.store.size, 1); +}); + test('a place no outline source can trace is refused, not guessed', async () => { const miss = makeAreas({ annotationResolver: fakeLadder({ outline: null }),