Deliver A7 Fase 2 cityCode TSE geography filters and absorb simplify debts.
Switch election geography queries from cityName to TSE cityCode via a static Bahia map and nucleusElectionGeography module, with simplify cleanup and plan updates for F3–F5 follow-ups.
This commit is contained in:
parent
3f6220c9d4
commit
c2381cecde
21 changed files with 2679 additions and 187 deletions
|
|
@ -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<br/>(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<br/>+ gapVs2022 helpers"]
|
||||
F1 --> F4["Fase 4 — Fetch 2022 único<br/>lista overview + coroplético"]
|
||||
F1 --> F5["Fase 5 — Batch federal lista<br/>+ 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<Payload, 'find' | 'db'>`.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
202
scripts/build-bahia-tse-city-codes.mjs
Normal file
202
scripts/build-bahia-tse-city-codes.mjs
Normal file
|
|
@ -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<Record<string, string>> = {
|
||||
${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<string, string>} canonical name → TSE city code */
|
||||
const codeByCanonical = new Map()
|
||||
/** @type {Map<string, string>} 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)
|
||||
})
|
||||
449
src/lib/bahiaTseCityCodes.ts
Normal file
449
src/lib/bahiaTseCityCodes.ts
Normal file
|
|
@ -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<Record<string, string>> = {
|
||||
"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)
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<FederalCandidateTotal[]> => {
|
||||
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`, `,
|
||||
)}))`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
|
||||
import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
|
||||
|
||||
export type ChoroplethMetric = 'nucleusCount' | 'confirmedEstimate' | 'baseline2022Votes'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
120
src/utilities/nucleusElectionGeography.ts
Normal file
120
src/utilities/nucleusElectionGeography.ts
Normal file
|
|
@ -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<number | { zoneNumber: number }> | 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<string, number[]>
|
||||
cityZonePairs: NucleusCityZonePair[]
|
||||
}
|
||||
|
||||
const uniqueSortedCities = (values: Iterable<string>): 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<string, number[]>()
|
||||
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<string, number[]> => {
|
||||
const byCode = new Map<string, number[]>()
|
||||
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 } }],
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
|
@ -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<number | { zoneNumber: number }> | 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<string, number[]>
|
||||
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>): 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<string, number[]>()
|
||||
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<NucleusElectionGeography | null>,
|
||||
indexes: readonly number[],
|
||||
): NucleusElectionGeography | null => {
|
||||
const unionPairs = new Map<string, { cityName: string; zoneNumber: number }>()
|
||||
const unionPairs = new Map<string, NucleusCityZonePair>()
|
||||
const zonesByCity = new Map<string, number[]>()
|
||||
|
||||
for (const index of indexes) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1683
tests/fixtures/bahia-tse-city-codes.official.json
vendored
Normal file
1683
tests/fixtures/bahia-tse-city-codes.official.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
82
tests/int/bahiaTseCityCodes.int.spec.ts
Normal file
82
tests/int/bahiaTseCityCodes.int.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
79
tests/unit/nucleusElectionGeography.unit.spec.ts
Normal file
79
tests/unit/nucleusElectionGeography.unit.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Reference in a new issue