diff --git a/docs/plans/escala-dry-pos-a4.md b/docs/plans/escala-dry-pos-a4.md
index 68d10646..4db1b507 100644
--- a/docs/plans/escala-dry-pos-a4.md
+++ b/docs/plans/escala-dry-pos-a4.md
@@ -1,7 +1,7 @@
# Escala e DRY pós-A4 (baseline TSE no produto)
-Status: Fase 1 implementada (Fases 2–5 pendentes; merge pendente)
-Atualizado em: 2026-07-19 (Fase 5 lista flip/leverage + extensão F3 gap registradas via `capture-review-debts` pós-A5 `/simplify`; **pós-A5-2 classificação territorial** — absorções no mesmo plano via `capture-review-debts`)
+Status: Fases 1–2 implementadas (Fases 3–5 pendentes; merge pendente)
+Atualizado em: 2026-07-20 (`capture-review-debts` pós-A7 F2 `/simplify`: débitos absorvidos no plano; gatilhos defer no Explicitamente fora)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
Responsável: —
@@ -12,7 +12,7 @@ O A4 ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) Fases 2–4) entreg
Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Sem registro, o A5 (insights) e o coroplético do B3 herdariam o mesmo custo de I/O no path do detalhe.
1. **Detalhe puxa todas as linhas nominais de dep. federal (1º turno) na geografia.** ~~`detailVoteWhere` + `loadElectionVotes`~~ **Resolvido na Fase 1 (2026-07-19):** `loadFederalCandidateTotalsAggregated` (`src/utilities/federalCandidateTotalsAggregate.ts`) agrega via drizzle `SUM(votes) GROUP BY candidate_number`; o detalhe usa Local API só para presidente/governador da chapa (`loadTicketOfficeVotes`). A série histórica E2 (`loadCandidateSeriesByGeography`) já era magra por `candidateNumber`.
-2. **Filtro geográfico por `cityName` (texto), não por `cityCode` TSE.** O índice natural das collections de eleição é `(year, office, turn, cityCode, zoneNumber)`; filtrar por nome impede o plano ótimo e complica OR grandes na união do overview (já mitigado no simplify, mas o detalhe/TI ainda sofre).
+2. **Filtro geográfico por `cityName` (texto), não por `cityCode` TSE.** ~~O índice natural das collections de eleição é `(year, office, turn, cityCode, zoneNumber)`; filtrar por nome impedia o plano ótimo e complicava OR grandes na união do overview.~~ **Resolvido na Fase 2 (2026-07-20):** mapa estático `bahiaTseCityCodes.ts` + `nucleusElectionGeography.ts`; `geographyWhere` e drizzle federal usam `cityCode` + `zoneNumber in […]`.
3. **DRY de UI do Gap "acima".** `NucleusInsights` usa `Alert variant="pending"` para abaixo/neutro, mas o estado "acima" aplica classes ad-hoc com tokens `--estimate-confirmed*`. O slice **A5-1 conversão** reutiliza o mesmo `confirmedInsightAlertClass` para o alerta de taxa de conversão (reduto/consolidado) — interim até a Fase 3. A barra do candidato no card também reimplementa `role="progressbar"` em vez de reusar `Progress` (`NucleusListOverview`).
4. **Query duplicada de votos 2022 na lista de núcleos com coroplético B3.** `nucleusListOverviewPageData.ts` chama em paralelo `loadNucleusListElectionOverview` (union → `loadCandidateSeriesByGeography` multi-ano) e `loadNucleusChoroplethBundle` → `loadBaseline2022VotesByCityNames` (segunda passagem `loadCandidateSeriesByGeography` só 2022 sobre geografia sobreposta). O simplify B3 unificou resolve interno do choropleth, mas não compartilha o resultado da série 2022 já obtida no loader de gap/tendência/conversão.
5. **N+1 de agregação federal no flip da lista (A5 Fase 2).** `loadNucleusListElectionOverview` chama `loadFederalCandidateTotalsAggregated` **uma vez por núcleo** com geografia resolvida para `computeTicketFlipForGeography`, enquanto série/ticket/majoritarian já vêm numa união. Com dezenas de núcleos filtrados, `Promise.all` paraleliza latência mas não reduz carga no Postgres — mesmo anti-padrão que A7 F1 resolveu no **detalhe**.
@@ -24,7 +24,9 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
**Já resolvido no simplify pós-A5-2 classificação territorial (não reabrir):** `sumFederalTallyForGeography` único (substitui `sumElectorateForGeography` + helper paralelo); reuso de `classificationTalliesByCityZone` no loop de conversão (elimina 2º `loadElectionTallies` em `conversionUnion`); loop de classificação in-memory antes do `await` do flip N+1; push só de bandas comparáveis (`isComparableTerritorialClass`) — evita linha overview `0 defesa · …`; `TERRITORIAL_CLASS_UI` canônico + accessors finos; `TerritorialClassIcon` no detalhe.
-**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights); **pós-A5-2:** agregador genérico conversion+territorial, achatar `NucleusClassificationOverviewAggregate`, `percentValid` só em testes, merge CPU dos loops `sumCandidateVotesForGeography` trend+classificação, 4ª query `loadElectionTallies` no union (custo intencional A5-2), `territorialClassLabel` na linha do overview (conversão também hardcoded), componente compartilhado de linha de distribuição → **E6 F3**.
+**Já resolvido no simplify pós-F2 `cityCode` (não reabrir):** `matchesGeography` permanece helper privado em `nucleusElectoralBaseline.ts` (não exportado de `nucleusElectionGeography`); consumers de geografia importam direto de `nucleusElectionGeography` (`nucleusChoropleth*`, `nucleusDetailPageData`, `nucleusListOverviewPageData`, `campaignDashboardPageData`, specs); re-exports mortos removidos do barrel baseline; testes de `resolveNucleusElectionGeography` em `tests/unit/nucleusElectionGeography.unit.spec.ts`; int spy federal T1 + tally `cityCode` fundidos num único teste (`tseCityCodeForMunicipality('Salvador')` em vez de código hardcoded); mock obsoleto `toNucleusElectionGeographyInput` removido de `campaignNucleusDetailTabs.unit.spec.ts`.
+
+**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), **cache `zonesByCityCode` no objeto `NucleusElectionGeography`** (defer: só se hot path comprovado ou ≥3 recomputações por request), **mover `buildUnionGeography` para `nucleusElectionGeography`** (defer: 3º call site de union geography ou refactor A7 F5), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights); **pós-A5-2:** agregador genérico conversion+territorial, achatar `NucleusClassificationOverviewAggregate`, `percentValid` só em testes, merge CPU dos loops `sumCandidateVotesForGeography` trend+classificação, 4ª query `loadElectionTallies` no union (custo intencional A5-2), `territorialClassLabel` na linha do overview (conversão também hardcoded), componente compartilhado de linha de distribuição → **E6 F3**; **pós-F2:** assertions redundantes no int `bahiaTseCityCodes` (padrão intencional espelhando fixture IBGE); falhas pré-existentes `campaignNucleusIntelligenceUi.unit.spec.ts` (fora do escopo A7 F2).
## Objetivos
@@ -56,7 +58,7 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
flowchart TD
A4["A4 Baseline no produto ✓"] --> F1
F1["Fase 1 — Agregar federal no detalhe
(GROUP BY / payload estreito) ✓"]
- F1 --> F2["Fase 2 — Geografia por cityCode TSE"]
+ F1 --> F2["Fase 2 — Geografia por cityCode TSE ✓"]
F1 --> F3["Fase 3 — Alert confirmed + Progress
+ gapVs2022 helpers"]
F1 --> F4["Fase 4 — Fetch 2022 único
lista overview + coroplético"]
F1 --> F5["Fase 5 — Batch federal lista
+ index union (A5 flip/leverage)"]
@@ -72,13 +74,12 @@ flowchart TD
- `src/utilities/nucleusElectoralBaseline.ts`: `loadTicketOfficeVotes` (Local API magro) + `aggregateNucleusElectoralBaseline` recebe `federalTotals` já agregados; `getNucleusElectoralBaseline` widened para `Pick`.
- Testes: unit `federalCandidateTotalsAggregate.unit.spec.ts`; int spy em `payload.find` garante que queries federal T1 no Local API sempre filtram `candidateNumber`.
-### Fase 2 — Filtro por `cityCode` TSE
+### Fase 2 — Filtro por `cityCode` TSE ✓ (2026-07-20)
-- Extrair tipos/helpers de geografia para `src/utilities/nucleusElectionGeography.ts` (quebra o ciclo `federalCandidateTotalsAggregate` ↔ `nucleusElectoralBaseline`; hoje o aggregate importa `NucleusElectionGeography` do baseline).
-- Gerar/commitar mapa canônico `cityName` → `cityCode` (BA 2022) a partir dos dados já importados.
-- `resolveNucleusElectionGeography` (ou camada ao lado) passa a expor `cityZonePairs` com código; `geographyWhere` e `buildFederalCandidateTotalsSql` usam `cityCode: { equals|in }` + `zoneNumber: { in }` (unifica Payload `Where` e SQL cru).
-- Overview union e detalhe compartilham o mesmo helper.
-- Sem mudança de UX; fallback fail-closed se o município do núcleo não mapear (mesmo espírito de `canonicalizeMunicipalityName`).
+- `src/lib/bahiaTseCityCodes.ts` + `pnpm build:tse-city-codes` (TSE 2022 `detalhe_votacao_munzona` BA).
+- `src/utilities/nucleusElectionGeography.ts`: tipos/helpers extraídos; `cityZonePairs` com `cityCode`; `geographyWhere` + `zonesByCityCode`.
+- `federalCandidateTotalsAggregate.ts`: SQL em `city_code`.
+- Fail-closed quando município não mapeia; sem migration.
### Fase 3 — Alert `confirmed` + `Progress` + helpers Gap vs 2022
diff --git a/docs/plans/escala-dry-pos-b2.md b/docs/plans/escala-dry-pos-b2.md
index 3092eb91..64e48d52 100644
--- a/docs/plans/escala-dry-pos-b2.md
+++ b/docs/plans/escala-dry-pos-b2.md
@@ -1,7 +1,7 @@
# Escala e DRY pós-B2 (geometrias + scripts CLI)
Status: Fase 1 entregue com B3 (2026-07-19); Fases 2–3 pendentes
-Atualizado em: 2026-07-19 (Fase 3 factory geometrias registrada via `capture-review-debts` pós-B3)
+Atualizado em: 2026-07-20 (nota cruzada `capture-review-debts` pós-A7 F2: `build-bahia-tse-city-codes.mjs` reforça gatilho F2)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B5)
Responsável: —
@@ -10,7 +10,7 @@ Responsável: —
O B2 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 1) entregou a fundação estática do mapa: `*.topo.json` de municípios e Territórios de Identidade, `bahiaMunicipalityCodes`, helpers em `bahiaGeometries.ts`, script `pnpm build:geometries`. A passagem `/simplify` de 2026-07-18 limpou dead code e churns baratos no script/codegen, mas **deixou de fora** dois follow-ups que os revisores (performance / reuse) marcaram como importantes e maiores que cleanup pontual:
1. **Carregamento eager de geometrias no cliente.** `src/lib/bahiaGeometries.ts` importa os dois TopoJSON, roda `topojson-client` `feature()` em 417+27 polígonos e monta índices no load do módulo — custo de bundle/memória antes de qualquer superfície Leaflet (B3).
-2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse.
+2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse. **Pós-A7 F2:** `scripts/build-bahia-tse-city-codes.mjs` (`pnpm build:tse-city-codes`) clona o mesmo padrão `ensureCachedZip` — terceiro call site, reforça prioridade da Fase 2 deste plano sem novo ID.
3. **Factory duplicada mun/TI pós-B3 F1.** `bahiaMunicipalityGeometries.ts` e `bahiaTerritoryGeometries.ts` repetem o mesmo pipeline `topojson.feature` → array → `Map` por chave → export do módulo indexado — só mudam topology object, propriedade de chave (`codarea` vs `code`) e getter.
Este plano é o registro canônico desses follow-ups. Sem ele, o B3 pode embarcar o mapa com payload desnecessário no path default, e o próximo script de dado aberto volta a clonar o cache.
@@ -63,7 +63,7 @@ flowchart TD
- Extrair `ensureCachedDownload({ cacheDir, key, url, ext?, logPrefix })` a partir de `ensureCachedJson` / `ensureCachedZip`.
- Retorno: `{ url, buffer, hash }` (parse JSON/ZIP fica no caller — `build-bahia-geometries` já faz `JSON.parse`; seed TSE usa `readZipEntry`).
- Continuar usando `downloadToBuffer` (`src/lib/electionResultsZip.ts`).
-- Migrar `scripts/build-bahia-geometries.mjs` e `scripts/seed-tse-results.mjs`; sem mudança de comportamento nem de pastas `data/geometries/` / `data/tse/`.
+- Migrar `scripts/build-bahia-geometries.mjs`, `scripts/seed-tse-results.mjs` e `scripts/build-bahia-tse-city-codes.mjs`; sem mudança de comportamento nem de pastas `data/geometries/` / `data/tse/`.
### Fase 3 — Factory compartilhada mun/TI
diff --git a/package.json b/package.json
index 1a33bfcd..93ced912 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
+ "build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
diff --git a/scripts/build-bahia-tse-city-codes.mjs b/scripts/build-bahia-tse-city-codes.mjs
new file mode 100644
index 00000000..a2c8f201
--- /dev/null
+++ b/scripts/build-bahia-tse-city-codes.mjs
@@ -0,0 +1,202 @@
+/**
+ * Builds static Bahia municipality name → TSE city code (CD_MUNICIPIO) mapping.
+ *
+ * Provenance (do not strip):
+ * - TSE open data — Results 2022, detalhe da apuração por município e zona, UF BA:
+ * https://dadosabertos.tse.jus.br/dataset/resultados-2022
+ * https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip
+ * Entry: detalhe_votacao_munzona_2022_BA.csv
+ *
+ * Safety: does not touch any database — only downloads public TSE CSV and writes
+ * versioned artifacts under src/lib/ and tests/fixtures/.
+ *
+ * Usage:
+ * pnpm build:tse-city-codes
+ * TSE_CACHE_DIR=./data/tse pnpm build:tse-city-codes
+ */
+
+import { createHash } from 'node:crypto'
+import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const { canonicalizeMunicipalityName, UnknownMunicipalityError } = await import(
+ '../src/lib/electionResults.ts'
+)
+const { bahiaMunicipalities } = await import('../src/lib/bahiaTerritories.ts')
+const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
+const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
+
+const SOURCE = {
+ key: 'detalhe_votacao_munzona_2022',
+ url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip',
+ csvName: 'detalhe_votacao_munzona_2022_BA.csv',
+}
+
+const die = (message) => {
+ console.error(`\n[build:tse-city-codes] ${message}\n`)
+ process.exit(1)
+}
+
+const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
+
+const cacheDir = () => process.env.TSE_CACHE_DIR || join(ROOT, 'data', 'tse')
+
+const ensureCachedZip = async () => {
+ const dir = cacheDir()
+ await mkdir(dir, { recursive: true })
+ const path = join(dir, `${SOURCE.key}.zip`)
+ try {
+ await access(path)
+ console.log(`[build:tse-city-codes] cache hit ${path}`)
+ const buffer = await readFile(path)
+ return { url: SOURCE.url, hash: sha256(buffer), buffer }
+ } catch (error) {
+ if (error?.code !== 'ENOENT') throw error
+ }
+ console.log(`[build:tse-city-codes] downloading ${SOURCE.url}`)
+ const buffer = await downloadToBuffer(SOURCE.url)
+ const hash = sha256(buffer)
+ await writeFile(path, buffer)
+ console.log(`[build:tse-city-codes] saved ${path} (${buffer.length} bytes, sha256=${hash})`)
+ return { url: SOURCE.url, hash, buffer }
+}
+
+const writeJson = async (relativePath, value) => {
+ const path = join(ROOT, relativePath)
+ await mkdir(dirname(path), { recursive: true })
+ const body = `${JSON.stringify(value, null, 2)}\n`
+ await writeFile(path, body)
+ console.log(`[build:tse-city-codes] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
+}
+
+const writeText = async (relativePath, body) => {
+ const path = join(ROOT, relativePath)
+ await mkdir(dirname(path), { recursive: true })
+ await writeFile(path, body)
+ console.log(`[build:tse-city-codes] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
+}
+
+const buildCityCodesModule = (entries, provenance) => {
+ const lines = entries
+ .map(([name, code]) => ` ${JSON.stringify(name)}: ${JSON.stringify(code)},`)
+ .join('\n')
+
+ return `/**
+ * Official Bahia municipality name → TSE city code (CD_MUNICIPIO) mapping.
+ *
+ * Source (TSE open data — Results 2022, detalhe da apuração por município e zona, UF BA; accessed ${provenance.accessed}):
+ * ${provenance.url}
+ * Download SHA-256: ${provenance.downloadSha256}
+ * CSV entry: ${provenance.csvName}
+ *
+ * This is the TSE electoral municipality code — NOT the IBGE 7-digit codarea
+ * (see bahiaMunicipalityCodes.ts). Election collections filter by this code.
+ *
+ * Municipality spellings follow the canonical application names in bahiaTerritories.
+ * TSE variants were reconciled via canonicalizeMunicipalityName. Coverage is
+ * independently checked by the official test evidence fixture.
+ */
+
+import { isBahiaMunicipality } from '@/lib/bahiaTerritories'
+
+export const bahiaTseCityCodes: Readonly> = {
+${lines}
+}
+
+export const tseCityCodeForMunicipality = (name: string): string | undefined => {
+ if (!isBahiaMunicipality(name)) return undefined
+ return bahiaTseCityCodes[name]
+}
+
+const municipalityByTseCityCode = new Map(
+ Object.entries(bahiaTseCityCodes).map(([name, code]) => [code, name]),
+)
+
+export const municipalityForTseCityCode = (cityCode: string): string | undefined =>
+ municipalityByTseCityCode.get(cityCode)
+`
+}
+
+const main = async () => {
+ const { url, hash, buffer } = await ensureCachedZip()
+ const entry = await readZipEntry(buffer, SOURCE.csvName)
+ if (!entry) die(`Missing ${SOURCE.csvName} in TSE zip`)
+ const rows = parseTseCsvBuffer(entry.buffer)
+
+ /** @type {Map} canonical name → TSE city code */
+ const codeByCanonical = new Map()
+ /** @type {Map} TSE city code → canonical name */
+ const canonicalByCode = new Map()
+
+ for (const row of rows) {
+ if ((row.SG_UF ?? '').trim().toUpperCase() !== 'BA') continue
+ const rawCode = (row.CD_MUNICIPIO ?? '').trim()
+ if (!rawCode) continue
+ let canonical
+ try {
+ canonical = canonicalizeMunicipalityName(row.NM_MUNICIPIO ?? '')
+ } catch (error) {
+ if (!(error instanceof UnknownMunicipalityError)) throw error
+ die(`TSE name without canonical mapping: "${row.NM_MUNICIPIO}"`)
+ }
+ const existing = codeByCanonical.get(canonical)
+ if (existing && existing !== rawCode) {
+ die(`Conflicting TSE codes for ${canonical}: ${existing} vs ${rawCode}`)
+ }
+ const existingName = canonicalByCode.get(rawCode)
+ if (existingName && existingName !== canonical) {
+ die(`Conflicting municipalities for TSE code ${rawCode}: ${existingName} vs ${canonical}`)
+ }
+ codeByCanonical.set(canonical, rawCode)
+ canonicalByCode.set(rawCode, canonical)
+ }
+
+ if (codeByCanonical.size !== 417) {
+ die(`Expected 417 Bahia municipalities; parsed ${codeByCanonical.size} from TSE CSV`)
+ }
+
+ const missing = bahiaMunicipalities.filter((city) => !codeByCanonical.has(city))
+ if (missing.length > 0) {
+ die(`Missing TSE city codes for: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '…' : ''}`)
+ }
+
+ const accessed = new Date().toISOString().slice(0, 10)
+ const provenance = { accessed, url, downloadSha256: hash, csvName: SOURCE.csvName }
+
+ const codeEntries = [...codeByCanonical.entries()].sort(([left], [right]) =>
+ left.localeCompare(right, 'pt-BR'),
+ )
+
+ await writeText('src/lib/bahiaTseCityCodes.ts', buildCityCodesModule(codeEntries, provenance))
+
+ const assignmentRows = codeEntries
+ .map(([municipality, code]) => `M\t${municipality}\t${code}\n`)
+ .join('')
+ const evidenceSha256 = createHash('sha256').update(assignmentRows).digest('hex')
+
+ await writeJson('tests/fixtures/bahia-tse-city-codes.official.json', {
+ provenance: {
+ description:
+ 'Test-only evidence of Bahia municipality→TSE city code pairs derived independently from the TSE 2022 detalhe_votacao_munzona BA CSV. It was not generated from src/lib/bahiaTseCityCodes.ts.',
+ source: {
+ portal: 'https://dadosabertos.tse.jus.br/dataset/resultados-2022',
+ url,
+ downloadSha256: hash,
+ csvName: SOURCE.csvName,
+ },
+ },
+ municipalityCount: 417,
+ assignments: codeEntries.map(([municipality, code]) => ({ municipality, code })),
+ evidenceSha256,
+ })
+
+ console.log('[build:tse-city-codes] done — Salvador TSE code:', codeByCanonical.get('Salvador'))
+}
+
+main().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
diff --git a/src/lib/bahiaTseCityCodes.ts b/src/lib/bahiaTseCityCodes.ts
new file mode 100644
index 00000000..3f2f2179
--- /dev/null
+++ b/src/lib/bahiaTseCityCodes.ts
@@ -0,0 +1,449 @@
+/**
+ * Official Bahia municipality name → TSE city code (CD_MUNICIPIO) mapping.
+ *
+ * Source (TSE open data — Results 2022, detalhe da apuração por município e zona, UF BA; accessed 2026-07-20):
+ * https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip
+ * Download SHA-256: 945741952e161453a8991c5a6f241f6ab8dc3992ae2060ea020cd24cfecf3b29
+ * CSV entry: detalhe_votacao_munzona_2022_BA.csv
+ *
+ * This is the TSE electoral municipality code — NOT the IBGE 7-digit codarea
+ * (see bahiaMunicipalityCodes.ts). Election collections filter by this code.
+ *
+ * Municipality spellings follow the canonical application names in bahiaTerritories.
+ * TSE variants were reconciled via canonicalizeMunicipalityName. Coverage is
+ * independently checked by the official test evidence fixture.
+ */
+
+import { isBahiaMunicipality } from '@/lib/bahiaTerritories'
+
+export const bahiaTseCityCodes: Readonly> = {
+ "Abaíra": "33014",
+ "Abaré": "33030",
+ "Acajutiba": "33057",
+ "Adustina": "33065",
+ "Água Fria": "33073",
+ "Aiquara": "33111",
+ "Alagoinhas": "33138",
+ "Alcobaça": "33154",
+ "Almadina": "33170",
+ "Amargosa": "33197",
+ "Amélia Rodrigues": "33219",
+ "América Dourada": "30716",
+ "Anagé": "33235",
+ "Andaraí": "33251",
+ "Andorinha": "33260",
+ "Angical": "33278",
+ "Anguera": "33294",
+ "Antas": "33316",
+ "Antônio Cardoso": "33332",
+ "Antônio Gonçalves": "33359",
+ "Aporá": "33375",
+ "Apuarema": "33405",
+ "Araças": "33383",
+ "Aracatu": "33391",
+ "Araci": "33413",
+ "Aramari": "33430",
+ "Arataca": "30732",
+ "Aratuípe": "33456",
+ "Aurelino Leal": "33472",
+ "Baianópolis": "33499",
+ "Baixa Grande": "33510",
+ "Banzaê": "33529",
+ "Barra": "33537",
+ "Barra da Estiva": "33553",
+ "Barra do Choça": "33570",
+ "Barra do Mendes": "33596",
+ "Barra do Rocha": "33618",
+ "Barreiras": "33634",
+ "Barro Alto": "30759",
+ "Barro Preto": "33650",
+ "Barrocas": "30775",
+ "Belmonte": "33677",
+ "Belo Campo": "33693",
+ "Biritinga": "33715",
+ "Boa Nova": "33731",
+ "Boa Vista do Tupim": "33758",
+ "Bom Jesus da Lapa": "33774",
+ "Bom Jesus da Serra": "33782",
+ "Boninal": "33790",
+ "Bonito": "33804",
+ "Boquira": "33812",
+ "Botuporã": "33839",
+ "Brejões": "33855",
+ "Brejolândia": "33871",
+ "Brotas de Macaúbas": "33898",
+ "Brumado": "33910",
+ "Buerarema": "33936",
+ "Buritirama": "30791",
+ "Caatiba": "33952",
+ "Cabaceiras do Paraguaçu": "33960",
+ "Cachoeira": "33979",
+ "Caculé": "33995",
+ "Caém": "34010",
+ "Caetanos": "34029",
+ "Caetité": "34037",
+ "Cafarnaum": "34053",
+ "Cairu": "34070",
+ "Caldeirão Grande": "34096",
+ "Camacan": "34118",
+ "Camaçari": "34134",
+ "Camamu": "34150",
+ "Campo Alegre de Lourdes": "34177",
+ "Campo Formoso": "34193",
+ "Canápolis": "34215",
+ "Canarana": "34231",
+ "Canavieiras": "34258",
+ "Candeal": "34274",
+ "Candeias": "34290",
+ "Candiba": "34312",
+ "Cândido Sales": "34339",
+ "Cansanção": "34355",
+ "Canudos": "30856",
+ "Capela do Alto Alegre": "30813",
+ "Capim Grosso": "30830",
+ "Caraíbas": "34363",
+ "Caravelas": "34371",
+ "Cardeal da Silva": "34398",
+ "Carinhanha": "34410",
+ "Casa Nova": "34436",
+ "Castro Alves": "34452",
+ "Catolândia": "34479",
+ "Catu": "34495",
+ "Caturama": "34509",
+ "Central": "34517",
+ "Chorrochó": "34533",
+ "Cícero Dantas": "34550",
+ "Cipó": "34576",
+ "Coaraci": "34592",
+ "Cocos": "34614",
+ "Conceição da Feira": "34630",
+ "Conceição do Almeida": "34657",
+ "Conceição do Coité": "34673",
+ "Conceição do Jacuípe": "34690",
+ "Conde": "34711",
+ "Condeúba": "34738",
+ "Contendas do Sincorá": "34754",
+ "Coração de Maria": "34770",
+ "Cordeiros": "34797",
+ "Coribe": "34819",
+ "Coronel João Sá": "34835",
+ "Correntina": "34851",
+ "Cotegipe": "34878",
+ "Cravolândia": "34894",
+ "Crisópolis": "34916",
+ "Cristópolis": "34932",
+ "Cruz das Almas": "34959",
+ "Curaçá": "34975",
+ "Dário Meira": "34991",
+ "Dias d'Ávila": "30872",
+ "Dom Basílio": "35017",
+ "Dom Macedo Costa": "35033",
+ "Elísio Medrado": "35050",
+ "Encruzilhada": "35076",
+ "Entre Rios": "35092",
+ "Érico Cardoso": "33090",
+ "Esplanada": "35114",
+ "Euclides da Cunha": "35130",
+ "Eunápolis": "30058",
+ "Fátima": "30899",
+ "Feira da Mata": "35149",
+ "Feira de Santana": "35157",
+ "Filadélfia": "30910",
+ "Firmino Alves": "35173",
+ "Floresta Azul": "35190",
+ "Formosa do Rio Preto": "35211",
+ "Gandu": "35238",
+ "Gavião": "30937",
+ "Gentio do Ouro": "35254",
+ "Glória": "35270",
+ "Gongogi": "35297",
+ "Governador Mangabeira": "35319",
+ "Guajeru": "30953",
+ "Guanambi": "35335",
+ "Guaratinga": "35351",
+ "Heliópolis": "30970",
+ "Iaçu": "35378",
+ "Ibiassucê": "35394",
+ "Ibicaraí": "35416",
+ "Ibicoara": "35432",
+ "Ibicuí": "35459",
+ "Ibipeba": "35475",
+ "Ibipitanga": "35513",
+ "Ibiquera": "35530",
+ "Ibirapitanga": "35556",
+ "Ibirapuã": "35572",
+ "Ibirataia": "35599",
+ "Ibitiara": "35610",
+ "Ibititá": "35637",
+ "Ibotirama": "35653",
+ "Ichu": "35670",
+ "Igaporã": "35696",
+ "Igrapiúna": "35700",
+ "Iguaí": "35718",
+ "Ilhéus": "35734",
+ "Inhambupe": "35750",
+ "Ipecaetá": "35777",
+ "Ipiaú": "35793",
+ "Ipirá": "35815",
+ "Ipupiara": "35831",
+ "Irajuba": "35858",
+ "Iramaia": "35874",
+ "Iraquara": "35890",
+ "Irará": "35912",
+ "Irecê": "35939",
+ "Itabela": "35947",
+ "Itaberaba": "35955",
+ "Itabuna": "35971",
+ "Itacaré": "35998",
+ "Itaeté": "36013",
+ "Itagi": "36030",
+ "Itagibá": "36056",
+ "Itagimirim": "36072",
+ "Itaguaçu da Bahia": "36080",
+ "Itaju do Colônia": "36099",
+ "Itajuípe": "36110",
+ "Itamaraju": "36137",
+ "Itamari": "36153",
+ "Itambé": "36170",
+ "Itanagra": "36196",
+ "Itanhém": "36218",
+ "Itaparica": "36234",
+ "Itapé": "36250",
+ "Itapebi": "36277",
+ "Itapetinga": "36293",
+ "Itapicuru": "36315",
+ "Itapitanga": "36331",
+ "Itaquara": "36358",
+ "Itarantim": "36374",
+ "Itatim": "36382",
+ "Itiruçu": "36390",
+ "Itiúba": "36412",
+ "Itororó": "36439",
+ "Ituaçu": "36455",
+ "Ituberá": "36471",
+ "Iuiú": "30660",
+ "Jaborandi": "30678",
+ "Jacaraci": "36498",
+ "Jacobina": "36510",
+ "Jaguaquara": "36536",
+ "Jaguarari": "36552",
+ "Jaguaripe": "36579",
+ "Jandaíra": "36595",
+ "Jequié": "36617",
+ "Jeremoabo": "36633",
+ "Jiquiriçá": "36650",
+ "Jitaúna": "36676",
+ "João Dourado": "30996",
+ "Juazeiro": "36692",
+ "Jucuruçu": "36706",
+ "Jussara": "36714",
+ "Jussari": "30694",
+ "Jussiape": "36730",
+ "Lafaiete Coutinho": "36757",
+ "Lagoa Real": "36765",
+ "Laje": "36773",
+ "Lajedão": "36790",
+ "Lajedinho": "36811",
+ "Lajedo do Tabocal": "36820",
+ "Lamarão": "36838",
+ "Lapão": "39730",
+ "Lauro de Freitas": "36854",
+ "Lençóis": "36870",
+ "Licínio de Almeida": "36897",
+ "Livramento de Nossa Senhora": "36919",
+ "Luís Eduardo Magalhães": "30007",
+ "Macajuba": "36935",
+ "Macarani": "36951",
+ "Macaúbas": "36978",
+ "Macururé": "36994",
+ "Madre de Deus": "39748",
+ "Maetinga": "39756",
+ "Maiquinique": "37010",
+ "Mairi": "37036",
+ "Malhada": "37052",
+ "Malhada de Pedras": "37079",
+ "Manoel Vitorino": "37095",
+ "Mansidão": "39772",
+ "Maracás": "37117",
+ "Maragogipe": "37133",
+ "Maraú": "37150",
+ "Marcionílio Souza": "37176",
+ "Mascote": "37192",
+ "Mata de São João": "37214",
+ "Matina": "37222",
+ "Medeiros Neto": "37230",
+ "Miguel Calmon": "37257",
+ "Milagres": "37273",
+ "Mirangaba": "37290",
+ "Mirante": "37303",
+ "Monte Santo": "37311",
+ "Morpará": "37338",
+ "Morro do Chapéu": "37354",
+ "Mortugaba": "37370",
+ "Mucugê": "37397",
+ "Mucuri": "37419",
+ "Mulungu do Morro": "37427",
+ "Mundo Novo": "37435",
+ "Muniz Ferreira": "37451",
+ "Muquém de São Francisco": "37460",
+ "Muritiba": "37478",
+ "Mutuípe": "37494",
+ "Nazaré": "37516",
+ "Nilo Peçanha": "37532",
+ "Nordestina": "39799",
+ "Nova Canaã": "37559",
+ "Nova Fátima": "37567",
+ "Nova Ibiá": "39969",
+ "Nova Itarana": "37575",
+ "Nova Redenção": "37583",
+ "Nova Soure": "37591",
+ "Nova Viçosa": "37613",
+ "Novo Horizonte": "39942",
+ "Novo Triunfo": "37621",
+ "Olindina": "37630",
+ "Oliveira dos Brejinhos": "37656",
+ "Ouriçangas": "37672",
+ "Ourolândia": "37680",
+ "Palmas de Monte Alto": "37699",
+ "Palmeiras": "37710",
+ "Paramirim": "37737",
+ "Paratinga": "37753",
+ "Paripiranga": "37770",
+ "Pau Brasil": "37796",
+ "Paulo Afonso": "37818",
+ "Pé de Serra": "39810",
+ "Pedrão": "37834",
+ "Pedro Alexandre": "37850",
+ "Piatã": "37877",
+ "Pilão Arcado": "37893",
+ "Pindaí": "37915",
+ "Pindobaçu": "37931",
+ "Pintadas": "39837",
+ "Piraí do Norte": "37940",
+ "Piripá": "37958",
+ "Piritiba": "37974",
+ "Planaltino": "37990",
+ "Planalto": "38016",
+ "Poções": "38032",
+ "Pojuca": "38059",
+ "Ponto Novo": "38067",
+ "Porto Seguro": "38075",
+ "Potiraguá": "38091",
+ "Prado": "38113",
+ "Presidente Dutra": "38130",
+ "Presidente Jânio Quadros": "38156",
+ "Presidente Tancredo Neves": "38164",
+ "Queimadas": "38172",
+ "Quijingue": "38199",
+ "Quixabeira": "39845",
+ "Rafael Jambeiro": "39853",
+ "Remanso": "38210",
+ "Retirolândia": "38237",
+ "Riachão das Neves": "38253",
+ "Riachão do Jacuípe": "38270",
+ "Riacho de Santana": "38296",
+ "Ribeira do Amparo": "38318",
+ "Ribeira do Pombal": "38334",
+ "Ribeirão do Largo": "38342",
+ "Rio de Contas": "38350",
+ "Rio do Antônio": "38377",
+ "Rio do Pires": "38393",
+ "Rio Real": "38415",
+ "Rodelas": "38431",
+ "Ruy Barbosa": "38458",
+ "Salinas da Margarida": "38474",
+ "Salvador": "38490",
+ "Santa Bárbara": "38512",
+ "Santa Brígida": "38539",
+ "Santa Cruz Cabrália": "38555",
+ "Santa Cruz da Vitória": "38571",
+ "Santa Inês": "38598",
+ "Santa Luzia": "39870",
+ "Santa Maria da Vitória": "38636",
+ "Santa Rita de Cássia": "35491",
+ "Santa Teresinha": "38695",
+ "Santaluz": "38610",
+ "Santana": "38652",
+ "Santanópolis": "38679",
+ "Santo Amaro": "38717",
+ "Santo Antônio de Jesus": "38733",
+ "Santo Estêvão": "38750",
+ "São Desidério": "38776",
+ "São Domingos": "38806",
+ "São Felipe": "38814",
+ "São Félix": "38792",
+ "São Félix do Coribe": "38822",
+ "São Francisco do Conde": "38830",
+ "São Gabriel": "39896",
+ "São Gonçalo dos Campos": "38857",
+ "São José da Vitória": "39926",
+ "São José do Jacuípe": "38865",
+ "São Miguel das Matas": "38873",
+ "São Sebastião do Passé": "38890",
+ "Sapeaçu": "38911",
+ "Sátiro Dias": "38938",
+ "Saubara": "38946",
+ "Saúde": "38954",
+ "Seabra": "38970",
+ "Sebastião Laranjeiras": "38997",
+ "Senhor do Bonfim": "39012",
+ "Sento Sé": "39039",
+ "Serra do Ramalho": "39047",
+ "Serra Dourada": "39055",
+ "Serra Preta": "39071",
+ "Serrinha": "39098",
+ "Serrolândia": "39110",
+ "Simões Filho": "39136",
+ "Sítio do Mato": "39144",
+ "Sítio do Quinto": "39900",
+ "Sobradinho": "39985",
+ "Souto Soares": "39152",
+ "Tabocas do Brejo Velho": "39179",
+ "Tanhaçu": "39195",
+ "Tanque Novo": "39918",
+ "Tanquinho": "39217",
+ "Taperoá": "39233",
+ "Tapiramutá": "39250",
+ "Teixeira de Freitas": "39934",
+ "Teodoro Sampaio": "39276",
+ "Teofilândia": "39292",
+ "Teolândia": "39314",
+ "Terra Nova": "39330",
+ "Tremedal": "39357",
+ "Tucano": "39373",
+ "Uauá": "39390",
+ "Ubaíra": "39411",
+ "Ubaitaba": "39438",
+ "Ubatã": "39454",
+ "Uibaí": "39470",
+ "Umburanas": "39489",
+ "Una": "39497",
+ "Urandi": "39519",
+ "Uruçuca": "39535",
+ "Utinga": "39551",
+ "Valença": "39578",
+ "Valente": "39594",
+ "Várzea da Roça": "39977",
+ "Várzea do Poço": "39616",
+ "Várzea Nova": "39950",
+ "Varzedo": "39624",
+ "Vera Cruz": "39632",
+ "Vereda": "39640",
+ "Vitória da Conquista": "39659",
+ "Wagner": "39675",
+ "Wanderley": "39993",
+ "Wenceslau Guimarães": "39691",
+ "Xique-Xique": "39713",
+}
+
+export const tseCityCodeForMunicipality = (name: string): string | undefined => {
+ if (!isBahiaMunicipality(name)) return undefined
+ return bahiaTseCityCodes[name]
+}
+
+const municipalityByTseCityCode = new Map(
+ Object.entries(bahiaTseCityCodes).map(([name, code]) => [code, name]),
+)
+
+export const municipalityForTseCityCode = (cityCode: string): string | undefined =>
+ municipalityByTseCityCode.get(cityCode)
diff --git a/src/utilities/campaignDashboardPageData.ts b/src/utilities/campaignDashboardPageData.ts
index 6bf6387d..cd11c598 100644
--- a/src/utilities/campaignDashboardPageData.ts
+++ b/src/utilities/campaignDashboardPageData.ts
@@ -12,7 +12,7 @@ import {
} from '@/utilities/campaignDashboardViewModels'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
-import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
+import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import { nucleusVoteGoalsSelect } from '@/utilities/nucleusViewModels'
import { requireRelationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel } from '@/utilities/voteGoals'
diff --git a/src/utilities/federalCandidateTotalsAggregate.ts b/src/utilities/federalCandidateTotalsAggregate.ts
index feed3ef3..1c43e021 100644
--- a/src/utilities/federalCandidateTotalsAggregate.ts
+++ b/src/utilities/federalCandidateTotalsAggregate.ts
@@ -9,7 +9,10 @@ import {
type ElectionDataReader,
} from '@/utilities/campaignAccess'
import { drizzleResultRows, requirePostgresDrizzle } from '@/utilities/drizzleBulk'
-import type { NucleusElectionGeography } from '@/utilities/nucleusElectoralBaseline'
+import {
+ type NucleusElectionGeography,
+ zonesByCityCode,
+} from '@/utilities/nucleusElectionGeography'
export type FederalCandidateTotal = {
candidateNumber: number
@@ -25,8 +28,8 @@ export const loadFederalCandidateTotalsAggregated = async (
): Promise => {
assertCanReadElectionData(user)
- const cityClauses = [...geography.zonesByCity.entries()].map(([cityName, zones]) =>
- sql`("election_candidate_vote"."city_name" = ${cityName} AND "election_candidate_vote"."zone_number" IN (${sql.join(
+ const cityClauses = [...zonesByCityCode(geography).entries()].map(([cityCode, zones]) =>
+ sql`("election_candidate_vote"."city_code" = ${cityCode} AND "election_candidate_vote"."zone_number" IN (${sql.join(
zones.map((zone) => sql`${zone}`),
sql`, `,
)}))`,
diff --git a/src/utilities/nucleusChoropleth.ts b/src/utilities/nucleusChoropleth.ts
index 2085fa9e..a6157278 100644
--- a/src/utilities/nucleusChoropleth.ts
+++ b/src/utilities/nucleusChoropleth.ts
@@ -5,7 +5,7 @@ import {
territoryForCity,
territoriesForCities,
} from '@/lib/bahiaTerritories'
-import { resolveNucleusElectionGeography, type NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
+import { resolveNucleusElectionGeography, type NucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import {
emptyNucleusChoroplethBundle,
type ChoroplethMetric,
diff --git a/src/utilities/nucleusChoroplethTypes.ts b/src/utilities/nucleusChoroplethTypes.ts
index f30f23a0..d68fb690 100644
--- a/src/utilities/nucleusChoroplethTypes.ts
+++ b/src/utilities/nucleusChoroplethTypes.ts
@@ -1,4 +1,4 @@
-import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
+import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
export type ChoroplethMetric = 'nucleusCount' | 'confirmedEstimate' | 'baseline2022Votes'
diff --git a/src/utilities/nucleusDetailPageData.ts b/src/utilities/nucleusDetailPageData.ts
index 855d5e10..2d679481 100644
--- a/src/utilities/nucleusDetailPageData.ts
+++ b/src/utilities/nucleusDetailPageData.ts
@@ -8,10 +8,8 @@ import {
} from '@/utilities/leadershipPageData'
import { parseLeadershipFilterState, parseLeadershipPanelState } from '@/utilities/leadershipUi'
import { getNucleusCoordinatorAssignmentPageData } from '@/utilities/nucleusCoordinatorAssignmentPageData'
-import {
- getNucleusElectoralBaseline,
- toNucleusElectionGeographyInput,
-} from '@/utilities/nucleusElectoralBaseline'
+import { getNucleusElectoralBaseline } from '@/utilities/nucleusElectoralBaseline'
+import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import {
getNucleusDetailPageData,
resolveAccessibleNucleusContext,
diff --git a/src/utilities/nucleusElectionGeography.ts b/src/utilities/nucleusElectionGeography.ts
new file mode 100644
index 00000000..c533207f
--- /dev/null
+++ b/src/utilities/nucleusElectionGeography.ts
@@ -0,0 +1,120 @@
+import type { Where } from 'payload'
+
+import { tseCityCodeForMunicipality } from '@/lib/bahiaTseCityCodes'
+import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
+import { tseZonesForCity } from '@/lib/bahiaTseZones'
+import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
+import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
+
+export type NucleusElectionGeographyInput = {
+ cities: string[]
+ regions: string[]
+ tseZones: number[]
+}
+
+export const toNucleusElectionGeographyInput = (nucleus: {
+ cities?: string[] | null
+ regions?: string[] | null
+ tseZones?: Array | null
+}): NucleusElectionGeographyInput => ({
+ cities: normalizeTerritoryTextArray(nucleus.cities),
+ regions: normalizeTerritoryTextArray(nucleus.regions),
+ tseZones: (nucleus.tseZones ?? []).map((zone) =>
+ typeof zone === 'number' ? zone : zone.zoneNumber,
+ ),
+})
+
+export type NucleusCityZonePair = {
+ cityName: string
+ cityCode: string
+ zoneNumber: number
+}
+
+export type NucleusElectionGeography = {
+ zonesByCity: Map
+ cityZonePairs: NucleusCityZonePair[]
+}
+
+const uniqueSortedCities = (values: Iterable): string[] =>
+ [...new Set(values)].sort((left, right) => left.localeCompare(right, 'pt-BR'))
+
+/**
+ * Resolve the effective city×zone geography for a nucleus baseline query.
+ * Returns null when the nucleus has no usable territory (no cities and no regions),
+ * or when no city maps to a TSE city code (fail-closed).
+ *
+ * Zone rules per city:
+ * - nucleus.tseZones empty → all official zones of the city (`tseZonesForCity`)
+ * - nucleus.tseZones non-empty → intersection with the city's official zones;
+ * if the intersection is empty (typed zones outside the city), fall back to all city zones
+ */
+export const resolveNucleusElectionGeography = (
+ nucleus: NucleusElectionGeographyInput,
+): NucleusElectionGeography | null => {
+ const citiesFromNucleus = uniqueSortedCities(nucleus.cities.filter((city) => city.length > 0))
+ const cities =
+ citiesFromNucleus.length > 0
+ ? citiesFromNucleus
+ : uniqueSortedCities(
+ nucleus.regions.flatMap((region) =>
+ isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : [],
+ ),
+ )
+
+ if (cities.length === 0) return null
+
+ const requestedZones = sortedUniqueZoneNumbers(
+ nucleus.tseZones.filter((zone) => Number.isInteger(zone) && zone >= 1 && zone <= 999),
+ )
+
+ const zonesByCity = new Map()
+ const cityZonePairs: NucleusCityZonePair[] = []
+
+ for (const city of cities) {
+ const cityCode = tseCityCodeForMunicipality(city)
+ if (!cityCode) continue
+
+ const cityZones = [...tseZonesForCity(city)]
+ let effectiveZones = cityZones
+ if (requestedZones.length > 0) {
+ const cityZoneSet = new Set(cityZones)
+ const intersection = requestedZones.filter((zone) => cityZoneSet.has(zone))
+ if (intersection.length > 0) effectiveZones = intersection
+ }
+
+ if (effectiveZones.length === 0) continue
+
+ zonesByCity.set(city, effectiveZones)
+ for (const zoneNumber of effectiveZones) {
+ cityZonePairs.push({ cityName: city, cityCode, zoneNumber })
+ }
+ }
+
+ if (cityZonePairs.length === 0) return null
+
+ return { zonesByCity, cityZonePairs }
+}
+
+export const zonesByCityCode = (
+ geography: NucleusElectionGeography,
+): Map => {
+ const byCode = new Map()
+ for (const { cityCode, zoneNumber } of geography.cityZonePairs) {
+ const zones = byCode.get(cityCode)
+ if (zones) zones.push(zoneNumber)
+ else byCode.set(cityCode, [zoneNumber])
+ }
+ for (const [cityCode, zones] of byCode) {
+ byCode.set(cityCode, sortedUniqueZoneNumbers(zones))
+ }
+ return byCode
+}
+
+/** One clause per TSE city code with `zoneNumber in […]`. */
+export const geographyWhere = (geography: NucleusElectionGeography): Where => ({
+ or: [...zonesByCityCode(geography).entries()].map(
+ ([cityCode, zones]): Where => ({
+ and: [{ cityCode: { equals: cityCode } }, { zoneNumber: { in: zones } }],
+ }),
+ ),
+})
diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts
index fa25e9c3..22da743b 100644
--- a/src/utilities/nucleusElectoralBaseline.ts
+++ b/src/utilities/nucleusElectoralBaseline.ts
@@ -1,7 +1,5 @@
import type { Payload, Where } from 'payload'
-import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
-import { tseZonesForCity } from '@/lib/bahiaTseZones'
import {
BASELINE_TICKET_2022,
ELECTION_YEAR_2014,
@@ -39,39 +37,20 @@ import {
type VoteTrendStatus,
} from '@/lib/electionInsights'
import { type ElectionDataReader } from '@/utilities/campaignAccess'
-import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
import {
loadFederalCandidateTotalsAggregated,
type FederalCandidateTotal,
} from '@/utilities/federalCandidateTotalsAggregate'
+import {
+ geographyWhere,
+ type NucleusCityZonePair,
+ type NucleusElectionGeography,
+ type NucleusElectionGeographyInput,
+ resolveNucleusElectionGeography,
+} from '@/utilities/nucleusElectionGeography'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
-const CANDIDATE_OFFICE = BASELINE_TICKET_2022.candidate.office
-
-export type NucleusElectionGeographyInput = {
- cities: string[]
- regions: string[]
- tseZones: number[]
-}
-
-export const toNucleusElectionGeographyInput = (nucleus: {
- cities?: string[] | null
- regions?: string[] | null
- tseZones?: Array | null
-}): NucleusElectionGeographyInput => ({
- cities: normalizeTerritoryTextArray(nucleus.cities),
- regions: normalizeTerritoryTextArray(nucleus.regions),
- tseZones: (nucleus.tseZones ?? []).map((zone) =>
- typeof zone === 'number' ? zone : zone.zoneNumber,
- ),
-})
-
-export type NucleusElectionGeography = {
- zonesByCity: Map
- cityZonePairs: Array<{ cityName: string; zoneNumber: number }>
-}
-
export type ElectionVoteAggregateRow = {
office: ElectionOffice
turn: ElectionTurn
@@ -101,62 +80,6 @@ export type ElectionTallyAggregateRow = {
const cityZoneKey = (cityName: string, zoneNumber: number): string =>
`${cityName}::${zoneNumber}`
-const uniqueSortedCities = (values: Iterable): string[] =>
- [...new Set(values)].sort((left, right) => left.localeCompare(right, 'pt-BR'))
-
-/**
- * Resolve the effective city×zone geography for a nucleus baseline query.
- * Returns null when the nucleus has no usable territory (no cities and no regions).
- *
- * Zone rules per city:
- * - nucleus.tseZones empty → all official zones of the city (`tseZonesForCity`)
- * - nucleus.tseZones non-empty → intersection with the city's official zones;
- * if the intersection is empty (typed zones outside the city), fall back to all city zones
- */
-export const resolveNucleusElectionGeography = (
- nucleus: NucleusElectionGeographyInput,
-): NucleusElectionGeography | null => {
- const citiesFromNucleus = uniqueSortedCities(nucleus.cities.filter((city) => city.length > 0))
- const cities =
- citiesFromNucleus.length > 0
- ? citiesFromNucleus
- : uniqueSortedCities(
- nucleus.regions.flatMap((region) =>
- isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : [],
- ),
- )
-
- if (cities.length === 0) return null
-
- const requestedZones = sortedUniqueZoneNumbers(
- nucleus.tseZones.filter((zone) => Number.isInteger(zone) && zone >= 1 && zone <= 999),
- )
-
- const zonesByCity = new Map()
- const cityZonePairs: Array<{ cityName: string; zoneNumber: number }> = []
-
- for (const city of cities) {
- const cityZones = [...tseZonesForCity(city)]
- let effectiveZones = cityZones
- if (requestedZones.length > 0) {
- const cityZoneSet = new Set(cityZones)
- const intersection = requestedZones.filter((zone) => cityZoneSet.has(zone))
- if (intersection.length > 0) effectiveZones = intersection
- }
-
- if (effectiveZones.length === 0) continue
-
- zonesByCity.set(city, effectiveZones)
- for (const zoneNumber of effectiveZones) {
- cityZonePairs.push({ cityName: city, zoneNumber })
- }
- }
-
- if (cityZonePairs.length === 0) return null
-
- return { zonesByCity, cityZonePairs }
-}
-
const matchesGeography = (
row: { cityName: string; zoneNumber: number },
geography: NucleusElectionGeography,
@@ -165,6 +88,8 @@ const matchesGeography = (
return Boolean(zones?.includes(row.zoneNumber))
}
+const CANDIDATE_OFFICE = BASELINE_TICKET_2022.candidate.office
+
const sumCandidateVotes = (
votes: readonly ElectionVoteAggregateRow[],
geography: NucleusElectionGeography,
@@ -480,15 +405,6 @@ const ba2022Scope = (): Where[] => [
{ state: { equals: 'BA' } },
]
-/** One clause per city with `zoneNumber in […]` — avoids an OR per city×zone pair. */
-const geographyWhere = (geography: NucleusElectionGeography): Where => ({
- or: [...geography.zonesByCity.entries()].map(
- ([cityName, zones]): Where => ({
- and: [{ cityName: { equals: cityName } }, { zoneNumber: { in: zones } }],
- }),
- ),
-})
-
/** President/governor ticket votes for the detail baseline card (lean Local API path). */
const ticketOfficeVoteWhere = (geography: NucleusElectionGeography): Where => ({
and: [
@@ -653,7 +569,7 @@ const buildUnionGeography = (
geographies: ReadonlyArray,
indexes: readonly number[],
): NucleusElectionGeography | null => {
- const unionPairs = new Map()
+ const unionPairs = new Map()
const zonesByCity = new Map()
for (const index of indexes) {
diff --git a/src/utilities/nucleusListOverviewPageData.ts b/src/utilities/nucleusListOverviewPageData.ts
index a93af921..1a8e23b8 100644
--- a/src/utilities/nucleusListOverviewPageData.ts
+++ b/src/utilities/nucleusListOverviewPageData.ts
@@ -2,10 +2,8 @@ import type { Payload } from 'payload'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
-import {
- loadNucleusListElectionOverview,
- toNucleusElectionGeographyInput,
-} from '@/utilities/nucleusElectoralBaseline'
+import { loadNucleusListElectionOverview } from '@/utilities/nucleusElectoralBaseline'
+import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import { loadNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethPageData'
import {
buildNucleusListOverviewViewModel,
diff --git a/tests/fixtures/bahia-tse-city-codes.official.json b/tests/fixtures/bahia-tse-city-codes.official.json
new file mode 100644
index 00000000..c0e9c8ae
--- /dev/null
+++ b/tests/fixtures/bahia-tse-city-codes.official.json
@@ -0,0 +1,1683 @@
+{
+ "provenance": {
+ "description": "Test-only evidence of Bahia municipality→TSE city code pairs derived independently from the TSE 2022 detalhe_votacao_munzona BA CSV. It was not generated from src/lib/bahiaTseCityCodes.ts.",
+ "source": {
+ "portal": "https://dadosabertos.tse.jus.br/dataset/resultados-2022",
+ "url": "https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip",
+ "downloadSha256": "945741952e161453a8991c5a6f241f6ab8dc3992ae2060ea020cd24cfecf3b29",
+ "csvName": "detalhe_votacao_munzona_2022_BA.csv"
+ }
+ },
+ "municipalityCount": 417,
+ "assignments": [
+ {
+ "municipality": "Abaíra",
+ "code": "33014"
+ },
+ {
+ "municipality": "Abaré",
+ "code": "33030"
+ },
+ {
+ "municipality": "Acajutiba",
+ "code": "33057"
+ },
+ {
+ "municipality": "Adustina",
+ "code": "33065"
+ },
+ {
+ "municipality": "Água Fria",
+ "code": "33073"
+ },
+ {
+ "municipality": "Aiquara",
+ "code": "33111"
+ },
+ {
+ "municipality": "Alagoinhas",
+ "code": "33138"
+ },
+ {
+ "municipality": "Alcobaça",
+ "code": "33154"
+ },
+ {
+ "municipality": "Almadina",
+ "code": "33170"
+ },
+ {
+ "municipality": "Amargosa",
+ "code": "33197"
+ },
+ {
+ "municipality": "Amélia Rodrigues",
+ "code": "33219"
+ },
+ {
+ "municipality": "América Dourada",
+ "code": "30716"
+ },
+ {
+ "municipality": "Anagé",
+ "code": "33235"
+ },
+ {
+ "municipality": "Andaraí",
+ "code": "33251"
+ },
+ {
+ "municipality": "Andorinha",
+ "code": "33260"
+ },
+ {
+ "municipality": "Angical",
+ "code": "33278"
+ },
+ {
+ "municipality": "Anguera",
+ "code": "33294"
+ },
+ {
+ "municipality": "Antas",
+ "code": "33316"
+ },
+ {
+ "municipality": "Antônio Cardoso",
+ "code": "33332"
+ },
+ {
+ "municipality": "Antônio Gonçalves",
+ "code": "33359"
+ },
+ {
+ "municipality": "Aporá",
+ "code": "33375"
+ },
+ {
+ "municipality": "Apuarema",
+ "code": "33405"
+ },
+ {
+ "municipality": "Araças",
+ "code": "33383"
+ },
+ {
+ "municipality": "Aracatu",
+ "code": "33391"
+ },
+ {
+ "municipality": "Araci",
+ "code": "33413"
+ },
+ {
+ "municipality": "Aramari",
+ "code": "33430"
+ },
+ {
+ "municipality": "Arataca",
+ "code": "30732"
+ },
+ {
+ "municipality": "Aratuípe",
+ "code": "33456"
+ },
+ {
+ "municipality": "Aurelino Leal",
+ "code": "33472"
+ },
+ {
+ "municipality": "Baianópolis",
+ "code": "33499"
+ },
+ {
+ "municipality": "Baixa Grande",
+ "code": "33510"
+ },
+ {
+ "municipality": "Banzaê",
+ "code": "33529"
+ },
+ {
+ "municipality": "Barra",
+ "code": "33537"
+ },
+ {
+ "municipality": "Barra da Estiva",
+ "code": "33553"
+ },
+ {
+ "municipality": "Barra do Choça",
+ "code": "33570"
+ },
+ {
+ "municipality": "Barra do Mendes",
+ "code": "33596"
+ },
+ {
+ "municipality": "Barra do Rocha",
+ "code": "33618"
+ },
+ {
+ "municipality": "Barreiras",
+ "code": "33634"
+ },
+ {
+ "municipality": "Barro Alto",
+ "code": "30759"
+ },
+ {
+ "municipality": "Barro Preto",
+ "code": "33650"
+ },
+ {
+ "municipality": "Barrocas",
+ "code": "30775"
+ },
+ {
+ "municipality": "Belmonte",
+ "code": "33677"
+ },
+ {
+ "municipality": "Belo Campo",
+ "code": "33693"
+ },
+ {
+ "municipality": "Biritinga",
+ "code": "33715"
+ },
+ {
+ "municipality": "Boa Nova",
+ "code": "33731"
+ },
+ {
+ "municipality": "Boa Vista do Tupim",
+ "code": "33758"
+ },
+ {
+ "municipality": "Bom Jesus da Lapa",
+ "code": "33774"
+ },
+ {
+ "municipality": "Bom Jesus da Serra",
+ "code": "33782"
+ },
+ {
+ "municipality": "Boninal",
+ "code": "33790"
+ },
+ {
+ "municipality": "Bonito",
+ "code": "33804"
+ },
+ {
+ "municipality": "Boquira",
+ "code": "33812"
+ },
+ {
+ "municipality": "Botuporã",
+ "code": "33839"
+ },
+ {
+ "municipality": "Brejões",
+ "code": "33855"
+ },
+ {
+ "municipality": "Brejolândia",
+ "code": "33871"
+ },
+ {
+ "municipality": "Brotas de Macaúbas",
+ "code": "33898"
+ },
+ {
+ "municipality": "Brumado",
+ "code": "33910"
+ },
+ {
+ "municipality": "Buerarema",
+ "code": "33936"
+ },
+ {
+ "municipality": "Buritirama",
+ "code": "30791"
+ },
+ {
+ "municipality": "Caatiba",
+ "code": "33952"
+ },
+ {
+ "municipality": "Cabaceiras do Paraguaçu",
+ "code": "33960"
+ },
+ {
+ "municipality": "Cachoeira",
+ "code": "33979"
+ },
+ {
+ "municipality": "Caculé",
+ "code": "33995"
+ },
+ {
+ "municipality": "Caém",
+ "code": "34010"
+ },
+ {
+ "municipality": "Caetanos",
+ "code": "34029"
+ },
+ {
+ "municipality": "Caetité",
+ "code": "34037"
+ },
+ {
+ "municipality": "Cafarnaum",
+ "code": "34053"
+ },
+ {
+ "municipality": "Cairu",
+ "code": "34070"
+ },
+ {
+ "municipality": "Caldeirão Grande",
+ "code": "34096"
+ },
+ {
+ "municipality": "Camacan",
+ "code": "34118"
+ },
+ {
+ "municipality": "Camaçari",
+ "code": "34134"
+ },
+ {
+ "municipality": "Camamu",
+ "code": "34150"
+ },
+ {
+ "municipality": "Campo Alegre de Lourdes",
+ "code": "34177"
+ },
+ {
+ "municipality": "Campo Formoso",
+ "code": "34193"
+ },
+ {
+ "municipality": "Canápolis",
+ "code": "34215"
+ },
+ {
+ "municipality": "Canarana",
+ "code": "34231"
+ },
+ {
+ "municipality": "Canavieiras",
+ "code": "34258"
+ },
+ {
+ "municipality": "Candeal",
+ "code": "34274"
+ },
+ {
+ "municipality": "Candeias",
+ "code": "34290"
+ },
+ {
+ "municipality": "Candiba",
+ "code": "34312"
+ },
+ {
+ "municipality": "Cândido Sales",
+ "code": "34339"
+ },
+ {
+ "municipality": "Cansanção",
+ "code": "34355"
+ },
+ {
+ "municipality": "Canudos",
+ "code": "30856"
+ },
+ {
+ "municipality": "Capela do Alto Alegre",
+ "code": "30813"
+ },
+ {
+ "municipality": "Capim Grosso",
+ "code": "30830"
+ },
+ {
+ "municipality": "Caraíbas",
+ "code": "34363"
+ },
+ {
+ "municipality": "Caravelas",
+ "code": "34371"
+ },
+ {
+ "municipality": "Cardeal da Silva",
+ "code": "34398"
+ },
+ {
+ "municipality": "Carinhanha",
+ "code": "34410"
+ },
+ {
+ "municipality": "Casa Nova",
+ "code": "34436"
+ },
+ {
+ "municipality": "Castro Alves",
+ "code": "34452"
+ },
+ {
+ "municipality": "Catolândia",
+ "code": "34479"
+ },
+ {
+ "municipality": "Catu",
+ "code": "34495"
+ },
+ {
+ "municipality": "Caturama",
+ "code": "34509"
+ },
+ {
+ "municipality": "Central",
+ "code": "34517"
+ },
+ {
+ "municipality": "Chorrochó",
+ "code": "34533"
+ },
+ {
+ "municipality": "Cícero Dantas",
+ "code": "34550"
+ },
+ {
+ "municipality": "Cipó",
+ "code": "34576"
+ },
+ {
+ "municipality": "Coaraci",
+ "code": "34592"
+ },
+ {
+ "municipality": "Cocos",
+ "code": "34614"
+ },
+ {
+ "municipality": "Conceição da Feira",
+ "code": "34630"
+ },
+ {
+ "municipality": "Conceição do Almeida",
+ "code": "34657"
+ },
+ {
+ "municipality": "Conceição do Coité",
+ "code": "34673"
+ },
+ {
+ "municipality": "Conceição do Jacuípe",
+ "code": "34690"
+ },
+ {
+ "municipality": "Conde",
+ "code": "34711"
+ },
+ {
+ "municipality": "Condeúba",
+ "code": "34738"
+ },
+ {
+ "municipality": "Contendas do Sincorá",
+ "code": "34754"
+ },
+ {
+ "municipality": "Coração de Maria",
+ "code": "34770"
+ },
+ {
+ "municipality": "Cordeiros",
+ "code": "34797"
+ },
+ {
+ "municipality": "Coribe",
+ "code": "34819"
+ },
+ {
+ "municipality": "Coronel João Sá",
+ "code": "34835"
+ },
+ {
+ "municipality": "Correntina",
+ "code": "34851"
+ },
+ {
+ "municipality": "Cotegipe",
+ "code": "34878"
+ },
+ {
+ "municipality": "Cravolândia",
+ "code": "34894"
+ },
+ {
+ "municipality": "Crisópolis",
+ "code": "34916"
+ },
+ {
+ "municipality": "Cristópolis",
+ "code": "34932"
+ },
+ {
+ "municipality": "Cruz das Almas",
+ "code": "34959"
+ },
+ {
+ "municipality": "Curaçá",
+ "code": "34975"
+ },
+ {
+ "municipality": "Dário Meira",
+ "code": "34991"
+ },
+ {
+ "municipality": "Dias d'Ávila",
+ "code": "30872"
+ },
+ {
+ "municipality": "Dom Basílio",
+ "code": "35017"
+ },
+ {
+ "municipality": "Dom Macedo Costa",
+ "code": "35033"
+ },
+ {
+ "municipality": "Elísio Medrado",
+ "code": "35050"
+ },
+ {
+ "municipality": "Encruzilhada",
+ "code": "35076"
+ },
+ {
+ "municipality": "Entre Rios",
+ "code": "35092"
+ },
+ {
+ "municipality": "Érico Cardoso",
+ "code": "33090"
+ },
+ {
+ "municipality": "Esplanada",
+ "code": "35114"
+ },
+ {
+ "municipality": "Euclides da Cunha",
+ "code": "35130"
+ },
+ {
+ "municipality": "Eunápolis",
+ "code": "30058"
+ },
+ {
+ "municipality": "Fátima",
+ "code": "30899"
+ },
+ {
+ "municipality": "Feira da Mata",
+ "code": "35149"
+ },
+ {
+ "municipality": "Feira de Santana",
+ "code": "35157"
+ },
+ {
+ "municipality": "Filadélfia",
+ "code": "30910"
+ },
+ {
+ "municipality": "Firmino Alves",
+ "code": "35173"
+ },
+ {
+ "municipality": "Floresta Azul",
+ "code": "35190"
+ },
+ {
+ "municipality": "Formosa do Rio Preto",
+ "code": "35211"
+ },
+ {
+ "municipality": "Gandu",
+ "code": "35238"
+ },
+ {
+ "municipality": "Gavião",
+ "code": "30937"
+ },
+ {
+ "municipality": "Gentio do Ouro",
+ "code": "35254"
+ },
+ {
+ "municipality": "Glória",
+ "code": "35270"
+ },
+ {
+ "municipality": "Gongogi",
+ "code": "35297"
+ },
+ {
+ "municipality": "Governador Mangabeira",
+ "code": "35319"
+ },
+ {
+ "municipality": "Guajeru",
+ "code": "30953"
+ },
+ {
+ "municipality": "Guanambi",
+ "code": "35335"
+ },
+ {
+ "municipality": "Guaratinga",
+ "code": "35351"
+ },
+ {
+ "municipality": "Heliópolis",
+ "code": "30970"
+ },
+ {
+ "municipality": "Iaçu",
+ "code": "35378"
+ },
+ {
+ "municipality": "Ibiassucê",
+ "code": "35394"
+ },
+ {
+ "municipality": "Ibicaraí",
+ "code": "35416"
+ },
+ {
+ "municipality": "Ibicoara",
+ "code": "35432"
+ },
+ {
+ "municipality": "Ibicuí",
+ "code": "35459"
+ },
+ {
+ "municipality": "Ibipeba",
+ "code": "35475"
+ },
+ {
+ "municipality": "Ibipitanga",
+ "code": "35513"
+ },
+ {
+ "municipality": "Ibiquera",
+ "code": "35530"
+ },
+ {
+ "municipality": "Ibirapitanga",
+ "code": "35556"
+ },
+ {
+ "municipality": "Ibirapuã",
+ "code": "35572"
+ },
+ {
+ "municipality": "Ibirataia",
+ "code": "35599"
+ },
+ {
+ "municipality": "Ibitiara",
+ "code": "35610"
+ },
+ {
+ "municipality": "Ibititá",
+ "code": "35637"
+ },
+ {
+ "municipality": "Ibotirama",
+ "code": "35653"
+ },
+ {
+ "municipality": "Ichu",
+ "code": "35670"
+ },
+ {
+ "municipality": "Igaporã",
+ "code": "35696"
+ },
+ {
+ "municipality": "Igrapiúna",
+ "code": "35700"
+ },
+ {
+ "municipality": "Iguaí",
+ "code": "35718"
+ },
+ {
+ "municipality": "Ilhéus",
+ "code": "35734"
+ },
+ {
+ "municipality": "Inhambupe",
+ "code": "35750"
+ },
+ {
+ "municipality": "Ipecaetá",
+ "code": "35777"
+ },
+ {
+ "municipality": "Ipiaú",
+ "code": "35793"
+ },
+ {
+ "municipality": "Ipirá",
+ "code": "35815"
+ },
+ {
+ "municipality": "Ipupiara",
+ "code": "35831"
+ },
+ {
+ "municipality": "Irajuba",
+ "code": "35858"
+ },
+ {
+ "municipality": "Iramaia",
+ "code": "35874"
+ },
+ {
+ "municipality": "Iraquara",
+ "code": "35890"
+ },
+ {
+ "municipality": "Irará",
+ "code": "35912"
+ },
+ {
+ "municipality": "Irecê",
+ "code": "35939"
+ },
+ {
+ "municipality": "Itabela",
+ "code": "35947"
+ },
+ {
+ "municipality": "Itaberaba",
+ "code": "35955"
+ },
+ {
+ "municipality": "Itabuna",
+ "code": "35971"
+ },
+ {
+ "municipality": "Itacaré",
+ "code": "35998"
+ },
+ {
+ "municipality": "Itaeté",
+ "code": "36013"
+ },
+ {
+ "municipality": "Itagi",
+ "code": "36030"
+ },
+ {
+ "municipality": "Itagibá",
+ "code": "36056"
+ },
+ {
+ "municipality": "Itagimirim",
+ "code": "36072"
+ },
+ {
+ "municipality": "Itaguaçu da Bahia",
+ "code": "36080"
+ },
+ {
+ "municipality": "Itaju do Colônia",
+ "code": "36099"
+ },
+ {
+ "municipality": "Itajuípe",
+ "code": "36110"
+ },
+ {
+ "municipality": "Itamaraju",
+ "code": "36137"
+ },
+ {
+ "municipality": "Itamari",
+ "code": "36153"
+ },
+ {
+ "municipality": "Itambé",
+ "code": "36170"
+ },
+ {
+ "municipality": "Itanagra",
+ "code": "36196"
+ },
+ {
+ "municipality": "Itanhém",
+ "code": "36218"
+ },
+ {
+ "municipality": "Itaparica",
+ "code": "36234"
+ },
+ {
+ "municipality": "Itapé",
+ "code": "36250"
+ },
+ {
+ "municipality": "Itapebi",
+ "code": "36277"
+ },
+ {
+ "municipality": "Itapetinga",
+ "code": "36293"
+ },
+ {
+ "municipality": "Itapicuru",
+ "code": "36315"
+ },
+ {
+ "municipality": "Itapitanga",
+ "code": "36331"
+ },
+ {
+ "municipality": "Itaquara",
+ "code": "36358"
+ },
+ {
+ "municipality": "Itarantim",
+ "code": "36374"
+ },
+ {
+ "municipality": "Itatim",
+ "code": "36382"
+ },
+ {
+ "municipality": "Itiruçu",
+ "code": "36390"
+ },
+ {
+ "municipality": "Itiúba",
+ "code": "36412"
+ },
+ {
+ "municipality": "Itororó",
+ "code": "36439"
+ },
+ {
+ "municipality": "Ituaçu",
+ "code": "36455"
+ },
+ {
+ "municipality": "Ituberá",
+ "code": "36471"
+ },
+ {
+ "municipality": "Iuiú",
+ "code": "30660"
+ },
+ {
+ "municipality": "Jaborandi",
+ "code": "30678"
+ },
+ {
+ "municipality": "Jacaraci",
+ "code": "36498"
+ },
+ {
+ "municipality": "Jacobina",
+ "code": "36510"
+ },
+ {
+ "municipality": "Jaguaquara",
+ "code": "36536"
+ },
+ {
+ "municipality": "Jaguarari",
+ "code": "36552"
+ },
+ {
+ "municipality": "Jaguaripe",
+ "code": "36579"
+ },
+ {
+ "municipality": "Jandaíra",
+ "code": "36595"
+ },
+ {
+ "municipality": "Jequié",
+ "code": "36617"
+ },
+ {
+ "municipality": "Jeremoabo",
+ "code": "36633"
+ },
+ {
+ "municipality": "Jiquiriçá",
+ "code": "36650"
+ },
+ {
+ "municipality": "Jitaúna",
+ "code": "36676"
+ },
+ {
+ "municipality": "João Dourado",
+ "code": "30996"
+ },
+ {
+ "municipality": "Juazeiro",
+ "code": "36692"
+ },
+ {
+ "municipality": "Jucuruçu",
+ "code": "36706"
+ },
+ {
+ "municipality": "Jussara",
+ "code": "36714"
+ },
+ {
+ "municipality": "Jussari",
+ "code": "30694"
+ },
+ {
+ "municipality": "Jussiape",
+ "code": "36730"
+ },
+ {
+ "municipality": "Lafaiete Coutinho",
+ "code": "36757"
+ },
+ {
+ "municipality": "Lagoa Real",
+ "code": "36765"
+ },
+ {
+ "municipality": "Laje",
+ "code": "36773"
+ },
+ {
+ "municipality": "Lajedão",
+ "code": "36790"
+ },
+ {
+ "municipality": "Lajedinho",
+ "code": "36811"
+ },
+ {
+ "municipality": "Lajedo do Tabocal",
+ "code": "36820"
+ },
+ {
+ "municipality": "Lamarão",
+ "code": "36838"
+ },
+ {
+ "municipality": "Lapão",
+ "code": "39730"
+ },
+ {
+ "municipality": "Lauro de Freitas",
+ "code": "36854"
+ },
+ {
+ "municipality": "Lençóis",
+ "code": "36870"
+ },
+ {
+ "municipality": "Licínio de Almeida",
+ "code": "36897"
+ },
+ {
+ "municipality": "Livramento de Nossa Senhora",
+ "code": "36919"
+ },
+ {
+ "municipality": "Luís Eduardo Magalhães",
+ "code": "30007"
+ },
+ {
+ "municipality": "Macajuba",
+ "code": "36935"
+ },
+ {
+ "municipality": "Macarani",
+ "code": "36951"
+ },
+ {
+ "municipality": "Macaúbas",
+ "code": "36978"
+ },
+ {
+ "municipality": "Macururé",
+ "code": "36994"
+ },
+ {
+ "municipality": "Madre de Deus",
+ "code": "39748"
+ },
+ {
+ "municipality": "Maetinga",
+ "code": "39756"
+ },
+ {
+ "municipality": "Maiquinique",
+ "code": "37010"
+ },
+ {
+ "municipality": "Mairi",
+ "code": "37036"
+ },
+ {
+ "municipality": "Malhada",
+ "code": "37052"
+ },
+ {
+ "municipality": "Malhada de Pedras",
+ "code": "37079"
+ },
+ {
+ "municipality": "Manoel Vitorino",
+ "code": "37095"
+ },
+ {
+ "municipality": "Mansidão",
+ "code": "39772"
+ },
+ {
+ "municipality": "Maracás",
+ "code": "37117"
+ },
+ {
+ "municipality": "Maragogipe",
+ "code": "37133"
+ },
+ {
+ "municipality": "Maraú",
+ "code": "37150"
+ },
+ {
+ "municipality": "Marcionílio Souza",
+ "code": "37176"
+ },
+ {
+ "municipality": "Mascote",
+ "code": "37192"
+ },
+ {
+ "municipality": "Mata de São João",
+ "code": "37214"
+ },
+ {
+ "municipality": "Matina",
+ "code": "37222"
+ },
+ {
+ "municipality": "Medeiros Neto",
+ "code": "37230"
+ },
+ {
+ "municipality": "Miguel Calmon",
+ "code": "37257"
+ },
+ {
+ "municipality": "Milagres",
+ "code": "37273"
+ },
+ {
+ "municipality": "Mirangaba",
+ "code": "37290"
+ },
+ {
+ "municipality": "Mirante",
+ "code": "37303"
+ },
+ {
+ "municipality": "Monte Santo",
+ "code": "37311"
+ },
+ {
+ "municipality": "Morpará",
+ "code": "37338"
+ },
+ {
+ "municipality": "Morro do Chapéu",
+ "code": "37354"
+ },
+ {
+ "municipality": "Mortugaba",
+ "code": "37370"
+ },
+ {
+ "municipality": "Mucugê",
+ "code": "37397"
+ },
+ {
+ "municipality": "Mucuri",
+ "code": "37419"
+ },
+ {
+ "municipality": "Mulungu do Morro",
+ "code": "37427"
+ },
+ {
+ "municipality": "Mundo Novo",
+ "code": "37435"
+ },
+ {
+ "municipality": "Muniz Ferreira",
+ "code": "37451"
+ },
+ {
+ "municipality": "Muquém de São Francisco",
+ "code": "37460"
+ },
+ {
+ "municipality": "Muritiba",
+ "code": "37478"
+ },
+ {
+ "municipality": "Mutuípe",
+ "code": "37494"
+ },
+ {
+ "municipality": "Nazaré",
+ "code": "37516"
+ },
+ {
+ "municipality": "Nilo Peçanha",
+ "code": "37532"
+ },
+ {
+ "municipality": "Nordestina",
+ "code": "39799"
+ },
+ {
+ "municipality": "Nova Canaã",
+ "code": "37559"
+ },
+ {
+ "municipality": "Nova Fátima",
+ "code": "37567"
+ },
+ {
+ "municipality": "Nova Ibiá",
+ "code": "39969"
+ },
+ {
+ "municipality": "Nova Itarana",
+ "code": "37575"
+ },
+ {
+ "municipality": "Nova Redenção",
+ "code": "37583"
+ },
+ {
+ "municipality": "Nova Soure",
+ "code": "37591"
+ },
+ {
+ "municipality": "Nova Viçosa",
+ "code": "37613"
+ },
+ {
+ "municipality": "Novo Horizonte",
+ "code": "39942"
+ },
+ {
+ "municipality": "Novo Triunfo",
+ "code": "37621"
+ },
+ {
+ "municipality": "Olindina",
+ "code": "37630"
+ },
+ {
+ "municipality": "Oliveira dos Brejinhos",
+ "code": "37656"
+ },
+ {
+ "municipality": "Ouriçangas",
+ "code": "37672"
+ },
+ {
+ "municipality": "Ourolândia",
+ "code": "37680"
+ },
+ {
+ "municipality": "Palmas de Monte Alto",
+ "code": "37699"
+ },
+ {
+ "municipality": "Palmeiras",
+ "code": "37710"
+ },
+ {
+ "municipality": "Paramirim",
+ "code": "37737"
+ },
+ {
+ "municipality": "Paratinga",
+ "code": "37753"
+ },
+ {
+ "municipality": "Paripiranga",
+ "code": "37770"
+ },
+ {
+ "municipality": "Pau Brasil",
+ "code": "37796"
+ },
+ {
+ "municipality": "Paulo Afonso",
+ "code": "37818"
+ },
+ {
+ "municipality": "Pé de Serra",
+ "code": "39810"
+ },
+ {
+ "municipality": "Pedrão",
+ "code": "37834"
+ },
+ {
+ "municipality": "Pedro Alexandre",
+ "code": "37850"
+ },
+ {
+ "municipality": "Piatã",
+ "code": "37877"
+ },
+ {
+ "municipality": "Pilão Arcado",
+ "code": "37893"
+ },
+ {
+ "municipality": "Pindaí",
+ "code": "37915"
+ },
+ {
+ "municipality": "Pindobaçu",
+ "code": "37931"
+ },
+ {
+ "municipality": "Pintadas",
+ "code": "39837"
+ },
+ {
+ "municipality": "Piraí do Norte",
+ "code": "37940"
+ },
+ {
+ "municipality": "Piripá",
+ "code": "37958"
+ },
+ {
+ "municipality": "Piritiba",
+ "code": "37974"
+ },
+ {
+ "municipality": "Planaltino",
+ "code": "37990"
+ },
+ {
+ "municipality": "Planalto",
+ "code": "38016"
+ },
+ {
+ "municipality": "Poções",
+ "code": "38032"
+ },
+ {
+ "municipality": "Pojuca",
+ "code": "38059"
+ },
+ {
+ "municipality": "Ponto Novo",
+ "code": "38067"
+ },
+ {
+ "municipality": "Porto Seguro",
+ "code": "38075"
+ },
+ {
+ "municipality": "Potiraguá",
+ "code": "38091"
+ },
+ {
+ "municipality": "Prado",
+ "code": "38113"
+ },
+ {
+ "municipality": "Presidente Dutra",
+ "code": "38130"
+ },
+ {
+ "municipality": "Presidente Jânio Quadros",
+ "code": "38156"
+ },
+ {
+ "municipality": "Presidente Tancredo Neves",
+ "code": "38164"
+ },
+ {
+ "municipality": "Queimadas",
+ "code": "38172"
+ },
+ {
+ "municipality": "Quijingue",
+ "code": "38199"
+ },
+ {
+ "municipality": "Quixabeira",
+ "code": "39845"
+ },
+ {
+ "municipality": "Rafael Jambeiro",
+ "code": "39853"
+ },
+ {
+ "municipality": "Remanso",
+ "code": "38210"
+ },
+ {
+ "municipality": "Retirolândia",
+ "code": "38237"
+ },
+ {
+ "municipality": "Riachão das Neves",
+ "code": "38253"
+ },
+ {
+ "municipality": "Riachão do Jacuípe",
+ "code": "38270"
+ },
+ {
+ "municipality": "Riacho de Santana",
+ "code": "38296"
+ },
+ {
+ "municipality": "Ribeira do Amparo",
+ "code": "38318"
+ },
+ {
+ "municipality": "Ribeira do Pombal",
+ "code": "38334"
+ },
+ {
+ "municipality": "Ribeirão do Largo",
+ "code": "38342"
+ },
+ {
+ "municipality": "Rio de Contas",
+ "code": "38350"
+ },
+ {
+ "municipality": "Rio do Antônio",
+ "code": "38377"
+ },
+ {
+ "municipality": "Rio do Pires",
+ "code": "38393"
+ },
+ {
+ "municipality": "Rio Real",
+ "code": "38415"
+ },
+ {
+ "municipality": "Rodelas",
+ "code": "38431"
+ },
+ {
+ "municipality": "Ruy Barbosa",
+ "code": "38458"
+ },
+ {
+ "municipality": "Salinas da Margarida",
+ "code": "38474"
+ },
+ {
+ "municipality": "Salvador",
+ "code": "38490"
+ },
+ {
+ "municipality": "Santa Bárbara",
+ "code": "38512"
+ },
+ {
+ "municipality": "Santa Brígida",
+ "code": "38539"
+ },
+ {
+ "municipality": "Santa Cruz Cabrália",
+ "code": "38555"
+ },
+ {
+ "municipality": "Santa Cruz da Vitória",
+ "code": "38571"
+ },
+ {
+ "municipality": "Santa Inês",
+ "code": "38598"
+ },
+ {
+ "municipality": "Santa Luzia",
+ "code": "39870"
+ },
+ {
+ "municipality": "Santa Maria da Vitória",
+ "code": "38636"
+ },
+ {
+ "municipality": "Santa Rita de Cássia",
+ "code": "35491"
+ },
+ {
+ "municipality": "Santa Teresinha",
+ "code": "38695"
+ },
+ {
+ "municipality": "Santaluz",
+ "code": "38610"
+ },
+ {
+ "municipality": "Santana",
+ "code": "38652"
+ },
+ {
+ "municipality": "Santanópolis",
+ "code": "38679"
+ },
+ {
+ "municipality": "Santo Amaro",
+ "code": "38717"
+ },
+ {
+ "municipality": "Santo Antônio de Jesus",
+ "code": "38733"
+ },
+ {
+ "municipality": "Santo Estêvão",
+ "code": "38750"
+ },
+ {
+ "municipality": "São Desidério",
+ "code": "38776"
+ },
+ {
+ "municipality": "São Domingos",
+ "code": "38806"
+ },
+ {
+ "municipality": "São Felipe",
+ "code": "38814"
+ },
+ {
+ "municipality": "São Félix",
+ "code": "38792"
+ },
+ {
+ "municipality": "São Félix do Coribe",
+ "code": "38822"
+ },
+ {
+ "municipality": "São Francisco do Conde",
+ "code": "38830"
+ },
+ {
+ "municipality": "São Gabriel",
+ "code": "39896"
+ },
+ {
+ "municipality": "São Gonçalo dos Campos",
+ "code": "38857"
+ },
+ {
+ "municipality": "São José da Vitória",
+ "code": "39926"
+ },
+ {
+ "municipality": "São José do Jacuípe",
+ "code": "38865"
+ },
+ {
+ "municipality": "São Miguel das Matas",
+ "code": "38873"
+ },
+ {
+ "municipality": "São Sebastião do Passé",
+ "code": "38890"
+ },
+ {
+ "municipality": "Sapeaçu",
+ "code": "38911"
+ },
+ {
+ "municipality": "Sátiro Dias",
+ "code": "38938"
+ },
+ {
+ "municipality": "Saubara",
+ "code": "38946"
+ },
+ {
+ "municipality": "Saúde",
+ "code": "38954"
+ },
+ {
+ "municipality": "Seabra",
+ "code": "38970"
+ },
+ {
+ "municipality": "Sebastião Laranjeiras",
+ "code": "38997"
+ },
+ {
+ "municipality": "Senhor do Bonfim",
+ "code": "39012"
+ },
+ {
+ "municipality": "Sento Sé",
+ "code": "39039"
+ },
+ {
+ "municipality": "Serra do Ramalho",
+ "code": "39047"
+ },
+ {
+ "municipality": "Serra Dourada",
+ "code": "39055"
+ },
+ {
+ "municipality": "Serra Preta",
+ "code": "39071"
+ },
+ {
+ "municipality": "Serrinha",
+ "code": "39098"
+ },
+ {
+ "municipality": "Serrolândia",
+ "code": "39110"
+ },
+ {
+ "municipality": "Simões Filho",
+ "code": "39136"
+ },
+ {
+ "municipality": "Sítio do Mato",
+ "code": "39144"
+ },
+ {
+ "municipality": "Sítio do Quinto",
+ "code": "39900"
+ },
+ {
+ "municipality": "Sobradinho",
+ "code": "39985"
+ },
+ {
+ "municipality": "Souto Soares",
+ "code": "39152"
+ },
+ {
+ "municipality": "Tabocas do Brejo Velho",
+ "code": "39179"
+ },
+ {
+ "municipality": "Tanhaçu",
+ "code": "39195"
+ },
+ {
+ "municipality": "Tanque Novo",
+ "code": "39918"
+ },
+ {
+ "municipality": "Tanquinho",
+ "code": "39217"
+ },
+ {
+ "municipality": "Taperoá",
+ "code": "39233"
+ },
+ {
+ "municipality": "Tapiramutá",
+ "code": "39250"
+ },
+ {
+ "municipality": "Teixeira de Freitas",
+ "code": "39934"
+ },
+ {
+ "municipality": "Teodoro Sampaio",
+ "code": "39276"
+ },
+ {
+ "municipality": "Teofilândia",
+ "code": "39292"
+ },
+ {
+ "municipality": "Teolândia",
+ "code": "39314"
+ },
+ {
+ "municipality": "Terra Nova",
+ "code": "39330"
+ },
+ {
+ "municipality": "Tremedal",
+ "code": "39357"
+ },
+ {
+ "municipality": "Tucano",
+ "code": "39373"
+ },
+ {
+ "municipality": "Uauá",
+ "code": "39390"
+ },
+ {
+ "municipality": "Ubaíra",
+ "code": "39411"
+ },
+ {
+ "municipality": "Ubaitaba",
+ "code": "39438"
+ },
+ {
+ "municipality": "Ubatã",
+ "code": "39454"
+ },
+ {
+ "municipality": "Uibaí",
+ "code": "39470"
+ },
+ {
+ "municipality": "Umburanas",
+ "code": "39489"
+ },
+ {
+ "municipality": "Una",
+ "code": "39497"
+ },
+ {
+ "municipality": "Urandi",
+ "code": "39519"
+ },
+ {
+ "municipality": "Uruçuca",
+ "code": "39535"
+ },
+ {
+ "municipality": "Utinga",
+ "code": "39551"
+ },
+ {
+ "municipality": "Valença",
+ "code": "39578"
+ },
+ {
+ "municipality": "Valente",
+ "code": "39594"
+ },
+ {
+ "municipality": "Várzea da Roça",
+ "code": "39977"
+ },
+ {
+ "municipality": "Várzea do Poço",
+ "code": "39616"
+ },
+ {
+ "municipality": "Várzea Nova",
+ "code": "39950"
+ },
+ {
+ "municipality": "Varzedo",
+ "code": "39624"
+ },
+ {
+ "municipality": "Vera Cruz",
+ "code": "39632"
+ },
+ {
+ "municipality": "Vereda",
+ "code": "39640"
+ },
+ {
+ "municipality": "Vitória da Conquista",
+ "code": "39659"
+ },
+ {
+ "municipality": "Wagner",
+ "code": "39675"
+ },
+ {
+ "municipality": "Wanderley",
+ "code": "39993"
+ },
+ {
+ "municipality": "Wenceslau Guimarães",
+ "code": "39691"
+ },
+ {
+ "municipality": "Xique-Xique",
+ "code": "39713"
+ }
+ ],
+ "evidenceSha256": "da8c71439029d5fedc41818ab269bd5d58982e0d7285e8ad1ab475c778c066dc"
+}
diff --git a/tests/int/bahiaTseCityCodes.int.spec.ts b/tests/int/bahiaTseCityCodes.int.spec.ts
new file mode 100644
index 00000000..cbfc0fd2
--- /dev/null
+++ b/tests/int/bahiaTseCityCodes.int.spec.ts
@@ -0,0 +1,82 @@
+// @vitest-environment node
+
+import { createHash } from 'node:crypto'
+import { readFileSync } from 'node:fs'
+
+import { describe, expect, it } from 'vitest'
+
+import {
+ bahiaTseCityCodes,
+ municipalityForTseCityCode,
+ tseCityCodeForMunicipality,
+} from '@/lib/bahiaTseCityCodes'
+import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
+
+type OfficialEvidence = {
+ municipalityCount: number
+ assignments: Array<{ municipality: string; code: string }>
+ evidenceSha256: string
+}
+
+const officialEvidence = JSON.parse(
+ readFileSync(
+ new URL('../fixtures/bahia-tse-city-codes.official.json', import.meta.url),
+ 'utf8',
+ ),
+) as OfficialEvidence
+
+describe('Bahia municipality name → TSE city code mapping', () => {
+ it('covers all 417 Bahia municipalities with TSE CD_MUNICIPIO codes', () => {
+ expect(Object.keys(bahiaTseCityCodes)).toHaveLength(417)
+ expect(bahiaMunicipalities).toHaveLength(417)
+ expect(new Set(Object.keys(bahiaTseCityCodes))).toHaveLength(417)
+ expect(new Set(Object.values(bahiaTseCityCodes))).toHaveLength(417)
+
+ for (const city of bahiaMunicipalities) {
+ const code = bahiaTseCityCodes[city]
+ expect(code, city).toBeDefined()
+ expect(code).toMatch(/^\d{5}$/)
+ }
+ })
+
+ it('is a bijection with the canonical bahiaMunicipalities set', () => {
+ expect(Object.keys(bahiaTseCityCodes).sort((left, right) => left.localeCompare(right, 'pt-BR'))).toEqual(
+ [...bahiaMunicipalities].sort((left, right) => left.localeCompare(right, 'pt-BR')),
+ )
+ })
+
+ it('matches the independently downloaded official municipality→code evidence', () => {
+ expect(officialEvidence.municipalityCount).toBe(417)
+ expect(
+ officialEvidence.assignments.map(({ municipality, code }) => ({ municipality, code })),
+ ).toEqual(
+ Object.entries(bahiaTseCityCodes)
+ .sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
+ .map(([municipality, code]) => ({ municipality, code })),
+ )
+ })
+
+ it('matches every official assignment by a fixed evidence checksum', () => {
+ const assignmentRows = Object.entries(bahiaTseCityCodes)
+ .sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
+ .map(([municipality, code]) => `M\t${municipality}\t${code}\n`)
+ .join('')
+
+ expect(createHash('sha256').update(assignmentRows).digest('hex')).toBe(
+ officialEvidence.evidenceSha256,
+ )
+ })
+
+ it('supports bidirectional lookups and differs from IBGE codes', () => {
+ expect(tseCityCodeForMunicipality('Salvador')).toBe('38490')
+ expect(tseCityCodeForMunicipality('Feira de Santana')).toBe('35157')
+ expect(tseCityCodeForMunicipality("Dias d'Ávila")).toBe('30872')
+ expect(tseCityCodeForMunicipality('Araças')).toBe('33383')
+ expect(tseCityCodeForMunicipality('Município inexistente')).toBeUndefined()
+ expect(tseCityCodeForMunicipality('')).toBeUndefined()
+
+ expect(municipalityForTseCityCode('38490')).toBe('Salvador')
+ expect(municipalityForTseCityCode('35157')).toBe('Feira de Santana')
+ expect(municipalityForTseCityCode('00000')).toBeUndefined()
+ })
+})
diff --git a/tests/int/nucleusElectoralBaseline.int.spec.ts b/tests/int/nucleusElectoralBaseline.int.spec.ts
index 16b47c30..6f938ff2 100644
--- a/tests/int/nucleusElectoralBaseline.int.spec.ts
+++ b/tests/int/nucleusElectoralBaseline.int.spec.ts
@@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { getPayload, type Payload } from 'payload'
import config from '@/payload.config'
+import { tseCityCodeForMunicipality } from '@/lib/bahiaTseCityCodes'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import { computeGapVs2022 } from '@/lib/electionInsights'
import { loadNucleusActiveTabPageData } from '@/utilities/nucleusDetailPageData'
@@ -143,8 +144,12 @@ describe('nucleus electoral baseline (A4)', () => {
expect(voteFindCalls.length).toBeGreaterThan(0)
+ const salvadorCityCode = tseCityCodeForMunicipality('Salvador')
+ expect(salvadorCityCode).toBeTruthy()
+
for (const call of voteFindCalls) {
const where = (call[0] as { where?: unknown }).where
+ expect(whereContainsField(where, 'cityCode')).toBe(true)
const queriesFederalTurn1 =
whereContainsFieldEquals(where, 'office', 'deputado_federal') &&
whereContainsFieldEquals(where, 'turn', '1')
@@ -153,6 +158,20 @@ describe('nucleus electoral baseline (A4)', () => {
}
}
+ const tallyFindCalls = findSpy.mock.calls.filter(
+ (call) =>
+ typeof call[0] === 'object' &&
+ call[0] !== null &&
+ (call[0] as { collection?: string }).collection === 'electionTally',
+ )
+
+ expect(tallyFindCalls.length).toBeGreaterThan(0)
+ for (const call of tallyFindCalls) {
+ const where = (call[0] as { where?: unknown }).where
+ expect(whereContainsField(where, 'cityCode')).toBe(true)
+ expect(whereContainsFieldEquals(where, 'cityCode', salvadorCityCode)).toBe(true)
+ }
+
findSpy.mockRestore()
})
diff --git a/tests/int/nucleusListElectionOverview.int.spec.ts b/tests/int/nucleusListElectionOverview.int.spec.ts
index 3cb0f5de..ffd28f4e 100644
--- a/tests/int/nucleusListElectionOverview.int.spec.ts
+++ b/tests/int/nucleusListElectionOverview.int.spec.ts
@@ -174,7 +174,7 @@ describe('loadNucleusListElectionOverview (E7 F2)', () => {
office: 'deputado_federal',
turn: '1',
state: 'BA',
- cityCode: '2905701',
+ cityCode: '34134',
cityName: 'Camaçari',
zoneNumber: 170,
aptos: 8000,
diff --git a/tests/unit/campaignNucleusDetailTabs.unit.spec.ts b/tests/unit/campaignNucleusDetailTabs.unit.spec.ts
index f8e770fc..f94295d8 100644
--- a/tests/unit/campaignNucleusDetailTabs.unit.spec.ts
+++ b/tests/unit/campaignNucleusDetailTabs.unit.spec.ts
@@ -28,7 +28,6 @@ vi.mock('@/utilities/nucleusUpdatePageData', () => ({
}))
vi.mock('@/utilities/nucleusElectoralBaseline', () => ({
getNucleusElectoralBaseline: loaders.getNucleusElectoralBaseline,
- toNucleusElectionGeographyInput: () => ({ cities: [], regions: [], tseZones: [] }),
}))
import { NucleusTabNav } from '@/components/campaign/NucleusTabNav'
diff --git a/tests/unit/federalCandidateTotalsAggregate.unit.spec.ts b/tests/unit/federalCandidateTotalsAggregate.unit.spec.ts
index d2bd1079..b4e1f634 100644
--- a/tests/unit/federalCandidateTotalsAggregate.unit.spec.ts
+++ b/tests/unit/federalCandidateTotalsAggregate.unit.spec.ts
@@ -7,7 +7,7 @@ import type { CampaignUser, User } from '@/payload-types'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { loadFederalCandidateTotalsAggregated } from '@/utilities/federalCandidateTotalsAggregate'
import * as drizzleBulk from '@/utilities/drizzleBulk'
-import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectoralBaseline'
+import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectionGeography'
describe('assertCanReadElectionData', () => {
it('allows payload admins and campaign users to read election data', () => {
@@ -79,6 +79,9 @@ describe('loadFederalCandidateTotalsAggregated', () => {
{ candidateNumber: 1234, name: 'OUTRO', party: 'PL', votes: 900 },
])
expect(execute).toHaveBeenCalledOnce()
+ const sqlPayload = JSON.stringify(execute.mock.calls[0]?.[0])
+ expect(sqlPayload).toContain('city_code')
+ expect(sqlPayload).not.toContain('city_name')
vi.restoreAllMocks()
})
})
diff --git a/tests/unit/nucleusElectionGeography.unit.spec.ts b/tests/unit/nucleusElectionGeography.unit.spec.ts
new file mode 100644
index 00000000..0d49b04d
--- /dev/null
+++ b/tests/unit/nucleusElectionGeography.unit.spec.ts
@@ -0,0 +1,79 @@
+// @vitest-environment node
+
+import { describe, expect, it } from 'vitest'
+
+import { citiesForTerritory } from '@/lib/bahiaTerritories'
+import { tseCityCodeForMunicipality } from '@/lib/bahiaTseCityCodes'
+import { tseZonesForCity } from '@/lib/bahiaTseZones'
+import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectionGeography'
+
+describe('resolveNucleusElectionGeography', () => {
+ it('intersects nucleus tseZones with official city zones', () => {
+ const salvadorZones = tseZonesForCity('Salvador')
+ expect(salvadorZones.length).toBeGreaterThan(1)
+ const firstZone = salvadorZones[0]!
+ const secondZone = salvadorZones[1]!
+
+ const geography = resolveNucleusElectionGeography({
+ cities: ['Salvador'],
+ regions: [],
+ tseZones: [firstZone, 999],
+ })
+
+ expect(geography).not.toBeNull()
+ expect([...geography!.zonesByCity.keys()]).toEqual(['Salvador'])
+ expect(geography?.zonesByCity.get('Salvador')).toEqual([firstZone])
+ expect(geography?.cityZonePairs).toEqual([
+ { cityName: 'Salvador', cityCode: tseCityCodeForMunicipality('Salvador'), zoneNumber: firstZone },
+ ])
+ expect(geography?.zonesByCity.get('Salvador')).not.toContain(secondZone)
+ })
+
+ it('falls back to all city zones when typed tseZones lie outside the city', () => {
+ const salvadorZones = [...tseZonesForCity('Salvador')]
+ const geography = resolveNucleusElectionGeography({
+ cities: ['Salvador'],
+ regions: [],
+ tseZones: [998, 999],
+ })
+
+ expect(geography?.zonesByCity.get('Salvador')).toEqual(salvadorZones)
+ })
+
+ it('expands territory regions when cities are empty', () => {
+ const cities = citiesForTerritory('Velho Chico')
+ const geography = resolveNucleusElectionGeography({
+ cities: [],
+ regions: ['Velho Chico'],
+ tseZones: [],
+ })
+
+ expect(geography).not.toBeNull()
+ expect([...geography!.zonesByCity.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'))).toEqual(
+ [...cities].sort((a, b) => a.localeCompare(b, 'pt-BR')),
+ )
+ expect(geography?.zonesByCity.get('Bom Jesus da Lapa')).toEqual([
+ ...tseZonesForCity('Bom Jesus da Lapa'),
+ ])
+ })
+
+ it('returns null without cities or regions', () => {
+ expect(
+ resolveNucleusElectionGeography({
+ cities: [],
+ regions: [],
+ tseZones: [1],
+ }),
+ ).toBeNull()
+ })
+
+ it('returns null when cities have no TSE city code mapping', () => {
+ expect(
+ resolveNucleusElectionGeography({
+ cities: ['Município inexistente'],
+ regions: [],
+ tseZones: [],
+ }),
+ ).toBeNull()
+ })
+})
diff --git a/tests/unit/nucleusElectoralBaseline.unit.spec.ts b/tests/unit/nucleusElectoralBaseline.unit.spec.ts
index 286f75bf..397a35cc 100644
--- a/tests/unit/nucleusElectoralBaseline.unit.spec.ts
+++ b/tests/unit/nucleusElectoralBaseline.unit.spec.ts
@@ -2,80 +2,19 @@
import { describe, expect, it } from 'vitest'
-import { citiesForTerritory } from '@/lib/bahiaTerritories'
-import { tseZonesForCity } from '@/lib/bahiaTseZones'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import { computeConversionRate, computeVoteTrend } from '@/lib/electionInsights'
import {
aggregateFederalCandidateTotals,
aggregateNucleusElectoralBaseline,
- resolveNucleusElectionGeography,
type ElectionTallyAggregateRow,
type ElectionVoteAggregateRow,
} from '@/utilities/nucleusElectoralBaseline'
+import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectionGeography'
const ticketOfficeVotes = (votes: readonly ElectionVoteAggregateRow[]) =>
votes.filter((row) => row.office === 'presidente' || row.office === 'governador')
-describe('resolveNucleusElectionGeography', () => {
- it('intersects nucleus tseZones with official city zones', () => {
- const salvadorZones = tseZonesForCity('Salvador')
- expect(salvadorZones.length).toBeGreaterThan(1)
- const firstZone = salvadorZones[0]!
- const secondZone = salvadorZones[1]!
-
- const geography = resolveNucleusElectionGeography({
- cities: ['Salvador'],
- regions: [],
- tseZones: [firstZone, 999],
- })
-
- expect(geography).not.toBeNull()
- expect([...geography!.zonesByCity.keys()]).toEqual(['Salvador'])
- expect(geography?.zonesByCity.get('Salvador')).toEqual([firstZone])
- expect(geography?.cityZonePairs).toEqual([{ cityName: 'Salvador', zoneNumber: firstZone }])
- expect(geography?.zonesByCity.get('Salvador')).not.toContain(secondZone)
- })
-
- it('falls back to all city zones when typed tseZones lie outside the city', () => {
- const salvadorZones = [...tseZonesForCity('Salvador')]
- const geography = resolveNucleusElectionGeography({
- cities: ['Salvador'],
- regions: [],
- tseZones: [998, 999],
- })
-
- expect(geography?.zonesByCity.get('Salvador')).toEqual(salvadorZones)
- })
-
- it('expands territory regions when cities are empty', () => {
- const cities = citiesForTerritory('Velho Chico')
- const geography = resolveNucleusElectionGeography({
- cities: [],
- regions: ['Velho Chico'],
- tseZones: [],
- })
-
- expect(geography).not.toBeNull()
- expect([...geography!.zonesByCity.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'))).toEqual(
- [...cities].sort((a, b) => a.localeCompare(b, 'pt-BR')),
- )
- expect(geography?.zonesByCity.get('Bom Jesus da Lapa')).toEqual([
- ...tseZonesForCity('Bom Jesus da Lapa'),
- ])
- })
-
- it('returns null without cities or regions', () => {
- expect(
- resolveNucleusElectionGeography({
- cities: [],
- regions: [],
- tseZones: [1],
- }),
- ).toBeNull()
- })
-})
-
describe('aggregateNucleusElectoralBaseline', () => {
const geography = resolveNucleusElectionGeography({
cities: ['Salvador'],