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'],