mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(B202): totais do recorte no rodapé da lista de municípios (Closes #1005)
This commit is contained in:
parent
e579406b1f
commit
0c0725835b
11 changed files with 691 additions and 0 deletions
1
docs/changelog/2026-09-15-b202.md
Normal file
1
docs/changelog/2026-09-15-b202.md
Normal file
|
|
@ -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 `<p>` 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).
|
||||
113
docs/plans/totais-recorte-lista-municipios-impl.md
Normal file
113
docs/plans/totais-recorte-lista-municipios-impl.md
Normal file
|
|
@ -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<br/>overrideAccess:false + user"]
|
||||
W --> BQ{"paged?"}
|
||||
BQ -- não --> FS["full set em memória<br/>listResult.docs → allDocs"]
|
||||
BQ -- sim --> SS["staffScope.municipalities<br/>loadMunicipalityScope limit 0"]
|
||||
FS --> T["computeMunicipalitySliceTotals<br/>src/lib/municipalitySliceTotals.ts"]
|
||||
SS --> T
|
||||
ART["computeVoteRankByYear(2022)<br/>artefato imutável, cache de módulo"] --> T
|
||||
T --> RB["bundle.sliceTotals"]
|
||||
RB --> PG[page.tsx]
|
||||
PG --> FT["CampaignListFooter totals?: ReactNode"]
|
||||
FT --> LN["MunicipalitySliceTotalsLine<br/>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 `<p>` é 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 ? <MunicipalitySliceTotalsLine 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.**
|
||||
|
|
@ -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 ? <MunicipalitySliceTotalsLine totals={sliceTotals} /> : undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<VoteEstimateScenario, string> = {
|
||||
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 (
|
||||
<p
|
||||
data-slot="municipality-slice-totals"
|
||||
className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 rounded-lg border bg-card px-3 py-1.5 text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
<span>Total do recorte:</span>
|
||||
<span>
|
||||
2022{' '}
|
||||
<span className="font-semibold text-foreground">{formatElectionNumber(votes2022)}</span>
|
||||
</span>
|
||||
{VOTE_ESTIMATE_SCENARIOS.map((scenario) => (
|
||||
<span key={scenario} className="inline-flex items-center gap-2">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span title={voteEstimateScenarioLabels[scenario]}>
|
||||
{scenarioShortLabels[scenario]}{' '}
|
||||
<span className={cn('text-foreground', scenario === activeScenario && 'font-semibold')}>
|
||||
{formatScenarioValue(expectedByScenario[scenario])}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{withEstimateCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{withEstimateCount} de {rowCount} com expectativa
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="sr-only">{screenReaderSummary}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}) => (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? singular : plural}
|
||||
</p>
|
||||
{totals}
|
||||
<CampaignListPagination page={page} totalPages={totalPages} hrefForPage={hrefForPage} />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
89
src/lib/municipalitySliceTotals.ts
Normal file
89
src/lib/municipalitySliceTotals.ts
Normal file
|
|
@ -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<string> = 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 }
|
||||
}
|
||||
|
|
@ -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<number, MunicipalityLeadershipSummary>
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
109
tests/unit/municipalitySliceTotals.unit.spec.ts
Normal file
109
tests/unit/municipalitySliceTotals.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
72
tests/unit/municipalitySliceTotalsLine.unit.spec.ts
Normal file
72
tests/unit/municipalitySliceTotalsLine.unit.spec.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue