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')
+ })
+})