From e462d39fda0778d437161a9db67b9bce3e504ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 16:26:37 -0300 Subject: [PATCH] =?UTF-8?q?B13+:=20concord=C3=A2ncia=20LQ,=20readout=20de?= =?UTF-8?q?=20escopo=20no=20mapa=20e=20DRY=20do=20build=20de=20agregados.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rede de segurança para E15, copy honesta no Quadro para assessores, uma varredura federal por ano no artefato e tupla de rank no bundle RSC. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 + docs/plans/escala-dry-pos-b13.md | 4 +- docs/roadmap.md | 2 +- scripts/build-election-aggregates.mjs | 109 ++++++++-------- .../(campaign)/campanha/(app)/quadro/page.tsx | 6 +- .../campaign/map/MunicipalityMapPanel.tsx | 52 ++++++-- .../map/MunicipalityMapPanelDynamic.tsx | 11 +- .../municipalityElectoralBaseline.ts | 116 ++++++++++-------- .../municipality/municipalityMapContract.ts | 18 ++- .../municipality/municipalityMapData.ts | 8 +- tests/int/municipalityMapData.int.spec.ts | 11 +- .../federalNominalSliceFolds.unit.spec.ts | 29 +++++ tests/unit/mapLqConcordance.unit.spec.ts | 75 +++++++++++ 13 files changed, 308 insertions(+), 135 deletions(-) create mode 100644 tests/unit/federalNominalSliceFolds.unit.spec.ts create mode 100644 tests/unit/mapLqConcordance.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 6071247c..c709fff7 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -72,6 +72,8 @@ alwaysApply: false Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba". +- **B13+ entregue em código (2026-07-29):** escala e DRY pós-B13 — quatro fases do [`escala-dry-pos-b13.md`](../../../docs/plans/escala-dry-pos-b13.md), sem migration e sem mudar o conteúdo do artefato TSE. **F1:** `mapLqConcordance.unit.spec.ts` trava a concordância entre o LQ do painel (client-safe) e `computeMunicipalityTerritorialClass.lq` — rede de segurança do **E15**, sem unificar os caminhos. **F2:** o mapa staff vive em `/campanha/quadro` (B43); `actorRole` desce da página até `MunicipalityMapPanel`, e o readout qualifica geografia (quantis/LQ "no seu escopo" para assessor; posição competitiva "na cidade inteira"; rótulo de classe E10 na bolha com "(lista)" quando o coropleto é relativo à carteira). **F3:** `build:election-aggregates` faz **uma** leitura `loadFederalVotesByCityZoneAndCandidate` por ano (`party` no `select`) e deriva votos do candidato e do campo em memória; loaders públicos antigos permanecem para majoritárias 2022. **F4:** `competitiveRankByYear` no bundle RSC passa a tupla `[rank, candidates]` com `readFederalCompetitivePlacement`; o JSON commitado continua `{ rank, candidates }`. Adiados no plano: memo `computeAggregateTerritorialClass`, split `municipalityLabels` no chunk do mapa. + - **B33+ entregue em código (2026-07-27):** `useCampaignListFilterNavigation` (`src/components/campaign/shared/`) absorve o scaffold de navegação/debounce que `StateDeputyFilters`, `TerritoryFilters` e `MunicipalityFilters` reimplementavam — 216 linhas removidas contra 35 adicionadas, sem uma única linha de markup (`className`/`id`/`aria-*`/`htmlFor`) alterada nos três. **A auditoria pré-implementação derrubou a premissa central do plano:** ele rejeitava um callback único porque "os três domínios têm assinaturas de canonicalização diferentes", mas `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente** (`parseXListParams(xListStateToRawParams(...))` dentro do próprio builder) e `buildMunicipalityFiltersKey` é só `buildMunicipalityListSearchParams(state).toString()` — a mesma query string canônica. Logo o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante, não diferença de domínio, e um só `toHref` cobre os três; o guardrail de fundo (o hook recebe callback e nunca importa os módulos de URL por domínio) ficou de pé. **Achado de comportamento, não de estrutura:** tocar um filtro/sort no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (`navigateTo` chamava `clearDebounce()` primeiro) enquanto o input seguia exibindo o texto — só municípios carregava o rascunho. Unificado na semântica de municípios por `navigateWithSearch`, que é a **terceira** função exposta (a "questão em aberto" do plano fecha com três nomes, não dois); `clearDebounce` não é exposto porque `navigateTo` limpa antes de qualquer guarda, o que também matou o `router.replace` solto do "Limpar" de municípios. `shouldUpdateMunicipalitySearchUrl` orfanou (era `normalizedText(input) !== currentQ`, com o componente e o próprio spec como únicos consumidores) e foi deletado com seu `describe`. **Rede de segurança onde não havia nenhuma:** `tests/unit/campaignListFilterNavigation.unit.spec.ts` renderiza os três filtros em jsdom (`next/navigation` mockado, `vi.useFakeTimers({ toFake: ['setTimeout','clearTimeout'] })` — o scheduler do React precisa de microtasks reais), 5 casos × 3 listas; escrito antes da migração, o caso do rascunho nasceu **vermelho exatamente em dobradinhas e territórios**. `CampaignSearchForm` (lideranças/assessores/organizações) ficou fora por decisão explícita: 4º irmão, mas sem debounce, com submit explícito, e B29 provavelmente o substitui. Gate verde e bare (tsc, lint, format, knip, check:cycles, 555 unit + 413 int, build local); Aikido 0 achados. **e2e não serviu de veredito nesta sessão:** máquina com load average ~58 (dois outros worktrees com dev server), `campaignMunicipalities` falhando de forma instável na árvore nova **e** na árvore limpa em stash (3 → 6 → 4 falhas em rodadas idênticas, e verde isolado) — o flake pré-existente já ledgerizado, amplificado por timeout; `campaignTerritories` passou em todas as rodadas, e nenhum teste de municípios exercita o formulário de filtros (navegam direto por `?q=`), então o e2e nunca cobriu esta superfície. **Rodada `/simplify` (mesmo dia) mudou números e achou um bug:** o fallback `useCampaignListPending() ?? useTransition()` estava copiado em 5 lugares e o hook novo virou o 6º — virou `useCampaignListTransition()` em `CampaignListPending.tsx` (e `useCampaignListPending` deixou de ser exportado); `draftQ` saiu para o hook; `MunicipalityFilters` perdeu um `aria-live` na **terceira** região viva da mesma página (anunciava o resumo de filtros a cada tecla) e um `div` de um filho só. O bug: o `setTimeout` do debounce capturava o `state` do render que o agendou, então em territórios/dobradinhas digitar + clicar num sort dentro de 1s **revertia o sort** um segundo depois (municípios escapava pelo `key=` da página, que remonta e mata o timer) — corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix. Números finais: 281 linhas removidas contra 75 em todo o `src`, 8 casos × 3 listas, 562 unit + 413 int. **2ª rodada de `/simplify` (após o rebase) fechou a questão que ficara parada** — os dois revisores tinham proposto direções opostas para ressincronizar o input (remontar as três páginas × ressincronizar no hook e remover o `key=`), e a saída foi o terceiro caminho: o hook segue o `q` commitado **só quando não há debounce pendente**, então Back/Forward e o "Limpar busca e filtros" do estado vazio atualizam a caixa sem nunca atropelar quem está a digitar. Com isso o `key={buildMunicipalityFiltersKey(state)}` de municípios saiu (era a única razão da divergência entre as três, e remontar a cada navegação tornava a guarda de dedup do `stateRef` código morto justamente ali) e `buildMunicipalityFiltersKey` foi deletado. Mais quatro correções de reuso: `setSearch`+`scheduleSearch` eram sempre chamados em par (em duas grafias) e viraram `onSearchChange`, o que baixou a superfície do hook de 8 para 6 nomes; `MunicipalityFilters` reimplementava inline o `buildMunicipalityFilterHref` que `municipalityListFilters.ts` já exporta com 4 consumidores; nasceu o irmão `buildStateDeputyFilterHref` (4 sítios pinavam `page: 1` à mão); e municípios ganhou o resumo de filtros ativos que faltava no bloco `md:hidden` — no telemóvel não havia indicação nenhuma de quais filtros estavam ligados. A normalização que a 1ª rodada tinha metido em `navigateWithSearch` foi **revertida** (era a segunda política que ela dizia estar a remover: `scheduleSearch` sempre entregou a tecla crua ao serializador). Números finais: **325 linhas removidas contra 228 em `src`** (138 delas o hook), 10 casos × 3 listas, 586 unit. A review de performance mediu a entrega como neutra e o seu único achado é pré-existente: `/campanha/municipios` embarca ~10 kB gzip do catálogo no cliente por um `isMunicipalitySlug` — no roadmap, junto com as outras deferidas (shell de `
`, `CampaignListSortSelect`, `serialize`/`parseXSortValue` triplicado). Plano: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md). - **B33 entregue em código (2026-07-26):** ordenação e filtro no header de `/campanha/dobradinhas` — `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` (contrato de URL/filtro próprio, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido") espelhando `municipalityListUrl.ts` (paginação/`resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) — não `leadershipListUrl.ts`, que o plano mandava espelhar mas **não existe** (B29 não entregou). `StateDeputySortableHead`/`StateDeputyHeaderFilter` consomem o chrome já compartilhado pelo **B21 ✓** sem extrair de novo (a corrida de ordem de chegada com B29 está encerrada); `loadStateDeputyPartyFacet` segue o contrato de faceta de `loadMunicipalityListFilterFacets` (respeita a busca, ignora o próprio filtro do popover). Ordem dos nulos de `party` **pinada por int test** em vez de assumida — Postgres/Payload ordena nulls por último em `asc` e primeiro em `desc`; "Sem partido" continua sendo o caminho direto via `party: { exists: false }`. Paridade mobile (`StateDeputyFilters`, espelho de `TerritoryFilters`) entrou por decisão desta sessão, fora do appetite original. Redirect canônico em duas fases (`resolveStateDeputyListUrl` → `totalPages` → resolve de novo) no molde de `municipios/page.tsx`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. **Correção pós-entrega no mesmo dia (design review independente):** mobile não tinha a opção "Sem partido" que o popover desktop já injetava (`hasNoParty` não chegava a `StateDeputyFilters`) e a tabela não passava `caption` ao `CampaignTable`, ao contrário dos dois precedentes citados no plano — ambos corrigidos no mesmo molde; copy do empty state também virou plural. **`/simplify` no mesmo dia:** a correção acima tinha copiado a montagem da linha "Sem partido" em vez de compartilhá-la — extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido por `StateDeputyFilters` e `StateDeputyHeaderFilter`; `StateDeputySortableHead` perdeu o discriminante `filterParam: 'party'` (só há uma coluna filtrável, `filterOptions` já basta); `parsePartiesParam` parou de reimplementar o dedupe de `allParamValues`; um `if` sempre-verdadeiro saiu de `buildStateDeputyListWhere`; `loadStateDeputyListPageData` ganhou `select` na query principal. Gate reverificado verde. **`/simplify` pós-`/rebase-on-main` (2ª rodada, mesmo dia):** rodou de novo sobre o commit squashado — removeu tipo+cast morto `StateDeputyPartyRow` (`stateDeputyData.ts`, `select` só afeta runtime, não a checagem estática), `stateDeputyColumns` (`page.tsx`) passou a importar `StateDeputyFilterOption` em vez de redeclarar a forma, `parsePartiesParam` foi inlineado (helper de uma linha usado uma vez). Achado maior: `firstPartyNamesLabel` era a 3ª cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites"; extraído `truncatedNamesLabel` em `campaignListUrl.ts`, os três domínios migrados. Recomendação de hook compartilhado para o scaffold de busca debounced (`StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo ~50 linhas) registrada mas não perseguida — tocaria os três domínios, fora do escopo deste item; capturada no fechamento como **B33+** (fill-in de engenharia, `useCampaignListFilterNavigation`, ~0,5d). Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. Agregado SQL para o facet de partido também adiado com gatilho (só compensa em escala). Plano: [`ordenacao-filtros-lista-dobradinhas.md`](../../../docs/plans/ordenacao-filtros-lista-dobradinhas.md) · fill-in: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md). - **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover "`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md). diff --git a/docs/plans/escala-dry-pos-b13.md b/docs/plans/escala-dry-pos-b13.md index 46f6ba8b..bdb13db4 100644 --- a/docs/plans/escala-dry-pos-b13.md +++ b/docs/plans/escala-dry-pos-b13.md @@ -1,7 +1,7 @@ # Escala e DRY pós-B13 (escala relativa no mapa dos Municípios) -Status: rascunho -Atualizado em: 2026-07-26 +Status: entregue (2026-07-29) +Atualizado em: 2026-07-29 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B13+**, fill-in de engenharia) Impeccable: A — N/A (nenhuma superfície nova; as quatro fases preservam o comportamento visível do B13, exceto uma linha de copy em F2) Appetite: ~0,5–0,75 dia eng; quatro fases independentes, nenhuma com migration, collection ou `Consent` diff --git a/docs/roadmap.md b/docs/roadmap.md index a5f29045..09292d28 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -168,7 +168,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**Cenário junto aos filtros**~~ — **✓ (entregue 2026-07-29)** `VoteEstimateScenarioField` na barra slim de `MunicipalityFilters` (desktop/mobile); overview só consome contexto; provider envolve filtros + resultados · ~0,25–0,5d · Impeccable B · [plano](plans/cenario-junto-filtros-municipios.md) - ~~**B16+** escala/DRY pós-B16~~ — **absorvido pelo Pass 2 W1-D1 (2026-07-25)**: `useOptimistic`, facet por slugs, hrefs via serializador canônico, facets no mesmo `Promise.all` · [plano](plans/escala-dry-pos-b16.md) - **E10+** escala/DRY pós-E10 — quatro fases do `/simplify` da classificação territorial: **F1** a lista lê `municipality` 2× por request (`listQuery` + `loadMunicipalityScope`, mesmo `where`, select superset) — **fecha o "Adiado com gatilho" do A11**, cujo gatilho ("3º path derivado no loader") disparou com `classe` sendo o 4º (`votos`, `deficit`, `frescor`); **F2** `computeAllMunicipalityPotentials()` recomputa 435 linhas puras do artefato por request (memo de processo, como `bahiaElectionAggregates`/`municipalityVoteRank`); **F3** um `TooltipProvider` por tooltip (~30 na lista) anula o agrupamento de delay do Radix — hoist para o layout de `(app)`; **F4** os 4 multi-filtros do mobile saem da mesma `municipalityFilterDefinitions` que os single-select vizinhos já usam · ~0,75–1d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e10.md) -- **B13+** escala/DRY pós-B13 — quatro fases do `/simplify` da escala relativa no mapa, as duas primeiras de **coerência** e não de custo: **F1** a razão do LQ é escrita duas vezes (painel cliente `:210` × classificador `server-only` `:108`, separação **deliberada** — o classificador arrasta o artefato de 600 KB) sem nenhum teste travando a concordância, que é a rede de segurança do **E15**; **F2** no readout do mapa a posição vale para a cidade inteira enquanto a classe é calculada sobre o escopo do usuário, então dois assessores veem classes diferentes para o mesmo polígono de Salvador — declarar a geografia, sem mudar o cálculo; **F3** `pnpm build:election-aggregates` varre a mesma fatia estadual do TSE 3× por ano (~225k docs extras) porque `loadFederalVotesByCityZoneAndCandidate` só precisa de `party` no `select` para ser superconjunto estrito dos outros dois; **F4** `competitiveRankByYear` soma ~57 dos ~63,6 KB que o B13 pôs no payload RSC, pagos mesmo por quem nunca abre a escala — tupla `[rank, candidates]` corta ~metade e responde ao "profiling antes de agir" que o ledger pediu em 2026-07-23 · ~0,5–0,75d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b13.md) +- ~~**B13+** escala/DRY pós-B13~~ — **✓ (entregue 2026-07-29)** quatro fases do `/simplify` pós-B13: **F1** `tests/unit/mapLqConcordance.unit.spec.ts` pina o LQ do painel contra `computeMunicipalityTerritorialClass` (rede de segurança do E15, sem extrair helper); **F2** `actorRole` no mapa do **Quadro** (`quadro/page.tsx` → `MunicipalityMapPanelDynamic`) e copy de escopo no readout (quantis/LQ "no seu escopo" para assessor; posição "na cidade inteira"; bolha E10 "(lista)"); **F3** uma varredura federal por ano no `build:election-aggregates` via `candidateVotesByCityZoneFromFederalSlice` / `campoFederalVotesByCityZoneFromFederalSlice` (`party` no select do slice); **F4** `competitiveRankByYear` no bundle RSC como tupla `[rank, candidates]` + `readFederalCompetitivePlacement` (artefato JSON inalterado) · sem migration · [plano](plans/escala-dry-pos-b13.md) - ~~**B8+** escala/DRY pós-B8 F2~~ — **✓ (entregue 2026-07-27)** as quatro fases do `/simplify` dos polígonos das ZE. **F1:** a política de escala de chão nasceu em `scripts/lib/topology.mjs` (primeiro módulo de `scripts/lib/` — **B5 F2** pousa nele) declarando tolerância de Visvalingam em **m² de chão** e derivando os dígitos de quantização do bbox de cada topologia; `SIMPLIFY_QUANTILE`/`QUANTIZE_DIGITS` morreram nos dois scripts. A auditoria corrigiu a alavanca do plano: quantização sozinha quase não corta (mesmo na grade estadual as zonas ficariam em 203 pontos/feature), o peso absoluto é que manda — as zonas caíram de **9.120 → 574 pontos de arco (480 → 30,2/feature) e 72.310 → 7.687 bytes**, `bahia-municipalities` saiu **byte-idêntico** e territórios mudou 1 byte (grade 102,730 → 102,822 m). Tolerância própria por malha, com o número medido no comentário: a tolerância municipal chegaria a 17,9/feature nas zonas mas comeria 5% da área de Salvador (306 → 291 km²), que é justamente a grandeza que prova que nenhum bairro sumiu. `MAX_TOPO_BYTES` ganhou ao lado `MAX_POINTS_PER_FEATURE = 60`. **F2:** `featureMapKey` passou a ser exercido sobre features **reais** das três malhas, cada uma no laço que já a varre inteira (417 `codarea` + 27 `code` + 19 `slug`) — o `/simplify` corrigiu a primeira escrita, que asseria a cidade dentro do laço das zonas e portanto cobria **uma** feature municipal 19 vezes; o teste de precedência sintético caiu e o comentário passou a declarar o invariante real (as três propriedades de chave são mutuamente exclusivas; `ibgeCode` na zona deliberadamente não é chave). **F3:** `.catch()` na metade das zonas — o mapa segue sem elas (com o motivo logado), com Salvador como polígono sem dado nem interação — e a ressalva de aproximação saiu do gate `!comparisonActive` para um `

` condicionado a `bundle.hasZoneMunicipalities`, campo novo publicado pelo loader a partir de `kind === 'zona'`: o `/simplify` **rejeitou** o predicado `isZoneMapKey` que lia isso da forma da chave (7 dígitos ⇒ município), porque era uma segunda codificação da regra de `mapKeyForMunicipality`, e gatear em `zoneBreakdown` recriaria o acoplamento que o fill-in da lista (✓ 2026-07-28) deletou. **F4:** dois acessores órfãos deletados, a união de navegação de 3 estados colapsada em `string | undefined`, `MunicipalitiesByMapKey` virou `Record` (435 strings fora do payload RSC do Início), `polygonRingsOf` exportado e os helpers de geometria retipados em `PolygonalFeature`/`BahiaFeature

`/`BahiaMeshFeature`. **Um item do plano foi rejeitado por medição:** `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` **ficam** — entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017, então não são código morto (só o `zoneNumber` do **artefato** TopoJSON saiu) · sem migration · [plano](plans/escala-dry-pos-b8f2.md) - **E12+** escala/DRY pós-E12 — **✓ (entregue 2026-07-29)** F1: `src/lib/metropolitanoTerritoryPeers.ts` (peers/sub-linhas Metropolitano unificados para rollup, classe agregada e benchmark T4); F2 adiada — `benchmarkBySlug` já cobre · ~0,25d · Impeccable A · [plano](plans/escala-dry-pos-e12.md) - **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md) diff --git a/scripts/build-election-aggregates.mjs b/scripts/build-election-aggregates.mjs index bb1b0288..846c490a 100644 --- a/scripts/build-election-aggregates.mjs +++ b/scripts/build-election-aggregates.mjs @@ -48,7 +48,8 @@ const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts' const { municipalityElectionGeography } = await import('../src/utilities/municipality/municipalityElectionGeography.ts') const { - loadCampoFederalVotesByCityZone, + campoFederalVotesByCityZoneFromFederalSlice, + candidateVotesByCityZoneFromFederalSlice, loadCandidateVotesByCityZone, loadFederalVotesByCityZoneAndCandidate, loadOfficeTallyByCityZone, @@ -83,17 +84,30 @@ for (const entry of municipalityCatalog) { } } +const geographiesByIbgeCode = new Map() +for (const entry of municipalityCatalog) { + const geographies = geographiesByIbgeCode.get(entry.ibgeCode) ?? [] + geographies.push(municipalityElectionGeography(entry)) + geographiesByIbgeCode.set(entry.ibgeCode, geographies) +} + +const federalRankByIbgeCode = {} + for (const year of HISTORICAL_SERIES_YEARS) { - const [candidateVotes, validVotes, campoVotes, federalTally] = await Promise.all([ - loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }), + const [federalNominalSlice, validVotes, federalTally] = await Promise.all([ + loadFederalVotesByCityZoneAndCandidate(payload, cliReader, { year }), loadValidVotesByCityZone(payload, cliReader, { year }), - loadCampoFederalVotesByCityZone(payload, cliReader, { year }), loadOfficeTallyByCityZone(payload, cliReader, { year, office: FEDERAL_DEPUTY_OFFICE, turn: '1', }), ]) + const candidateVotes = candidateVotesByCityZoneFromFederalSlice( + federalNominalSlice, + candidateNumber, + ) + const campoVotes = campoFederalVotesByCityZoneFromFederalSlice(federalNominalSlice, year) if (candidateVotes.size === 0) { console.error( @@ -122,6 +136,36 @@ for (const year of HISTORICAL_SERIES_YEARS) { `[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber} ` + `(campo total: ${campoTotal}).`, ) + + for (const [ibgeCode, geographies] of geographiesByIbgeCode) { + const byCandidate = new Map() + for (const geography of geographies) { + for (const [number, votes] of sumCandidateVotesForGeography(federalNominalSlice, geography)) { + byCandidate.set(number, (byCandidate.get(number) ?? 0) + votes) + } + } + + const ownVotes = byCandidate.get(candidateNumber) ?? 0 + if (ownVotes <= 0) continue + + let ahead = 0 + let candidates = 0 + for (const votes of byCandidate.values()) { + if (votes <= 0) continue + candidates += 1 + if (votes > ownVotes) ahead += 1 + } + + federalRankByIbgeCode[ibgeCode] ??= {} + federalRankByIbgeCode[ibgeCode][String(year)] = { rank: ahead + 1, candidates } + } + + const ranked = Object.values(federalRankByIbgeCode).filter( + (byYear) => byYear[String(year)] !== undefined, + ).length + console.log( + `[build:election-aggregates] ${year}: placement computed for ${ranked}/${geographiesByIbgeCode.size} IBGE municipalities.`, + ) } // 2022-only: majoritarian (presidente/governador #13) votes + tally, used for @@ -171,63 +215,6 @@ console.log( `[build:election-aggregates] 2022 majoritarian: ${presidentTotal} votes for president #${BASELINE_TICKET_2022.president.candidateNumber}.`, ) -// B13 "posição no município": where the candidate placed among every federal -// deputy voted inside each municipality. Keyed by IBGE code rather than by -// catalog slug because the competitive question only has an answer for a whole -// city — Salvador's 19 zone municipalities are one placement, and the map -// paints one polygon for them anyway. -const geographiesByIbgeCode = new Map() -for (const entry of municipalityCatalog) { - const geographies = geographiesByIbgeCode.get(entry.ibgeCode) ?? [] - geographies.push(municipalityElectionGeography(entry)) - geographiesByIbgeCode.set(entry.ibgeCode, geographies) -} - -const federalRankByIbgeCode = {} -for (const year of HISTORICAL_SERIES_YEARS) { - const votesByCityZoneAndCandidate = await loadFederalVotesByCityZoneAndCandidate( - payload, - cliReader, - { year }, - ) - - for (const [ibgeCode, geographies] of geographiesByIbgeCode) { - const byCandidate = new Map() - for (const geography of geographies) { - for (const [number, votes] of sumCandidateVotesForGeography( - votesByCityZoneAndCandidate, - geography, - )) { - byCandidate.set(number, (byCandidate.get(number) ?? 0) + votes) - } - } - - const ownVotes = byCandidate.get(candidateNumber) ?? 0 - // No votes here means no placement — a last place would read as a fact - // when it is really absence of data. - if (ownVotes <= 0) continue - - let ahead = 0 - let candidates = 0 - for (const votes of byCandidate.values()) { - if (votes <= 0) continue - candidates += 1 - if (votes > ownVotes) ahead += 1 - } - - federalRankByIbgeCode[ibgeCode] ??= {} - // Competition rank: tied candidates share a placement. - federalRankByIbgeCode[ibgeCode][String(year)] = { rank: ahead + 1, candidates } - } - - const ranked = Object.values(federalRankByIbgeCode).filter( - (byYear) => byYear[String(year)] !== undefined, - ).length - console.log( - `[build:election-aggregates] ${year}: placement computed for ${ranked}/${geographiesByIbgeCode.size} IBGE municipalities.`, - ) -} - const artifact = { provenance: 'Derived from TSE open data seeded by pnpm db:seed:tse; regenerate with pnpm build:election-aggregates.', diff --git a/src/app/(campaign)/campanha/(app)/quadro/page.tsx b/src/app/(campaign)/campanha/(app)/quadro/page.tsx index 8015e4f7..108562b7 100644 --- a/src/app/(campaign)/campanha/(app)/quadro/page.tsx +++ b/src/app/(campaign)/campanha/(app)/quadro/page.tsx @@ -110,7 +110,11 @@ const DashboardMapSection = async ({ return ( - + ) } diff --git a/src/components/campaign/map/MunicipalityMapPanel.tsx b/src/components/campaign/map/MunicipalityMapPanel.tsx index 779f3416..418909a2 100644 --- a/src/components/campaign/map/MunicipalityMapPanel.tsx +++ b/src/components/campaign/map/MunicipalityMapPanel.tsx @@ -22,6 +22,7 @@ import { type ChoroplethValues, } from '@/lib/bahiaMapStyle' import type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts' +import { isUnrestrictedCampaignRole, type CampaignRole } from '@/lib/campaignRoles' import { choroplethMaxValue, computeValidVoteShares, @@ -55,6 +56,7 @@ import { municipalityMapScaleModeHints, municipalityMapScaleModeLabels, municipalityMapYearLabels, + readFederalCompetitivePlacement, type MunicipalityMapBundle, type MunicipalityMapScaleMode, type MunicipalityMapYear, @@ -132,12 +134,14 @@ const MapBubbleKey = ({ largestValue }: { largestValue: number }) => ( type MunicipalityMapPanelProps = { bundle: MunicipalityMapBundle candidateOptions: FederalCandidateOption[] + actorRole: CampaignRole defaultYear?: MunicipalityMapYear } export const MunicipalityMapPanel = ({ bundle, candidateOptions, + actorRole, defaultYear = 2022, }: MunicipalityMapPanelProps) => { const router = useRouter() @@ -157,6 +161,7 @@ export const MunicipalityMapPanel = ({ const comparison = bundle.comparison const comparisonActive = comparison !== null && year !== 2026 + const portfolioScopedActor = !isUnrestrictedCampaignRole(actorRole) // Comparing forces the diverging absolute scale, and the competitive // placement has no 2026 reading (it is a TSE result). Rather than let the @@ -180,7 +185,14 @@ export const MunicipalityMapPanel = ({ return bundle.values2026ByScenario[estimateScenario] ?? {} } return bundle.valuesByYear[String(year)] ?? {} - }, [bundle, comparison, comparisonActive, estimateScenario, year]) + }, [ + bundle.valuesByYear, + bundle.values2026ByScenario, + comparison, + comparisonActive, + estimateScenario, + year, + ]) const validVotesForYear = useMemo( () => bundle.validVotesByYear[String(year)] ?? {}, @@ -206,6 +218,8 @@ export const MunicipalityMapPanel = ({ /** LQ per município against his statewide standard — the same ratio E10 classes on. */ const lqByMapKey = useMemo(() => { + if (effectiveScaleMode !== 'lq') return {} + const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0 if (statewideShare <= 0) return {} @@ -216,7 +230,7 @@ export const MunicipalityMapPanel = ({ lq[mapKey] = votes / validVotes / statewideShare } return lq - }, [bundle.statewideShareByYear, rawValues, validVotesForYear, year]) + }, [bundle.statewideShareByYear, effectiveScaleMode, rawValues, validVotesForYear, year]) const competitiveRankForYear = useMemo( () => bundle.competitiveRankByYear[String(year)] ?? {}, @@ -234,7 +248,7 @@ export const MunicipalityMapPanel = ({ Object.fromEntries( Object.entries(competitiveRankForYear).map(([mapKey, placement]) => [ mapKey, - placement.rank, + placement[0], ]), ), ) @@ -248,7 +262,7 @@ export const MunicipalityMapPanel = ({ // classed and grey out every polygon under a red gradient legend. const classed = classing !== null && classing.classes.length > 0 const fillByKey = useMemo( - () => (classed && classing ? fillsForClassing(classing) : undefined), + () => (classed && classing !== null ? fillsForClassing(classing) : undefined), [classed, classing], ) @@ -342,9 +356,19 @@ export const MunicipalityMapPanel = ({ const territorialClass = bundle.territorialClassByMapKey[key] const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null - return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}` + const classSuffix = classLabel + ? portfolioScopedActor + ? ` · ${classLabel} (lista)` + : ` · ${classLabel}` + : '' + return `${formatElectionNumber(projected)} válidos projetados em jogo${classSuffix}` }, - [bundle.projectedValidVotesByMapKey, bundle.territorialClassByMapKey, showBubbles], + [ + bundle.projectedValidVotesByMapKey, + bundle.territorialClassByMapKey, + portfolioScopedActor, + showBubbles, + ], ) /** @@ -361,22 +385,26 @@ export const MunicipalityMapPanel = ({ case 'quantile': { if (classIndex === undefined) return null const range = classing.classes[classIndex]?.label - return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}` + const scopeSuffix = portfolioScopedActor ? ' no seu escopo' : '' + return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}${scopeSuffix}` } case 'lq': { const lq = lqByMapKey[key] - return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq) + if (lq === undefined) return null + const reading = formatDominanceAgainstOwnStandard(lq) + return portfolioScopedActor ? `${reading} no seu escopo` : reading } case 'competitiveRank': { - const placement = competitiveRankForYear[key] - if (!placement) return null - return `${formatPlacementOrdinal(placement.rank)} entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui` + const tuple = competitiveRankForYear[key] + if (!tuple) return null + const placement = readFederalCompetitivePlacement(tuple) + return `${formatPlacementOrdinal(placement.rank)} entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui, na cidade inteira` } default: return null } }, - [classing, competitiveRankForYear, effectiveScaleMode, lqByMapKey], + [classing, competitiveRankForYear, effectiveScaleMode, lqByMapKey, portfolioScopedActor], ) // Optimistic on the control, honest pending on the map: the comparison data diff --git a/src/components/campaign/map/MunicipalityMapPanelDynamic.tsx b/src/components/campaign/map/MunicipalityMapPanelDynamic.tsx index 96820656..572f1412 100644 --- a/src/components/campaign/map/MunicipalityMapPanelDynamic.tsx +++ b/src/components/campaign/map/MunicipalityMapPanelDynamic.tsx @@ -2,6 +2,7 @@ import dynamic from 'next/dynamic' +import type { CampaignRole } from '@/lib/campaignRoles' import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions' import type { MunicipalityMapBundle } from '@/utilities/municipality/municipalityMapContract' @@ -24,7 +25,15 @@ const MunicipalityMapPanelLazy = dynamic( export const MunicipalityMapPanelDynamic = ({ bundle, candidateOptions, + actorRole, }: { bundle: MunicipalityMapBundle candidateOptions: FederalCandidateOption[] -}) => + actorRole: CampaignRole +}) => ( + +) diff --git a/src/utilities/municipality/municipalityElectoralBaseline.ts b/src/utilities/municipality/municipalityElectoralBaseline.ts index e55b4cae..dc019b51 100644 --- a/src/utilities/municipality/municipalityElectoralBaseline.ts +++ b/src/utilities/municipality/municipalityElectoralBaseline.ts @@ -277,45 +277,6 @@ export const loadCandidateVotesByCityZone = async ( return votes } -/** - * cityCode:zone → sum of nominal deputado-federal T1 votes for every - * candidate whose party is in the curated `campoParties.ts` field for that - * year (E8 "share intracampo" denominator — CLI/build-time use only, this - * scans the whole statewide slice for the office). - */ -export const loadCampoFederalVotesByCityZone = async ( - payload: Payload, - user: ElectionReader, - { year }: { year: number }, -): Promise> => { - assertCanReadElectionData(user) - - const result = await payload.find({ - collection: 'electionCandidateVote', - where: { - and: [ - { year: { equals: year } }, - { office: { equals: FEDERAL_DEPUTY_OFFICE } }, - { turn: { equals: '1' } }, - { voteType: { equals: 'nominal' } }, - ], - }, - depth: 0, - limit: 0, - pagination: false, - select: { cityCode: true, zoneNumber: true, party: true, votes: true }, - overrideAccess: true, - }) - - const votes = new Map() - for (const row of result.docs) { - if (!isCampoParty(row.party, year)) continue - const key = `${row.cityCode}:${row.zoneNumber}` - votes.set(key, (votes.get(key) ?? 0) + (row.votes ?? 0)) - } - return votes -} - /** * cityCode:zone → (candidateNumber → nominal deputado-federal T1 votes) for * one year — the whole statewide slice, ungrouped by candidate, so a caller @@ -326,11 +287,16 @@ export const loadCampoFederalVotesByCityZone = async ( * scans ~100k rows and must never run on a request path — the ranking it * feeds is precomputed into the committed artifact. */ +export type FederalNominalVoteCell = { + votes: number + party: string | null +} + export const loadFederalVotesByCityZoneAndCandidate = async ( payload: Payload, user: ElectionReader, { year }: { year: number }, -): Promise>> => { +): Promise>> => { assertCanReadElectionData(user) const result = await payload.find({ @@ -346,37 +312,87 @@ export const loadFederalVotesByCityZoneAndCandidate = async ( depth: 0, limit: 0, pagination: false, - select: { cityCode: true, zoneNumber: true, candidateNumber: true, votes: true }, + select: { + cityCode: true, + zoneNumber: true, + candidateNumber: true, + votes: true, + party: true, + }, overrideAccess: true, }) - const byCityZone = new Map>() + const byCityZone = new Map>() for (const row of result.docs) { const key = `${row.cityCode}:${row.zoneNumber}` let byCandidate = byCityZone.get(key) if (!byCandidate) { - byCandidate = new Map() + byCandidate = new Map() byCityZone.set(key, byCandidate) } - byCandidate.set( - row.candidateNumber, - (byCandidate.get(row.candidateNumber) ?? 0) + (row.votes ?? 0), - ) + const previous = byCandidate.get(row.candidateNumber) + byCandidate.set(row.candidateNumber, { + votes: (previous?.votes ?? 0) + (row.votes ?? 0), + party: row.party ?? previous?.party ?? null, + }) } return byCityZone } +/** Derives the per-candidate map used by `loadCandidateVotesByCityZone` from one federal slice. */ +export const candidateVotesByCityZoneFromFederalSlice = ( + slice: Map>, + candidateNumber: number, +): Map => { + const votes = new Map() + for (const [key, byCandidate] of slice) { + const cell = byCandidate.get(candidateNumber) + if (cell && cell.votes > 0) votes.set(key, cell.votes) + } + return votes +} + +/** Derives the campo-parties map used by `loadCampoFederalVotesByCityZone` from one federal slice. */ +export const campoFederalVotesByCityZoneFromFederalSlice = ( + slice: Map>, + year: number, +): Map => { + const votes = new Map() + for (const [key, byCandidate] of slice) { + let total = 0 + for (const cell of byCandidate.values()) { + if (cell.party && isCampoParty(cell.party, year)) total += cell.votes + } + if (total > 0) votes.set(key, total) + } + return votes +} + +/** + * cityCode:zone → sum of nominal deputado-federal T1 votes for campo parties + * (E8 "share intracampo"). One DB scan via the federal nominal slice. + */ +export const loadCampoFederalVotesByCityZone = async ( + payload: Payload, + user: ElectionReader, + { year }: { year: number }, +): Promise> => { + assertCanReadElectionData(user) + const slice = await loadFederalVotesByCityZoneAndCandidate(payload, user, { year }) + return campoFederalVotesByCityZoneFromFederalSlice(slice, year) +} + /** Folds `loadFederalVotesByCityZoneAndCandidate` over one municipality's cityCode×zones. */ export const sumCandidateVotesForGeography = ( - votesByCityZoneAndCandidate: Map>, + votesByCityZoneAndCandidate: Map>, geography: MunicipalityElectionGeography, ): Map => { const total = new Map() for (const zone of geography.zones) { const byCandidate = votesByCityZoneAndCandidate.get(`${geography.cityCode}:${zone}`) if (!byCandidate) continue - for (const [candidateNumber, votes] of byCandidate) { - total.set(candidateNumber, (total.get(candidateNumber) ?? 0) + votes) + for (const [candidateNumber, cell] of byCandidate) { + total.set(candidateNumber, (total.get(candidateNumber) ?? 0) + cell.votes) } } return total diff --git a/src/utilities/municipality/municipalityMapContract.ts b/src/utilities/municipality/municipalityMapContract.ts index f5505cbd..85414c94 100644 --- a/src/utilities/municipality/municipalityMapContract.ts +++ b/src/utilities/municipality/municipalityMapContract.ts @@ -1,4 +1,3 @@ -import type { FederalCompetitiveRank } from '@/lib/bahiaElectionAggregates' import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults' import type { VoteEstimateScenario } from '@/lib/voteEstimate' import type { MunicipalitiesByMapKey } from '@/utilities/municipality/municipalityMapNavigation' @@ -86,6 +85,21 @@ export type MunicipalityMapComparison = { diffByYear: Record> } +/** B13+ F4 — compact wire format for competitive placement in the RSC bundle. */ +export type FederalCompetitiveRankTuple = readonly [rank: number, candidates: number] + +const FEDERAL_COMPETITIVE_RANK_INDEX = { + rank: 0, + candidates: 1, +} as const + +export const readFederalCompetitivePlacement = ( + tuple: FederalCompetitiveRankTuple, +): { rank: number; candidates: number } => ({ + rank: tuple[FEDERAL_COMPETITIVE_RANK_INDEX.rank], + candidates: tuple[FEDERAL_COMPETITIVE_RANK_INDEX.candidates], +}) + /** * Every record below is keyed by MAP KEY, not by IBGE codarea: since B8 F2 the * map paints one polygon per catalog unit, so Salvador's 19 zones are addressed @@ -113,7 +127,7 @@ export type MunicipalityMapBundle = { * TSE artifact ranks by codarea only, so Salvador's zones all carry the city's * position (the legend says so). */ - competitiveRankByYear: Record> + competitiveRankByYear: Record> /** map key → projected 2026 valid votes (E8) — the size of the proportional symbol. */ projectedValidVotesByMapKey: Record /** map key → the municipality it opens on click. */ diff --git a/src/utilities/municipality/municipalityMapData.ts b/src/utilities/municipality/municipalityMapData.ts index e098a2a7..ecfe13fc 100644 --- a/src/utilities/municipality/municipalityMapData.ts +++ b/src/utilities/municipality/municipalityMapData.ts @@ -6,7 +6,6 @@ import { getFederalCompetitiveRank, getMunicipalityFederalBaseline, getStatewideFederalTotals, - type FederalCompetitiveRank, } from '@/lib/bahiaElectionAggregates' import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults' import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' @@ -33,6 +32,7 @@ import { type MunicipalityListState, } from '@/utilities/municipality/municipalityListUrl' import type { + FederalCompetitiveRankTuple, MunicipalityMapBundle, MunicipalityMapComparison, } from '@/utilities/municipality/municipalityMapContract' @@ -105,7 +105,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( const valuesByYear: Record> = {} const validVotesByYear: Record> = {} const statewideShareByYear: Record = {} - const competitiveRankByYear: Record> = {} + const competitiveRankByYear: Record> = {} // One polygon per catalog unit since B8 F2, so this index — the same one the // map navigates by — is also the key of every rollup below. const municipalitiesByMapKey = buildMunicipalitiesByMapKey(municipalities) @@ -134,10 +134,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( // Rank exists per city only, so every zone of Salvador carries the city's // position — stated in the legend rather than faked per zone. - const ranks: Record = {} + const ranks: Record = {} for (const municipality of municipalities) { const rank = getFederalCompetitiveRank(municipality.ibgeCode, year) - if (rank) ranks[mapKeyForMunicipality(municipality)] = rank + if (rank) ranks[mapKeyForMunicipality(municipality)] = [rank.rank, rank.candidates] } competitiveRankByYear[String(year)] = ranks } diff --git a/tests/int/municipalityMapData.int.spec.ts b/tests/int/municipalityMapData.int.spec.ts index 936eef5c..9e1d3377 100644 --- a/tests/int/municipalityMapData.int.spec.ts +++ b/tests/int/municipalityMapData.int.spec.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { municipalityCatalog } from '@/lib/municipalityCatalog' import config from '@/payload.config' +import { readFederalCompetitivePlacement } from '@/utilities/municipality/municipalityMapContract' import { loadMunicipalityMapBundle } from '@/utilities/municipality/municipalityMapData' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -49,7 +50,15 @@ describe('loadMunicipalityMapBundle — list URL filters', () => { // The zones share the city's TSE rank — the artifact ranks by codarea only. const ranks = bundle!.competitiveRankByYear['2022'] ?? {} - const positions = new Set(mapKeys.map((key) => ranks[key]?.rank)) + const sampleKey = mapKeys[0] + const sampleTuple = ranks[sampleKey] + expect(sampleTuple).toEqual([expect.any(Number), expect.any(Number)]) + const positions = new Set( + mapKeys.map((key) => { + const tuple = ranks[key] + return tuple ? readFederalCompetitivePlacement(tuple).rank : undefined + }), + ) expect(positions.size).toBe(1) }) diff --git a/tests/unit/federalNominalSliceFolds.unit.spec.ts b/tests/unit/federalNominalSliceFolds.unit.spec.ts new file mode 100644 index 00000000..b6a986a3 --- /dev/null +++ b/tests/unit/federalNominalSliceFolds.unit.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults' +import { + campoFederalVotesByCityZoneFromFederalSlice, + candidateVotesByCityZoneFromFederalSlice, + type FederalNominalVoteCell, +} from '@/utilities/municipality/municipalityElectoralBaseline' + +describe('federal nominal slice folds (B13+ F3)', () => { + it('derives candidate votes and campo totals from one in-memory slice', () => { + const candidateNumber = BASELINE_TICKET_2022.candidate.candidateNumber + const slice = new Map>([ + [ + '2927408:1', + new Map([ + [candidateNumber, { votes: 40, party: 'PT' }], + [9999, { votes: 10, party: 'PL' }], + ]), + ], + ]) + + const byCandidate = candidateVotesByCityZoneFromFederalSlice(slice, candidateNumber) + expect(byCandidate.get('2927408:1')).toBe(40) + + const campo = campoFederalVotesByCityZoneFromFederalSlice(slice, ELECTION_YEAR_2022) + expect(campo.get('2927408:1')).toBe(40) + }) +}) diff --git a/tests/unit/mapLqConcordance.unit.spec.ts b/tests/unit/mapLqConcordance.unit.spec.ts new file mode 100644 index 00000000..ad0b844e --- /dev/null +++ b/tests/unit/mapLqConcordance.unit.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import { + getMunicipalityFederalBaseline, + getStatewideFederalTotals, +} from '@/lib/bahiaElectionAggregates' +import { ELECTION_YEAR_2022 } from '@/lib/electionResults' +import { municipalityCatalog } from '@/lib/municipalityCatalog' +import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass' + +/** + * B13+ F1 — the map panel computes LQ client-side; E10 classifies server-side + * over the same artifact. E15 will touch the anchors — this pin is the safety + * net that both paths still agree on the ratio. + */ +const mapLqForSlug = (slug: string, year: number): number | null => { + const baseline = getMunicipalityFederalBaseline(slug) + const votes = baseline.votesByYear[String(year)] ?? 0 + const validVotes = baseline.validVotesByYear[String(year)] ?? 0 + const statewide = getStatewideFederalTotals(year) + const statewideShare = statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0 + + if (votes <= 0 || validVotes <= 0 || statewideShare <= 0) return null + + return votes / validVotes / statewideShare +} + +const firstSlugWithFederalVotes2022 = (): string => { + const slug = municipalityCatalog.find((entry) => { + const baseline = getMunicipalityFederalBaseline(entry.slug) + return (baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0) > 0 + })?.slug + + if (!slug) { + throw new Error('expected at least one municipality with 2022 federal votes in the catalog') + } + return slug +} + +describe('map LQ concordance (B13+ F1)', () => { + it('matches computeMunicipalityTerritorialClass.lq for a municipality with 2022 votes', () => { + const slug = firstSlugWithFederalVotes2022() + + const mapLq = mapLqForSlug(slug, ELECTION_YEAR_2022) + const classifierLq = computeMunicipalityTerritorialClass(slug).lq + + expect(mapLq).not.toBeNull() + expect(classifierLq).not.toBeNull() + if (mapLq === null || classifierLq === null) return + + expect(mapLq).toBeCloseTo(classifierLq, 10) + }) + + it('uses the same statewide standard as the classifier inputs', () => { + const slug = firstSlugWithFederalVotes2022() + const totals = getStatewideFederalTotals(ELECTION_YEAR_2022) + const statewideShare = totals.validVotes > 0 ? totals.ownVotes / totals.validVotes : 0 + + const baseline = getMunicipalityFederalBaseline(slug) + const ownVotes = baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0 + const validVotes = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0 + + expect(ownVotes).toBeGreaterThan(0) + expect(validVotes).toBeGreaterThan(0) + expect(statewideShare).toBeGreaterThan(0) + + const expectedLq = ownVotes / validVotes / statewideShare + const classifierLq = computeMunicipalityTerritorialClass(slug).lq + + expect(classifierLq).not.toBeNull() + if (classifierLq === null) return + + expect(classifierLq).toBeCloseTo(expectedLq, 10) + }) +})