diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 2cb963e0..7670f384 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -8,6 +8,7 @@ alwaysApply: false
## Status
- **B6 entregue em código (2026-07-21; deploy pendente com remodelagem):** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers puros em `bahiaMapStyle.ts` + unit `bahiaMapStyle.unit.spec.ts`. Plano: [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md). Fecha débitos de perf de **B10** e **B11**.
+- **B8 F1 entregue em código (2026-07-21; deploy pendente com remodelagem):** catálogo estático `plazaZoneNeighborhoods` (21 Praças-zona SSA/CMS; Salvador = TRE-BA RA 02/2017; Camaçari = locais de votação + curadoria) + fixture `plaza-zone-neighborhoods.official.json` + int `plazaZoneNeighborhoods.int.spec.ts`; card **Bairros desta Praça** na aba Visão geral de `/campanha/pracas/[slug]` quando `kind === 'zona'` (staff e líder). Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). **F2 pendente** (dissolve IBGE → TopoJSON + mapa).
- **A9+ entregue em código (2026-07-21; deploy pendente com remodelagem):** loader compartilhado da lista — `loadPlazaListPageBundle` (1× `aggregatePledgesByPlaza`), `buildPlazaMapBundleFromPlazas`, `plazaRevalidation.ts` + hidden `plazaSlug`; int `plazaPageData.int.spec.ts`. Plano: [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md). Débito restante: twin `listFormActions` ↔ `/editar` → **C8** F4.
- **B11 entregue em código (2026-07-21; deploy pendente com remodelagem):** escala **% dos válidos** no Mapa das Praças — seletor `Total (votos)` / `% dos válidos` (default %); domínio 0–100% fixo (`scaleMax` em `BahiaMap`); `validVotesByYear` no bundle; 2026 usa válidos 2022; compare desliga %; readout em % + votos absolutos; `Promise.all` nos loads TSE. Plano: [`escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md).
- **Fill-in filtros-auto entregue em código (2026-07-21; deploy pendente com remodelagem):** debounce 1s + Enter imediato na busca de `/campanha/pracas`; remove Buscar; pending UI alinhado a `ActionPlanFilters`; `shouldUpdatePlazaSearchUrl` + no-op `buildPlazaFiltersKey`. Plano: [`filtros-auto-pracas.md`](../../../docs/plans/filtros-auto-pracas.md). Débitos pós-`/simplify` (sync back/forward, shell pending) → gatilhos no plano.
@@ -127,5 +128,5 @@ alwaysApply: false
- Em 2026-07-17 o roadmap foi repriorizado orientado ao calendário eleitoral (Res. TSE 23.760/2026: propaganda a partir de 16/08, 1º turno 04/10), com ordem de execução, trilhas paralelas (A dados eleitorais/território, B superfícies de coordenação, C operação de campo, D plataforma) e grafo de dependências. Decisões de sequenciamento: lançar o MVP antes da migração multi-município; multi-município antes de zonas-por-municipio/eventos/demandas; baseline TSE independente de zonas-por-municipio (dependência rebaixada a suave); WhatsApp Business API movido a fora de escopo (vedação Meta + Res. 23.610 art. 33).
- Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md).
- **B7 entregue (2026-07-21):** mapa em `/campanha/pracas` usa `buildPlazaListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**.
-- **A9 / B9 / B7 / B10 / B11 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **R6**, deploy+smoke remodelagem; Janela 3: **B6** (setStyle incremental pós-B10/B11), **B8**.
-- **B8 registrado (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. Pesquisa: SSA tem lista oficial TRE-BA zona↔bairro (RA 02/2017); CMS **não** — só locais de votação por ZE 170/171 + curadoria; TSE/TRE não publicam polígonos de ZE. Plano: F1 catálogo+UI na Praça; F2 dissolve malha de bairros (IBGE/GeoSalvador) → TopoJSON. Janela 3; F2 cortável. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
+- **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **R6**, deploy+smoke remodelagem, **B12**; Janela 3: **B8 F2** (polígonos no mapa).
+- **B8 registrado (2026-07-21); F1 entregue (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. F1: catálogo `plazaZoneNeighborhoods` + card na Praça. F2 pendente: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
diff --git a/docs/plans/poligonos-pracas-zona.md b/docs/plans/poligonos-pracas-zona.md
index d71e70ba..11b2cce6 100644
--- a/docs/plans/poligonos-pracas-zona.md
+++ b/docs/plans/poligonos-pracas-zona.md
@@ -1,7 +1,9 @@
# Polígonos das Praças-zona (Salvador / Camaçari)
-Status: rascunho
+Status: F1 entregue em código (2026-07-21); F2 pendente
Atualizado em: 2026-07-21
+Revisão 2026-07-21: F1 implementada — `plazaZoneNeighborhoods.ts`, fixture+int, `PlazaZoneNeighborhoodsCard` no overview da Praça-zona; mapa inalterado (F2).
+Revisão 2026-07-21 (pós-`/simplify` + capture-review-debts): débitos S1–S3 absorvidos como **F2 prep** (~½ dia, dentro do appetite F2); S4–S10 em Explicitamente fora / Adiado; JR1–JR7 em Já resolvido (não reabrir no simplify).
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B8)
Impeccable: B — encaixe em `/campanha/pracas/[slug]` (lista de bairros) + `PlazaMapPanel`/`BahiaMap` (polígonos); sem rota nova
Appetite: ~2,5–3,5 dias eng; F1 catálogo+UI (~1 dia) shipável sozinha; F2 dissolve+TopoJSON+mapa (~1,5–2,5 dias)
@@ -76,7 +78,7 @@ flowchart TD
Componentes:
-- **`src/lib/plazaZoneNeighborhoods.ts`** — mapa estático `{ city: 'Salvador'|'Camaçari', zoneNumber, neighborhoods: string[] }` indexado por `plazaSlug` (via `plazaCatalog`); helpers `neighborhoodsForPlazaSlug(slug)`; cabeçalho de proveniência (URL + data da resolução / lista de locais). Fixture `tests/fixtures/plaza-zone-neighborhoods.official.json` + int (19+2 zonas, sem bairro órfão duplicado entre ZE do mesmo município quando a fonte for exclusiva).
+- **`src/lib/plazaZoneNeighborhoods.ts`** — mapa estático `{ city: 'Salvador'|'Camaçari', zoneNumber, neighborhoods: string[] }` indexado por `plazaSlug`; helper `plazaZoneNeighborhoodEntryForSlug(slug)` + `plazaZoneNeighborhoodSourceLabel(source)`; cabeçalho de proveniência (URL + data da resolução / lista de locais). Fixture `tests/fixtures/plaza-zone-neighborhoods.official.json` + int (19+2 zonas, sem bairro órfão duplicado entre ZE do mesmo município quando a fonte for exclusiva). **F2 prep:** refatorar para autorar só `{ plazaSlug, source, neighborhoods[] }` e hidratar `city`/`zoneNumber` de `getPlazaCatalogEntry` (ver seção abaixo).
- **UI detalhe** (`pracas/[slug]/page.tsx` overview): se `view.kind === 'zona'`, card/lista “Bairros desta Praça” a partir do helper (sem Payload). Copy: fonte TRE / aproximação Camaçari.
- **`scripts/build-plaza-zone-geometries.mjs`** (`pnpm build:plaza-zone-geometries`): baixa/cache malha de bairros (padrão `downloadToBuffer` / cache `data/geometries/`), filtra município, dissolve por zona via catálogo reconciliado (`topojson` merge como TIs), emite `src/lib/geometries/bahia-plaza-zones.topo.json`; reporta bairros sem match.
- **`src/lib/bahiaGeometries.ts`** (ou módulo irmão profundo, sem pass-through): `getPlazaZoneFeature(slug)` + tipo da topologia; lazy com o mapa (alinhar B5 F1 se ainda aberto).
@@ -86,10 +88,17 @@ Componentes:
### Fases
-| Fase | Entrega | Appetite |
-| ---- | ------------------------------------------------ | ------------- |
-| F1 | Catálogo + testes + bloco na Praça | ~1 dia |
-| F2 | Spike cobertura malha → script → TopoJSON → mapa | ~1,5–2,5 dias |
+| Fase | Entrega | Appetite |
+| -------- | ----------------------------------------------------------------------- | ------------- |
+| F1 | Catálogo + testes + bloco na Praça | ~1 dia |
+| F2 prep | Hidratação via `plazaCatalog` + canônico JSON→TS + teste ordem de slugs | ~½ dia |
+| F2 | Spike cobertura malha → script → TopoJSON → mapa | ~1,5–2,5 dias |
+
+**F2 prep** (débitos pós-`/simplify`, capture-review-debts 2026-07-21 — não item paralelo no roadmap):
+
+1. **S1 — Hidratar identidade do catálogo:** autorar entradas como `{ plazaSlug, source, neighborhoods[] }`; derivar `city` e `zoneNumber` de `getPlazaCatalogEntry(slug)` em `entry()` ou no read path (padrão `plazaElectionGeographyForSlug`). Reduz ~42 campos duplicados e risco de drift antes do dissolve.
+2. **S2 — Fonte única TS ↔ fixture:** o script `build-plaza-zone-geometries.mjs` (ou gerador irmão) emite `plazaZoneNeighborhoods.ts` a partir de `plaza-zone-neighborhoods.official.json` (ou o inverso com JSON como canônico de evidência). Elimina ~300 linhas espelhadas; `evidenceSha256` permanece no fixture.
+3. **S3 — Ordem de slugs:** int leve `plazaZoneNeighborhoods.map(e => e.plazaSlug)` vs slugs `kind === 'zona'` de `plazaCatalog` (alinha ao snapshot SHA de `plazaCatalog.int.spec.ts`).
## Dependências
@@ -112,8 +121,29 @@ Componentes:
- **Desenhar 19 polígonos Salvador à mão no QGIS sem catálogo.** Irrepetível e sem teste. **Mitigação:** F1 obrigatória; manual só para gaps Camaçari/ unmatched.
- **Tratar polígono derivado como limite oficial TSE.** Risco jurídico/comunicação. **Mitigação:** copy “aproximação a partir de bairros / locais de votação; não é limite oficial”.
+## Já resolvido no simplify/critique (não reabrir)
+
+- **JR1** — `PlazaZoneNeighborhoodsCard`: um lookup `plazaZoneNeighborhoodEntryForSlug` (sem duplo hit no `entryBySlug`).
+- **JR2** — export `neighborhoodsForPlazaSlug` removido (wrapper redundante).
+- **JR3** — guard do card: `!entry?.neighborhoods.length`.
+- **JR4** — `assertPlazaZoneNeighborhoodCatalogIntegrity` só no módulo (não exportado).
+- **JR5** — testes redundantes de sort e checksum duplicado removidos do int spec.
+- **JR6** — cobertura usa `zonePlazas.length` em vez de `21` fixo.
+- **JR7** — comentário no spec: sem teste de exclusividade Camaçari (fonte curada/aproximada).
+- Impeccable compacto na F1: sem snapshot `.impeccable/critique/` nem P0–P3 abertos nesta superfície; polish visual amplo → **R6**.
+
+## Explicitamente fora (skips `/simplify` + descartes do triage)
+
+- **S4 — Remover assert de integridade no import.** Fail-fast no import é aceitável aqui; int spec já cobre slugs zona. Não mover só para spec sem motivo de perf.
+- **S5 — Incluir `source` no SHA-256 canônico.** `deepEqual` fixture↔código já detecta mudança de `source`; refresh de `evidenceSha256` seria churn sem ganho.
+- **S7 — `PlazaZoneNeighborhoodCity` vs `ZONE_PLAZA_CITIES`.** Absorvido por S1 se F2 prep rodar; não tipo paralelo isolado.
+- **S8 — Proveniência em 3 camadas** (header TS, fixture `provenance`, `plazaZoneNeighborhoodSourceLabel`). Padrão aceitável para catálogo com copy UI distinta por `source`.
+- **S9 — Double gate** `view.kind === 'zona'` na página + card `null`. Defesa em profundidade; não simplificar só por DRY.
+- **S10 — `key={neighborhood}` no card.** Seguro com exclusividade Salvador testada; prefixar slug só se QA reportar colisão intra-zona.
+
## Adiado com gatilho
+- **S6 — CSS compartilhado lista zona** (`PlazaMapPanel` zone breakdown ↔ `PlazaZoneNeighborhoodsCard`). **Gatilho:** F2 alterar `PlazaMapPanel`/`BahiaMap` ou R6 citar duplicação visual em ≥2 superfícies de zona.
- **Malha GeoSalvador em vez de IBGE.** Revisitar quando: spike F2 mostrar unmatched críticos em Salvador (>10% bairros TRE sem polígono).
- **Refino fino de Camaçari (povoados).** Revisitar quando: coordenação de campo reportar confusão operacional entre ZE 170/171 no mapa.
- **Camada ZE estadual (B4 antigo).** Revisitar quando: produto pedir mapa de ZE fora de SSA/CMS (improvável neste ciclo).
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 346aa213..c41e44dc 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-21 (B6 entregue em código; B12 registrado — aproximar mapa ao filtro; A9+/B7/B9–B11/filtros-auto entregues; débitos B9 → C8 F4)
+Atualizado em: 2026-07-21 (B6 + B8 F1 entregues em código; débitos B8 F1 pós-/simplify absorvidos no plano F2 prep; B12 registrado — aproximar mapa ao filtro; A9+/B7/B9–B11/filtros-auto entregues; débitos B9 → C8 F4)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
@@ -114,6 +114,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
- **B10 (2026-07-21)** — **Hover/tap no Mapa das Praças** (destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 → `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`) — [plano](plans/hover-mapa-pracas.md).
- **B11 (2026-07-21)** — **Escala % dos válidos no Mapa das Praças** (`validVotesByYear` no bundle; seletor `Total (votos)` / `% dos válidos`; domínio fixo 0–100%; 2026 usa válidos 2022; compare desliga %; readout em %) — [plano](plans/escala-percentual-mapa-pracas.md).
- **B6 (2026-07-21)** — **`BahiaMap` setStyle incremental** (layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers em `bahiaMapStyle.ts`) — [plano](plans/escala-dry-pos-b3.md).
+- **B8 F1 (2026-07-21)** — **Catálogo bairros das Praças-zona** (`plazaZoneNeighborhoods`: Salvador TRE-BA RA 02/2017 + Camaçari curado; fixture+int; card **Bairros desta Praça** no overview de Praças `kind=zona`; sem migration) — [plano](plans/poligonos-pracas-zona.md). **F2 pendente** (F2 prep catálogo ~½ dia + TopoJSON + mapa). Débitos pós-`/simplify`: hidratação via `plazaCatalog`, canônico JSON→TS, teste ordem slugs → F2 prep no plano (S6 CSS compartilhado adiado até F2/R6).
- **Fill-in filtros-auto (2026-07-21)** — **Filtros auto-aplicados na lista de Praças** (`PlazaFilters`: debounce 1s no `q`, Enter imediato, selects imediatos, remove Buscar; `useTransition` + pending a11y; `shouldUpdatePlazaSearchUrl` + no-op via `buildPlazaFiltersKey`; sem migration) — [plano](plans/filtros-auto-pracas.md). Débitos pós-`/simplify`: sync back/forward `search`↔`state.q` e shell pending compartilhado — gatilhos no plano.
## Supersedidos pela remodelagem (2026-07-20)
@@ -136,7 +137,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · [plano](plans/insight-dobradinha-2026.md)
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
-- **B8** Polígonos das Praças-zona (Salvador ZE 1–19 / Camaçari ZE 170–171): F1 catálogo zona→bairros + UI na Praça; F2 dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md)
+- **B8** Polígonos das Praças-zona (Salvador ZE 1–19 / Camaçari ZE 170–171): ~~F1 catálogo zona→bairros + UI na Praça~~ (entregue 2026-07-21); F2 prep catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md)
- **B12** Aproximar o Mapa das Praças ao footprint filtrado (`fitToKeys` no `BahiaMap`; sem re-zoom em Ano/Escala) · Janela 1 · cortável · gatilho do B7 · [plano](plans/aproximar-mapa-pracas.md)
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx
index 32c6bdb1..70b928b1 100644
--- a/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx
@@ -12,6 +12,7 @@ import { PlazaCandidateComparisonTable } from '@/components/campaign/PlazaCandid
import { PlazaLeadershipsPanel } from '@/components/campaign/PlazaLeadershipsPanel'
import { PlazaPledgesPanel } from '@/components/campaign/PlazaPledgesPanel'
import { PlazaStrategyCard } from '@/components/campaign/PlazaStrategyCard'
+import { PlazaZoneNeighborhoodsCard } from '@/components/campaign/PlazaZoneNeighborhoodsCard'
import { PlazaTabNav } from '@/components/campaign/PlazaTabNav'
import { PlazaUpdateFeed } from '@/components/campaign/PlazaUpdateFeed'
import { PlazaUpdateForm } from '@/components/campaign/PlazaUpdateForm'
@@ -153,11 +154,15 @@ const OverviewTab = async ({
isStaffView: boolean
payloadUser: PayloadUser
}) => {
+ const zoneNeighborhoodsCard =
+ view.kind === 'zona' ? : null
+
if (isStaffView && view.strategy) {
const pledges = await loadPlazaPledges(payload, user, view.id)
const leadershipVoteTotal = sumStaffPledgeEffectiveTotal(pledges)
return (
+ {zoneNeighborhoodsCard}
pledge.plazaID === view.id) ?? null
return (
-
-
-
Seus votos nesta Praça
-
- Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser.
-
-
-
-
+
+ {zoneNeighborhoodsCard}
+
+
+
Seus votos nesta Praça
+
+ Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser.
+
+
+
+
+
)
}
diff --git a/src/components/campaign/PlazaZoneNeighborhoodsCard.tsx b/src/components/campaign/PlazaZoneNeighborhoodsCard.tsx
new file mode 100644
index 00000000..cac2b0ac
--- /dev/null
+++ b/src/components/campaign/PlazaZoneNeighborhoodsCard.tsx
@@ -0,0 +1,39 @@
+import {
+ plazaZoneNeighborhoodEntryForSlug,
+ plazaZoneNeighborhoodSourceLabel,
+} from '@/lib/plazaZoneNeighborhoods'
+
+export const PlazaZoneNeighborhoodsCard = ({ plazaSlug }: { plazaSlug: string }) => {
+ const entry = plazaZoneNeighborhoodEntryForSlug(plazaSlug)
+ if (!entry?.neighborhoods.length) return null
+
+ const { neighborhoods, source } = entry
+
+ return (
+
+
+
+ Bairros desta Praça
+
+
+ {neighborhoods.length}{' '}
+ {neighborhoods.length === 1 ? 'bairro' : 'bairros'} ·{' '}
+ {plazaZoneNeighborhoodSourceLabel(source)}
+
+
+
+ {neighborhoods.map((neighborhood) => (
+ -
+ {neighborhood}
+
+ ))}
+
+
+ )
+}
diff --git a/src/lib/plazaZoneNeighborhoods.ts b/src/lib/plazaZoneNeighborhoods.ts
new file mode 100644
index 00000000..900de1d8
--- /dev/null
+++ b/src/lib/plazaZoneNeighborhoods.ts
@@ -0,0 +1,321 @@
+/**
+ * Static catalog of neighborhoods per Praça-zona (Salvador ZE 1–19, Camaçari ZE 170/171).
+ *
+ * Salvador source (official):
+ * - TRE-BA Resolução Administrativa nº 2/2017, Anexo I — circunscrição por bairro
+ * https://www.tre-ba.jus.br/legislacao/compilada/resolucao/2017/resolucao-administrativa-no-2-de-10-de-maio-de-2017
+ * (accessed 2026-07-21)
+ *
+ * Camaçari source (approximate):
+ * - Localidades derivadas de locais de votação TRE-BA (ZE 170/171) + curadoria manual;
+ * não há lista oficial zona↔bairro comparável a Salvador.
+ * https://www.tre-ba.jus.br/servicos-eleitorais/titulo-e-local-de-votacao
+ * (accessed 2026-07-21)
+ *
+ * Coverage is independently checked by tests/fixtures/plaza-zone-neighborhoods.official.json.
+ */
+
+import { getPlazaCatalogEntry } from '@/lib/plazaCatalog'
+
+export type PlazaZoneNeighborhoodSource = 'tre-ra-02-2017' | 'tre-voting-locations-curated'
+
+export type PlazaZoneNeighborhoodCity = 'Salvador' | 'Camaçari'
+
+export type PlazaZoneNeighborhoodEntry = {
+ readonly plazaSlug: string
+ readonly city: PlazaZoneNeighborhoodCity
+ readonly zoneNumber: number
+ readonly source: PlazaZoneNeighborhoodSource
+ readonly neighborhoods: readonly string[]
+}
+
+const sortNeighborhoods = (names: readonly string[]): readonly string[] =>
+ [...names].sort((left, right) => left.localeCompare(right, 'pt-BR'))
+
+const entry = (
+ plazaSlug: string,
+ city: PlazaZoneNeighborhoodCity,
+ zoneNumber: number,
+ source: PlazaZoneNeighborhoodSource,
+ neighborhoods: readonly string[],
+): PlazaZoneNeighborhoodEntry => ({
+ plazaSlug,
+ city,
+ zoneNumber,
+ source,
+ neighborhoods: sortNeighborhoods(neighborhoods),
+})
+
+export const plazaZoneNeighborhoods: readonly PlazaZoneNeighborhoodEntry[] = [
+ entry('salvador-ze-1', 'Salvador', 1, 'tre-ra-02-2017', [
+ 'Alto das Pombas',
+ 'Barra',
+ 'Calabar',
+ 'Canela',
+ 'Centro',
+ 'Centro Histórico',
+ 'Garcia',
+ 'Graça',
+ 'Vitória',
+ ]),
+ entry('salvador-ze-2', 'Salvador', 2, 'tre-ra-02-2017', [
+ 'Amaralina',
+ 'Chapada do Rio Vermelho',
+ 'Engenho Velho da Federação',
+ 'Federação',
+ 'Ondina',
+ 'Rio Vermelho',
+ ]),
+ entry('salvador-ze-3', 'Salvador', 3, 'tre-ra-02-2017', [
+ 'Baixa de Quintas',
+ 'Cidade Nova',
+ 'Fazenda Grande do Retiro',
+ 'IAPI',
+ 'Pau Miúdo',
+ 'Pero Vaz',
+ 'Santa Mônica',
+ ]),
+ entry('salvador-ze-4', 'Salvador', 4, 'tre-ra-02-2017', [
+ 'Coutos',
+ 'Fazenda Coutos',
+ 'Ilha de Bom Jesus dos Passos',
+ 'Ilha de Maré',
+ 'Ilha dos Frades',
+ 'Nova Constituinte',
+ 'Paripe',
+ 'Periperi (parte ao norte da Rua das Pedrinhas)',
+ 'São Tomé',
+ ]),
+ entry('salvador-ze-5', 'Salvador', 5, 'tre-ra-02-2017', [
+ 'Barreiras',
+ 'Cabula',
+ 'Engomadeira',
+ 'Mata Escura',
+ 'Resgate',
+ 'Retiro',
+ 'São Gonçalo',
+ ]),
+ entry('salvador-ze-6', 'Salvador', 6, 'tre-ra-02-2017', [
+ 'Acupe',
+ 'Boa Vista de Brotas',
+ 'Brotas',
+ 'Candeal',
+ 'Engenho Velho de Brotas',
+ ]),
+ entry('salvador-ze-7', 'Salvador', 7, 'tre-ra-02-2017', [
+ 'Barbalho',
+ 'Barris',
+ 'Comércio',
+ 'Cosme de Farias',
+ 'Luiz Anselmo',
+ 'Macaúbas',
+ 'Matatu',
+ 'Nazaré',
+ 'Santo Agostinho',
+ 'Santo Antônio',
+ 'Saúde',
+ 'Tororó',
+ 'Vila Laura',
+ ]),
+ entry('salvador-ze-8', 'Salvador', 8, 'tre-ra-02-2017', [
+ 'Cajazeiras II',
+ 'Cajazeiras IV',
+ 'Cajazeiras V',
+ 'Cajazeiras VI',
+ 'Cajazeiras VII',
+ 'Cajazeiras VIII',
+ 'Campinas de Pirajá',
+ 'Marechal Rondon',
+ 'Moradas da Lagoa',
+ 'Palestina',
+ 'Pirajá',
+ 'Valéria',
+ ]),
+ entry('salvador-ze-9', 'Salvador', 9, 'tre-ra-02-2017', [
+ 'Boa Viagem',
+ 'Bonfim',
+ 'Jardim Cruzeiro',
+ 'Mangueira',
+ 'Massaranduba',
+ 'Monte Serrat',
+ 'Ribeira',
+ 'Santa Luzia',
+ 'Uruguai',
+ 'Vila Ruy Barbosa',
+ ]),
+ entry('salvador-ze-10', 'Salvador', 10, 'tre-ra-02-2017', [
+ 'Bairro da Paz',
+ 'Boca do Rio',
+ 'Costa Azul',
+ 'Imbuí',
+ 'Jardim Armação',
+ 'Patamares',
+ 'Piatã',
+ 'Pituaçu',
+ 'STIEP',
+ ]),
+ entry('salvador-ze-11', 'Salvador', 11, 'tre-ra-02-2017', [
+ 'Águas Claras',
+ 'Calabetão',
+ 'Castelo Branco',
+ 'Dom Avelar',
+ 'Granjas Rurais Presidente Vargas',
+ 'Jardim Cajazeiras',
+ 'Jardim Santo Inácio',
+ 'Pau da Lima',
+ 'Porto Seco Pirajá',
+ 'Sete de Abril',
+ 'Vila Canária',
+ ]),
+ entry('salvador-ze-12', 'Salvador', 12, 'tre-ra-02-2017', [
+ 'Aeroporto',
+ 'Alto do Coqueirinho',
+ 'Areia Branca',
+ 'Itapuã',
+ 'Itinga',
+ 'Jardim das Margaridas',
+ 'São Cristóvão',
+ 'Stella Maris',
+ ]),
+ entry('salvador-ze-13', 'Salvador', 13, 'tre-ra-02-2017', [
+ 'Caminho das Árvores',
+ 'Itaigara',
+ 'Nordeste de Amaralina',
+ 'Pituba',
+ 'Santa Cruz',
+ 'Vale das Pedrinhas',
+ ]),
+ entry('salvador-ze-14', 'Salvador', 14, 'tre-ra-02-2017', [
+ 'Arenoso',
+ 'Novo Horizonte',
+ 'São Marcos',
+ 'Sussuarana',
+ 'Tancredo Neves',
+ ]),
+ entry('salvador-ze-15', 'Salvador', 15, 'tre-ra-02-2017', [
+ 'Arraial do Retiro',
+ 'Boa Vista de São Caetano',
+ 'Bom Juá',
+ 'Capelinha',
+ 'Lobato',
+ 'São Caetano',
+ ]),
+ entry('salvador-ze-16', 'Salvador', 16, 'tre-ra-02-2017', [
+ 'Cabula VI',
+ 'Canabrava',
+ 'Centro Administrativo da Bahia',
+ 'Doron',
+ 'Narandiba',
+ 'Nova Sussuarana',
+ 'Pernambués',
+ 'Saboeiro',
+ 'São Rafael',
+ 'Saramandaia',
+ 'Vale dos Lagos',
+ ]),
+ entry('salvador-ze-17', 'Salvador', 17, 'tre-ra-02-2017', [
+ 'Alto da Terezinha',
+ 'Alto do Cabrito',
+ 'Itacaranha',
+ 'Periperi (parte ao sul da Rua das Pedrinhas)',
+ 'Plataforma',
+ 'Praia Grande',
+ 'Rio Sena',
+ 'São João do Cabrito',
+ ]),
+ entry('salvador-ze-18', 'Salvador', 18, 'tre-ra-02-2017', [
+ "Caixa D'água",
+ 'Calçada',
+ 'Caminho de Areia',
+ 'Curuzu',
+ 'Lapinha',
+ 'Liberdade',
+ 'Mares',
+ 'Roma',
+ ]),
+ entry('salvador-ze-19', 'Salvador', 19, 'tre-ra-02-2017', [
+ 'Boca da Mata',
+ 'Cajazeiras X',
+ 'Cajazeiras XI',
+ 'Cassange',
+ 'Fazenda Grande I',
+ 'Fazenda Grande II',
+ 'Fazenda Grande III',
+ 'Fazenda Grande IV',
+ 'Jaguaripe I',
+ 'Jardim Nova Esperança',
+ 'Mussurunga',
+ 'Nova Brasília',
+ 'Nova Esperança',
+ 'Novo Marotinho',
+ 'Trobogy',
+ ]),
+ entry('camacari-ze-170', 'Camaçari', 170, 'tre-voting-locations-curated', [
+ 'Alto da Cruz',
+ 'Bairro do Natal',
+ 'Centro',
+ 'Camaçari de Dentro',
+ 'Dois de Julho',
+ 'Gleba A',
+ 'Gleba B',
+ 'Gleba C',
+ 'Gleba E',
+ 'Gleba H',
+ 'Gravatá',
+ 'Inocoop',
+ 'Jardim Limoeiro',
+ 'Lama Preta',
+ 'Limoeiro',
+ 'Mangueiral',
+ 'Nova Vitória',
+ 'Novo Horizonte',
+ 'Parque Verde',
+ 'Phoc I',
+ 'Phoc II',
+ 'Phoc III',
+ 'Polo Petroquímico',
+ 'Triângulo',
+ ]),
+ entry('camacari-ze-171', 'Camaçari', 171, 'tre-voting-locations-curated', [
+ 'Abrantes',
+ 'Arembepe',
+ 'Barra do Jacuípe',
+ 'Barra do Pojuca',
+ 'Catu de Abrantes',
+ 'Guarajuba',
+ 'Itacimirim',
+ 'Jauá',
+ 'Monte Gordo',
+ 'Povoado de Areias',
+ 'Vila de Abrantes',
+ ]),
+]
+
+const entryBySlug = new Map(plazaZoneNeighborhoods.map((record) => [record.plazaSlug, record]))
+
+export const plazaZoneNeighborhoodEntryForSlug = (
+ slug: string,
+): PlazaZoneNeighborhoodEntry | undefined => entryBySlug.get(slug)
+
+export const plazaZoneNeighborhoodSourceLabel = (source: PlazaZoneNeighborhoodSource): string => {
+ switch (source) {
+ case 'tre-ra-02-2017':
+ return 'Bairros conforme circunscrição oficial do TRE-BA (Resolução Administrativa nº 2/2017).'
+ case 'tre-voting-locations-curated':
+ return 'Lista aproximada a partir de locais de votação do TRE-BA; não há circunscrição oficial zona↔bairro em Camaçari.'
+ }
+}
+
+const assertPlazaZoneNeighborhoodCatalogIntegrity = (): void => {
+ for (const record of plazaZoneNeighborhoods) {
+ const catalogEntry = getPlazaCatalogEntry(record.plazaSlug)
+ if (!catalogEntry || catalogEntry.kind !== 'zona') {
+ throw new Error(`Missing zona Praça catalog entry for slug: ${record.plazaSlug}`)
+ }
+ if (catalogEntry.city !== record.city || catalogEntry.zoneNumber !== record.zoneNumber) {
+ throw new Error(`Catalog mismatch for slug: ${record.plazaSlug}`)
+ }
+ }
+}
+
+assertPlazaZoneNeighborhoodCatalogIntegrity()
diff --git a/tests/fixtures/plaza-zone-neighborhoods.official.json b/tests/fixtures/plaza-zone-neighborhoods.official.json
new file mode 100644
index 00000000..81b46e7b
--- /dev/null
+++ b/tests/fixtures/plaza-zone-neighborhoods.official.json
@@ -0,0 +1,387 @@
+{
+ "provenance": {
+ "salvador": {
+ "description": "Circunscrição oficial por bairro das ZE 1–19, transcrita do Anexo I da Resolução Administrativa TRE-BA nº 2/2017.",
+ "url": "https://www.tre-ba.jus.br/legislacao/compilada/resolucao/2017/resolucao-administrativa-no-2-de-10-de-maio-de-2017",
+ "resolution": "RA 02/2017",
+ "accessed": "2026-07-21"
+ },
+ "camacari": {
+ "description": "Lista aproximada derivada de localidades nos locais de votação TRE-BA (ZE 170/171) e curadoria manual — não há circunscrição oficial zona↔bairro.",
+ "url": "https://www.tre-ba.jus.br/servicos-eleitorais/titulo-e-local-de-votacao",
+ "accessed": "2026-07-21"
+ }
+ },
+ "zoneCount": 21,
+ "assignments": [
+ {
+ "plazaSlug": "salvador-ze-1",
+ "city": "Salvador",
+ "zoneNumber": 1,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Alto das Pombas",
+ "Barra",
+ "Calabar",
+ "Canela",
+ "Centro",
+ "Centro Histórico",
+ "Garcia",
+ "Graça",
+ "Vitória"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-2",
+ "city": "Salvador",
+ "zoneNumber": 2,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Amaralina",
+ "Chapada do Rio Vermelho",
+ "Engenho Velho da Federação",
+ "Federação",
+ "Ondina",
+ "Rio Vermelho"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-3",
+ "city": "Salvador",
+ "zoneNumber": 3,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Baixa de Quintas",
+ "Cidade Nova",
+ "Fazenda Grande do Retiro",
+ "IAPI",
+ "Pau Miúdo",
+ "Pero Vaz",
+ "Santa Mônica"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-4",
+ "city": "Salvador",
+ "zoneNumber": 4,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Coutos",
+ "Fazenda Coutos",
+ "Ilha de Bom Jesus dos Passos",
+ "Ilha de Maré",
+ "Ilha dos Frades",
+ "Nova Constituinte",
+ "Paripe",
+ "Periperi (parte ao norte da Rua das Pedrinhas)",
+ "São Tomé"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-5",
+ "city": "Salvador",
+ "zoneNumber": 5,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Barreiras",
+ "Cabula",
+ "Engomadeira",
+ "Mata Escura",
+ "Resgate",
+ "Retiro",
+ "São Gonçalo"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-6",
+ "city": "Salvador",
+ "zoneNumber": 6,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Acupe",
+ "Boa Vista de Brotas",
+ "Brotas",
+ "Candeal",
+ "Engenho Velho de Brotas"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-7",
+ "city": "Salvador",
+ "zoneNumber": 7,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Barbalho",
+ "Barris",
+ "Comércio",
+ "Cosme de Farias",
+ "Luiz Anselmo",
+ "Macaúbas",
+ "Matatu",
+ "Nazaré",
+ "Santo Agostinho",
+ "Santo Antônio",
+ "Saúde",
+ "Tororó",
+ "Vila Laura"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-8",
+ "city": "Salvador",
+ "zoneNumber": 8,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Cajazeiras II",
+ "Cajazeiras IV",
+ "Cajazeiras V",
+ "Cajazeiras VI",
+ "Cajazeiras VII",
+ "Cajazeiras VIII",
+ "Campinas de Pirajá",
+ "Marechal Rondon",
+ "Moradas da Lagoa",
+ "Palestina",
+ "Pirajá",
+ "Valéria"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-9",
+ "city": "Salvador",
+ "zoneNumber": 9,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Boa Viagem",
+ "Bonfim",
+ "Jardim Cruzeiro",
+ "Mangueira",
+ "Massaranduba",
+ "Monte Serrat",
+ "Ribeira",
+ "Santa Luzia",
+ "Uruguai",
+ "Vila Ruy Barbosa"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-10",
+ "city": "Salvador",
+ "zoneNumber": 10,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Bairro da Paz",
+ "Boca do Rio",
+ "Costa Azul",
+ "Imbuí",
+ "Jardim Armação",
+ "Patamares",
+ "Piatã",
+ "Pituaçu",
+ "STIEP"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-11",
+ "city": "Salvador",
+ "zoneNumber": 11,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Águas Claras",
+ "Calabetão",
+ "Castelo Branco",
+ "Dom Avelar",
+ "Granjas Rurais Presidente Vargas",
+ "Jardim Cajazeiras",
+ "Jardim Santo Inácio",
+ "Pau da Lima",
+ "Porto Seco Pirajá",
+ "Sete de Abril",
+ "Vila Canária"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-12",
+ "city": "Salvador",
+ "zoneNumber": 12,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Aeroporto",
+ "Alto do Coqueirinho",
+ "Areia Branca",
+ "Itapuã",
+ "Itinga",
+ "Jardim das Margaridas",
+ "São Cristóvão",
+ "Stella Maris"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-13",
+ "city": "Salvador",
+ "zoneNumber": 13,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Caminho das Árvores",
+ "Itaigara",
+ "Nordeste de Amaralina",
+ "Pituba",
+ "Santa Cruz",
+ "Vale das Pedrinhas"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-14",
+ "city": "Salvador",
+ "zoneNumber": 14,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Arenoso",
+ "Novo Horizonte",
+ "São Marcos",
+ "Sussuarana",
+ "Tancredo Neves"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-15",
+ "city": "Salvador",
+ "zoneNumber": 15,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Arraial do Retiro",
+ "Boa Vista de São Caetano",
+ "Bom Juá",
+ "Capelinha",
+ "Lobato",
+ "São Caetano"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-16",
+ "city": "Salvador",
+ "zoneNumber": 16,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Cabula VI",
+ "Canabrava",
+ "Centro Administrativo da Bahia",
+ "Doron",
+ "Narandiba",
+ "Nova Sussuarana",
+ "Pernambués",
+ "Saboeiro",
+ "São Rafael",
+ "Saramandaia",
+ "Vale dos Lagos"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-17",
+ "city": "Salvador",
+ "zoneNumber": 17,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Alto da Terezinha",
+ "Alto do Cabrito",
+ "Itacaranha",
+ "Periperi (parte ao sul da Rua das Pedrinhas)",
+ "Plataforma",
+ "Praia Grande",
+ "Rio Sena",
+ "São João do Cabrito"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-18",
+ "city": "Salvador",
+ "zoneNumber": 18,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Caixa D'água",
+ "Calçada",
+ "Caminho de Areia",
+ "Curuzu",
+ "Lapinha",
+ "Liberdade",
+ "Mares",
+ "Roma"
+ ]
+ },
+ {
+ "plazaSlug": "salvador-ze-19",
+ "city": "Salvador",
+ "zoneNumber": 19,
+ "source": "tre-ra-02-2017",
+ "neighborhoods": [
+ "Boca da Mata",
+ "Cajazeiras X",
+ "Cajazeiras XI",
+ "Cassange",
+ "Fazenda Grande I",
+ "Fazenda Grande II",
+ "Fazenda Grande III",
+ "Fazenda Grande IV",
+ "Jaguaripe I",
+ "Jardim Nova Esperança",
+ "Mussurunga",
+ "Nova Brasília",
+ "Nova Esperança",
+ "Novo Marotinho",
+ "Trobogy"
+ ]
+ },
+ {
+ "plazaSlug": "camacari-ze-170",
+ "city": "Camaçari",
+ "zoneNumber": 170,
+ "source": "tre-voting-locations-curated",
+ "neighborhoods": [
+ "Alto da Cruz",
+ "Bairro do Natal",
+ "Camaçari de Dentro",
+ "Centro",
+ "Dois de Julho",
+ "Gleba A",
+ "Gleba B",
+ "Gleba C",
+ "Gleba E",
+ "Gleba H",
+ "Gravatá",
+ "Inocoop",
+ "Jardim Limoeiro",
+ "Lama Preta",
+ "Limoeiro",
+ "Mangueiral",
+ "Nova Vitória",
+ "Novo Horizonte",
+ "Parque Verde",
+ "Phoc I",
+ "Phoc II",
+ "Phoc III",
+ "Polo Petroquímico",
+ "Triângulo"
+ ]
+ },
+ {
+ "plazaSlug": "camacari-ze-171",
+ "city": "Camaçari",
+ "zoneNumber": 171,
+ "source": "tre-voting-locations-curated",
+ "neighborhoods": [
+ "Abrantes",
+ "Arembepe",
+ "Barra do Jacuípe",
+ "Barra do Pojuca",
+ "Catu de Abrantes",
+ "Guarajuba",
+ "Itacimirim",
+ "Jauá",
+ "Monte Gordo",
+ "Povoado de Areias",
+ "Vila de Abrantes"
+ ]
+ }
+ ],
+ "evidenceSha256": "1879319cb12b85697a60e6903f633ef20d23bc031361f862991f26ffb6168187"
+}
diff --git a/tests/int/plazaZoneNeighborhoods.int.spec.ts b/tests/int/plazaZoneNeighborhoods.int.spec.ts
new file mode 100644
index 00000000..bd2a172d
--- /dev/null
+++ b/tests/int/plazaZoneNeighborhoods.int.spec.ts
@@ -0,0 +1,126 @@
+// @vitest-environment node
+
+import { createHash } from 'node:crypto'
+import { readFileSync } from 'node:fs'
+
+import { describe, expect, it } from 'vitest'
+
+import { plazaCatalog } from '@/lib/plazaCatalog'
+import {
+ plazaZoneNeighborhoodEntryForSlug,
+ plazaZoneNeighborhoodSourceLabel,
+ plazaZoneNeighborhoods,
+} from '@/lib/plazaZoneNeighborhoods'
+
+type OfficialEvidence = {
+ zoneCount: number
+ assignments: Array<{
+ plazaSlug: string
+ city: 'Salvador' | 'Camaçari'
+ zoneNumber: number
+ source: 'tre-ra-02-2017' | 'tre-voting-locations-curated'
+ neighborhoods: string[]
+ }>
+ evidenceSha256: string
+}
+
+const officialEvidence = JSON.parse(
+ readFileSync(new URL('../fixtures/plaza-zone-neighborhoods.official.json', import.meta.url), 'utf8'),
+) as OfficialEvidence
+
+const canonicalEvidenceRows = (assignments: OfficialEvidence['assignments']): string =>
+ [...assignments]
+ .sort((left, right) => left.plazaSlug.localeCompare(right.plazaSlug, 'pt-BR'))
+ .map(
+ ({ plazaSlug, city, zoneNumber, neighborhoods }) =>
+ `${plazaSlug}\t${city}\t${zoneNumber}\t${[...neighborhoods]
+ .sort((left, right) => left.localeCompare(right, 'pt-BR'))
+ .join('|')}\n`,
+ )
+ .join('')
+
+describe('Praça-zona neighborhood catalog (Salvador + Camaçari)', () => {
+ const zonePlazas = plazaCatalog.filter((entry) => entry.kind === 'zona')
+
+ it('covers every zona Praça with a non-empty neighborhood list', () => {
+ expect(plazaZoneNeighborhoods).toHaveLength(zonePlazas.length)
+ expect(new Set(plazaZoneNeighborhoods.map((entry) => entry.plazaSlug)).size).toBe(
+ zonePlazas.length,
+ )
+
+ for (const plaza of zonePlazas) {
+ const record = plazaZoneNeighborhoodEntryForSlug(plaza.slug)
+ expect(record, plaza.slug).toBeDefined()
+ expect(record?.city).toBe(plaza.city)
+ expect(record?.zoneNumber).toBe(plaza.zoneNumber)
+ expect(record?.neighborhoods.length, plaza.slug).toBeGreaterThan(0)
+ }
+ })
+
+ it('keeps Salvador neighborhoods exclusive across zones (Camaçari is curated — no cross-zone exclusivity test)', () => {
+ const seen = new Map()
+
+ for (const record of plazaZoneNeighborhoods.filter((entry) => entry.city === 'Salvador')) {
+ for (const neighborhood of record.neighborhoods) {
+ const previous = seen.get(neighborhood)
+ expect(previous, `${neighborhood} in ZE ${record.zoneNumber}`).toBeUndefined()
+ seen.set(neighborhood, record.plazaSlug)
+ }
+ }
+ })
+
+ it('matches the independently transcribed official evidence fixture', () => {
+ expect(officialEvidence.zoneCount).toBe(zonePlazas.length)
+ expect(
+ officialEvidence.assignments.map(
+ ({ plazaSlug, city, zoneNumber, source, neighborhoods }) => ({
+ plazaSlug,
+ city,
+ zoneNumber,
+ source,
+ neighborhoods,
+ }),
+ ),
+ ).toEqual(
+ plazaZoneNeighborhoods.map(({ plazaSlug, city, zoneNumber, source, neighborhoods }) => ({
+ plazaSlug,
+ city,
+ zoneNumber,
+ source,
+ neighborhoods: [...neighborhoods],
+ })),
+ )
+ })
+
+ it('matches every assignment by a fixed evidence checksum', () => {
+ const digest = createHash('sha256')
+ .update(
+ canonicalEvidenceRows(
+ plazaZoneNeighborhoods.map(
+ ({ plazaSlug, city, zoneNumber, source, neighborhoods }) => ({
+ plazaSlug,
+ city,
+ zoneNumber,
+ source,
+ neighborhoods: [...neighborhoods],
+ }),
+ ),
+ ),
+ )
+ .digest('hex')
+ expect(digest).toBe(officialEvidence.evidenceSha256)
+ })
+
+ it('exposes lookup helpers for zona slugs only', () => {
+ expect(plazaZoneNeighborhoodEntryForSlug('salvador-ze-3')?.neighborhoods).toContain(
+ 'Santa Mônica',
+ )
+ expect(plazaZoneNeighborhoodEntryForSlug('camacari-ze-171')?.neighborhoods).toContain(
+ 'Arembepe',
+ )
+ expect(plazaZoneNeighborhoodEntryForSlug('feira-de-santana')).toBeUndefined()
+ expect(plazaZoneNeighborhoodEntryForSlug('salvador-ze-1')?.source).toBe('tre-ra-02-2017')
+ expect(plazaZoneNeighborhoodSourceLabel('tre-ra-02-2017')).toMatch(/Resolução Administrativa/)
+ expect(plazaZoneNeighborhoodSourceLabel('tre-voting-locations-curated')).toMatch(/aproximada/)
+ })
+})