mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Deliver A7 Fase 1 federal ranking aggregate and register simplify follow-ups.
Replace full Local API federal T1 loads with drizzle GROUP BY on nucleus detail, gate election reads via assertCanReadElectionData, and absorb post-simplify cleanup into the A7 plan and roadmap.
This commit is contained in:
parent
96329bd46e
commit
8420969b4b
9 changed files with 332 additions and 61 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-A4 (baseline TSE no produto)
|
||||
|
||||
Status: registrado no roadmap (fases pendentes)
|
||||
Atualizado em: 2026-07-19 (nota A5-1 em Fase 3 / `capture-review-debts`)
|
||||
Status: Fase 1 implementada (Fases 2–3 pendentes; merge pendente)
|
||||
Atualizado em: 2026-07-19 (Fase 1 + `capture-review-debts` pós-`/simplify` — cleanup F1 absorvido; F2 ganha quebra de ciclo de módulo)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -11,11 +11,13 @@ 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` em `src/utilities/nucleusElectoralBaseline.ts` buscam o ranking local inteiro para `winnerFederal` e `candidate.rank`. Em núcleo multi-município / TI inteiro isso pode ser milhares de rows por abertura da aba Visão geral (`pagination: false`).
|
||||
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).
|
||||
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`).
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify):** 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), e o cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4).
|
||||
**Já resolvido no simplify pós-F1 / `capture-review-debts` (não reabrir):** remoção de `cities` derivado em `NucleusElectionGeography` (só `zonesByCity` + `cityZonePairs`); `assertCanReadElectionData` com `asserts` e entrada `CampaignUser | User | null | undefined`; testes reais de `loadFederalCandidateTotalsAggregated` (mock drizzle) em `federalCandidateTotalsAggregate.unit.spec.ts`; int spy estrutural (`whereContainsField*`) em vez de `JSON.stringify(where)`; inline do SQL builder federal; comentário em `aggregateFederalCandidateTotals` como oracle in-memory para unit tests.
|
||||
|
||||
**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).
|
||||
|
||||
## Objetivos
|
||||
|
||||
|
|
@ -32,10 +34,11 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
|
|||
- **Cortável se a geografia real dos núcleos permanecer pequena** (1–2 municípios tipados). Vira não-cortável de qualidade quando A5/B3 passarem a martelar o mesmo helper em TI inteiro.
|
||||
- **Não registrar no A7 o cast `as unknown` do overview B1** — débito de tipagem do select Payload da lista; só tocar se um PR de A7 já estiver no arquivo.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`aggregateFederalTotalsByGeography`, `tseCityCodeForMunicipality`, `alertVariants.confirmed`), strings visíveis em pt-BR inalteradas.
|
||||
- **Impeccable:** Fase 1 = classe **A** (só backend); Fase 2 = **A**; Fase 3 = **B** (encaixe em `Alert`/`NucleusInsights`/`NucleusElectoralBaseline` existentes).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Fase 1: Local API `find` + agregação em memória vs `payload.db.drizzle` `GROUP BY`?** **Recomendação:** drizzle (como o seed TSE) para `SUM(votes) GROUP BY candidate_number` no escopo federal T1 + geografia, mais uma query estreita para presidente/governador da chapa; manter `overrideAccess`/ACL lendo via Local API só se o drizzle for privilegiado — espelhar o padrão do import (`overrideAccess: true` só em CLI). No app, preferir Local API com `select` mínimo **ou** uma query drizzle encapsulada que ainda respeite `canReadElectionData` (checagem explícita antes). Definir no PR da F1 com um teste int de TI grande (fixture expandida ou assert de limite de rows).
|
||||
- **Fase 1: Local API `find` + agregação em memória vs `payload.db.drizzle` `GROUP BY`?** **Resolvido (2026-07-19):** drizzle `GROUP BY candidate_number` em `federalCandidateTotalsAggregate.ts`, com gate ACL explícito (`assertCanReadElectionData`) antes do `execute`; presidente/governador e série histórica permanecem no Local API magro.
|
||||
- **Fase 2: de onde vem o mapa nome canônico → `CD_MUNICIPIO` TSE?** **Recomendação:** extrair do próprio seed/`electionTally`/`electionCandidateVote` (já têm `cityCode`+`cityName`) para uma tabela estática gerada (`src/lib/bahiaTseCityCodes.ts`) no mesmo espírito de `bahiaMunicipalityCodes` (IBGE) — **não** confundir com código IBGE. Script re-executável opcional; sem migration se for só arquivo versionado.
|
||||
- **Fase 3: nome da variante Alert — `confirmed` ou `estimate-confirmed`?** **Recomendação:** `confirmed`, alinhado aos tokens CSS `--estimate-confirmed` / classe utilitária já usada pelo Badge `estimate-confirmed`, sem inventar terceira nomenclatura.
|
||||
|
||||
|
|
@ -51,19 +54,17 @@ flowchart TD
|
|||
F1 -.métricas.-> B3["B3 Leaflet coroplético"]
|
||||
```
|
||||
|
||||
### Fase 1 — Agregação do ranking federal no detalhe
|
||||
### Fase 1 — Agregação do ranking federal no detalhe ✓ (2026-07-19)
|
||||
|
||||
- Em `src/utilities/nucleusElectoralBaseline.ts`: deixar de materializar todas as rows nominais federais da geografia só para `aggregateFederalCandidateTotals`.
|
||||
- Caminho preferido: query agregada (drizzle) → lista `{ candidateNumber, name, party, votes }` já somada; candidato da chapa + winner/rank derivados dessa lista curta (N candidatos distintos, não N×zonas).
|
||||
- Manter query estreita para presidente/governador da `BASELINE_TICKET_2022` (já filtrada por número).
|
||||
- Tallies: inalterado (já é 1 row por city×zone federal T1).
|
||||
- Testes: unit da agregação pura; int com geografia multi-zona assertando winner/rank/solla votes **e** teto de rows transferidas (ou mock do finder).
|
||||
- Critério: abrir overview de núcleo com ≥2 municípios / TI não escala linearmente com (#candidatos × #zonas) no wire.
|
||||
- `src/utilities/federalCandidateTotalsAggregate.ts`: drizzle `SUM(votes) GROUP BY candidate_number` + `assertCanReadElectionData`.
|
||||
- `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
|
||||
|
||||
- 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` usa `cityCode: { equals|in }` + `zoneNumber: { in }`.
|
||||
- `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`).
|
||||
|
||||
|
|
@ -95,7 +96,10 @@ flowchart TD
|
|||
- `docs/roadmap.md` (Trilha A, item A7; A4/A5/B3)
|
||||
- `docs/plans/baseline-eleitoral-tse.md` — plano pai do A3/A4
|
||||
- `docs/plans/escala-dry-pos-b2.md` / `escala-dry-pos-c3.md` / `escala-dry-pos-c6.md` — precedente pós-`/simplify`
|
||||
- `src/utilities/nucleusElectoralBaseline.ts` — `detailVoteWhere`, `loadElectionVotes`, `aggregateFederalCandidateTotals`
|
||||
- `src/utilities/federalCandidateTotalsAggregate.ts` — drizzle aggregate federal T1 (Fase 1)
|
||||
- `src/utilities/nucleusElectoralBaseline.ts` — `loadTicketOfficeVotes`, `aggregateNucleusElectoralBaseline`, série E2 lean
|
||||
- `src/utilities/campaignAccess.ts` — `assertCanReadElectionData` / `ElectionDataReader` (gate ACL do path drizzle)
|
||||
- `src/utilities/drizzleBulk.ts` — `requirePostgresDrizzle` (read-only aggregates)
|
||||
- `docs/plans/insight-taxa-conversao.md` — A5-1 usa `confirmedInsightAlertClass` até F3
|
||||
- `src/lib/electionInsights.ts` / `src/components/campaign/NucleusInsights.tsx` — Gap + conversão UI
|
||||
- `src/components/campaign/NucleusElectoralBaseline.tsx` — barra do candidato
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md))
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); **A7 Fase 1 entregue** (agregação drizzle ranking federal; merge pendente; Fases 2–3 pendentes) — [plano](plans/escala-dry-pos-a4.md); C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md))
|
||||
|
||||
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
|
|||
### Ciclo 2+ — A4 implementado e mesclado em `main` (2026-07-18)
|
||||
|
||||
- **A4 Baseline no produto + Gap vs 2022** — agregação `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` + `computeGapVs2022`; bloco "Baseline eleitoral 2022" e insight Gap na aba Visão geral do núcleo; card "Baseline 2022" no overview da lista filtrada. Chapa tipada via `BASELINE_TICKET_2022` (papéis `candidate` / `president` / `governor`). Sem migration, sem Consent (dado público TSE). Três passagens `/simplify` no PR; débitos maiores → A7. [Plano](plans/baseline-eleitoral-tse.md).
|
||||
- **A7 Escala e DRY pós-A4 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: agregar ranking federal do detalhe (não puxar todas as rows nominais), filtro geográfico por `cityCode` TSE, variant Alert `confirmed` + `Progress` no card. [Plano](plans/escala-dry-pos-a4.md).
|
||||
- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–3 pendentes (`cityCode` TSE, Alert `confirmed` + `Progress`). Passagem `/simplify` + `capture-review-debts` absorveu cleanup F1 no plano. [Plano](plans/escala-dry-pos-a4.md).
|
||||
- **A8 Perfis do eleitorado IBGE registrado (2026-07-19)** — perfil médio/comum do território derivado de indicadores municipais do Censo IBGE (artefato estático + ponderação por `cities[]`), exibido na aba Eleitorado junto com `voterProfiles` manuais já existentes; sem migration, sem Consent. [Plano](plans/perfil-eleitorado-ibge.md).
|
||||
|
||||
### Ciclo 2+ — B2 fundação de geometrias do mapa (2026-07-18)
|
||||
|
|
@ -149,7 +149,7 @@ flowchart TD
|
|||
A2["A2 Zonas TSE + sugestões cruzadas<br/>(chips opt-in território↔ZE) ✓"]
|
||||
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections) ✓"]
|
||||
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview) ✓"]
|
||||
A7["A7 Escala e DRY pós-A4<br/>(agregar federal / cityCode / Alert)"]
|
||||
A7["A7 Escala e DRY pós-A4<br/>(F1 agregar federal ✓ / cityCode / Alert)"]
|
||||
A5["A5 Insights: conversão, classificação,<br/>alavancagem (+virada), mobilização, competitiva"]
|
||||
A6["A6 Dobradinha 2026"]
|
||||
A8["A8 Perfis eleitorado IBGE<br/>(médio calculado + manuais)"]
|
||||
|
|
@ -284,7 +284,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
|
|||
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 ✓ + E3 ✓ (suave: E2 ✓ — tendência visível na revisão; E6 F1 — perf da lista) | A5, A7, A8, B3, B5, C4, D2 |
|
||||
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto; alavancagem inclui Fase 2 virada por vencedor **ou** share da direita) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, A8, B3, B5, C4, E4, D1 |
|
||||
| 20 | A8 Perfis do eleitorado — perfil médio IBGE (Censo) + manuais na aba Eleitorado _(cortável: manuais já existem)_ | [detalhes](plans/perfil-eleitorado-ibge.md) | A1 ✓ (suave: B2 ✓ — `codarea`) | A5, A7, B3, B5, C4, E4, D1 |
|
||||
| 21 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, C4, E4, D1 |
|
||||
| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + Progress) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–3 pendentes; `/simplify` + `capture-review-debts` absorvidos no plano)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, C4, E4, D1 |
|
||||
| 22 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, A8, B5, C4, E4, D1 |
|
||||
| 23 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, A8, B3, C4, E4, D1 |
|
||||
| 24 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, A8, B3, B5, E4, D1 |
|
||||
|
|
|
|||
|
|
@ -390,9 +390,19 @@ export const canManageNucleusLifecycle: FieldAccess = async ({ req }) => {
|
|||
|
||||
export const canSetDerivedNucleusField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
export type ElectionDataReader = CampaignUser | User
|
||||
|
||||
export const canReadElectionDataAsUser = (user: CampaignActor): user is ElectionDataReader =>
|
||||
isPayloadAdmin(user) || isCampaignUser(user)
|
||||
|
||||
export function assertCanReadElectionData(user: CampaignActor): asserts user is ElectionDataReader {
|
||||
if (!canReadElectionDataAsUser(user)) {
|
||||
throw new Error('Leitura de dados eleitorais negada.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Public TSE election data: any authenticated campaign or admin user may read. */
|
||||
export const canReadElectionData: Access = ({ req }) =>
|
||||
isPayloadAdmin(req.user) || isCampaignUser(req.user)
|
||||
export const canReadElectionData: Access = ({ req }) => canReadElectionDataAsUser(req.user)
|
||||
|
||||
/** Election reference data is mutated only by Payload admins (or CLI with overrideAccess). */
|
||||
export const canMutateElectionData: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'server-only'
|
||||
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
export type DrizzleTables = Record<string, Record<string, unknown>>
|
||||
|
|
@ -8,6 +9,10 @@ type PayloadDbTables = {
|
|||
tables: DrizzleTables
|
||||
}
|
||||
|
||||
type PostgresExecuteDb = {
|
||||
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** ~15-16 columns × 500 ≈ 7500-8000 bind params — well under PG's 65535 param limit. */
|
||||
export const INSERT_CHUNK_SIZE = 500
|
||||
|
||||
|
|
@ -61,3 +66,18 @@ export const drizzleResultRows = (result: unknown): Array<Record<string, unknown
|
|||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Returns the pool drizzle client for read-only `execute` aggregates (non-transactional). */
|
||||
export const requirePostgresDrizzle = (
|
||||
payload: Pick<Payload, 'db'>,
|
||||
contextLabel: string,
|
||||
): PostgresExecuteDb => {
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error(`${contextLabel} exige o adaptador PostgreSQL.`)
|
||||
}
|
||||
const drizzle = (payload.db as unknown as { drizzle?: PostgresExecuteDb }).drizzle
|
||||
if (!drizzle || typeof drizzle.execute !== 'function') {
|
||||
throw new Error(`A sessão PostgreSQL de ${contextLabel} não está disponível.`)
|
||||
}
|
||||
return drizzle
|
||||
}
|
||||
|
|
|
|||
60
src/utilities/federalCandidateTotalsAggregate.ts
Normal file
60
src/utilities/federalCandidateTotalsAggregate.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'server-only'
|
||||
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults'
|
||||
import {
|
||||
assertCanReadElectionData,
|
||||
type ElectionDataReader,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { drizzleResultRows, requirePostgresDrizzle } from '@/utilities/drizzleBulk'
|
||||
import type { NucleusElectionGeography } from '@/utilities/nucleusElectoralBaseline'
|
||||
|
||||
export type FederalCandidateTotal = {
|
||||
candidateNumber: number
|
||||
name: string
|
||||
party: string
|
||||
votes: number
|
||||
}
|
||||
|
||||
export const loadFederalCandidateTotalsAggregated = async (
|
||||
payload: Pick<Payload, 'db'>,
|
||||
user: ElectionDataReader,
|
||||
geography: NucleusElectionGeography,
|
||||
): 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(
|
||||
zones.map((zone) => sql`${zone}`),
|
||||
sql`, `,
|
||||
)}))`,
|
||||
)
|
||||
|
||||
const drizzle = requirePostgresDrizzle(payload, 'agregação federal')
|
||||
const result = await drizzle.execute(sql`
|
||||
SELECT
|
||||
"election_candidate_vote"."candidate_number" AS "candidate_number",
|
||||
MAX("election_candidate_vote"."candidate_name") AS "candidate_name",
|
||||
COALESCE(MAX("election_candidate_vote"."party"), '') AS "party",
|
||||
SUM("election_candidate_vote"."votes")::int AS "votes"
|
||||
FROM "election_candidate_vote"
|
||||
WHERE
|
||||
"election_candidate_vote"."year" = ${ELECTION_YEAR_2022}
|
||||
AND "election_candidate_vote"."state" = 'BA'
|
||||
AND "election_candidate_vote"."office" = ${BASELINE_TICKET_2022.candidate.office}
|
||||
AND "election_candidate_vote"."turn" = '1'
|
||||
AND "election_candidate_vote"."vote_type" = 'nominal'
|
||||
AND (${sql.join(cityClauses, sql` OR `)})
|
||||
GROUP BY "election_candidate_vote"."candidate_number"
|
||||
ORDER BY "votes" DESC, "election_candidate_vote"."candidate_number" ASC
|
||||
`)
|
||||
|
||||
return drizzleResultRows(result).map((row) => ({
|
||||
candidateNumber: Number(row.candidate_number),
|
||||
name: String(row.candidate_name ?? ''),
|
||||
party: String(row.party),
|
||||
votes: Number(row.votes ?? 0),
|
||||
}))
|
||||
}
|
||||
|
|
@ -23,8 +23,12 @@ import {
|
|||
type VoteTrendSeries,
|
||||
type VoteTrendStatus,
|
||||
} from '@/lib/electionInsights'
|
||||
import type { CampaignUser, User } from '@/payload-types'
|
||||
import { type ElectionDataReader } from '@/utilities/campaignAccess'
|
||||
import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
|
||||
import {
|
||||
loadFederalCandidateTotalsAggregated,
|
||||
type FederalCandidateTotal,
|
||||
} from '@/utilities/federalCandidateTotalsAggregate'
|
||||
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
|
||||
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
|
||||
|
||||
|
|
@ -49,7 +53,6 @@ export const toNucleusElectionGeographyInput = (nucleus: {
|
|||
})
|
||||
|
||||
export type NucleusElectionGeography = {
|
||||
cities: string[]
|
||||
zonesByCity: Map<string, number[]>
|
||||
cityZonePairs: Array<{ cityName: string; zoneNumber: number }>
|
||||
}
|
||||
|
|
@ -77,8 +80,6 @@ export type ElectionTallyAggregateRow = {
|
|||
abstencoes: number
|
||||
}
|
||||
|
||||
type ElectionReader = CampaignUser | User
|
||||
|
||||
const cityZoneKey = (cityName: string, zoneNumber: number): string =>
|
||||
`${cityName}::${zoneNumber}`
|
||||
|
||||
|
|
@ -135,7 +136,7 @@ export const resolveNucleusElectionGeography = (
|
|||
|
||||
if (cityZonePairs.length === 0) return null
|
||||
|
||||
return { cities: [...zonesByCity.keys()], zonesByCity, cityZonePairs }
|
||||
return { zonesByCity, cityZonePairs }
|
||||
}
|
||||
|
||||
const matchesGeography = (
|
||||
|
|
@ -176,14 +177,12 @@ const decisiveTicketVotes = (
|
|||
return null
|
||||
}
|
||||
|
||||
const aggregateFederalCandidateTotals = (
|
||||
/** In-memory reference aggregate for unit tests; production uses drizzle GROUP BY. */
|
||||
export const aggregateFederalCandidateTotals = (
|
||||
votes: readonly ElectionVoteAggregateRow[],
|
||||
geography: NucleusElectionGeography,
|
||||
): Array<{ candidateNumber: number; name: string; party: string; votes: number }> => {
|
||||
const byCandidate = new Map<
|
||||
number,
|
||||
{ candidateNumber: number; name: string; party: string; votes: number }
|
||||
>()
|
||||
): FederalCandidateTotal[] => {
|
||||
const byCandidate = new Map<number, FederalCandidateTotal>()
|
||||
|
||||
for (const row of votes) {
|
||||
if (row.office !== CANDIDATE_OFFICE || row.turn !== '1') continue
|
||||
|
|
@ -250,11 +249,12 @@ const sumElectorateForGeography = (
|
|||
|
||||
export const aggregateNucleusElectoralBaseline = (
|
||||
geography: NucleusElectionGeography,
|
||||
votes: readonly ElectionVoteAggregateRow[],
|
||||
federalTotals: readonly FederalCandidateTotal[],
|
||||
ticketVotes: readonly ElectionVoteAggregateRow[],
|
||||
tallies: readonly ElectionTallyAggregateRow[],
|
||||
historicalVotes: Pick<VoteTrendSeries, 'y2014' | 'y2018'>,
|
||||
): NucleusElectoralBaselineViewModel => {
|
||||
const federalTotals = aggregateFederalCandidateTotals(votes, geography)
|
||||
// `federalTotals` must be sorted by votes desc (SQL aggregate or aggregateFederalCandidateTotals).
|
||||
const candidateIndex = federalTotals.findIndex(
|
||||
(candidate) => candidate.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber,
|
||||
)
|
||||
|
|
@ -268,13 +268,13 @@ export const aggregateNucleusElectoralBaseline = (
|
|||
rank: candidateIndex >= 0 ? candidateIndex + 1 : null,
|
||||
},
|
||||
president: decisiveTicketVotes(
|
||||
votes,
|
||||
ticketVotes,
|
||||
geography,
|
||||
'presidente',
|
||||
BASELINE_TICKET_2022.president.candidateNumber,
|
||||
),
|
||||
governor: decisiveTicketVotes(
|
||||
votes,
|
||||
ticketVotes,
|
||||
geography,
|
||||
'governador',
|
||||
BASELINE_TICKET_2022.governor.candidateNumber,
|
||||
|
|
@ -305,17 +305,14 @@ const geographyWhere = (geography: NucleusElectionGeography): Where => ({
|
|||
),
|
||||
})
|
||||
|
||||
/** Votes for the detail baseline card (federal ranking + president/governor tickets). */
|
||||
const detailVoteWhere = (geography: NucleusElectionGeography): Where => ({
|
||||
/** President/governor ticket votes for the detail baseline card (lean Local API path). */
|
||||
const ticketOfficeVoteWhere = (geography: NucleusElectionGeography): Where => ({
|
||||
and: [
|
||||
...ba2022Scope(),
|
||||
{ voteType: { equals: 'nominal' } },
|
||||
geographyWhere(geography),
|
||||
{
|
||||
or: [
|
||||
{
|
||||
and: [{ office: { equals: CANDIDATE_OFFICE } }, { turn: { equals: '1' } }],
|
||||
},
|
||||
{
|
||||
and: [
|
||||
{ office: { equals: 'presidente' } },
|
||||
|
|
@ -333,14 +330,14 @@ const detailVoteWhere = (geography: NucleusElectionGeography): Where => ({
|
|||
],
|
||||
})
|
||||
|
||||
const loadElectionVotes = async (
|
||||
const loadTicketOfficeVotes = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: ElectionReader,
|
||||
user: ElectionDataReader,
|
||||
geography: NucleusElectionGeography,
|
||||
): Promise<ElectionVoteAggregateRow[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'electionCandidateVote',
|
||||
where: detailVoteWhere(geography),
|
||||
where: ticketOfficeVoteWhere(geography),
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {
|
||||
|
|
@ -371,7 +368,7 @@ const loadElectionVotes = async (
|
|||
|
||||
const loadElectionTallies = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: ElectionReader,
|
||||
user: ElectionDataReader,
|
||||
geography: NucleusElectionGeography,
|
||||
): Promise<ElectionTallyAggregateRow[]> => {
|
||||
const result = await payload.find({
|
||||
|
|
@ -442,7 +439,6 @@ const buildUnionGeography = (
|
|||
}
|
||||
|
||||
return {
|
||||
cities: uniqueSortedCities(zonesByCity.keys()),
|
||||
zonesByCity,
|
||||
cityZonePairs: [...unionPairs.values()],
|
||||
}
|
||||
|
|
@ -461,7 +457,7 @@ const sumCandidateVotesForGeography = (
|
|||
/** Candidate-only votes indexed by year then city×zone (E2 lean loader). */
|
||||
const loadCandidateSeriesByGeography = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: ElectionReader,
|
||||
user: ElectionDataReader,
|
||||
geography: NucleusElectionGeography,
|
||||
years: readonly number[] = [...HISTORICAL_SERIES_YEARS],
|
||||
): Promise<Map<number, Map<string, number>>> => {
|
||||
|
|
@ -522,15 +518,16 @@ const sumCandidateSeriesForGeography = (
|
|||
}
|
||||
|
||||
export const getNucleusElectoralBaseline = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: ElectionReader,
|
||||
payload: Pick<Payload, 'find' | 'db'>,
|
||||
user: ElectionDataReader,
|
||||
nucleus: NucleusElectionGeographyInput,
|
||||
): Promise<NucleusElectoralBaselineViewModel | null> => {
|
||||
const geography = resolveNucleusElectionGeography(nucleus)
|
||||
if (!geography) return null
|
||||
|
||||
const [votes, tallies, seriesByYear] = await Promise.all([
|
||||
loadElectionVotes(payload, user, geography),
|
||||
const [federalTotals, ticketVotes, tallies, seriesByYear] = await Promise.all([
|
||||
loadFederalCandidateTotalsAggregated(payload, user, geography),
|
||||
loadTicketOfficeVotes(payload, user, geography),
|
||||
loadElectionTallies(payload, user, geography),
|
||||
loadCandidateSeriesByGeography(payload, user, geography, HISTORICAL_PRIOR_SERIES_YEARS),
|
||||
])
|
||||
|
|
@ -540,7 +537,13 @@ export const getNucleusElectoralBaseline = async (
|
|||
seriesByYear,
|
||||
HISTORICAL_PRIOR_SERIES_YEARS,
|
||||
)
|
||||
return aggregateNucleusElectoralBaseline(geography, votes, tallies, historicalVotes)
|
||||
return aggregateNucleusElectoralBaseline(
|
||||
geography,
|
||||
federalTotals,
|
||||
ticketVotes,
|
||||
tallies,
|
||||
historicalVotes,
|
||||
)
|
||||
}
|
||||
|
||||
export type NucleusBaseline2022OverviewInput = NucleusElectionGeographyInput & {
|
||||
|
|
@ -571,7 +574,7 @@ export type NucleusListElectionOverview = {
|
|||
*/
|
||||
export const loadNucleusListElectionOverview = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: ElectionReader,
|
||||
user: ElectionDataReader,
|
||||
nuclei: readonly NucleusBaseline2022OverviewInput[],
|
||||
): Promise<NucleusListElectionOverview> => {
|
||||
const geographies = nuclei.map((nucleus) => resolveNucleusElectionGeography(nucleus))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
|
||||
import config from '@/payload.config'
|
||||
|
|
@ -17,6 +17,33 @@ import {
|
|||
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
const whereContainsFieldEquals = (node: unknown, field: string, value: unknown): boolean => {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
if (Array.isArray(node)) return node.some((item) => whereContainsFieldEquals(item, field, value))
|
||||
|
||||
const record = node as Record<string, unknown>
|
||||
const fieldValue = record[field]
|
||||
if (
|
||||
fieldValue &&
|
||||
typeof fieldValue === 'object' &&
|
||||
'equals' in fieldValue &&
|
||||
(fieldValue as { equals: unknown }).equals === value
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Object.values(record).some((child) => whereContainsFieldEquals(child, field, value))
|
||||
}
|
||||
|
||||
const whereContainsField = (node: unknown, field: string): boolean => {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
if (Array.isArray(node)) return node.some((item) => whereContainsField(item, field))
|
||||
|
||||
const record = node as Record<string, unknown>
|
||||
if (field in record) return true
|
||||
return Object.values(record).some((child) => whereContainsField(child, field))
|
||||
}
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
|
|
@ -91,6 +118,44 @@ describe('nucleus electoral baseline (A4)', () => {
|
|||
expect(BASELINE_TICKET_2022.candidate.candidateNumber).toBe(1313)
|
||||
})
|
||||
|
||||
it('does not fetch all federal T1 vote rows via Local API for the detail baseline', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const user = await fixtures.createCampaignUser('geral', {
|
||||
name: fixtures.value('Baseline aggregate geral'),
|
||||
email: `${fixtures.value('baseline-aggregate-geral')}@example.com`,
|
||||
password: fixtures.value('password'),
|
||||
})
|
||||
|
||||
const findSpy = vi.spyOn(payload, 'find')
|
||||
|
||||
await getNucleusElectoralBaseline(payload, user, {
|
||||
cities: ['Salvador'],
|
||||
regions: ['Metropolitano de Salvador'],
|
||||
tseZones: [1, 2],
|
||||
})
|
||||
|
||||
const voteFindCalls = findSpy.mock.calls.filter(
|
||||
(call) =>
|
||||
typeof call[0] === 'object' &&
|
||||
call[0] !== null &&
|
||||
(call[0] as { collection?: string }).collection === 'electionCandidateVote',
|
||||
)
|
||||
|
||||
expect(voteFindCalls.length).toBeGreaterThan(0)
|
||||
|
||||
for (const call of voteFindCalls) {
|
||||
const where = (call[0] as { where?: unknown }).where
|
||||
const queriesFederalTurn1 =
|
||||
whereContainsFieldEquals(where, 'office', 'deputado_federal') &&
|
||||
whereContainsFieldEquals(where, 'turn', '1')
|
||||
if (queriesFederalTurn1) {
|
||||
expect(whereContainsField(where, 'candidateNumber')).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
findSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null without geography and loads baseline on the overview tab', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const user = await fixtures.createCampaignUser('coordenador', {
|
||||
|
|
|
|||
84
tests/unit/federalCandidateTotalsAggregate.unit.spec.ts
Normal file
84
tests/unit/federalCandidateTotalsAggregate.unit.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
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'
|
||||
|
||||
describe('assertCanReadElectionData', () => {
|
||||
it('allows payload admins and campaign users to read election data', () => {
|
||||
expect(() =>
|
||||
assertCanReadElectionData({
|
||||
collection: 'users',
|
||||
id: 1,
|
||||
} as User),
|
||||
).not.toThrow()
|
||||
|
||||
expect(() =>
|
||||
assertCanReadElectionData({
|
||||
collection: 'campaignUser',
|
||||
id: 1,
|
||||
role: 'lideranca',
|
||||
} as CampaignUser),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('denies readers outside campaign/admin collections', () => {
|
||||
expect(() =>
|
||||
assertCanReadElectionData({
|
||||
collection: 'contact',
|
||||
id: 1,
|
||||
} as never),
|
||||
).toThrow('Leitura de dados eleitorais negada.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadFederalCandidateTotalsAggregated', () => {
|
||||
it('maps drizzle aggregate rows and scopes geography in SQL', async () => {
|
||||
const geography = resolveNucleusElectionGeography({
|
||||
cities: ['Salvador'],
|
||||
regions: [],
|
||||
tseZones: [1, 2],
|
||||
})!
|
||||
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
candidate_number: 1313,
|
||||
candidate_name: 'JORGE SOLLA',
|
||||
party: 'PT',
|
||||
votes: 2100,
|
||||
},
|
||||
{
|
||||
candidate_number: 1234,
|
||||
candidate_name: 'OUTRO',
|
||||
party: 'PL',
|
||||
votes: 900,
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.spyOn(drizzleBulk, 'requirePostgresDrizzle').mockReturnValue({ execute })
|
||||
|
||||
const payload = { db: { name: 'postgres' } } as Pick<Payload, 'db'>
|
||||
const totals = await loadFederalCandidateTotalsAggregated(
|
||||
payload,
|
||||
{
|
||||
collection: 'campaignUser',
|
||||
id: 1,
|
||||
role: 'geral',
|
||||
} as CampaignUser,
|
||||
geography,
|
||||
)
|
||||
|
||||
expect(totals).toEqual([
|
||||
{ candidateNumber: 1313, name: 'JORGE SOLLA', party: 'PT', votes: 2100 },
|
||||
{ candidateNumber: 1234, name: 'OUTRO', party: 'PL', votes: 900 },
|
||||
])
|
||||
expect(execute).toHaveBeenCalledOnce()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
|
@ -7,12 +7,16 @@ 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'
|
||||
|
||||
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')
|
||||
|
|
@ -27,7 +31,7 @@ describe('resolveNucleusElectionGeography', () => {
|
|||
})
|
||||
|
||||
expect(geography).not.toBeNull()
|
||||
expect(geography?.cities).toEqual(['Salvador'])
|
||||
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)
|
||||
|
|
@ -53,7 +57,9 @@ describe('resolveNucleusElectionGeography', () => {
|
|||
})
|
||||
|
||||
expect(geography).not.toBeNull()
|
||||
expect(geography?.cities).toEqual([...cities].sort((a, b) => a.localeCompare(b, 'pt-BR')))
|
||||
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'),
|
||||
])
|
||||
|
|
@ -177,10 +183,17 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
]
|
||||
|
||||
it('sums the candidate, ticket leaders, electorate, winner, rank, and series for the geography', () => {
|
||||
const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies, {
|
||||
const federalTotals = aggregateFederalCandidateTotals(votes, geography)
|
||||
const baseline = aggregateNucleusElectoralBaseline(
|
||||
geography,
|
||||
federalTotals,
|
||||
ticketOfficeVotes(votes),
|
||||
tallies,
|
||||
{
|
||||
y2014: 1000,
|
||||
y2018: 1500,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
expect(baseline.candidate).toEqual({ votes: 2100, rank: 1 })
|
||||
expect(baseline.president).toEqual({ votes: 8000, turn: 2 })
|
||||
|
|
@ -208,10 +221,16 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
regions: [],
|
||||
tseZones: [2],
|
||||
})!
|
||||
const baseline = aggregateNucleusElectoralBaseline(zone1Only, votes, tallies, {
|
||||
const baseline = aggregateNucleusElectoralBaseline(
|
||||
zone1Only,
|
||||
aggregateFederalCandidateTotals(votes, zone1Only),
|
||||
ticketOfficeVotes(votes),
|
||||
tallies,
|
||||
{
|
||||
y2014: 0,
|
||||
y2018: 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
expect(baseline.candidate).toEqual({ votes: 900, rank: 2 })
|
||||
expect(baseline.winnerFederal).toEqual({
|
||||
|
|
@ -222,10 +241,16 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
})
|
||||
|
||||
it('supports conversion rate from electorate aptos and confirmed estimate', () => {
|
||||
const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies, {
|
||||
const baseline = aggregateNucleusElectoralBaseline(
|
||||
geography,
|
||||
aggregateFederalCandidateTotals(votes, geography),
|
||||
ticketOfficeVotes(votes),
|
||||
tallies,
|
||||
{
|
||||
y2014: 0,
|
||||
y2018: 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
expect(
|
||||
computeConversionRate({
|
||||
|
|
|
|||
Loading…
Reference in a new issue