Deliver E7 Fase 2 int tests for nucleus list election overview.

Add integration coverage for loadNucleusListElectionOverview (gap, trend,
conversion), extend TSE fixtures for multi-year seeding, register E7 Fase 4
test-helper debts on the roadmap, and simplify seed-consent exit handling.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 16:44:51 -03:00
parent 14cfcdb3f6
commit 96329bd46e
5 changed files with 272 additions and 22 deletions

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-E2 (série TSE 2014/2018 + tendência)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19 (Fase 2 estendida pós-A5-1 / `capture-review-debts`)
Status: Fase 2 entregue (2026-07-19); Fases 1, 3 e 4 pendentes
Atualizado em: 2026-07-19 (Fase 4 helpers de teste eleitoral registrada via `capture-review-debts` pós-simplify E7 F2)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E7)
Responsável: —
@ -14,24 +14,29 @@ Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup:
Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Débitos que **já têm dono em outro item** ficam fora deste plano (ver Não escopo).
1. **Tendência calculada e exibida duas vezes na aba Visão geral do núcleo.** `NucleusActiveTab.tsx` renderiza `NucleusElectoralBaseline` e `NucleusInsights` em sequência; cada um chama `computeVoteTrend(baseline.series)` e ambos mostram a série 2014→2018→2022 (badge + mensagem no card; alerta + mensagem + série no insights). Duplicação de trabalho no cliente e de informação para o usuário.
2. **Sem testes de integração do loader combinado.** `loadNucleusListElectionOverview` substitui loaders separados de gap e tendência; só há cobertura unitária de `computeVoteTrend` / `aggregateVoteTrend`, `computeConversionRate` / `aggregateConversionBand` e unit pontual de electorate→conversão — não há int que exercite a união geográfica, o caso "só tendência sem estimativas comparáveis", o agregado gap+trend **e** conversão ponderada (`Σestimate / Σaptos` + distribuição por faixa) num único fetch.
2. **Sem testes de integração do loader combinado.** ~~`loadNucleusListElectionOverview` substitui loaders separados de gap e tendência; só há cobertura unitária…~~ **Resolvido na Fase 2 (2026-07-19):** `tests/int/nucleusListElectionOverview.int.spec.ts` + `seedMultiYearFederalCandidateFixture` em `tests/helpers/tseFixtures.ts`.
3. **Tipos de distribuição duplicados.** `NucleusTrendOverviewAggregate` em `nucleusElectoralBaseline.ts` é `Record<VoteTrendStatus, number>` enquanto `electionInsights.ts` já expõe `VoteTrendDistribution` + `emptyVoteTrendDistribution` — mesmo shape, dois nomes.
4. **`VoteTrendResult.ratio` exposto na API pública** mas usado sobretudo em asserts de teste; o produto só consome `status` + `message` na UI.
5. **Helpers de teste eleitoral duplicados e wipe lento.** `deleteAllElectionData` está copiado em três int specs (`electionResultsImport`, `nucleusElectoralBaseline`, `nucleusListElectionOverview`); cada um faz `find` + `delete` por documento nas três collections `election*`. O import de produção já usa delete por escopo via Drizzle (`electionResultsImport.ts`).
**Já resolvido no simplify A5-1 / capture-review-debts (não reabrir):** tallies só quando há núcleos comparáveis; union de tallies restrita a `comparableIndexes`; `sumElectorateForGeography` indexado por `cityZoneKey` (mesmo padrão de `votes2022`); conversão desacoplada do gate `candidateVotes > 0`; gap no overview via `computeGapVs2022`; `isComparableConversionBand` / `formatElectionNumber` DRY no overview.
**Explicitamente fora (revisores pediram skip no simplify ou já têm item):** ranking federal completo no detalhe e filtro por `cityCode` → **A7**; query duplicada lista+overview e re-resolve de geografia no baseline do overview → **E6**; DRY de `formatElectionNumber` nos cards não-tendência do overview → **E6 F3**; remover a query TSE do overview quando não há estimativas comparáveis (o card de tendência exige distribuição mesmo sem gap — comportamento intencional de produto); chip de faixa no Alert de conversão (produto adia); DRY do stack de 3 `Alert`s em `NucleusInsights` até mais insights A5.
**Já resolvido no simplify pós-E7 F2 (não reabrir):** `loadOverview` sem `findByID` por teste (`generalUser` em closure); assert único de `conversion.distribution`; `TSE_FIXTURE_ZONE_EXPECTED` com `abstencoes`/`confirmedVoteEstimate`; remoção de `feiraZ10` (zone 10 do CSV ≠ zonas TSE oficiais).
**Explicitamente fora (revisores pediram skip no simplify, capture-review-debts ou já têm item):** ranking federal completo no detalhe e filtro por `cityCode` → **A7**; query duplicada lista+overview e re-resolve de geografia no baseline do overview → **E6**; DRY de `formatElectionNumber` nos cards não-tendência do overview → **E6 F3**; remover a query TSE do overview quando não há estimativas comparáveis (o card de tendência exige distribuição mesmo sem gap — comportamento intencional de produto); chip de faixa no Alert de conversão (produto adia); DRY do stack de 3 `Alert`s em `NucleusInsights` até mais insights A5; **imports sequenciais 2014/2018/2022** em `seedMultiYearFederalCandidateFixture` (fixture pequena; paralelizar só se CI atrasar); **documentação de isolamento** entre suites int que apagam `election*` na mesma DB (mitigado por `teqo_test` por worker).
## Objetivos
- Abrir a aba Visão geral de um núcleo calcula a tendência **uma vez** e não repete a série histórica em dois blocos adjacentes.
- `loadNucleusListElectionOverview` tem pelo menos um teste int com fixture TSE que cobre gap agregado + distribuição de tendência + agregado de conversão (`weightedRate`, `distribution` reduto/consolidado/oportunidade) no mesmo path.
- Tipos de distribuição de tendência têm uma única fonte (`VoteTrendDistribution`); loaders e VMs importam de `electionInsights.ts`.
- Helpers compartilhados para wipe/seed de dados eleitorais nos int tests (sem triplicar `deleteAllElectionData`).
- Guardrails: sem migration, sem Consent (dado público TSE). Access continua `overrideAccess: false`. Identificadores em inglês; strings visíveis em pt-BR.
- **Impeccable:** N/A nas fases 2 e 4 (só testes/helpers); Fase 1 é classe **B** (encaixe em `NucleusActiveTab` / cards existentes).
## Decisões travadas
- **Um item E7, três fases ordenadas.** Mesmo racional de A7/C8/E6: um ID no roadmap, PRs por fase. Ordem: UX do detalhe (duplicação visível) → testes int do loader → higiene de tipos/API.
- **Um item E7, quatro fases ordenadas.** Mesmo racional de A7/C8/E6: um ID no roadmap, PRs por fase. Ordem: UX do detalhe → int do loader → higiene de tipos/API → helpers de teste eleitoral.
- **Dependência dura de E2 mergeado.** Só faz sentido com seed multi-ano, `computeVoteTrend` e `loadNucleusListElectionOverview` já no código.
- **Fase 1 não remove o insight de tendência nem o badge no baseline** — apenas unifica o cálculo e evita repetir a série literal; o alerta pode mostrar só `trend.message` se o card acima já exibir `formatVoteTrendSeriesCompact`.
- **Cortável se poucos núcleos e time de campo não reclamar da duplicação visual** — Fase 2 (int) é a mais valiosa antes de E4 import em massa amplificar o conjunto filtrado.
@ -49,8 +54,9 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
flowchart TD
E2["E2 Série + tendência ✓"] --> F1
F1["Fase 1 — Trend VM único no detalhe<br/>(NucleusActiveTab → filhos)"]
F1 --> F2["Fase 2 — Int loadNucleusListElectionOverview"]
F1 --> F2["Fase 2 — Int loadNucleusListElectionOverview ✓"]
F1 --> F3["Fase 3 — VoteTrendDistribution único + API ratio"]
F2 --> F4["Fase 4 — Helpers teste eleitoral<br/>(clearElectionFixtureData)"]
F2 -.confiança antes.-> E4["E4 Import planilha"]
A7["A7 Loader baseline"] -.mesmo módulo.-> F2
E6["E6 Lista/overview"] -.geo cache.-> F2
@ -63,24 +69,30 @@ flowchart TD
- `NucleusInsights`: aceitar `trend` por prop; remover segunda cópia da série no `AlertDescription` (manter só mensagem classificada).
- Testes: ajustar `nucleusElectoralBaselineUi.unit.spec.ts` se asserts dependerem do texto duplicado da série.
### Fase 2 — Testes int do loader combinado
### Fase 2 — Testes int do loader combinado ✓ (2026-07-19)
- Novo ou estendido `tests/int/nucleusListElectionOverview.int.spec.ts` (ou seção em spec existente de election baseline).
- Cenários mínimos: (a) dois núcleos com geografia + estimativas → `gapTotal` e contagem `above`/`below`; (b) núcleos com geografia mas sem estimativa confirmada → `gapTotal: null`, `trend` populado e `conversion: null`; (c) núcleo sem território resolvível → `baseline2022: null`, `trend: null`, `conversion: null`; (d) dois núcleos comparáveis com `electionTally` na union → `conversion.weightedRate` = `Σestimate / Σaptos` e `conversion.distribution` coerente com `computeConversionRate` por núcleo (inclui território novo com aptos + estimativa e sem votos 2022 do candidato).
- Usar `getPayload` + DB de teste (`teqo_test`); seed fixture TSE via helper, não Neon.
- `tests/helpers/tseFixtures.ts` — `seedMultiYearFederalCandidateFixture`, `TSE_FIXTURE_ZONE_EXPECTED`.
- `tests/int/nucleusListElectionOverview.int.spec.ts` — gap+trend+conversão, tendência sem estimate, geografia vazia, conversão ponderada e aptos sem votos 2022 do candidato.
### Fase 3 — Tipos e API
- Substituir `NucleusTrendOverviewAggregate` por import de `VoteTrendDistribution` nos VMs (`nucleusListOverviewViewModels.ts`, `nucleusElectoralBaseline.ts` exports).
- Avaliar remoção de `VoteTrendResult.ratio` ou torná-lo `@internal` documentado; atualizar `electionInsights.unit.spec.ts`.
**Migration:** nenhuma nas três fases.
### Fase 4 — Helpers de teste eleitoral
- `tests/helpers/electionTestHelpers.ts` (nome final a confirmar na implementação): `clearElectionFixtureData(payload)` que apaga `electionCandidateVote`, `electionTally` e `electionCandidate` em lote (espelhar `deleteScope` / `drizzleBulk` de [`src/utilities/electionResultsImport.ts`](../../src/utilities/electionResultsImport.ts), não N× `payload.delete`).
- Migrar os três int specs para o helper: [`tests/int/electionResultsImport.int.spec.ts`](../../tests/int/electionResultsImport.int.spec.ts), [`tests/int/nucleusElectoralBaseline.int.spec.ts`](../../tests/int/nucleusElectoralBaseline.int.spec.ts), [`tests/int/nucleusListElectionOverview.int.spec.ts`](../../tests/int/nucleusListElectionOverview.int.spec.ts).
- Manter `seedMultiYearFederalCandidateFixture` em `tseFixtures.ts`; Fase 4 só consome o wipe compartilhado.
- **Verificar:** `pnpm test:int` verde; sem tocar Neon (`teqo_test`).
**Migration:** nenhuma nas quatro fases.
## Dependências
- **Dura:** E2 Série TSE 2014/2018 + tendência — implementado no branch (aguardando merge).
- **Suave:** E4 import planilha — amplifica valor da Fase 2; A7 F1 compartilha `nucleusElectoralBaseline.ts` (não misturar PRs sem necessidade); E6 F2 cache de geografia reduz custo do mesmo path do overview.
- Reusa: `electionInsights.ts`, `nucleusElectoralBaseline.ts`, `nucleusListOverviewPageData.ts`, `tests/helpers/tseFixtures.ts`, padrão int de `electionResultsImport.int.spec.ts`.
- Reusa: `electionInsights.ts`, `nucleusElectoralBaseline.ts`, `nucleusListOverviewPageData.ts`, `tests/helpers/tseFixtures.ts`, `src/utilities/electionResultsImport.ts` (`deleteScope`), `src/utilities/drizzleBulk.ts`, padrão int de `electionResultsImport.int.spec.ts`.
## Não escopo
@ -104,5 +116,6 @@ flowchart TD
- `src/utilities/nucleusElectoralBaseline.ts` — `loadNucleusListElectionOverview`, `getNucleusElectoralBaseline`
- `src/utilities/nucleusListOverviewPageData.ts`
- `tests/unit/electionInsights.unit.spec.ts` / `tests/unit/nucleusElectoralBaselineUi.unit.spec.ts`
- `tests/helpers/tseFixtures.ts` / `tests/int/electionResultsImport.int.spec.ts`
- `tests/helpers/tseFixtures.ts` / `tests/int/electionResultsImport.int.spec.ts` / `tests/int/nucleusListElectionOverview.int.spec.ts`
- `tests/helpers/electionTestHelpers.ts` — alvo da Fase 4 (a criar)
- AGENTS.md — naming, `overrideAccess: false`, seed guards

View file

@ -91,7 +91,7 @@ Objetivo: equiparar e superar as planilhas "Mapa de projeção de votos Solla 20
- **E1 Metas em cenários + prioridade** — grupo `voteGoals` (`good`/`regular`/`minimum`) + `priority` (`alta|normal`) em `electoralNucleus` (migration compartilhada com E3); card "Metas 2026" no detalhe, filtro na lista, somas no overview B1 e no dashboard (meta estadual derivada). **Implementado e mesclado em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6).
- **E6 Escala e DRY pós-E1+E3 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: query duplicada lista+overview, re-resolve de geografia no baseline do overview, aggregate SQL de metas/prioridade (padrão C6), DRY `formatElectionNumber`/`campaignPercentage`/`voteGoalScenarioLabels`, tipos do dashboard por papel. [Plano](plans/escala-dry-pos-e1.md).
- **E2 Série histórica TSE 2014/2018 + tendência** — seed `pnpm db:seed:tse -- --year=2014|2018` (federal T1 BA only); `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`); série + badge no baseline card, insight e card de distribuição no overview; query candidate-only multi-ano (sem migration). **Implementado 2026-07-19.** Passagem `/simplify` no branch; débitos maiores → E7.
- **E7 Escala e DRY pós-E2 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: trend VM único no detalhe (sem série duplicada baseline+insights), testes int de `loadNucleusListElectionOverview` (gap + tendência **+ conversão A5-1**), tipo único `VoteTrendDistribution`. [Plano](plans/escala-dry-pos-e2.md).
- **E7 Escala e DRY pós-E2 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: trend VM único no detalhe (sem série duplicada baseline+insights), testes int de `loadNucleusListElectionOverview` (gap + tendência **+ conversão A5-1**), tipo único `VoteTrendDistribution`, helpers compartilhados de wipe eleitoral nos int tests. **Fase 2 int loader entregue (2026-07-19)** — `tests/int/nucleusListElectionOverview.int.spec.ts` + `seedMultiYearFederalCandidateFixture`; Fases 1, 3 e 4 pendentes (Fase 4 via `capture-review-debts` pós-simplify F2). [Plano](plans/escala-dry-pos-e2.md).
- **E3 Dobradinhas + encaminhamentos manuais** — `dobradinhaNotes` e `nextSteps` staff-only na inteligência do núcleo (excluídos dos view models de `lideranca`); versão manual imediata que A6/C4 complementam depois. **Implementado e mesclado em `main` 2026-07-19** (mesma migration E1+E3; UI em `NucleusIntelligenceDialog` + cards staff na Visão geral).
- **E4 Import único da planilha** — `pnpm db:seed:mapa` idempotente por slug, município canônico via `canonicalizeMunicipalityName`; lideranças/assessores da planilha entram como **texto staff-only, nunca `Contact`/`leadership`** (sem telefone/consent).
- **E5 Salvador por bairro** — registrado como futuro (exigiria `votacao_secao` + DE-PARA local↔bairro versionado); a visão por zona já é coberta pelo baseline município×zona.
@ -125,7 +125,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| FD2 UX Field Desk pós-critique | — (critique Impeccable; reusa dashboard + badges) | [field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência/conversão) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência/conversão) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) (Fase 2 ✓; Fases 1, 3, 4 pendentes) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
| C5 Operação dia D / GOTV | `Dia-D-GOTV` | sem plano ainda — o design é a primeira materialização do item (check-off de apoiadores por seção/zona, progresso "62% da base", filtro "Ainda não confirmados", estado "Sincronizado" offline-tolerante) |
@ -185,7 +185,7 @@ flowchart TD
subgraph TrilhaE["Trilha E — mapa de projeção por município"]
E1["E1 Metas em cenários + prioridade<br/>(migration c/ E3) ✓"]
E2["E2 Série TSE 2014/2018<br/>+ tendência ✓"]
E7["E7 Escala e DRY pós-E2<br/>(trend VM / int loader / tipos)"]
E7["E7 Escala e DRY pós-E2<br/>(trend VM / int loader ✓ / tipos / test helpers)"]
E3["E3 Dobradinhas + encaminhamentos<br/>manuais (staff-only) ✓"]
E4["E4 Import único da planilha<br/>(pnpm db:seed:mapa)"]
E5["E5 Salvador por bairro<br/>(futuro; validar valor)"]
@ -275,7 +275,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais **(implementado e mesclado em `main` 2026-07-19 — migration `20260719_054522`; `/simplify` → E6)** | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 17 | E6 Escala e DRY pós-E1+E3 (aggregate SQL metas/prioridade, geografia baseline, DRY formatação) _(cortável se poucos núcleos ativos)_ | [detalhes](plans/escala-dry-pos-e1.md) | E1+E3 ✓ | E2, C10, A7, E4 |
| 18 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) **(implementado 2026-07-19; `/simplify` → E7)** | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, E6, E7, C7, C8, C9, C10, C11 |
| 19 | E7 Escala e DRY pós-E2 (trend VM único no detalhe, int `loadNucleusListElectionOverview`, tipo `VoteTrendDistribution`) _(cortável se poucos núcleos; Fase 2 int preferível antes de E4)_ | [detalhes](plans/escala-dry-pos-e2.md) | E2 (merge) | E6, A7, E4, C10 |
| 19 | E7 Escala e DRY pós-E2 (trend VM único no detalhe, int `loadNucleusListElectionOverview`, tipo `VoteTrendDistribution`, helpers wipe eleitoral nos int tests) _(Fase 2 int entregue 2026-07-19; Fases 1, 3 e 4 pendentes; F4 cortável — só DX de teste)_ | [detalhes](plans/escala-dry-pos-e2.md) | E2 (merge) | E6, A7, E4, C10 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
@ -313,7 +313,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1, F3 e F4** (dedup visual/API e helpers de teste — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -33,8 +33,6 @@ try {
} catch (error) {
console.error(`\n[seed:consent] ${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
} finally {
if (typeof payload.db?.destroy === 'function') {
await payload.db.destroy()
}
}
process.exit(0)

View file

@ -1,11 +1,14 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { ELECTION_YEAR_2014, ELECTION_YEAR_2018 } from '@/lib/electionResults'
import { parseTseCsvString } from '@/lib/electionResultsCsv'
import { buildElectionResultsFromCsvRows, FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild'
import type { BuiltElectionResults } from '@/lib/electionResultsBuild'
import type { TseCsvRow } from '@/lib/electionResultsParse'
import type { ElectionOffice } from '@/lib/electionResults'
import { buildImportBundles, importElectionBundles } from '@/utilities/electionResultsImport'
const fixtureDir = join(process.cwd(), 'tests/fixtures/tse')
@ -58,3 +61,22 @@ export const TSE_FIXTURE_EXPECTED = {
voteRowCount: 15, // 16 CSV rows minus the zero-vote row
tallyRowCount: 8,
} as const
/** Per-zone federal deputy T1 totals used by list-election overview int tests. */
export const TSE_FIXTURE_ZONE_EXPECTED = {
salvadorZ1: { aptos: 10_000, abstencoes: 2000, sollaVotes2022: 1200, confirmedVoteEstimate: 2000 },
salvadorZ2: { aptos: 9000, abstencoes: 1800, sollaVotes2022: 900, confirmedVoteEstimate: 100 },
} as const
/** Import 2022 full scope plus federal-only 2018/2014 for E2 trend series int tests. */
export const seedMultiYearFederalCandidateFixture = async (payload: Payload): Promise<void> => {
await importElectionBundles(payload, buildImportBundles(loadTseFixtureResults()))
await importElectionBundles(
payload,
buildImportBundles(loadTseFixtureResultsForYear(ELECTION_YEAR_2018, FEDERAL_ONLY_OFFICES)),
)
await importElectionBundles(
payload,
buildImportBundles(loadTseFixtureResultsForYear(ELECTION_YEAR_2014, FEDERAL_ONLY_OFFICES)),
)
}

View file

@ -0,0 +1,217 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import {
aggregateConversionBand,
computeConversionRate,
} from '@/lib/electionInsights'
import {
loadNucleusListElectionOverview,
type NucleusBaseline2022OverviewInput,
} from '@/utilities/nucleusElectoralBaseline'
import {
seedMultiYearFederalCandidateFixture,
TSE_FIXTURE_ZONE_EXPECTED,
} from '../helpers/tseFixtures'
let payload: Payload
let generalUser: CampaignUser
const deleteAllElectionData = async () => {
for (const collection of [
'electionCandidateVote',
'electionTally',
'electionCandidate',
] as const) {
const existing = await payload.find({
collection,
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
for (const doc of existing.docs) {
await payload.delete({
collection,
id: doc.id,
overrideAccess: true,
})
}
}
}
const salvadorZ1: NucleusBaseline2022OverviewInput = {
cities: ['Salvador'],
regions: ['Metropolitano de Salvador'],
tseZones: [1],
confirmedVoteEstimate: TSE_FIXTURE_ZONE_EXPECTED.salvadorZ1.confirmedVoteEstimate,
}
const salvadorZ2: NucleusBaseline2022OverviewInput = {
cities: ['Salvador'],
regions: ['Metropolitano de Salvador'],
tseZones: [2],
confirmedVoteEstimate: TSE_FIXTURE_ZONE_EXPECTED.salvadorZ2.confirmedVoteEstimate,
}
describe('loadNucleusListElectionOverview (E7 F2)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
await deleteAllElectionData()
await seedMultiYearFederalCandidateFixture(payload)
generalUser = await payload.create({
collection: 'campaignUser',
data: {
name: 'List election overview geral',
role: 'geral',
email: `list-election-overview-geral-${Date.now()}@example.com`,
password: 'Password123!',
},
overrideAccess: true,
})
})
afterAll(async () => {
await payload.delete({
collection: 'campaignUser',
id: generalUser.id,
overrideAccess: true,
})
await deleteAllElectionData()
})
const loadOverview = (nuclei: Parameters<typeof loadNucleusListElectionOverview>[2]) =>
loadNucleusListElectionOverview(payload, generalUser, nuclei)
it('aggregates gap and trend when nuclei have geography and confirmed estimates', async () => {
const result = await loadOverview([salvadorZ1, salvadorZ2])
const { salvadorZ1: z1, salvadorZ2: z2 } = TSE_FIXTURE_ZONE_EXPECTED
expect(result.baseline2022).toEqual({
gapTotal:
z1.confirmedVoteEstimate +
z2.confirmedVoteEstimate -
(z1.sollaVotes2022 + z2.sollaVotes2022),
above: 1,
below: 1,
})
expect(result.trend?.stable).toBeGreaterThanOrEqual(2)
expect(result.conversion).not.toBeNull()
})
it('returns trend without gap or conversion when estimates are absent', async () => {
const result = await loadOverview([
{ ...salvadorZ1, confirmedVoteEstimate: null },
{ ...salvadorZ2, confirmedVoteEstimate: null },
])
expect(result.baseline2022).toEqual({
gapTotal: null,
above: 0,
below: 0,
})
expect(result.trend?.stable).toBeGreaterThanOrEqual(2)
expect(result.conversion).toBeNull()
})
it('returns null aggregates when no nucleus resolves geography', async () => {
const result = await loadOverview([
{
cities: [],
regions: [],
tseZones: [],
confirmedVoteEstimate: 500,
},
])
expect(result).toEqual({
baseline2022: null,
trend: null,
conversion: null,
})
})
it('aggregates weighted conversion rate and band distribution over comparable nuclei', async () => {
const { salvadorZ1: z1, salvadorZ2: z2 } = TSE_FIXTURE_ZONE_EXPECTED
const result = await loadOverview([salvadorZ1, salvadorZ2])
const aptosSum = z1.aptos + z2.aptos
const estimateSum = z1.confirmedVoteEstimate + z2.confirmedVoteEstimate
const expectedDistribution = aggregateConversionBand([
computeConversionRate({
aptos: z1.aptos,
abstencoes: z1.abstencoes,
confirmedVoteEstimate: z1.confirmedVoteEstimate,
}).band,
computeConversionRate({
aptos: z2.aptos,
abstencoes: z2.abstencoes,
confirmedVoteEstimate: z2.confirmedVoteEstimate,
}).band,
])
expect(result.conversion).toEqual({
weightedRate: Math.round((estimateSum / aptosSum) * 100),
distribution: expectedDistribution,
})
})
it('aggregates conversion from aptos and estimates when 2022 candidate votes are absent', async () => {
const tally = await payload.create({
collection: 'electionTally',
data: {
year: 2022,
office: 'deputado_federal',
turn: '1',
state: 'BA',
cityCode: '2905701',
cityName: 'Camaçari',
zoneNumber: 170,
aptos: 8000,
comparecimento: 6400,
abstencoes: 1600,
votosValidos: 6000,
votosNominaisValidos: 5800,
votosLegenda: 200,
votosBranco: 200,
votosNulo: 200,
votosAnulados: 0,
},
overrideAccess: true,
})
try {
const estimate = 400
const result = await loadOverview([
{
cities: ['Camaçari'],
regions: ['Metropolitano de Salvador'],
tseZones: [170],
confirmedVoteEstimate: estimate,
},
])
expect(result.baseline2022).toEqual({
gapTotal: null,
above: 0,
below: 0,
})
expect(result.conversion).toEqual({
weightedRate: Math.round((estimate / 8000) * 100),
distribution: { reduto: 0, consolidado: 0, oportunidade: 1 },
})
} finally {
await payload.delete({
collection: 'electionTally',
id: tally.id,
overrideAccess: true,
})
}
})
})