From 0c0725835b32a00ed44dd3ba16b9e1298e84db75 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 15 Sep 2026 07:14:46 -0300 Subject: [PATCH] =?UTF-8?q?feat(B202):=20totais=20do=20recorte=20no=20roda?= =?UTF-8?q?p=C3=A9=20da=20lista=20de=20munic=C3=ADpios=20(Closes=20#1005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-15-b202.md | 1 + .../totais-recorte-lista-municipios-impl.md | 113 ++++++++++++++ .../campanha/(app)/municipios/page.tsx | 3 + .../MunicipalitySliceTotalsLine.tsx | 74 +++++++++ .../campaign/shared/CampaignListFooter.tsx | 9 ++ src/lib/municipalitySliceTotals.ts | 89 +++++++++++ .../municipality/municipalityPageData.ts | 14 ++ tests/e2e/campaignMunicipalities.e2e.spec.ts | 67 +++++++++ tests/int/municipalityPageData.int.spec.ts | 140 ++++++++++++++++++ .../unit/municipalitySliceTotals.unit.spec.ts | 109 ++++++++++++++ .../municipalitySliceTotalsLine.unit.spec.ts | 72 +++++++++ 11 files changed, 691 insertions(+) create mode 100644 docs/changelog/2026-09-15-b202.md create mode 100644 docs/plans/totais-recorte-lista-municipios-impl.md create mode 100644 src/components/campaign/municipality/MunicipalitySliceTotalsLine.tsx create mode 100644 src/lib/municipalitySliceTotals.ts create mode 100644 tests/unit/municipalitySliceTotals.unit.spec.ts create mode 100644 tests/unit/municipalitySliceTotalsLine.unit.spec.ts diff --git a/docs/changelog/2026-09-15-b202.md b/docs/changelog/2026-09-15-b202.md new file mode 100644 index 00000000..991c5aa4 --- /dev/null +++ b/docs/changelog/2026-09-15-b202.md @@ -0,0 +1 @@ +**B202 — Totais do recorte na lista de municípios (votos 2022 + expectativas 2026) (2026-09-15):** o rodapé compartilhado de `/campanha/municipios` ganhou uma linha compacta com os totais do recorte filtrado **inteiro** (todas as páginas, nunca só as 25 visíveis): votos Solla 2022 + expectativa 2026 nos três cenários ("Pess./Média/Otim.", o ativo em destaque, via estado client compartilhado com a coluna) + a contagem honesta "N de M com expectativa". A soma é **server-side, sem consulta nova**: o loader já carrega o conjunto completo do recorte para o staff (`staffScope.municipalities` na trilha paginada, `allDocs` na load-all) e a matemática pura vive em `src/lib/municipalitySliceTotals.ts` (mesma fonte por linha do `votePosition2022`; Salvador conta **uma vez** — cidade substitui as 19 ZE nos votos e as ZE entram nas expectativas, que a cidade não tem; assessor soma exatamente o que as linhas dele enxergam; Liderança segue bloqueada e nunca recebe o campo). Cenário sem nenhum preenchimento vira "—" (nunca "0") e a frase secundária é omitida; recorte vazio não renderiza a linha (nenhum total inventado). O rodapé só mudou com um prop opcional `totals?: ReactNode` — as demais listas ficam idênticas — e o anti-goal do B129 segue respeitado (nenhum bloco acima da lista, nenhuma linha dentro da `table`, guardado por e2e). Sem migration, sem Consent, sem PII. Achados do simplify aplicados: identificadores em inglês (`slice`, não `recorte`, que fica só na copy), `aria-label` em `

` trocado por um `sr-only` com a leitura completa dos rótulos ("não informado"), oráculos de teste desacoplados do accessor de produção e contagens derivadas do catálogo; a regra "cidade folda votos mas não expectativas" foi pega na conferência visual (o fold pulava o corpo da linha das ZE) e pinada no unit. Verificação: `pnpm gate:fast` verde (312 arquivos / 3072 unit), int de `municipalityPageData` 30/30, e2e B202 2/2 (recorte de 1 município com somas exatas e guardrail B129; lista sem filtro somando 18 páginas até o `ownVotes` estadual) + specs afetados do manifesto (`campaignZoneMap`, `campaignNearestMunicipality`, `campaignSavedFilters` verdes; as 2 falhas do `campaignColumnPicker`/B197 são pré-existentes, reproduzidas sem este diff). diff --git a/docs/plans/totais-recorte-lista-municipios-impl.md b/docs/plans/totais-recorte-lista-municipios-impl.md new file mode 100644 index 00000000..9ad55b1e --- /dev/null +++ b/docs/plans/totais-recorte-lista-municipios-impl.md @@ -0,0 +1,113 @@ +# Impl: Totais do recorte na lista de municípios (votos 2022 + expectativas 2026) + +Status: aprovado (gate humano 2026-09-15) +Atualizado em: 2026-09-15 +Issue: #1005 +Intenção: docs/plans/totais-recorte-lista-municipios.md +Appetite restante: herdado — ~0,5–1 dia eng, sem corte (a abordagem não adiciona consulta nova) + +## Leitura da intenção + +- **Outcome:** no rodapé de `/campanha/municipios`, uma linha compacta soma o recorte filtrado **inteiro** (não a página visível): votos Solla 2022 + expectativa 2026 nos três cenários, mais "N de M com expectativa" — com o mesmo escopo de acesso das linhas e sem reabrir o bloco de overview que o B129 removeu. +- **O que NÃO negociar:** somas de todas as páginas; mesma fonte por linha (`computeVoteRankByYear`); Salvador conta uma vez (cidade **ou** 19 ZE); expectativas somam as ZE e a cidade nunca entra; assessor soma só o que enxerga; Liderança segue bloqueada; sem pledges, médias, percentuais ou cobertura; sem linha dentro da tabela (tfoot) nem bloco nos cards; rodapé compartilhado idêntico nas outras listas; recorte vazio não inventa totais e "nenhuma expectativa" não vira "0". +- **O que reavaliar (hipóteses da intenção):** + - _"sem consulta nova se os dados do recorte já estiverem carregados"_ — confirmado: para **todo** o staff (inclui assessor — `isCampaignStaff`), o conjunto filtrado completo já está em memória: `staffScope.municipalities` (`municipalityPageData.ts:529-541`, `limit: 0`) na trilha paginada e `listResult.docs`/`allDocs` na trilha load-all. Zero consultas novas. + - _"o cenário ativo hoje é estado local do cliente"_ — a linha mostra os três sempre, mas o destaque do cenário ativo exige uma folha client (decisão em Componentes). + - _"o assessor nunca vê total maior que a carteira"_ — o B178 já expõe a linha da cidade (agregado das 19 ZE) ao assessor que busca "salvador" (`municipalityPageData.ts:481`); o total apenas espelha as linhas visíveis. É semântica de linha herdada, não canal novo (registrado em Riscos). + +## Abordagem recomendada + +```mermaid +flowchart LR + SP[searchParams] --> L[loadMunicipalityListPageBundle] + L --> W["where + access
overrideAccess:false + user"] + W --> BQ{"paged?"} + BQ -- não --> FS["full set em memória
listResult.docs → allDocs"] + BQ -- sim --> SS["staffScope.municipalities
loadMunicipalityScope limit 0"] + FS --> T["computeMunicipalitySliceTotals
src/lib/municipalitySliceTotals.ts"] + SS --> T + ART["computeVoteRankByYear(2022)
artefato imutável, cache de módulo"] --> T + T --> RB["bundle.sliceTotals"] + RB --> PG[page.tsx] + PG --> FT["CampaignListFooter totals?: ReactNode"] + FT --> LN["MunicipalitySliceTotalsLine
folha client: destaque do cenário ativo"] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** **A** — somar no loader sobre o conjunto filtrado completo já carregado (fonte: `staffScope.municipalities` na trilha paginada; `allDocs` na load-all), com a matemática pura num módulo novo de `src/lib` (`municipalitySliceTotals.ts`). Porque não adiciona consulta alguma, usa exatamente o mesmo `where`+`user` que produziu as linhas (escopo e soma são o mesmo objeto), reusa o artefato memoizado que já alimenta `votePosition2022` por linha e cabe no appetite. +**Rejeitadas:** + +- **B — agregado SQL/drizzle espelhando `supporterListOverviewAggregate`:** a soma de 2022 não existe no banco (fonte é o artefato commitado por slug), então o SQL ainda precisaria da lista completa de slugs; duplicaria `where`+access em SQL cru; cobre só 3 das 4 somas; e o recorte tem ≤436 linhas — o custo não se paga. +- **C — reusar `loadTerritoryOverview`/`territoryOverview`:** roda com `overrideAccess: true`, mistura pledges (`expected[S] ?? declared`), fixa um único cenário e carrega cobertura/metas/lideranças que a v1 não usa — viola a decisão "sem pledges" e o escopo de access. +- Também rejeitado: qualquer cache cross-request (`unstable_cache`/`cache()` novo) — o total é user-scoped. + +### Componentes / mudanças + +- **`computeMunicipalitySliceTotals`** (`src/lib/municipalitySliceTotals.ts`, novo, puro/client-safe): recebe linhas `{ slug, expectedVotes? }` e devolve `MunicipalitySliceTotals | null` (o ano é fixo em `DEFAULT_VOTE_RANK_YEAR` — a coluna e a linha são 2022, um parâmetro de ano sem call site seria abstração especulativa). Nome em inglês (`slice`) por AGENTS: o rascunho aprovado escrevia `recorte` nos identificadores; o termo fica só na copy pt-BR ("Total do recorte") e no `cityInRecorte` pré-existente (B178, fora do escopo): + - `votes2022`: soma `computeVoteRankByYear(ano).get(slug)?.votes ?? 0`; se a linha da cidade está no conjunto, as 19 slugs de `salvadorCity.zoneSlugs` são puladas e o fold das MESMAS células entra **uma vez** (nunca `ranks.get('salvador')`, que não existe no artefato; nunca cidade + zonas). Sem a cidade, as ZE somam normalmente — e como a cidade não tem entrada no artefato, o total é idêntico nos dois arranjos (cidade **ou** zonas). + - `expectedByScenario`: soma por cenário via `toVoteEstimateScenarioViewModel` + valores não nulos, **linha a linha inclusive quando a cidade está no recorte** (a cidade não carrega expectativa; as 19 ZE são a única fonte — só os VOTOS têm a escolha cidade-ou-zonas). Cenário que ninguém preencheu fica `null` (a UI mostra "—", nunca "0"); valores explícitos (inclusive 0) somam. + - `withEstimateCount`: nº de linhas com ≥1 cenário preenchido (`hasAnyVoteEstimate`); `rowCount` = nº de linhas do recorte (denominador do "N de M", igual ao `totalDocs` exibido; inclui a linha virtual quando presente). + - Conjunto vazio → `null` (o rodapé não renderiza a linha). +- **`loadMunicipalityListPageBundle`** (`src/utilities/municipality/municipalityPageData.ts:446-673`): novo campo `sliceTotals: MunicipalitySliceTotals | null` no `MunicipalityListPageBundle`; o early-return do líder (`:456-464`) ganha `sliceTotals: null`; cálculo no fim do loader a partir de `staffScope.municipalities` (trilha paginada) ou `allDocs` (load-all) — os mesmos docs que produzem `totalDocs`. **Sem consulta nova.** +- **`CampaignListFooter`** (`src/components/campaign/shared/CampaignListFooter.tsx:8-31`): novo prop opcional `totals?: ReactNode` renderizado entre a contagem e a paginação. Nenhuma outra mudança; as ~12 listas que não passam o prop ficam byte-idênticas. +- **`MunicipalitySliceTotalsLine`** (`src/components/campaign/municipality/MunicipalitySliceTotalsLine.tsx`, novo, `'use client'`): linha compacta com `data-slot="municipality-slice-totals"`; formata com `formatElectionNumber`; destaca o cenário ativo via `useMunicipalityEstimateScenarioOptional()` com fallback `DEFAULT_VOTE_ESTIMATE_SCENARIO` (mesmo padrão de `MunicipalityListExpectedVotesControl.tsx:73-74`); rótulos completos no `title` e um `sr-only` com `formatVoteEstimateScenarioAriaLabel` ("não informado") + a contagem — `aria-label` em `

` é ignorado por AT (achado do simplify). Sem estado próprio. +- **`MunicipalitiesPage`** (`src/app/(campaign)/campanha/(app)/municipios/page.tsx:79-86,199-206`): desestrutura `sliceTotals` e passa `totals={sliceTotals ? : undefined}`. +- **Migration:** sem migration (nenhuma collection/global/field novo). +- **Access / Consent:** nenhum helper novo; a soma lê só os docs já carregados com `user` + `overrideAccess: false`, então o escopo do total é literalmente o da query das linhas (não re-derivar com `getAdvisorMunicipalityIds`, que divergiria de `canReadMunicipality` no caso `visibility: 'tudo'`, `access/municipalities.ts:201`). Sem Consent/LGPD; sem PII (slug + expectedVotes). Gate `noLeader` da página e early-return do loader intactos. +- **UI:** Impeccable B — shape do rascunho aprovado → craft com os tokens da casa (`text-muted-foreground`, `border`, `bg-card`; o zinc do rascunho é mock), `tabular-nums`, wrap mobile → critique contra o anti-goal B129 e o rodapé compartilhado → polish dos estados vazio/sem expectativa. + +### Dados → forma + +- **Uma linha compacta no rodapé compartilhado** (decisão do gate): `Total do recorte: 2022 … · Pess. … · Média … · Otim. … · N de M com expectativa`, com o cenário ativo em destaque e os três sempre legíveis. Rótulos abreviados como no rascunho (com o rótulo completo em `title`/`aria`), ano "2022" igual ao da coluna. +- Rejeitadas: `tfoot`/linha na tabela (quebraria o header sticky e não cobre os cards mobile); bloco dedicado nos cards (duas superfícies para manter); overview acima da lista (anti-goal B129); tooltip-only (esconderia o número, que é o ponto do item). +- Sem médias, percentuais, cobertura, pledges ou seletor novo; números em pt-BR via `formatElectionNumber`; "—" para cenário sem nenhum preenchimento; frase secundária omitida quando `withEstimateCount === 0`. + +## Fases verificáveis + +1. **Tracer / server + testes** (~60% do appetite): módulo puro + campo no bundle + testes unit/int. Verificação: `pnpm test:unit` e `pnpm exec vitest run --config ./vitest.config.mts tests/int/municipalityPageData.int.spec.ts`. +2. **UI** (~30%): slot opcional no rodapé + folha client + fio na página; shape→craft→critique→polish (Impeccable B), conferindo desktop, mobile, recorte vazio e recorte sem expectativa contra `docs/plans/totais-recorte-lista-municipios-ui-draft.html`. +3. **Gates** (~10%): `pnpm gate:fast` (lint + typecheck + unit); int alvo; `node scripts/e2e-affected.mjs` (esperado: `campaignMunicipalities` selecionado pelos paths `src/utilities/municipality` e `src/components/campaign/municipality`); `pnpm test:e2e:affected`; `pnpm format:check`; push via `pnpm push`. + +### Testes previstos + +- **Unit `tests/unit/municipalitySliceTotals.unit.spec.ts`**: soma 2022 por slug contra `computeVoteRankByYear`; catálogo inteiro (sem cidade) = `getStatewideFederalTotals(2022)`; Salvador cidade+zonas e só zonas (ambos = fold da cidade, nunca 2×); **cidade presente ainda soma as expectativas das ZE** (o fold é só dos votos — regressão pega na conferência visual); conjunto vazio → `null`; sem expectativa → cenários `null` e contagem 0; soma parcial por cenário + contagem de linhas (não de cenários). (Unit roda com `DATABASE_URL` inválida — o módulo é puro.) +- **Int `tests/int/municipalityPageData.int.spec.ts` (estende)**: default do coordenador → `sliceTotals.rowCount === totalDocs === 436` e `votes2022 === getStatewideFederalTotals(2022).ownVotes`; trilha paginada (região sem a cidade + `sort: 'name'`) → 25 linhas visíveis mas `rowCount === totalDocs > 25` e soma independente por região; expectativas somadas por cenário e `withEstimateCount` (com uma linha toda `null`); `q: 'salvador'` → 20 linhas e 2022 = `cityFederalBaseline().votesByYear['2022']` (não cidade+zonas); `slug: ['salvador']` → 1 linha e 2022 = fold da cidade; assessor com carteira → total = carteira; assessor `visibility: 'tudo'` → espelha o catálogo (435); líder → `sliceTotals === null` (estende o pin `:445-461`); recorte vazio → `null`. A regra "cidade folda votos mas não expectativas" fica no unit: mutar `salvador-ze-1` por slug fixo violaria a disciplina do alocador (`testMunicipalityAllocatorConventions`). +- **E2E `tests/e2e/campaignMunicipalities.e2e.spec.ts`**: dois fluxos novos no describe B202 — recorte de 1 município (com `expectedVotes` do fixture) confere `2022` formatado + os três cenários no texto visível/sr-only e **nenhum** `table [data-slot="municipality-slice-totals"]` (guardrail B129); lista sem filtro soma o catálogo inteiro (contagem derivada do catálogo, não literal) até o `ownVotes` estadual (a string de settle `'436 municípios encontrados'` segue intocada). + +## Rabbit holes / Não escopo (engenharia) + +- Reusar `rollupMunicipalityStaffVotes` (`votePledgeViews.ts:118-153`): cai para pledges (`estimated[S] ?? declared`) — proibido na v1. +- Reusar `loadTerritoryOverview` (`territory/loadTerritoryOverview.ts:97-216`): `overrideAccess: true`, cenário único, pledges. +- Agregado SQL novo, cache cross-request, `cache()` novo: o total é user-scoped e os dados já estão em memória. +- Linha dentro da tabela (tfoot), bloco nos cards, overview acima da lista (B129), médias/percentuais/cobertura, pledges, seletor de cenário na URL, exportação. +- Mexer em `computeVoteRankByYear`/artefato, criar rank da cidade por slug, ou alterar células/ordenação existentes (a linha é aditiva). +- Reabrir a exposição B178 da linha da cidade ao assessor com `q` — o total apenas espelha as linhas; mudança de access é outro item. +- Tocar no rodapé das outras listas ou no contrato de URL congelado (B18). +- **Débito deferido (simplify, gatilho):** `municipalitySliceTotals` reimplementa o fold "cidade = 19 células das ZE" em cima do rank map, enquanto `salvadorCityAggregates.foldCityFederalBaseline` é o dono do mesmo princípio sobre o baseline rico. Unificar quando houver um **3º consumidor do fold** ou quando as duas fontes (rank map × baseline) forem unificadas; hoje a igualdade está pinada no unit. + +## Riscos e mitigação + +- **Dupla contagem de Salvador:** regra estrutural no módulo puro (presença da cidade ⇒ pulo das ZE nos VOTOS, com o fold entrando uma vez; expectativas somadas linha a linha) + pins unit/int (`q:'salvador'` = baseline da cidade, não 2×). O primeiro corte pulava o corpo inteiro da linha das ZE e derrubava também as expectativas — pego na conferência visual (a linha mostrava "—" com a ZE 1 preenchida) e pinado no unit antes do push. +- **Total divergir das linhas/do escopo:** soma sobre os MESMOS docs da query (`staffScope`/`allDocs`), nunca re-derivando access; int cobre trilha paginada (25 visíveis × total do recorte), carteira do assessor e `visibility: 'tudo'`. +- **`staffScope` ausente:** o cálculo só ocorre sob `staffScope` (staff); líder retorna cedo com `null`; a página não renderiza a linha sem `sliceTotals`. +- **Rodapé compartilhado quebrar outras listas:** prop opcional (`totals?: ReactNode`); sem prop, nenhum nó novo; revisão do `git diff` + e2e das listas já existentes. +- **Frase secundária/zeros enganosos:** cenário sem preenchimento → `null` → "—"; `withEstimateCount === 0` → frase omitida; pins unit + int. +- **B129:** nada acima da lista; a linha vive no rodapé; e2e falha se aparecer dentro de `table`. +- **Performance:** soma O(recorte) ≤436 sobre artefato memoizado, sem consulta/IO; nenhuma mudança no stream (a string de settle dos e2e segue igual). +- **Assessor + cidade via `q`:** o total pode exceder a carteira porque a linha da cidade (B178) já excede — comportamento herdado das linhas, registrado; se a mesa rejeitar, a mudança é na exposição da linha (outro item), não no total. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto: 4 somas do recorte inteiro + contagem "N de M com expectativa", Salvador uma vez, escopo de access das linhas, lockdown da Liderança, vazio/sem-expectativa honestos, sem reabrir B129. +- [x] Invariantes AGENTS/engineering-standards: Local API com `overrideAccess: false` + `user`; sem collection/Consent/migration; identificadores em inglês e copy pt-BR; sem pledges; sem cache cross-request; rodapé compartilhado intacto para as demais listas. +- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam: suíte unit do módulo puro, int do loader (coordenador/assessor/`visibility:'tudo'`/líder/Salvador/vazio/paginado) e e2e do slot. + +## Self-score (decision-quality) + +1. **Decisões caras têm rejeitadas?** 1/1 — A/B/C com porquês; forma do prop no rodapé; folha client vs destaque estático; localização do módulo (`lib` puro). +2. **Cabe no appetite da intenção?** 1/1 — zero consulta nova, um arquivo puro, um prop opcional, um componente pequeno, sem migration. +3. **Rabbit holes nomeados?** 1/1 — pledges, territory overview, SQL, cache, tfoot/B129, exposição B178. +4. **Depth check (reusa shells/helpers)?** 1/1 — `CampaignListFooter`, `computeVoteRankByYear`, `salvadorCity.zoneSlugs`, `hasAnyVoteEstimate`, contexto de cenário; nada de twin. +5. **Intenção permanece satisfeita?** 1/1 — todos os pontos do aceite mapeados em decisão/teste; a engenharia não reescreveu o outcome. + +**Total: 5/5.** diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index 21fa6b20..f111aebe 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -14,6 +14,7 @@ import { municipalityListPickerColumns, } from '@/components/campaign/municipality/MunicipalityList' import { MunicipalityListPageChrome } from '@/components/campaign/municipality/MunicipalityListPageChrome' +import { MunicipalitySliceTotalsLine } from '@/components/campaign/municipality/MunicipalitySliceTotalsLine' import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' @@ -81,6 +82,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie municipalities: listMunicipalities, totalDocs, totalPages, + sliceTotals, filterFacets, leadershipNamesById, } = pageBundle @@ -203,6 +205,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie page={state.page} totalPages={totalPages} hrefForPage={(page) => buildMunicipalityListHref(state, page)} + totals={sliceTotals ? : undefined} /> ) diff --git a/src/components/campaign/municipality/MunicipalitySliceTotalsLine.tsx b/src/components/campaign/municipality/MunicipalitySliceTotalsLine.tsx new file mode 100644 index 00000000..f2f3ea8c --- /dev/null +++ b/src/components/campaign/municipality/MunicipalitySliceTotalsLine.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' +import { formatElectionNumber } from '@/lib/electionFormat' +import type { MunicipalitySliceTotals } from '@/lib/municipalitySliceTotals' +import { cn } from '@/lib/utils' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + formatVoteEstimateScenarioAriaLabel, + VOTE_ESTIMATE_SCENARIOS, + voteEstimateScenarioLabels, + type VoteEstimateScenario, +} from '@/lib/voteEstimate' + +/** Compact labels of the approved draft; the full label rides in `title` and the sr-only summary. */ +const scenarioShortLabels: Record = { + pessimistic: 'Pess.', + central: 'Média', + optimistic: 'Otim.', +} + +const formatScenarioValue = (value: number | null): string => + value == null ? '—' : formatElectionNumber(value) + +/** + * B202 — the compact totals line of the municipality list footer: Solla 2022 + * votes + the three 2026 expectation scenarios summed over the WHOLE filtered + * slice, plus how many rows carry at least one estimate. Only the active + * scenario (the same client state the votes column reads) is emphasized; a + * scenario nobody filled renders "—", never a fake zero. The numbers arrive + * pre-summed from the server — this leaf owns only the emphasis. + */ +export const MunicipalitySliceTotalsLine = ({ totals }: { totals: MunicipalitySliceTotals }) => { + const scenarioContext = useMunicipalityEstimateScenarioOptional() + const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO + const { expectedByScenario, rowCount, votes2022, withEstimateCount } = totals + + const coverageSummary = + withEstimateCount > 0 ? `; ${withEstimateCount} de ${rowCount} com expectativa` : '' + const screenReaderSummary = `Total do recorte: 2022 ${formatElectionNumber(votes2022)}; ${formatVoteEstimateScenarioAriaLabel(expectedByScenario)}${coverageSummary}` + + return ( +

+ Total do recorte: + + 2022{' '} + {formatElectionNumber(votes2022)} + + {VOTE_ESTIMATE_SCENARIOS.map((scenario) => ( + + + + {scenarioShortLabels[scenario]}{' '} + + {formatScenarioValue(expectedByScenario[scenario])} + + + + ))} + {withEstimateCount > 0 ? ( + + + + {withEstimateCount} de {rowCount} com expectativa + + + ) : null} + {screenReaderSummary} +

+ ) +} diff --git a/src/components/campaign/shared/CampaignListFooter.tsx b/src/components/campaign/shared/CampaignListFooter.tsx index 599ec734..5dd9d89a 100644 --- a/src/components/campaign/shared/CampaignListFooter.tsx +++ b/src/components/campaign/shared/CampaignListFooter.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination' /** @@ -12,6 +14,7 @@ export const CampaignListFooter = ({ page, totalPages, hrefForPage, + totals, }: { totalDocs: number /** e.g. "município encontrado" */ @@ -21,11 +24,17 @@ export const CampaignListFooter = ({ page: number totalPages: number hrefForPage: (page: number) => string + /** + * B202 — optional extra line between the count and the pagination. Only the + * municipalities list passes it; the other lists render byte-identically. + */ + totals?: ReactNode }) => (

{totalDocs} {totalDocs === 1 ? singular : plural}

+ {totals}
) diff --git a/src/lib/municipalitySliceTotals.ts b/src/lib/municipalitySliceTotals.ts new file mode 100644 index 00000000..2cc82b06 --- /dev/null +++ b/src/lib/municipalitySliceTotals.ts @@ -0,0 +1,89 @@ +import { computeVoteRankByYear, DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank' +import { SALVADOR_CITY_SLUG, salvadorCity } from '@/lib/salvadorCity' +import { + hasAnyVoteEstimate, + toVoteEstimateScenarioViewModel, + VOTE_ESTIMATE_SCENARIOS, + type VoteEstimateScenarioFields, + type VoteEstimateScenarioViewModel, +} from '@/lib/voteEstimate' + +/** + * B202 — the only shape the slice totals need from a row. The scoped docs the + * loader sums and the list view models both satisfy it, so the sum can read + * whatever set already produced the visible rows. + */ +export type MunicipalitySliceTotalsRow = { + slug: string + expectedVotes?: VoteEstimateScenarioFields | null +} + +export type MunicipalitySliceTotals = { + /** Rows of the slice — the same set the footer count shows. */ + rowCount: number + /** Solla 2022 votes over the slice; the city replaces its 19 ZE when present. */ + votes2022: number + /** Per scenario: `null` when no row of the slice filled it (never a fake zero). */ + expectedByScenario: VoteEstimateScenarioViewModel + /** Rows with at least one scenario filled — the numerator of "N de M com expectativa". */ + withEstimateCount: number +} + +const salvadorZoneSlugs: ReadonlySet = new Set(salvadorCity.zoneSlugs) + +/** + * Totals of the WHOLE filtered slice (every page), summed over the same docs + * the rows came from — the sum never re-derives scope. Pure and honest: + * + * - 2022 uses the same per-slug source as the row column + * (`computeVoteRankByYear`), so the sum matches the lines by construction. + * - Salvador counts once: when the city row is in the slice, its 19 zone votes + * enter once as the fold and the zone rows are skipped; with only zones, they + * sum normally. The city carries no expectation of its own, so the zone + * EXPECTATIONS always sum row by row. + * - A scenario nobody filled stays `null` (the UI renders "—"), while filled + * values — including explicit zeros — sum. + * - An empty slice returns `null`: no invented totals. + */ +export const computeMunicipalitySliceTotals = ( + rows: readonly MunicipalitySliceTotalsRow[], +): MunicipalitySliceTotals | null => { + if (rows.length === 0) return null + + const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR) + const cityInSlice = rows.some((row) => row.slug === SALVADOR_CITY_SLUG) + + const expectedByScenario: VoteEstimateScenarioViewModel = { + pessimistic: null, + central: null, + optimistic: null, + } + let votes2022 = 0 + let withEstimateCount = 0 + + // The city has no artifact entry of its own: its votes are the fold of the + // 19 zone cells, added ONCE whether or not the zones ride along. + if (cityInSlice) { + for (const zoneSlug of salvadorCity.zoneSlugs) { + votes2022 += ranks.get(zoneSlug)?.votes ?? 0 + } + } + + for (const row of rows) { + // Only the VOTES need the city-or-zones choice; expectations sum for every + // row (the city carries none, so its 19 ZE are the only source). + if (!(cityInSlice && (row.slug === SALVADOR_CITY_SLUG || salvadorZoneSlugs.has(row.slug)))) { + votes2022 += ranks.get(row.slug)?.votes ?? 0 + } + + const estimates = toVoteEstimateScenarioViewModel(row.expectedVotes) + if (hasAnyVoteEstimate(estimates)) withEstimateCount += 1 + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + const value = estimates[scenario] + if (value == null) continue + expectedByScenario[scenario] = (expectedByScenario[scenario] ?? 0) + value + } + } + + return { rowCount: rows.length, votes2022, expectedByScenario, withEstimateCount } +} diff --git a/src/utilities/municipality/municipalityPageData.ts b/src/utilities/municipality/municipalityPageData.ts index 9822e635..5b6e4e25 100644 --- a/src/utilities/municipality/municipalityPageData.ts +++ b/src/utilities/municipality/municipalityPageData.ts @@ -6,6 +6,10 @@ import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories' import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile' import { engagementLevelRank } from '@/lib/engagementLevel' import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog' +import { + computeMunicipalitySliceTotals, + type MunicipalitySliceTotals, +} from '@/lib/municipalitySliceTotals' import { compareMunicipalityVotesForSort, computeVoteRankByYear, @@ -128,6 +132,8 @@ export type MunicipalityListPageBundle = { totalDocs: number totalPages: number scopeTotal: number + /** B202 — totals of the WHOLE filtered slice (every page), staff-only; null for an empty slice. */ + sliceTotals: MunicipalitySliceTotals | null filterFacets: MunicipalityListFilterFacets /** B155 — contact-name lookup for the chips of the município list rows. */ leadershipNamesById: ReadonlyMap @@ -459,6 +465,7 @@ export const loadMunicipalityListPageBundle = async ( totalDocs: 0, totalPages: 0, scopeTotal: 0, + sliceTotals: null, filterFacets: emptyMunicipalityListFilterFacets, leadershipNamesById: EMPTY_LEADERSHIP_NAMES, } @@ -595,12 +602,16 @@ export const loadMunicipalityListPageBundle = async ( let pageDocs: Municipality[] let totalDocs: number let totalPages: number + let sliceTotals: MunicipalitySliceTotals | null if (isPagedByPayload) { // Payload select narrows the inferred type; the selected fields cover the view model. pageDocs = listResult.docs as Municipality[] totalDocs = listResult.totalDocs totalPages = listResult.totalPages + // B202 — the scope rows ARE the whole filtered slice on this path (same + // `where` + `user`, `limit: 0`), so the totals cost no extra query. + sliceTotals = computeMunicipalitySliceTotals(staffScope?.municipalities ?? []) } else { const scopedDocs = classMatches ? (listResult.docs as Municipality[]).filter((municipality) => @@ -628,6 +639,8 @@ export const loadMunicipalityListPageBundle = async ( totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize)) const start = (state.page - 1) * municipalityPageSize pageDocs = allDocs.slice(start, start + municipalityPageSize) + // B202 — totals over the same in-memory slice the count came from. + sliceTotals = computeMunicipalitySliceTotals(allDocs) } // B155 — the leaderships of the visible page only (staff surfaces; the @@ -667,6 +680,7 @@ export const loadMunicipalityListPageBundle = async ( totalDocs, totalPages, scopeTotal: scopeCount.totalDocs, + sliceTotals, filterFacets, leadershipNamesById: leadershipBundle?.summariesById ?? EMPTY_LEADERSHIP_NAMES, } diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index f19284db..ad91895b 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -1,8 +1,15 @@ import type { Page } from '@playwright/test' +import { getStatewideFederalTotals } from '../../src/lib/bahiaElectionAggregates.js' import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js' import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js' import { fallbackDemandTitle } from '../../src/lib/demandTitle.js' +import { formatElectionNumber } from '../../src/lib/electionFormat.js' +import { municipalityCatalog } from '../../src/lib/municipalityCatalog.js' +import { + computeVoteRankByYear, + DEFAULT_VOTE_RANK_YEAR, +} from '../../src/lib/municipalityVoteRank.js' import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js' import { ensureLeasedConsent, @@ -1923,3 +1930,63 @@ test.describe('Municípios — FAB overlay polish (B126)', () => { await expect(overlay.getByRole('region', { name: 'Sugestões' })).toHaveCount(0) }) }) + +/** + * B202 — the compact totals line of the municipality list footer. The numbers + * are computed server-side over the WHOLE filtered slice; the browser asserts + * the reading, the scope and the B129 guardrail (never a line inside the table). + */ +test.describe('Municípios — totais do recorte (B202)', () => { + test('single-municipality recorte reads its 2022 votes and scenario sums in the footer', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const municipality = await fixtures.claimMunicipality() + await fixtures.payload.update({ + collection: 'municipality', + id: municipality.id, + data: { expectedVotes: { pessimistic: 100, central: 200, optimistic: 300 } }, + depth: 0, + }) + fixtures.touchMunicipality(municipality.id) + + await page.goto(`${campaign.baseURL}/campanha/municipios?slug=${municipality.slug}`) + await waitForStreamSettled(page) + + const totals = page.locator('[data-slot="municipality-slice-totals"]') + await expect(totals).toBeVisible() + await expect(page.getByText('1 município encontrado')).toBeVisible() + + const votes2022 = + computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR).get(municipality.slug)?.votes ?? 0 + const text = ((await totals.textContent()) ?? '').replace(/\s+/g, ' ').trim() + expect(text).toContain(`2022 ${formatElectionNumber(votes2022)}`) + expect(text).toContain('Pess. 100') + expect(text).toContain('Média 200') + expect(text).toContain('Otim. 300') + expect(text).toContain('1 de 1 com expectativa') + expect(text).toContain('Pessimista: 100; Média: 200; Otimista: 300') + + // B129 — the aggregate never becomes a line inside the table. + await expect(page.locator('table [data-slot="municipality-slice-totals"]')).toHaveCount(0) + }) + + test('unfiltered recorte totals every page, not only the visible 25', async ({ + campaign, + page, + }) => { + await page.goto(`${campaign.baseURL}/campanha/municipios`) + await waitForStreamSettled(page) + + // The full catalog + the virtual city row span many pages; the summed 2022 + // votes are the artifact's statewide total, which no single page produces. + const rowCount = municipalityCatalog.length + 1 + await expect(page.getByText(`${rowCount} municípios encontrados`)).toBeVisible() + const totals = page.locator('[data-slot="municipality-slice-totals"]') + await expect(totals).toBeVisible() + await expect(totals).toContainText( + formatElectionNumber(getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes), + ) + }) +}) diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts index 4d6d2d49..9130cf87 100644 --- a/tests/int/municipalityPageData.int.spec.ts +++ b/tests/int/municipalityPageData.int.spec.ts @@ -4,11 +4,17 @@ import type { Payload } from 'payload' import { getPayload } from 'payload' import { beforeAll, describe, expect, it } from 'vitest' +import { + getMunicipalityFederalBaseline, + getStatewideFederalTotals, +} from '@/lib/bahiaElectionAggregates' import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog' +import { DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank' import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight' import config from '@/payload.config' import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData' import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass' +import { cityFederalBaseline } from '@/utilities/municipality/salvadorCityAggregates' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -32,6 +38,7 @@ describe('loadMunicipalityListPageBundle', () => { expect(bundle.municipalities).toHaveLength(0) expect(bundle.totalDocs).toBe(0) + expect(bundle.sliceTotals).toBeNull() }) it('includes 2022 vote position on list rows', async () => { @@ -458,6 +465,7 @@ describe('loadMunicipalityListPageBundle', () => { expect(bundle.municipalities).toHaveLength(0) expect(bundle.scopeTotal).toBe(0) + expect(bundle.sliceTotals).toBeNull() }) // ------------------------------------------------------------------------- @@ -700,4 +708,136 @@ describe('loadMunicipalityListPageBundle', () => { const searched = await loadMunicipalityListPageBundle(payload, advisor, { q: 'salvador' }) expect(cityRow(searched)).toBeDefined() }) + + // ------------------------------------------------------------------------- + // B202 — totals of the filtered slice (footer line) + // ------------------------------------------------------------------------- + + const baselineVotes = (slug: string): number => + getMunicipalityFederalBaseline(slug).votesByYear[String(DEFAULT_VOTE_RANK_YEAR)] ?? 0 + + it('sums the whole default recorte to the statewide 2022 votes', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {}) + + expect(bundle.sliceTotals?.rowCount).toBe(bundle.totalDocs) + expect(bundle.sliceTotals?.rowCount).toBe(municipalityCatalog.length + 1) + expect(bundle.sliceTotals?.votes2022).toBe( + getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes, + ) + }) + + it('covers every page of a paged recorte: the visible page is not the total', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const region = 'Litoral Sul' + const regionEntries = municipalityCatalog.filter((entry) => entry.region === region) + const expectedVotes = regionEntries.reduce((sum, entry) => sum + baselineVotes(entry.slug), 0) + + const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { + region: [region], + sort: 'name', + }) + + expect(regionEntries.length).toBeGreaterThan(25) + expect(bundle.municipalities).toHaveLength(25) + expect(bundle.totalDocs).toBe(regionEntries.length) + expect(bundle.sliceTotals?.rowCount).toBe(regionEntries.length) + expect(bundle.sliceTotals?.votes2022).toBe(expectedVotes) + }) + + it('sums expectations per scenario and counts the rows with any estimate', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const first = await fixtures.getMunicipality() + const second = await fixtures.getMunicipality() + const third = await fixtures.getMunicipality() + + await payload.update({ + collection: 'municipality', + id: first.id, + data: { expectedVotes: { pessimistic: 100, central: 250, optimistic: 400 } }, + depth: 0, + overrideAccess: true, + }) + await payload.update({ + collection: 'municipality', + id: second.id, + data: { expectedVotes: { pessimistic: 50, central: null, optimistic: null } }, + depth: 0, + overrideAccess: true, + }) + for (const municipality of [first, second, third]) { + fixtures.touchMunicipality(municipality.id) + } + + const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { + slug: [first.slug, second.slug, third.slug], + }) + + expect(bundle.sliceTotals?.rowCount).toBe(3) + expect(bundle.sliceTotals?.expectedByScenario).toEqual({ + pessimistic: 150, + central: 250, + optimistic: 400, + }) + expect(bundle.sliceTotals?.withEstimateCount).toBe(2) + }) + + it('counts Salvador once under q: 2022 equals the city fold, never city plus zones', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: 'salvador' }) + const cityVotes = cityFederalBaseline().votesByYear[String(DEFAULT_VOTE_RANK_YEAR)]! + + expect(cityVotes).toBeGreaterThan(0) + expect(bundle.municipalities).toHaveLength(20) + expect(bundle.sliceTotals?.rowCount).toBe(20) + expect(bundle.sliceTotals?.votes2022).toBe(cityVotes) + }) + + it('sums the city baseline when only the city row is selected', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { + slug: ['salvador'], + }) + + expect(bundle.sliceTotals?.rowCount).toBe(1) + expect(bundle.sliceTotals?.votes2022).toBe( + cityFederalBaseline().votesByYear[String(DEFAULT_VOTE_RANK_YEAR)]!, + ) + }) + + it('scopes the totals to the advisor carteira', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const administered = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) + + const bundle = await loadMunicipalityListPageBundle(payload, advisor, {}) + + expect(bundle.sliceTotals?.rowCount).toBe(1) + expect(bundle.sliceTotals?.votes2022).toBe(baselineVotes(administered.slug)) + }) + + it('mirrors the row scope for an advisor with Visão Tudo', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor', { + visibility: 'tudo', + editing: 'tudo', + }) + + const bundle = await loadMunicipalityListPageBundle(payload, advisor, {}) + + expect(bundle.municipalities.length).toBeLessThan(bundle.totalDocs) + expect(bundle.sliceTotals?.rowCount).toBe(municipalityCatalog.length) + expect(bundle.sliceTotals?.votes2022).toBe( + getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes, + ) + }) }) diff --git a/tests/unit/municipalitySliceTotals.unit.spec.ts b/tests/unit/municipalitySliceTotals.unit.spec.ts new file mode 100644 index 00000000..8722f1e2 --- /dev/null +++ b/tests/unit/municipalitySliceTotals.unit.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { + federalBaselineMunicipalitySlugs, + getMunicipalityFederalBaseline, + getStatewideFederalTotals, +} from '@/lib/bahiaElectionAggregates' +import { computeMunicipalitySliceTotals } from '@/lib/municipalitySliceTotals' +import { computeVoteRankByYear, DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank' +import { SALVADOR_CITY_SLUG, salvadorCity } from '@/lib/salvadorCity' +import { cityFederalBaseline } from '@/utilities/municipality/salvadorCityAggregates' + +const zeroEstimates = { pessimistic: null, central: null, optimistic: null } + +describe('computeMunicipalitySliceTotals', () => { + it('returns null for an empty slice — no invented totals', () => { + expect(computeMunicipalitySliceTotals([])).toBeNull() + }) + + it('sums 2022 over the given rows and ignores unknown slugs', () => { + const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR) + const withVotes = federalBaselineMunicipalitySlugs().filter( + (slug) => (ranks.get(slug)?.votes ?? 0) > 0, + ) + const first = withVotes[0]! + const second = withVotes[1]! + + const totals = computeMunicipalitySliceTotals([ + { slug: first }, + { slug: second }, + { slug: 'nao-existe' }, + ]) + + expect(totals).not.toBeNull() + expect(totals!.rowCount).toBe(3) + expect(totals!.votes2022).toBe( + getMunicipalityFederalBaseline(first).votesByYear['2022']! + + getMunicipalityFederalBaseline(second).votesByYear['2022']!, + ) + }) + + it('sums the whole catalog (no city row) to the statewide own votes', () => { + const totals = computeMunicipalitySliceTotals( + federalBaselineMunicipalitySlugs().map((slug) => ({ slug })), + ) + + expect(totals!.rowCount).toBe(435) + expect(totals!.votes2022).toBe(getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes) + }) + + it('counts Salvador once: city plus its 19 ZE equals the city fold, never both', () => { + const cityVotes = cityFederalBaseline().votesByYear['2022']! + const cityAndZones = computeMunicipalitySliceTotals([ + { slug: SALVADOR_CITY_SLUG }, + ...salvadorCity.zoneSlugs.map((slug) => ({ slug })), + ]) + const zonesOnly = computeMunicipalitySliceTotals( + salvadorCity.zoneSlugs.map((slug) => ({ slug })), + ) + + expect(cityVotes).toBeGreaterThan(0) + expect(cityAndZones!.votes2022).toBe(cityVotes) + expect(zonesOnly!.votes2022).toBe(cityVotes) + }) + + it('still sums the zone expectations when the city folds their votes', () => { + const totals = computeMunicipalitySliceTotals([ + { slug: SALVADOR_CITY_SLUG }, + { slug: salvadorCity.zoneSlugs[0]!, expectedVotes: { central: 5_000 } }, + { + slug: salvadorCity.zoneSlugs[1]!, + expectedVotes: { pessimistic: 1_000, optimistic: 9_000 }, + }, + ]) + + expect(totals!.votes2022).toBe(cityFederalBaseline().votesByYear['2022']!) + expect(totals!.expectedByScenario).toEqual({ + pessimistic: 1_000, + central: 5_000, + optimistic: 9_000, + }) + expect(totals!.withEstimateCount).toBe(2) + }) + + it('keeps a scenario nobody filled null and counts rows with any estimate', () => { + const totals = computeMunicipalitySliceTotals([ + { slug: 'a', expectedVotes: zeroEstimates }, + { slug: 'b', expectedVotes: { pessimistic: 100, central: 200, optimistic: null } }, + { slug: 'c', expectedVotes: { central: 50, optimistic: 0 } }, + ]) + + expect(totals!.expectedByScenario).toEqual({ + pessimistic: 100, + central: 250, + optimistic: 0, + }) + expect(totals!.withEstimateCount).toBe(2) + }) + + it('reports all scenarios null when no row has an estimate', () => { + const totals = computeMunicipalitySliceTotals([ + { slug: 'a', expectedVotes: zeroEstimates }, + { slug: 'b' }, + ]) + + expect(totals!.expectedByScenario).toEqual(zeroEstimates) + expect(totals!.withEstimateCount).toBe(0) + }) +}) diff --git a/tests/unit/municipalitySliceTotalsLine.unit.spec.ts b/tests/unit/municipalitySliceTotalsLine.unit.spec.ts new file mode 100644 index 00000000..d6e8e62e --- /dev/null +++ b/tests/unit/municipalitySliceTotalsLine.unit.spec.ts @@ -0,0 +1,72 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +import { MunicipalitySliceTotalsLine } from '@/components/campaign/municipality/MunicipalitySliceTotalsLine' +import type { MunicipalitySliceTotals } from '@/lib/municipalitySliceTotals' +import type { VoteEstimateScenario } from '@/lib/voteEstimate' + +const scenarioState = { scenario: 'central' as VoteEstimateScenario } + +vi.mock('@/components/campaign/municipality/MunicipalityEstimateScenarioContext', () => ({ + useMunicipalityEstimateScenarioOptional: () => ({ + scenario: scenarioState.scenario, + setScenario: () => {}, + }), +})) + +const render = (totals: MunicipalitySliceTotals): string => + renderToStaticMarkup(createElement(MunicipalitySliceTotalsLine, { totals })) + +describe('MunicipalitySliceTotalsLine', () => { + it('formats the four sums and reads the full scenario summary to screen readers', () => { + const html = render({ + rowCount: 3, + votes2022: 1_234, + expectedByScenario: { pessimistic: 100, central: null, optimistic: 0 }, + withEstimateCount: 1, + }) + + expect(html).toContain('data-slot="municipality-slice-totals"') + expect(html).toContain('1.234') + expect(html).toContain('Pess.') + expect(html).toContain('100') + expect(html).toContain('Média') + expect(html).toContain('—') + expect(html).toContain('Otim.') + expect(html).toContain('1 de 3 com expectativa') + expect(html).toContain( + 'Pessimista: 100; Média: não informado; Otimista: 0; 1 de 3 com expectativa', + ) + expect(html).not.toContain('aria-label=') + }) + + it('omits the coverage sentence and keeps the scenario dashes when nobody filled one', () => { + const html = render({ + rowCount: 2, + votes2022: 0, + expectedByScenario: { pessimistic: null, central: null, optimistic: null }, + withEstimateCount: 0, + }) + + expect(html).not.toContain('com expectativa') + expect(html).toContain( + 'Pessimista: não informado; Média: não informado; Otimista: não informado', + ) + }) + + it('emphasizes only the active scenario', () => { + scenarioState.scenario = 'optimistic' + const html = render({ + rowCount: 1, + votes2022: 0, + expectedByScenario: { pessimistic: 100, central: 200, optimistic: 300 }, + withEstimateCount: 1, + }) + scenarioState.scenario = 'central' + + expect(html).toContain('text-foreground font-semibold">300') + expect(html).not.toContain('text-foreground font-semibold">200') + expect(html).toContain('text-foreground">200') + }) +})