diff --git a/docs/plans/escala-dry-pos-e2.md b/docs/plans/escala-dry-pos-e2.md index 4392a1ed..87be6a46 100644 --- a/docs/plans/escala-dry-pos-e2.md +++ b/docs/plans/escala-dry-pos-e2.md @@ -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` 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
(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
(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 diff --git a/docs/roadmap.md b/docs/roadmap.md index cf675dfe..12ed6128 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
(migration c/ E3) ✓"] E2["E2 Série TSE 2014/2018
+ tendência ✓"] - E7["E7 Escala e DRY pós-E2
(trend VM / int loader / tipos)"] + E7["E7 Escala e DRY pós-E2
(trend VM / int loader ✓ / tipos / test helpers)"] E3["E3 Dobradinhas + encaminhamentos
manuais (staff-only) ✓"] E4["E4 Import único da planilha
(pnpm db:seed:mapa)"] E5["E5 Salvador por bairro
(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) diff --git a/scripts/seed-consent.mjs b/scripts/seed-consent.mjs index 266feec9..7fb6bb30 100644 --- a/scripts/seed-consent.mjs +++ b/scripts/seed-consent.mjs @@ -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) diff --git a/tests/helpers/tseFixtures.ts b/tests/helpers/tseFixtures.ts index 93e479b7..5005b4ae 100644 --- a/tests/helpers/tseFixtures.ts +++ b/tests/helpers/tseFixtures.ts @@ -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 => { + 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)), + ) +} diff --git a/tests/int/nucleusListElectionOverview.int.spec.ts b/tests/int/nucleusListElectionOverview.int.spec.ts new file mode 100644 index 00000000..d1165d51 --- /dev/null +++ b/tests/int/nucleusListElectionOverview.int.spec.ts @@ -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[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, + }) + } + }) +})