Deliver A5-2 territorial classification insight and absorb review debts.

Classify nuclei as defesa/ataque/indecisa/perdida from 2022 baseline share, surface Alert + overview distribution, and register post-simplify follow-ups in A7/E7 plans.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 18:15:07 -03:00
parent 909778eaea
commit 81b01b9907
14 changed files with 474 additions and 59 deletions

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-A4 (baseline TSE no produto)
Status: Fase 1 implementada (Fases 2–5 pendentes; merge pendente)
Atualizado em: 2026-07-19 (Fase 5 lista flip/leverage + extensão F3 gap registradas via `capture-review-debts` pós-A5 `/simplify`)
Atualizado em: 2026-07-19 (Fase 5 lista flip/leverage + extensão F3 gap registradas via `capture-review-debts` pós-A5 `/simplify`; **pós-A5-2 classificação territorial** — absorções no mesmo plano via `capture-review-debts`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
Responsável: —
@ -22,7 +22,9 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
**Já resolvido no simplify pós-A5 alavancagem (não reabrir):** `ticketFlip` pré-computado no VM (`aggregateNucleusElectoralBaseline`) e consumido em `NucleusInsights` (paridade detail/overview em empates majoritários); `resolveMajoritarianWinners` compartilhado; winners/federalRace reutilizados sem segunda passagem no detalhe; `Promise.all` série+ticket+majoritarian no overview (remove waterfall); loop flip sem `null` filter; guard redundante `leverage.ticketVotes`; testes gap com `GapVs2022Baseline`; assert `ticketFlip` no unit de baseline.
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights).
**Já resolvido no simplify pós-A5-2 classificação territorial (não reabrir):** `sumFederalTallyForGeography` único (substitui `sumElectorateForGeography` + helper paralelo); reuso de `classificationTalliesByCityZone` no loop de conversão (elimina 2º `loadElectionTallies` em `conversionUnion`); loop de classificação in-memory antes do `await` do flip N+1; push só de bandas comparáveis (`isComparableTerritorialClass`) — evita linha overview `0 defesa · …`; `TERRITORIAL_CLASS_UI` canônico + accessors finos; `TerritorialClassIcon` no detalhe.
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights); **pós-A5-2:** agregador genérico conversion+territorial, achatar `NucleusClassificationOverviewAggregate`, `percentValid` só em testes, merge CPU dos loops `sumCandidateVotesForGeography` trend+classificação, 4ª query `loadElectionTallies` no union (custo intencional A5-2), `territorialClassLabel` na linha do overview (conversão também hardcoded), componente compartilhado de linha de distribuição → **E6 F3**.
## Objetivos

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-E2 (série TSE 2014/2018 + tendência)
Status: Fase 2 entregue (2026-07-19); extensão F2 pós-A5 pendente; Fases 1, 3 e 4 pendentes
Atualizado em: 2026-07-19 (extensão int leverage/flip registrada via `capture-review-debts` pós-A5 `/simplify`)
Atualizado em: 2026-07-19 (extensão int leverage/flip/**classification** registrada via `capture-review-debts` pós-A5 `/simplify` + pós-A5-2)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E7)
Responsável: —
@ -25,12 +25,14 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
**Pendente pós-A5 (`capture-review-debts`):** int de `leverage`/`flipOpportunity` no loader combinado; spy estrutural de que `loadFederalCandidateTotalsAggregated` não é chamado N vezes na lista (documenta débito até A7 F5 fechar o batch).
**Pendente pós-A5-2 (`capture-review-debts`):** int de `classification.distribution` no mesmo `loadNucleusListElectionOverview` (fixture com `validos` > 0 e bandas comparáveis; assert `classification: null` quando só `semBaseline`).
**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; **extensão pós-A5:** agregados `leverage` / `flipOpportunity` + spy de query count federal (guardrail contra regressão N+1 até A7 F5).
- `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; **extensão pós-A5:** agregados `leverage` / `flipOpportunity` + spy de query count federal (guardrail contra regressão N+1 até A7 F5); **extensão pós-A5-2:** agregado `classification.distribution` (defesa/indecisa/ataque/perdida) + caso `classification: null` sem válidos.
- 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.
@ -76,11 +78,12 @@ flowchart TD
- `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.
#### Extensão Fase 2 (pós-A5 — pendente)
#### Extensão Fase 2 (pós-A5 / pós-A5-2 — pendente)
- Estender fixture TSE com votos de chapa majoritária + federal proporcional por partido suficientes para `leverage` comparável e `flipOpportunity` com `status: 'opportunity'`.
- Asserts em `loadNucleusListElectionOverview`: `leverage.weightedPercent` / `unconvertedCount` e `flipOpportunity.count` / `bothAlignedCount`.
- Spy estrutural: com N núcleos no filtro, `loadFederalCandidateTotalsAggregated` (ou execute drizzle federal) **não** deve ser invocado N vezes — falha documenta regressão até A7 F5; após F5, o spy valida uma única batch query.
- **Pós-A5-2:** asserts de `classification.distribution` sobre o conjunto filtrado com geografia; caso negativo com tallies sem `votosValidos` → `classification: null` e overview sem linha `Classificação:`.
### Fase 3 — Tipos e API

View file

@ -1,71 +1,78 @@
# Insight: classificação territorial (defesa/ataque/indecisa/perdida)
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Status: entregue (slice A5-2, 2026-07-19)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Janela 3, A5 — segundo dos cinco insights)
Responsável: —
**Revisão 2026-07-19:** as-built espelha A5-1 conversão — Alert no stack `NucleusInsights.tsx` (sem `NucleusTerritorialClassification.tsx`); overview via `loadNucleusListElectionOverview` + linha em `NucleusListOverview`; limiares 35%/20%/10% versionados em `electionInsights.ts` _(assumido — validar com produto)_; classificação sobre todos os núcleos filtrados com geografia + válidos > 0 (não exige estimativa confirmada).
## Referência visual (UX Pilot)
Design: [`Baseline-Eleitoral-2022.png`](../design-refs/latest/Baseline-Eleitoral-2022.png) — card "Insights do território", linha "Território de defesa · Base sólida — prioridade: manter engajamento" com chip de classificação à direita (`Defesa` em verde). Os quatro estados usam pares de badge do tema `campaign`: defesa = verde, ataque = contorno vermelho, indecisa = âmbar, perdida = cinza. Implementar como um card do stack `NucleusInsights.tsx` ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), ajustando a paleta antiga do HTML/PNG para os tokens claros.
Design: [`Baseline-Eleitoral-2022.png`](../design-refs/latest/Baseline-Eleitoral-2022.png) — card "Insights do território", linha "Território de defesa · Base sólida — prioridade: manter engajamento" com chip de classificação à direita (`Defesa` em verde). Os quatro estados usam pares de badge do tema `campaign`: defesa = verde, ataque = contorno vermelho, indecisa = âmbar, perdida = cinza. Implementar como um card do stack `NucleusInsights.tsx` ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), com os tokens claros do tema `campaign` em vez da paleta antiga do HTML/PNG.
## Contexto
A literatura de territorialização eleitoral (Politipédia AVM, OPUS, Seja Eleito) classifica cada território em quatro zonas operacionais — **defesa** (voto histórico favorável, meta: manter), **ataque** (desfavorável mas relevante, meta: virar/reduzir margem), **indecisa** (pulverizada, baixa rejeição, meta: consolidar) e **perdida** (desfavorável e elástica, meta: minimizar perda) — para alocar esforço de forma proporcional ao retorno. Hoje o `/campanha` não classifica núcleos; com o baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)) podemos derivar uma primeira classificação a partir do histórico.
A literatura de territorialização eleitoral (Politipédia AVM, OPUS, Seja Eleito) classifica cada território em quatro zonas operacionais — **defesa** (voto histórico favorável, meta: manter), **ataque** (desfavorável mas relevante, meta: virar/reduzir margem), **indecisa** (pulverizada, meta: consolidar) e **perdida** (baixa base histórica, meta: minimizar perda) — para alocar esforço de forma proporcional ao retorno. Com o baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)) derivamos a primeira classificação a partir de `sollaVotes2022 / votosValidosFederal2022`.
## Objetivos
- Computar `sollaPercentValid = sollaVotes2022 / votosValidosFederal2022` por núcleo (soma sobre cidades∩zonas).
- Classificar o núcleo em defesa/ataque/indecisa/perdida por `sollaPercentValid` vs. limiares configuráveis.
- Exibir badge no detalhe do núcleo e distribuição por classe no overview da lista (sobre o conjunto filtrado).
- (Opcional, quando o domínio existir) refinar a classe cruzando com pesquisa de intenção de voto atual.
- Computar `percentValid = sollaVotes2022 / votosValidosFederal2022` por núcleo (soma sobre cidades∩zonas).
- Classificar o núcleo em defesa/ataque/indecisa/perdida por `percentValid` vs. limiares configuráveis.
- Exibir Alert com badge no detalhe do núcleo e distribuição por classe no overview da lista (sobre o conjunto filtrado com geografia resolvível).
## Decisões travadas
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
- **Limiares versionados** em `src/lib/electionInsights.ts` (constantes); decisão de produto por limiar. A classe é **sugestão automática**, não rótulo definitivo — coordenador pode discordar.
- **Reusa** `getNucleusElectoralBaseline` (Solla + válidos federal) do plano baseline.
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration. Reusa `getNucleusElectoralBaseline` / `loadNucleusListElectionOverview`.
- **Limiares versionados** em `src/lib/electionInsights.ts`: `TERRITORIAL_DEFESA_MIN = 0.35`, `TERRITORIAL_INDECISA_MIN = 0.20`, `TERRITORIAL_ATAQUE_MIN = 0.10` _(assumido — validar com produto)_.
- A classe é **sugestão automática**, não rótulo definitivo — coordenador pode discordar.
- **Sem rejeição** por geografia (domínio inexistente); refinamento futuro com pesquisas.
- **`lideranca` vê** o insight (mesmo stack da Visão geral).
- **UI:** `Alert` em `NucleusInsights.tsx` (`data-insight="territorial-class"`), não componente separado; chip de classe no Alert (design-ref).
- **Overview:** distribuição sobre todos os núcleos filtrados com geografia + `validos > 0` (não exige estimativa confirmada).
## Questões em aberto
- Limiares exatos por classe — definir com produto (referência: alta % válidos → defesa; baixa + relevante → ataque; pulverizada → indecisa; baixa + alta rejeição → perdida).
- Como incorporar rejeição (não temos dado de rejeição por geografia hoje) — adiar até domínio de pesquisas.
- `lideranca` vê a classe do próprio núcleo? Recomendação: sim.
- Limiares exatos — **assumidos 35%/20%/10%** até validação de produto.
- Refinar com pesquisa de intenção/rejeição — adiado.
## Abordagem proposta
## Abordagem (as-built)
```mermaid
flowchart LR
Base["getNucleusElectoralBaseline<br/>sollaVotes + votosValidosFederal"]
Calc["computeTerritorialClass(sollaPercentValid)<br/>classe + confiança"]
Detail["Detalhe: badge da classe<br/>+ % válidos de Solla"]
Overview["Overview: distribuição<br/>defesa/ataque/indecisa/perdida<br/>sobre o filtro"]
Base["getNucleusElectoralBaseline<br/>candidate.votes + electorate.validos"]
Calc["computeTerritorialClass(...)"]
Detail["NucleusInsights Alert + Badge"]
Overview["loadNucleusListElectionOverview<br/>+ NucleusListOverview"]
Base --> Calc
Calc --> Detail
Calc --> Overview
```
- **Helper** `src/lib/electionInsights.ts`: `computeTerritorialClass(sollaVotes, votosValidosFederal)` → `{ percentValid, class: 'defesa'|'ataque'|'indecisa'|'perdida'|'semBaseline' }`.
- **Componente** `src/components/campaign/NucleusTerritorialClassification.tsx` (server). Overview: bloco com contagem por classe.
- **Teste int** cenários limítrofes e `votosValidosFederal=0`.
- **Helper** [`src/lib/electionInsights.ts`](../../src/lib/electionInsights.ts): `computeTerritorialClass`, `aggregateTerritorialClass`, `territorialClassBadgeVariant`, `territorialClassLabel`.
- **Detalhe** [`src/components/campaign/NucleusInsights.tsx`](../../src/components/campaign/NucleusInsights.tsx).
- **Overview** [`src/utilities/nucleusElectoralBaseline.ts`](../../src/utilities/nucleusElectoralBaseline.ts) (`classification.distribution`) + [`NucleusListOverview.tsx`](../../src/components/campaign/NucleusListOverview.tsx).
- **Testes** unitários em `tests/unit/electionInsights.unit.spec.ts`; UI em `tests/unit/nucleusElectoralBaselineUi.unit.spec.ts` e `tests/unit/campaignNucleusListOverview.unit.spec.ts`.
## Arquivos a criar/alterar
**Já resolvido no simplify / capture-review-debts (não reabrir):** `TERRITORIAL_CLASS_UI` + accessors; `sumFederalTallyForGeography` único; reuso de tallies union na conversão (sem 2º fetch); filtro `isComparableTerritorialClass` no loader; `TerritorialClassIcon`; branches `semBaseline` unificados em `computeTerritorialClass`.
- Criar: `src/components/campaign/NucleusTerritorialClassification.tsx`.
- Alterar: `src/lib/electionInsights.ts`, `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
**Débitos maiores (planos existentes):** batch federal flip/leverage na lista → [escala-dry-pos-a4.md](escala-dry-pos-a4.md) **A7 F5**; int `classification` + leverage/flip no loader → [escala-dry-pos-e2.md](escala-dry-pos-e2.md) **E7 F2 ext.**; fetch único 2022 lista+coroplético → **A7 F4**; DRY linha de distribuição no overview → [escala-dry-pos-e1.md](escala-dry-pos-e1.md) **E6 F3**; shell `NucleusInsightAlert` → **E7 F1** (adiado até estabilizar stack A5).
## Dependências
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) — **única dependência dura**.
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline (melhora a qualidade de `tseZones`; ver revisão 2026-07-17 no plano baseline).
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline.
## Não escopo
- Mapa/PostGIS com a classe pintada por território (roadmap linha 52).
- Pesquisa de intenção/rejeição (domínio inexistente; refinamento futuro).
- Mapa/PostGIS com a classe pintada por território (B3).
- Pesquisa de intenção/rejeição; override manual da classe; demais insights A5.
## Referências
- Politipédia AVM — "Territorialização eleitoral em campanha"
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)
- [insight-taxa-conversao.md](insight-taxa-conversao.md) — padrão as-built A5-1
- [escala-dry-pos-a4.md](escala-dry-pos-a4.md) — A7 F5 (batch flip/leverage)
- [escala-dry-pos-e2.md](escala-dry-pos-e2.md) — E7 F2 ext. (int loader)
- AGENTS.md

View file

@ -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 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–5 pendentes (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético B3, batch federal flip/leverage na lista pós-A5). Passagens `/simplify` + `capture-review-debts` pós-B3 e pós-A5. [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–5 pendentes (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético B3, batch federal flip/leverage na lista pós-A5). Passagens `/simplify` + `capture-review-debts` pós-B3, pós-A5 e **pós-A5-2 classificação territorial** (tally único, reuso tallies conversão, ordem classificação antes do flip N+1). [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)
@ -92,7 +92,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**; **extensão pós-A5:** leverage/flip + spy N+1 federal), 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`; extensão F2 pós-A5 pendente; Fases 1, 3 e 4 pendentes. [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**; **extensão pós-A5:** leverage/flip + spy N+1 federal; **extensão pós-A5-2:** `classification.distribution`), 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`; extensões F2 pós-A5 e pós-A5-2 pendentes; Fases 1, 3 e 4 pendentes. [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.

View file

@ -1,27 +1,36 @@
import type { LucideIcon } from 'lucide-react'
import {
CheckCircle2Icon,
Link2Icon,
MegaphoneIcon,
MinusIcon,
PercentIcon,
ShieldIcon,
TrendingDownIcon,
TrendingUpIcon,
TriangleAlertIcon,
} from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import {
computeConversionRate,
computeGapVs2022,
computeTerritorialClass,
computeTicketLeverage,
computeVoteTrend,
conversionRateAlertVariant,
formatElectionNumber,
formatVoteTrendSeries,
isComparableConversionBand,
isComparableTerritorialClass,
isComparableTicketLeverage,
territorialClassAlertVariant,
territorialClassBadgeVariant,
territorialClassLabel,
ticketLeverageAlertVariant,
voteTrendAlertVariant,
type ComparableTerritorialClass,
} from '@/lib/electionInsights'
import { cn } from '@/lib/utils'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
@ -29,6 +38,18 @@ import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewM
const confirmedInsightAlertClass =
'border-[color:var(--estimate-confirmed)] bg-[color:var(--estimate-confirmed)] text-[color:var(--estimate-confirmed-foreground)] *:data-[slot=alert-description]:text-[color:var(--estimate-confirmed-foreground)]'
const TERRITORIAL_CLASS_ICON: Record<ComparableTerritorialClass, LucideIcon> = {
defesa: ShieldIcon,
ataque: TriangleAlertIcon,
indecisa: MinusIcon,
perdida: TrendingDownIcon,
}
const TerritorialClassIcon = ({ band }: { band: ComparableTerritorialClass }) => {
const Icon = TERRITORIAL_CLASS_ICON[band]
return <Icon aria-hidden="true" />
}
export const NucleusInsights = ({
baseline,
confirmedVoteEstimate,
@ -73,6 +94,11 @@ export const NucleusInsights = ({
const flip = baseline?.ticketFlip ?? null
const territorial = computeTerritorialClass({
sollaVotes: baseline?.candidate.votes ?? 0,
federalValidVotes: baseline?.electorate.validos ?? null,
})
let supportLine: string | null = null
if ((gap.status === 'above' || gap.status === 'below') && baseline && confirmedVoteEstimate !== null) {
supportLine = `Estimativa atual (${formatElectionNumber(confirmedVoteEstimate)}) · resultado 2022 (${formatElectionNumber(baseline.candidate.votes)})`
@ -141,6 +167,32 @@ export const NucleusInsights = ({
</Alert>
) : null}
{isComparableTerritorialClass(territorial.band) ? (
<Alert
data-insight="territorial-class"
variant={territorialClassAlertVariant(territorial.band)}
className={cn(
'rounded-xl px-3.5 py-3',
territorial.band === 'defesa' && confirmedInsightAlertClass,
)}
>
<TerritorialClassIcon band={territorial.band} />
<AlertTitle className="flex items-start justify-between gap-2 font-bold">
<span>{territorial.title}</span>
<Badge
className="shrink-0"
variant={territorialClassBadgeVariant(territorial.band)}
>
{territorialClassLabel(territorial.band)}
</Badge>
</AlertTitle>
<AlertDescription>{territorial.priorityLine}</AlertDescription>
{territorial.supportLine ? (
<AlertDescription>{territorial.supportLine}</AlertDescription>
) : null}
</Alert>
) : null}
{baseline && trend && trend.status !== 'noBaseline' ? (
<Alert
data-insight="vote-trend"

View file

@ -174,6 +174,26 @@ export const NucleusListOverview = ({
) : null}
</p>
) : null}
{view.classification ? (
<p className="text-sm text-muted-foreground">
Classificação:{' '}
<span className="font-medium text-estimate-confirmed-foreground">
{formatElectionNumber(view.classification.distribution.defesa)} defesa
</span>
{' · '}
<span className="font-medium text-estimate-pending-foreground">
{formatElectionNumber(view.classification.distribution.indecisa)} indecisa
</span>
{' · '}
<span className="font-medium text-destructive">
{formatElectionNumber(view.classification.distribution.ataque)} ataque
</span>
{' · '}
<span className="font-medium text-muted-foreground">
{formatElectionNumber(view.classification.distribution.perdida)} perdida
</span>
</p>
) : null}
</CardContent>
</Card>

View file

@ -16,6 +16,15 @@ export const CONVERSION_REDUTO_MIN = 0.4
/** Share of federal nominal votes outside the chapa field → flip opportunity (A5 Fase 2). */
export const RIGHT_SHARE_THRESHOLD = 0.25
/** At or above this share of valid federal votes → defesa (literature reference, A5-2). */
export const TERRITORIAL_DEFESA_MIN = 0.35
/** At or above this share → indecisa (between defesa and ataque). */
export const TERRITORIAL_INDECISA_MIN = 0.2
/** At or above this share → ataque; below → perdida. */
export const TERRITORIAL_ATAQUE_MIN = 0.1
export type VoteTrendStatus = 'decline' | 'stable' | 'increase' | 'noBaseline'
export type VoteTrendSeries = {
@ -72,6 +81,35 @@ export type ConversionBandDistribution = {
oportunidade: number
}
export type TerritorialClassificationBand =
| 'defesa'
| 'ataque'
| 'indecisa'
| 'perdida'
| 'semBaseline'
export type ComparableTerritorialClass = Exclude<TerritorialClassificationBand, 'semBaseline'>
export type TerritorialClassificationInput = {
sollaVotes: number
federalValidVotes: number | null
}
export type TerritorialClassificationResult = {
percentValid: number | null
band: TerritorialClassificationBand
title: string
priorityLine: string
supportLine: string | null
}
export type TerritorialClassificationDistribution = {
defesa: number
ataque: number
indecisa: number
perdida: number
}
/** Minimal baseline shape for Gap vs 2022 (full detail VM is assignable). */
export type GapVs2022Baseline = {
candidate: { votes: number }
@ -122,6 +160,84 @@ export const isComparableConversionBand = (
): band is 'reduto' | 'consolidado' | 'oportunidade' =>
band === 'reduto' || band === 'consolidado' || band === 'oportunidade'
const emptyTerritorialClassificationDistribution =
(): TerritorialClassificationDistribution => ({
defesa: 0,
ataque: 0,
indecisa: 0,
perdida: 0,
})
export const aggregateTerritorialClass = (
bands: readonly TerritorialClassificationBand[],
): TerritorialClassificationDistribution => {
const distribution = emptyTerritorialClassificationDistribution()
for (const band of bands) {
if (isComparableTerritorialClass(band)) {
distribution[band] += 1
}
}
return distribution
}
export const isComparableTerritorialClass = (
band: TerritorialClassificationBand,
): band is ComparableTerritorialClass =>
band === 'defesa' || band === 'ataque' || band === 'indecisa' || band === 'perdida'
const TERRITORIAL_CLASS_UI = {
defesa: {
label: 'Defesa',
badgeVariant: 'estimate-confirmed',
alertVariant: 'default',
title: 'Território de defesa',
priorityLine: 'Base sólida — prioridade: manter engajamento',
},
indecisa: {
label: 'Indecisa',
badgeVariant: 'estimate-pending',
alertVariant: 'pending',
title: 'Território indeciso',
priorityLine: 'Pulverizado — prioridade: consolidar',
},
ataque: {
label: 'Ataque',
badgeVariant: 'destructive',
alertVariant: 'destructive',
title: 'Território de ataque',
priorityLine: 'Desfavorável relevante — prioridade: virar/reduzir margem',
},
perdida: {
label: 'Perdida',
badgeVariant: 'secondary',
alertVariant: 'default',
title: 'Território perdido',
priorityLine: 'Baixa base histórica — prioridade: minimizar perda',
},
} as const satisfies Record<
ComparableTerritorialClass,
{
label: string
badgeVariant: 'estimate-confirmed' | 'estimate-pending' | 'destructive' | 'secondary'
alertVariant: 'default' | 'pending' | 'destructive'
title: string
priorityLine: string
}
>
export const territorialClassLabel = (band: TerritorialClassificationBand): string =>
isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].label : 'Sem baseline'
export const territorialClassBadgeVariant = (
band: TerritorialClassificationBand,
): (typeof TERRITORIAL_CLASS_UI)[ComparableTerritorialClass]['badgeVariant'] =>
isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].badgeVariant : 'secondary'
export const territorialClassAlertVariant = (
band: TerritorialClassificationBand,
): (typeof TERRITORIAL_CLASS_UI)[ComparableTerritorialClass]['alertVariant'] =>
isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].alertVariant : 'default'
export const conversionRateAlertVariant = (band: ConversionRateBand): 'default' | 'pending' =>
band === 'oportunidade' ? 'pending' : 'default'
@ -666,3 +782,45 @@ export const ticketLeverageAlertVariant = (
isComparableTicketLeverage(leverage.status) && (leverage.headlinePercent ?? 0) < 100
? 'pending'
: 'default'
export const computeTerritorialClass = ({
sollaVotes,
federalValidVotes,
}: TerritorialClassificationInput): TerritorialClassificationResult => {
if (federalValidVotes === null || federalValidVotes <= 0) {
return {
percentValid: null,
band: 'semBaseline',
title: 'Classificação territorial',
priorityLine:
federalValidVotes === null
? NO_ELECTION_BASELINE_MESSAGE
: 'Sem votos válidos no baseline para esta geografia',
supportLine: null,
}
}
const percentValid = sollaVotes / federalValidVotes
const percent = Math.round(percentValid * 100)
let band: ComparableTerritorialClass
if (percentValid >= TERRITORIAL_DEFESA_MIN) {
band = 'defesa'
} else if (percentValid >= TERRITORIAL_INDECISA_MIN) {
band = 'indecisa'
} else if (percentValid >= TERRITORIAL_ATAQUE_MIN) {
band = 'ataque'
} else {
band = 'perdida'
}
const { title, priorityLine } = TERRITORIAL_CLASS_UI[band]
return {
percentValid,
band,
title,
priorityLine,
supportLine: `${percent}% dos votos válidos federais em 2022 (${formatElectionNumber(sollaVotes)} / ${formatElectionNumber(federalValidVotes)})`,
}
}

View file

@ -14,20 +14,24 @@ import {
} from '@/lib/electionResults'
import {
aggregateConversionBand,
aggregateTerritorialClass,
aggregateTicketFlipOverview,
aggregateTicketLeverageOverview,
aggregateVoteTrend,
computeConversionRate,
computeGapVs2022,
computeTerritorialClass,
computeTicketFlipOpportunity,
computeTicketLeverage,
isComparableTicketLeverage,
isComparableConversionBand,
isComparableTerritorialClass,
type ConversionBandDistribution,
type ConversionRateBand,
isComparableConversionBand,
type TerritorialClassificationBand,
type TerritorialClassificationDistribution,
type TicketFlipOpportunityResult,
type TicketLeverageOverviewAggregate,
type TicketFlipOverviewAggregate,
type TicketLeverageOverviewAggregate,
type VoteTrendSeries,
type VoteTrendStatus,
} from '@/lib/electionInsights'
@ -382,21 +386,23 @@ const aggregateElectorate = (
return { aptos, validos, brancos, nulos, abstencoes }
}
const sumElectorateForGeography = (
const sumFederalTallyForGeography = (
talliesByCityZone: ReadonlyMap<string, ElectionTallyAggregateRow>,
geography: NucleusElectionGeography,
): Pick<NucleusElectoralBaselineViewModel['electorate'], 'aptos' | 'abstencoes'> => {
): Pick<NucleusElectoralBaselineViewModel['electorate'], 'aptos' | 'abstencoes' | 'validos'> => {
let aptos = 0
let abstencoes = 0
let validos = 0
for (const { cityName, zoneNumber } of geography.cityZonePairs) {
const row = talliesByCityZone.get(cityZoneKey(cityName, zoneNumber))
if (!row) continue
aptos += row.aptos
abstencoes += row.abstencoes
validos += row.votosValidos
}
return { aptos, abstencoes }
return { aptos, abstencoes, validos }
}
export const aggregateNucleusElectoralBaseline = (
@ -786,12 +792,17 @@ export type NucleusConversionOverviewAggregate = {
distribution: ConversionBandDistribution
}
export type NucleusClassificationOverviewAggregate = {
distribution: TerritorialClassificationDistribution
}
export type NucleusListElectionOverview = {
baseline2022: NucleusBaseline2022OverviewAggregate | null
trend: NucleusTrendOverviewAggregate | null
conversion: NucleusConversionOverviewAggregate | null
leverage: TicketLeverageOverviewAggregate | null
flipOpportunity: TicketFlipOverviewAggregate | null
classification: NucleusClassificationOverviewAggregate | null
}
/**
@ -808,7 +819,14 @@ export const loadNucleusListElectionOverview = async (
)
if (withGeographyIndexes.length === 0) {
return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
return {
baseline2022: null,
trend: null,
conversion: null,
leverage: null,
flipOpportunity: null,
classification: null,
}
}
const comparableIndexes: number[] = []
@ -819,15 +837,26 @@ export const loadNucleusListElectionOverview = async (
const unionGeography = buildUnionGeography(geographies, withGeographyIndexes)
if (!unionGeography) {
return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
return {
baseline2022: null,
trend: null,
conversion: null,
leverage: null,
flipOpportunity: null,
classification: null,
}
}
const [seriesByYear, ticketVotes, majoritarianTallies] = await Promise.all([
const [seriesByYear, ticketVotes, majoritarianTallies, classificationTallies] = await Promise.all([
loadCandidateSeriesByGeography(payload, user, unionGeography),
loadTicketOfficeVotes(payload, user, unionGeography),
loadMajoritarianElectionTallies(payload, user, unionGeography),
loadElectionTallies(payload, user, unionGeography),
])
const votes2022 = seriesByYear.get(ELECTION_YEAR_2022) ?? new Map<string, number>()
const classificationTalliesByCityZone = new Map(
classificationTallies.map((row) => [cityZoneKey(row.cityName, row.zoneNumber), row]),
)
const trend = aggregateVoteTrend(
withGeographyIndexes.flatMap((index) => {
@ -836,6 +865,24 @@ export const loadNucleusListElectionOverview = async (
}),
)
const classificationBands: TerritorialClassificationBand[] = []
for (const index of withGeographyIndexes) {
const geography = geographies[index]!
const { validos } = sumFederalTallyForGeography(classificationTalliesByCityZone, geography)
const territorial = computeTerritorialClass({
sollaVotes: sumCandidateVotesForGeography(geography, votes2022),
federalValidVotes: validos,
})
if (isComparableTerritorialClass(territorial.band)) {
classificationBands.push(territorial.band)
}
}
const classification: NucleusClassificationOverviewAggregate | null =
classificationBands.length > 0
? { distribution: aggregateTerritorialClass(classificationBands) }
: null
const flipResults = await Promise.all(
withGeographyIndexes.map(async (index) => {
const geography = geographies[index]!
@ -852,17 +899,10 @@ export const loadNucleusListElectionOverview = async (
conversion: null,
leverage: null,
flipOpportunity,
classification,
}
}
const conversionUnion = buildUnionGeography(geographies, comparableIndexes)
const tallies = conversionUnion
? await loadElectionTallies(payload, user, conversionUnion)
: []
const talliesByCityZone = new Map(
tallies.map((row) => [cityZoneKey(row.cityName, row.zoneNumber), row]),
)
let estimateSum = 0
let candidateVotesSum = 0
let comparableCount = 0
@ -888,15 +928,18 @@ export const loadNucleusListElectionOverview = async (
comparableCount += 1
}
const electorate = sumElectorateForGeography(talliesByCityZone, geography)
const { aptos, abstencoes } = sumFederalTallyForGeography(
classificationTalliesByCityZone,
geography,
)
const conversion = computeConversionRate({
aptos: electorate.aptos,
abstencoes: electorate.abstencoes,
aptos,
abstencoes,
confirmedVoteEstimate: estimate,
})
if (isComparableConversionBand(conversion.band)) {
conversionEstimateSum += estimate
conversionAptosSum += electorate.aptos
conversionAptosSum += aptos
conversionBands.push(conversion.band)
}
@ -929,6 +972,7 @@ export const loadNucleusListElectionOverview = async (
conversion,
leverage: aggregateTicketLeverageOverview(leverageRows),
flipOpportunity,
classification,
}
}

View file

@ -178,6 +178,7 @@ export const loadNucleusListOverviewData = async (
conversion: listElectionOverview.conversion,
leverage: listElectionOverview.leverage,
flipOpportunity: listElectionOverview.flipOpportunity,
classification: listElectionOverview.classification,
choropleth,
})
}

View file

@ -2,7 +2,12 @@ import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { TicketFlipOverviewAggregate, TicketLeverageOverviewAggregate } from '@/lib/electionInsights'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { NucleusBaseline2022OverviewAggregate, NucleusConversionOverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
import type {
NucleusBaseline2022OverviewAggregate,
NucleusClassificationOverviewAggregate,
NucleusConversionOverviewAggregate,
NucleusTrendOverviewAggregate,
} from '@/utilities/nucleusElectoralBaseline'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
import {
aggregateVoteGoals,
@ -60,6 +65,8 @@ export type NucleusListOverviewViewModel = {
leverage: TicketLeverageOverviewAggregate | null
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
flipOpportunity: TicketFlipOverviewAggregate | null
/** Null when no nucleus in the filtered set has resolvable geography with valid votes. */
classification: NucleusClassificationOverviewAggregate | null
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
choropleth: NucleusChoroplethBundle
@ -78,6 +85,7 @@ export const buildNucleusListOverviewViewModel = ({
conversion = null,
leverage = null,
flipOpportunity = null,
classification = null,
choropleth,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
@ -89,6 +97,7 @@ export const buildNucleusListOverviewViewModel = ({
conversion?: NucleusConversionOverviewAggregate | null
leverage?: TicketLeverageOverviewAggregate | null
flipOpportunity?: TicketFlipOverviewAggregate | null
classification?: NucleusClassificationOverviewAggregate | null
choropleth: NucleusChoroplethBundle
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
@ -127,6 +136,7 @@ export const buildNucleusListOverviewViewModel = ({
conversion,
leverage,
flipOpportunity,
classification,
recentUpdates,
upcomingActionPlans,
choropleth,

View file

@ -134,6 +134,9 @@ describe('loadNucleusListElectionOverview (E7 F2)', () => {
baseline2022: null,
trend: null,
conversion: null,
leverage: null,
flipOpportunity: null,
classification: null,
})
})

View file

@ -231,4 +231,23 @@ describe('NucleusListOverview', () => {
expect(html).toContain('3 queda')
expect(html).not.toContain('Com série histórica')
})
it('renders the territorial classification distribution when aggregate data is present', () => {
const view = buildNucleusListOverviewViewModel({
role: 'geral',
nuclei: [nucleus({ id: 1 })],
recentUpdates: [],
upcomingActionPlans: [],
classification: { distribution: { defesa: 4, indecisa: 2, ataque: 1, perdida: 3 } },
choropleth: emptyChoropleth,
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
expect(html).toContain('Classificação:')
expect(html).toContain('4 defesa')
expect(html).toContain('2 indecisa')
expect(html).toContain('1 ataque')
expect(html).toContain('3 perdida')
})
})

View file

@ -4,15 +4,20 @@ import { describe, expect, it } from 'vitest'
import {
aggregateConversionBand,
aggregateTerritorialClass,
aggregateVoteTrend,
comparableTrendCount,
computeConversionRate,
computeGapVs2022,
computeTerritorialClass,
computeVoteTrend,
CONVERSION_OPPORTUNITY_MAX,
CONVERSION_REDUTO_MIN,
isComparableConversionBand,
type GapVs2022Baseline,
TERRITORIAL_ATAQUE_MIN,
TERRITORIAL_DEFESA_MIN,
TERRITORIAL_INDECISA_MIN,
} from '@/lib/electionInsights'
const baseline = (candidateVotes: number): GapVs2022Baseline => ({
@ -188,3 +193,89 @@ describe('isComparableConversionBand', () => {
expect(isComparableConversionBand('semEstimativa')).toBe(false)
})
})
describe('computeTerritorialClass', () => {
it('classifies defesa at 35% of valid votes', () => {
const validos = 2490
expect(
computeTerritorialClass({
sollaVotes: Math.ceil(validos * TERRITORIAL_DEFESA_MIN),
federalValidVotes: validos,
}),
).toMatchObject({
band: 'defesa',
title: 'Território de defesa',
})
})
it('classifies indecisa at 20% of valid votes', () => {
expect(
computeTerritorialClass({
sollaVotes: Math.floor(2490 * TERRITORIAL_INDECISA_MIN),
federalValidVotes: 2490,
}),
).toMatchObject({
band: 'indecisa',
title: 'Território indeciso',
})
})
it('classifies ataque at 10% of valid votes', () => {
expect(
computeTerritorialClass({
sollaVotes: Math.floor(2490 * TERRITORIAL_ATAQUE_MIN),
federalValidVotes: 2490,
}),
).toMatchObject({
band: 'ataque',
title: 'Território de ataque',
})
})
it('classifies perdida below 10%', () => {
expect(
computeTerritorialClass({
sollaVotes: Math.floor(2490 * (TERRITORIAL_ATAQUE_MIN - 0.001)),
federalValidVotes: 2490,
}),
).toMatchObject({
band: 'perdida',
title: 'Território perdido',
})
})
it('reports semBaseline when validos is zero', () => {
expect(computeTerritorialClass({ sollaVotes: 100, federalValidVotes: 0 })).toMatchObject({
band: 'semBaseline',
percentValid: null,
})
})
it('reports semBaseline when validos is null', () => {
expect(computeTerritorialClass({ sollaVotes: 100, federalValidVotes: null })).toMatchObject({
band: 'semBaseline',
})
})
})
describe('aggregateTerritorialClass', () => {
it('counts comparable territorial bands only', () => {
expect(
aggregateTerritorialClass(['defesa', 'ataque', 'indecisa', 'perdida', 'semBaseline']),
).toEqual({
defesa: 1,
ataque: 1,
indecisa: 1,
perdida: 1,
})
})
it('returns all zeros when every band is semBaseline', () => {
expect(aggregateTerritorialClass(['semBaseline', 'semBaseline'])).toEqual({
defesa: 0,
ataque: 0,
indecisa: 0,
perdida: 0,
})
})
})

View file

@ -88,7 +88,7 @@ describe('NucleusInsights', () => {
expect(html).toContain('Tendência:')
})
it('renders ticket leverage and flip opportunity insights', () => {
it('renders ticket leverage, flip opportunity, and territorial classification insights', () => {
const html = renderToStaticMarkup(
createElement(NucleusInsights, {
baseline,
@ -100,5 +100,10 @@ describe('NucleusInsights', () => {
expect(html).toContain('data-insight="ticket-flip"')
expect(html).toContain('completar a chapa')
expect(html).not.toMatch(/direita/i)
expect(html).toContain('data-insight="territorial-class"')
expect(html).toContain('Território indeciso')
expect(html).toContain('Pulverizado — prioridade: consolidar')
expect(html).toContain('Indecisa')
expect(html).toContain('34% dos votos válidos federais em 2022')
})
})