From 5ace1a04b2d10b0feff0205afc62c27ffb0db27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 18:03:44 -0300 Subject: [PATCH] simplify(B14): corrige o mecanismo da fronteira, estado unsupported e duplicatas Terceira rodada de /simplify sobre o B14, com dois enunciados errados corrigidos: - A propriedade da fronteira compartilhada NAO vem da ordem do `.find()`, e sim da convencao meio-aberta do ray casting (`point.lng < crossingLng`): a aresta pertence ao poligono cujo interior fica a leste (ao norte, se horizontal). Tres comentarios diziam o contrario, e o do unit test dizia "oeste" enquanto afirmava `east`. - O plano dizia que o `Promise.all` esconde o decode da malha atras do fix de GPS. Nao esconde: o decode roda no topo do modulo, quando o chunk executa. O paralelo continua certo por outro motivo (decode ~2 ms; sequencial jogaria os 42 KB no caminho critico quando o fix vem do cache). Simplificacoes: `unsupported` virou estado proprio, entao `failureCopy` deixa de ser anulavel e caem duas leituras defensivas inalcancaveis; "Voce esta em " era escrito duas vezes (frase sr-only + JSX), o que anulava justamente a invariante que o comentario alegava; `zoneCityHrefs` virou `Partial` porque a busca falha para os 416 municipios sem zona e o guard existia sem o tipo exigir; `MunicipalityForIbgeIndex` era duplicata campo-a-campo de `AccessibleMunicipality`; `kmFormatter` duplicava `oneDecimalFormatter`; `RecentlyVisitedCard` carregava tres props que nenhum caller passa (dez condicionais mortas); e o bbox de feature estava escrito em dois specs (agora `tests/helpers/featureBounds.ts`, compartilhado por vitest e Playwright). Copy: fora da Bahia sem nada por perto dizia a mesma coisa em duas frases e nunca dizia que voce estava fora do estado. Agora o headline afirma o fato e o detalhe reusa "Nenhum municipio da sua carteira por perto.". Tambem limpa um achado de knip herdado do main (`MunicipalityPoliticalTrendViewModel` exportado sem uso) e registra no plano o caminho afiado do debito de payload RSC mais dois adiados com gatilho. --- docs/plans/municipio-mais-proximo.md | 6 +- .../dashboard/NearestMunicipalityCard.tsx | 101 ++++++++++-------- .../dashboard/RecentlyVisitedCard.tsx | 52 +++------ src/lib/municipalityProximity.ts | 20 ++-- src/utilities/municipalityMapNavigation.ts | 10 +- src/utilities/municipalityViewModels.ts | 2 +- .../campaignNearestMunicipality.e2e.spec.ts | 13 +-- tests/helpers/featureBounds.ts | 30 ++++++ tests/int/bahiaGeometries.int.spec.ts | 24 ++--- tests/unit/municipalityProximity.unit.spec.ts | 5 +- 10 files changed, 130 insertions(+), 133 deletions(-) create mode 100644 tests/helpers/featureBounds.ts diff --git a/docs/plans/municipio-mais-proximo.md b/docs/plans/municipio-mais-proximo.md index aa07d17c..a23f1aab 100644 --- a/docs/plans/municipio-mais-proximo.md +++ b/docs/plans/municipio-mais-proximo.md @@ -22,9 +22,9 @@ Entregue: **Medições (as-built).** Payload RSC de `/campanha` com 435 entradas: ~6,6 KB gzip — abaixo do teto de ~10 KB do plano, então o campo ficou como objetos legíveis em vez de tuplas. First Load JS de `/campanha`: **265 kB → 268 kB**. A primeira versão chegou a 286 kB porque o card chamava `buildMunicipalityListHref` para o link das zonas de Salvador, e o serializador canônico arrasta `bahiaTerritories` + `municipalityCatalog` (que puxa códigos IBGE e zonas TSE) para o bundle do cliente: **21 kB por um único link**. Corrigido serializando o href no servidor (`zoneCityHrefs`, chaveado por IBGE, uma entrada só quando o ator enxerga mais de uma zona da cidade) — a regra "client components não importam módulos de dado do servidor para obter valores" também vale para serializadores de URL. No `/simplify` esse cálculo saiu do loader `server-only` para `CampaignDashboard`, reusando `buildMunicipalitiesByIbgeCode` (o índice por código IBGE que o painel do mapa já usa) em vez de contar linhas à mão. -**Medições de CPU (feitas no `/simplify`, sobre o artefato real — não estimadas).** A malha decodifica em **417 features, todas `Polygon` simples**: 417 anéis, **zero buracos, zero MultiPolygon**, 15.111 pares de coordenadas, anel mais longo com 147 pontos. Varredura point-in-polygon completa (o caminho "fora da Bahia", sem acerto): **0,106 ms**; até acertar Salvador: 0,143 ms; `resolveNearbyMunicipality` inteiro com carteira de 435: **0,32 ms**, dos quais 0,26 ms são os 417 centroides. Com penalidade de 6× para celular médio, ~2,6 ms — **um oitavo de um frame**, então não há caminho quente aqui e o artefato de centroides continuaria sem se pagar. Construir um `Map` novo por chamada mediu **0,337 ms, mais lento** que o `find`; o índice `getMunicipalityFeature` é usado porque o módulo **já o construiu** para o mapa, não como otimização. Chunk de geometria no build: 134.488 B crus / **42.158 B gzip**, referenciado por um único arquivo (o chunk da página), confirmando um download e um decode (1,69 ms) para os dois consumidores. O que muda com o card é o **agendamento**: a geometria passa a ser pedida na hidratação da página, competindo com Leaflet, em vez de só depois do chunk do painel — aceito porque o `Promise.all` esconde isso atrás do fix de GPS (100 ms a 10 s). +**Medições de CPU (feitas no `/simplify`, sobre o artefato real — não estimadas).** A malha decodifica em **417 features, todas `Polygon` simples**: 417 anéis, **zero buracos, zero MultiPolygon**, 15.111 pares de coordenadas, anel mais longo com 147 pontos. Varredura point-in-polygon completa (o caminho "fora da Bahia", sem acerto): **0,106 ms**; até acertar Salvador: 0,143 ms; `resolveNearbyMunicipality` inteiro com carteira de 435: **0,32 ms**, dos quais 0,26 ms são os 417 centroides. Com penalidade de 6× para celular médio, ~2,6 ms — **um oitavo de um frame**, então não há caminho quente aqui e o artefato de centroides continuaria sem se pagar. Construir um `Map` novo por chamada mediu **0,337 ms, mais lento** que o `find`; o índice `getMunicipalityFeature` é usado porque o módulo **já o construiu** para o mapa, não como otimização. Chunk de geometria no build: 134.488 B crus / **42.158 B gzip**, referenciado por um único arquivo (o chunk da página), confirmando um download e um decode (1,69 ms) para os dois consumidores. O que muda com o card é o **agendamento**: a geometria passa a ser pedida na hidratação da página, competindo com Leaflet (42.675 B gzip), em vez de só depois do chunk do painel. Aceito — mas **não** porque o `Promise.all` esconderia o decode atrás do fix de GPS: o decode roda no topo do módulo, ou seja, quando o chunk baixa e executa, independente do fix. Ele é aceito porque é **barato** (`JSON.parse` 1,62 ms + `topojson.feature()` 0,41 ms ≈ 2 ms no desktop, ~10 ms num celular 5× mais lento — abaixo da barra de 50 ms de long task) e porque a alternativa sequencial (fix primeiro, geometria depois) é **pior** no caso comum: com `maximumAge` de 5 min um fix em cache volta em milissegundos e aí os 42 KB cairiam inteiros no caminho crítico do card. O download também nunca é especulativo — `MunicipalityMapPanel` monta sempre em `mode="municipality"`, então todo staff em `/campanha` precisa desse chunk de qualquer forma. -**Débito registrado (não corrigido aqui).** As 435 linhas de `accessibleMunicipalities` (~6,6 KB gzip) duplicam o `municipalitiesByIbgeCode` que `MunicipalityMapBundle` já serializa na mesma página, a partir do mesmo `loadMunicipalityScope`. Unificar exige mexer na fronteira de Suspense do mapa (o painel só monta atrás dela, e pode ser `null`), o que é reestruturação, não limpeza. Tuplas em vez de objetos economizariam apenas 398 B gzip — medido, e não vale o tipo auto-descritivo. +**Débito registrado (não corrigido aqui).** As 435 linhas de `accessibleMunicipalities` (~6,6 KB gzip) duplicam o `municipalitiesByIbgeCode` que `MunicipalityMapBundle` já serializa na mesma página, a partir do mesmo `loadMunicipalityScope`. Unificar exige mexer na fronteira de Suspense do mapa (o painel só monta atrás dela, e pode ser `null`), o que é reestruturação, não limpeza. Tuplas em vez de objetos economizariam apenas 398 B gzip — medido, e não vale o tipo auto-descritivo. A 3ª rodada de `/simplify` afiou o caminho da correção: publicar `MunicipalitiesByIbgeCode` no lugar do array plano faria `resolveNearbyMunicipality` trocar o `filter` sobre 435 linhas por uma busca por chave, e a página pararia de montar o mesmo agrupamento duas vezes (`buildZoneCityHrefs` reconstrói o índice a partir do array). Só que exige mover `municipalityMapNavigation.ts` para `src/lib/` — hoje em `utilities/` **sem nenhum import**, portanto já puro e mal-alojado — e tocar os importadores do domínio do mapa. Continua reestruturação, não limpeza. ## Design (Impeccable) @@ -138,6 +138,8 @@ Componentes: - **Atalho também na lista `/campanha/municipios`.** Revisitar quando: evidência de uso no Início (campo pede o mesmo atalho na lista). - **Lembrar última sugestão em `sessionStorage` para paint imediato.** Revisitar quando: critique acusar flash/vazio perceptível no card. _(Não acusou: o chunk de geometria costuma estar resolvido antes do fix chegar.)_ - **Atalho geo também para `leader`.** Permanece fora: liderança não sai do `LeaderContactsPanel`. +- **Reusar `resolveMunicipalityMapNavigation` no branch `none|navigate|zones`.** Revisitar quando: um 3º chamador precisar do `zoneCount`/da entrada casada. Hoje a união existente descarta justamente o que o card precisa, então reusar significa alargar um contrato de que duas superfícies do mapa dependem para servir um caller — mais caro que as ~18 linhas duplicadas. +- **Helper compartilhado para flags de sessão (`sessionStorage`).** Revisitar quando: nascer a 3ª flag. Hoje são duas (`campaignGeolocation` e `InstallPwaToast`) e elas **discordam de propósito** no fallback de leitura — em modo privado o geo suprime o prompt automático e o toast continua aparecendo —, o que é a maior parte do que o helper faria. ## Referências diff --git a/src/components/campaign/dashboard/NearestMunicipalityCard.tsx b/src/components/campaign/dashboard/NearestMunicipalityCard.tsx index 6b407c04..3a8b82ea 100644 --- a/src/components/campaign/dashboard/NearestMunicipalityCard.tsx +++ b/src/components/campaign/dashboard/NearestMunicipalityCard.tsx @@ -23,6 +23,9 @@ import { type GeolocationFailureReason, } from '@/utilities/campaignGeolocation' +/** Everything except `unsupported`, which is a state of its own — the card hides. */ +type ActionableFailureReason = Exclude + type CardState = /** * Mounted, permission not read yet. Renders the explanation without the CTA: @@ -34,9 +37,14 @@ type CardState = | { status: 'ready' } | { status: 'locating' } | { status: 'resolved'; resolution: NearbyMunicipalityResolution; coarse: boolean } - | { status: 'failed'; reason: GeolocationFailureReason } + | { status: 'failed'; reason: ActionableFailureReason } + /** No geolocation in this browser: nothing to act on, so the card takes no space. */ + | { status: 'unsupported' } -const failureCopy: Record = { +/** The states that render — `unsupported` never reaches the body. */ +type VisibleCardState = Exclude + +const failureCopy: Record = { denied: { title: 'Localização bloqueada neste navegador', hint: 'Libere o acesso à localização nas permissões do site e tente de novo.', @@ -51,8 +59,6 @@ const failureCopy: Record>> + export const NearestMunicipalityCard = ({ accessible, zoneCityHrefs, }: { accessible: readonly AccessibleMunicipality[] - /** - * Filtered-list href per multi-zone city, keyed by IBGE code. Ready-made on - * purpose: importing the canonical list-URL builder here costs 21 KB of the - * dashboard's First Load JS (see `buildZoneCityHrefs` in `CampaignDashboard`). - */ - zoneCityHrefs: Readonly> + zoneCityHrefs: ZoneCityHrefs }) => { const [state, setState] = useState({ status: 'starting' }) const autoStartedRef = useRef(false) @@ -104,7 +114,11 @@ export const NearestMunicipalityCard = ({ const [result, geometry] = loaded if (!result.ok) { - setState({ status: 'failed', reason: result.reason }) + setState( + result.reason === 'unsupported' + ? { status: 'unsupported' } + : { status: 'failed', reason: result.reason }, + ) return } @@ -150,7 +164,7 @@ export const NearestMunicipalityCard = ({ void start() }, [accessible.length, locate]) - if (accessible.length === 0 || !isCardWorthShowing(state)) return null + if (accessible.length === 0 || state.status === 'unsupported') return null return ( @@ -174,59 +188,50 @@ export const NearestMunicipalityCard = ({ ) } -/** A browser without geolocation takes no space: there is nothing to act on. */ -const isCardWorthShowing = (state: CardState): boolean => - state.status !== 'failed' || state.reason !== 'unsupported' - /** * The multi-second wait and its outcome must reach a screen reader, and the * visible copy is spread over paragraph and button — hence one sentence here. */ -const statusAnnouncement = (state: CardState): string => { +const statusAnnouncement = (state: VisibleCardState): string => { if (state.status === 'locating') return 'Localizando…' - if (state.status === 'failed') return failureCopy[state.reason]?.title ?? '' + if (state.status === 'failed') return failureCopy[state.reason].title if (state.status !== 'resolved') return '' return headlineSentence(locationHeadline(state.resolution)) } /** - * Where the actor is, written once: the announcement above and the visible - * headline below both read it, so they cannot drift apart. + * Where the actor is, in parts: the announcement joins them into a sentence and + * the visible headline emphasizes the place, so the copy exists in one place and + * the two cannot drift apart. */ -type LocationHeadline = { place: string; suffix: string } | { place: null; sentence: string } +type LocationHeadline = { lead: string; place: string | null; tail: string } + +const PLACE_LEAD = 'Você está em ' const locationHeadline = (resolution: NearbyMunicipalityResolution): LocationHeadline => { switch (resolution.kind) { case 'inScope': - return { place: resolution.municipality.name, suffix: '.' } + return { lead: PLACE_LEAD, place: resolution.municipality.name, tail: '.' } case 'zoneCity': - return { place: resolution.city, suffix: ', dividida por zona eleitoral.' } + return { lead: PLACE_LEAD, place: resolution.city, tail: ', dividida por zona eleitoral.' } case 'outOfScope': - return { place: resolution.city, suffix: ', fora da sua carteira.' } + return { lead: PLACE_LEAD, place: resolution.city, tail: ', fora da sua carteira.' } case 'outsideBahia': - return { - place: null, - sentence: resolution.nearestInScope - ? 'Você está fora da Bahia.' - : 'Nenhum município da campanha perto de você.', - } + // What to do about it is the detail's job, so the headline states only the + // fact — the same sentence whether or not there is a município to offer. + return { lead: 'Você está fora da Bahia.', place: null, tail: '' } } } -const headlineSentence = (headline: LocationHeadline): string => - headline.place === null ? headline.sentence : `Você está em ${headline.place}${headline.suffix}` +const headlineSentence = ({ lead, place, tail }: LocationHeadline): string => + `${lead}${place ?? ''}${tail}` -const Headline = ({ headline }: { headline: LocationHeadline }) => ( +const Headline = ({ headline: { lead, place, tail } }: { headline: LocationHeadline }) => (

- {headline.place === null ? ( - headline.sentence - ) : ( - <> - Você está em {headline.place} - {headline.suffix} - - )} + {lead} + {place ? {place} : null} + {tail}

) @@ -256,10 +261,12 @@ const CoarseFixCaveat = () => ( ) +const NothingNearby = () => Nenhum município da sua carteira por perto. + const renderBody = ( - state: CardState, + state: VisibleCardState, locate: () => void, - zoneCityHrefs: Readonly>, + zoneCityHrefs: ZoneCityHrefs, ): ReactNode => { if (state.status === 'starting' || state.status === 'ready') { return ( @@ -285,7 +292,6 @@ const renderBody = ( if (state.status === 'failed') { const copy = failureCopy[state.reason] - if (!copy) return null return ( <> @@ -310,7 +316,7 @@ const renderBody = ( const resolutionDetail = ( resolution: NearbyMunicipalityResolution, locate: () => void, - zoneCityHrefs: Readonly>, + zoneCityHrefs: ZoneCityHrefs, ): ReactNode => { const nearest = 'nearestInScope' in resolution ? resolution.nearestInScope : null const nearestDetail = nearest ? ( @@ -356,13 +362,14 @@ const resolutionDetail = ( } case 'outOfScope': - return nearestDetail ?? Nenhum município da sua carteira por perto. + return nearestDetail ?? case 'outsideBahia': return ( nearestDetail ?? ( <> - Sua posição está longe do território da campanha. + + {/* Out of state with nothing to offer is also what a bad fix looks like. */} ) diff --git a/src/components/campaign/dashboard/RecentlyVisitedCard.tsx b/src/components/campaign/dashboard/RecentlyVisitedCard.tsx index bab45d34..8fb80c1a 100644 --- a/src/components/campaign/dashboard/RecentlyVisitedCard.tsx +++ b/src/components/campaign/dashboard/RecentlyVisitedCard.tsx @@ -6,7 +6,6 @@ import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { cn } from '@/lib/utils' import { formatRelativeAge } from '@/utilities/formatRelativeAge' import { clearRecentVisits, @@ -14,19 +13,7 @@ import { type RecentVisitEntry, } from '@/utilities/recentVisits' -type RecentlyVisitedCardProps = { - now: Date - compact?: boolean - clearControl?: 'icon' | 'labeled' - className?: string -} - -export const RecentlyVisitedCard = ({ - now, - compact = false, - clearControl = 'icon', - className, -}: RecentlyVisitedCardProps) => { +export const RecentlyVisitedCard = ({ now }: { now: Date }) => { const [entries, setEntries] = useState([]) useEffect(() => { @@ -43,29 +30,23 @@ export const RecentlyVisitedCard = ({ const nowMs = now.getTime() return ( - - - - {compact ? 'Recentes' : 'Visitados recentemente'} - + + + Visitados recentemente - +
    {entries.map((entry) => { const Icon = entry.kind === 'municipality' ? Building2Icon : FilterIcon @@ -74,22 +55,15 @@ export const RecentlyVisitedCard = ({ diff --git a/src/lib/municipalityProximity.ts b/src/lib/municipalityProximity.ts index 148008b7..dcbc396a 100644 --- a/src/lib/municipalityProximity.ts +++ b/src/lib/municipalityProximity.ts @@ -4,6 +4,7 @@ import type { BahiaMunicipalityFeature, MunicipalityGeometryModule, } from '@/lib/bahiaGeometriesTypes' +import { oneDecimalFormatter } from '@/lib/electionFormat' /** * B14 — "onde estou" resolvido contra a malha municipal da Bahia. @@ -79,9 +80,10 @@ export const haversineKm = (from: GeoPoint, to: GeoPoint): number => { } /** - * Even-odd ray casting. A point exactly on a ring is undefined by construction, - * which is why `findContainingMunicipality` documents a deterministic tie-break - * instead of pretending shared borders belong to nobody. + * Even-odd ray casting, half-open on purpose: only crossings strictly east of + * the point count (`point.lng < crossingLng`). That is what makes a point on a + * shared border belong to exactly one município — the one whose interior lies + * east of the edge (north, for a horizontal one) — instead of to both or neither. */ const isPointInRing = (point: GeoPoint, ring: readonly Position[]): boolean => { let inside = false @@ -168,9 +170,11 @@ export const featureCentroid = (feature: BahiaMunicipalityFeature): GeoPoint => } /** - * First feature whose polygons contain the point. Shared borders are resolved - * by feature order, so a point on a boundary belongs to exactly one município — - * never to both, never to none. + * First feature whose polygons contain the point. A point on a shared border + * lands in exactly one município by the half-open convention in `isPointInRing`, + * not by this scan's order: the mesh is TopoJSON-derived, so neighbours share + * numerically identical arcs and never overlap. Feature order only decides + * pathological overlaps the committed mesh does not contain. */ export const findContainingMunicipality = ( features: readonly BahiaMunicipalityFeature[], @@ -252,10 +256,8 @@ export const resolveNearbyMunicipality = ({ } } -const kmFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }) - /** Distance as field copy: precise where it is felt, rounded where it is not. */ export const formatDistanceKm = (distanceKm: number): string => { if (distanceKm < 1) return 'menos de 1 km' - return `${kmFormatter.format(distanceKm < 10 ? distanceKm : Math.round(distanceKm))} km` + return `${oneDecimalFormatter.format(distanceKm < 10 ? distanceKm : Math.round(distanceKm))} km` } diff --git a/src/utilities/municipalityMapNavigation.ts b/src/utilities/municipalityMapNavigation.ts index 53f2adc8..caa33974 100644 --- a/src/utilities/municipalityMapNavigation.ts +++ b/src/utilities/municipalityMapNavigation.ts @@ -1,3 +1,5 @@ +import type { AccessibleMunicipality } from '@/lib/municipalityProximity' + type MunicipalityMapSlugEntry = { slug: string name: string @@ -10,14 +12,8 @@ export type MunicipalityMapNavigation = | { kind: 'navigate'; slug: string } | { kind: 'zones' } -type MunicipalityForIbgeIndex = { - slug: string - name: string - ibgeCode: string -} - export const buildMunicipalitiesByIbgeCode = ( - municipalities: readonly MunicipalityForIbgeIndex[], + municipalities: readonly AccessibleMunicipality[], ): MunicipalitiesByIbgeCode => { const byIbge: MunicipalitiesByIbgeCode = {} diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts index e8c76a1c..3bf76abc 100644 --- a/src/utilities/municipalityViewModels.ts +++ b/src/utilities/municipalityViewModels.ts @@ -44,7 +44,7 @@ export const municipalityListSelect = { }, } as const -export type MunicipalityPoliticalTrendViewModel = { +type MunicipalityPoliticalTrendViewModel = { status: PoliticalTrendStatus | null note: string | null recordedByName: string | null diff --git a/tests/e2e/campaignNearestMunicipality.e2e.spec.ts b/tests/e2e/campaignNearestMunicipality.e2e.spec.ts index 672186bf..dbbd7295 100644 --- a/tests/e2e/campaignNearestMunicipality.e2e.spec.ts +++ b/tests/e2e/campaignNearestMunicipality.e2e.spec.ts @@ -8,6 +8,7 @@ import { haversineKm, type GeoPoint, } from '../../src/lib/municipalityProximity.js' +import { featureBounds } from '../helpers/featureBounds.js' import { expect, test } from './fixtures/campaignE2EFixtures.js' @@ -36,17 +37,7 @@ const geoCard = (page: Page) => page.locator('[data-slot="card"]').filter({ hasT * fall outside a concave município, and the fixture hands out an arbitrary one. */ const interiorPointOf = (feature: BahiaMunicipalityFeature): GeoPoint => { - const rings = - feature.geometry.type === 'Polygon' - ? [feature.geometry.coordinates] - : feature.geometry.coordinates - const positions = rings.flat(2) - const longitudes = positions.map(([lng]) => lng) - const latitudes = positions.map(([, lat]) => lat) - const west = Math.min(...longitudes) - const east = Math.max(...longitudes) - const south = Math.min(...latitudes) - const north = Math.max(...latitudes) + const { west, east, south, north } = featureBounds(feature) const steps = 24 for (let row = 1; row < steps; row += 1) { diff --git a/tests/helpers/featureBounds.ts b/tests/helpers/featureBounds.ts new file mode 100644 index 00000000..4587e4b9 --- /dev/null +++ b/tests/helpers/featureBounds.ts @@ -0,0 +1,30 @@ +import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js' + +export type FeatureBounds = { + west: number + east: number + south: number + north: number +} + +/** + * Bounding box of every ring of a municipality feature. Type-only import on + * purpose: this helper is shared by the vitest int suite and the Playwright e2e + * suite, which resolve runtime modules differently. + */ +export const featureBounds = (feature: BahiaMunicipalityFeature): FeatureBounds => { + const rings = + feature.geometry.type === 'Polygon' + ? [feature.geometry.coordinates] + : feature.geometry.coordinates + const positions = rings.flat(2) + const longitudes = positions.map(([lng]) => lng) + const latitudes = positions.map(([, lat]) => lat) + + return { + west: Math.min(...longitudes), + east: Math.max(...longitudes), + south: Math.min(...latitudes), + north: Math.max(...latitudes), + } +} diff --git a/tests/int/bahiaGeometries.int.spec.ts b/tests/int/bahiaGeometries.int.spec.ts index a5dfb4ef..3c5579bb 100644 --- a/tests/int/bahiaGeometries.int.spec.ts +++ b/tests/int/bahiaGeometries.int.spec.ts @@ -16,6 +16,8 @@ import { resolveNearbyMunicipality, } from '@/lib/municipalityProximity' +import { featureBounds } from '../helpers/featureBounds' + /** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */ const MAX_TOPO_BYTES = 600 * 1024 @@ -120,26 +122,18 @@ describe('Bahia static geometries', () => { for (const entry of features) { const centroid = featureCentroid(entry) - const rings = - entry.geometry.type === 'Polygon' - ? [entry.geometry.coordinates] - : entry.geometry.coordinates - const positions = rings.flat(2) - const longitudes = positions.map(([lng]) => lng) - const latitudes = positions.map(([, lat]) => lat) + const bounds = featureBounds(entry) - expect(centroid.lng, entry.properties.name).toBeGreaterThanOrEqual(Math.min(...longitudes)) - expect(centroid.lng, entry.properties.name).toBeLessThanOrEqual(Math.max(...longitudes)) - expect(centroid.lat, entry.properties.name).toBeGreaterThanOrEqual(Math.min(...latitudes)) - expect(centroid.lat, entry.properties.name).toBeLessThanOrEqual(Math.max(...latitudes)) + expect(centroid.lng, entry.properties.name).toBeGreaterThanOrEqual(bounds.west) + expect(centroid.lng, entry.properties.name).toBeLessThanOrEqual(bounds.east) + expect(centroid.lat, entry.properties.name).toBeGreaterThanOrEqual(bounds.south) + expect(centroid.lat, entry.properties.name).toBeLessThanOrEqual(bounds.north) } }) it('sends a Salvador position to the zone list and an out-of-portfolio one to the nearest', async () => { const geometry = await loadMunicipalityGeometryModule() - const salvadorZones = municipalityCatalog - .filter((entry) => entry.city === 'Salvador') - .map(({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode })) + const salvadorZones = municipalityCatalog.filter((entry) => entry.city === 'Salvador') const seabra = municipalityCatalog.find((entry) => entry.slug === 'seabra')! const salvadorPoint = { lat: -12.973, lng: -38.5121 } @@ -160,7 +154,7 @@ describe('Bahia static geometries', () => { resolveNearbyMunicipality({ point: salvadorPoint, geometry, - accessible: [{ slug: seabra.slug, name: seabra.name, ibgeCode: seabra.ibgeCode }], + accessible: [seabra], }), ).toEqual({ kind: 'outOfScope', city: 'Salvador', nearestInScope: null }) }) diff --git a/tests/unit/municipalityProximity.unit.spec.ts b/tests/unit/municipalityProximity.unit.spec.ts index 8b26bafe..182c6220 100644 --- a/tests/unit/municipalityProximity.unit.spec.ts +++ b/tests/unit/municipalityProximity.unit.spec.ts @@ -137,9 +137,10 @@ describe('municipalityProximity', () => { const onBorder = { lat: -12.5, lng: -39 } const matches = features.filter((feature) => featureContainsPoint(feature, onBorder)) + // Exactly one, and it is the eastern feature: the half-open ray test counts + // only crossings strictly east of the point, so a shared edge belongs to the + // polygon whose interior lies east of it. Scan order never gets a say. expect(matches).toHaveLength(1) - // The border belongs to the feature whose interior is west of it, and the - // tie-break is feature order — not "whichever the scan happened to hit". expect(findContainingMunicipality(features, onBorder)).toBe(east) })