diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index eeca2d4f..84233583 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -15,6 +15,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. 3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas. 4. **Campaign LGPD consent texts are not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist). +**Recently resolved (2026-07-31):** **A6** Insight de dobradinhas 2026 (Issue #27) — card "Dobradinhas potenciais para 2026" na aba Eleições do detalhe do município: candidatos proporcionais com `runningAgain2026 = sim` ranqueados por score `0,6 alinhamento + 0,4 força local` (tiers compostos de `campoParties` 2026 + `electionPartySpectrum` — sem terceiro mapa; votos 2022 normalizados pelo máximo da geografia). Leitura derivada, sem migration/Consent; loader `municipalityTicketPartnerData` cacheado sob `election-tse` com a política dos irmãos; estado `pending2026` ("indisponível até as candidaturas de 2026") até a Fase 5 reconciliar pós-15/08 — o runbook da conciliação termina com `POST /api/revalidate?tag=election-tse`. Solla excluído (referência); link para `/campanha/dobradinhas` como caminho de registro (não cria registro paralelo). Int specs eleitorais agora se serializam pelo lease `election-collections` (`testDatabaseLease.ts`). Overview da lista ficou fora de escopo (strip de métricas + varredura estadual por request — follow-up possível pós-Fase 5). Plano: [`docs/plans/insight-dobradinha-2026.md`](docs/plans/insight-dobradinha-2026.md). + **Recently resolved (2026-07-30):** **Agent pool orchestrator** — supervisor remoto stateless que mantém até 5 Cursor Cloud Agents rodando `work-issue` sobre Issues `ready` autônomas: workflow `.github/workflows/agent-pool.yml` (tick `*/10min` + merge em `stage` + `workflow_dispatch` como canal canônico) rodando `scripts/agent-pool.mjs` (zero deps npm); elegibilidade fail-closed `isAutonomousClaimable` (`blocked`/`requirements-changed`/`needs:consent` nunca claimadas; `needs:migration` cede ao `migration-lock`); claim coordenado pelo supervisor (alocador único + lock otimista + `agentId` idempotente `bc-` — zero duplicata por construção); workers spawnados via Cloud Agents API v1 com o `model:` da Issue (fallback `composer-2.5`); estado escalar em repo variables `POOL_*`, ativos derivados (marcadores `pool-worker` + status dos runs); falha terminal → Issue a `blocked` + archive; fila drenada → auto-stop. `buildClaimQueue` extraído de `agent-claim` (comportamento idêntico, pinado). Nunca chama `agent:promote`. Requer repo secret `CURSOR_API_KEY`; `schedule` ativa após promote a `main`. Ops: skill `agent-pool`; plano: [`docs/plans/agent-pool-orchestrator.md`](docs/plans/agent-pool-orchestrator.md). **Recently resolved (2026-07-30):** **CI/dev workflow + desbloqueio PR #50** — o PR #50 (B53) ficou BLOCKED por duas causas de infra empilhadas no job `migration-lock` (sem `actions/checkout` → `gh` sem `.git`; depois `GraphQL: Resource not accessible by integration` → `GITHUB_TOKEN` sem `pull-requests: read`) e por um spec int frágil ao `db:seed:minimal` (`homeSearchLeaderships` assertava conjunto global com nome fixo `'Lider'`, que casa word-start com `Seed Liderança Um` do seed). O incidente gerou: (a) política **"Dono do PR, dono do CI"** (`docs/AGENT-OPS.md`) — done = PR mergeado em stage com CI verde, falha fora da feature também é do dono; (b) `ci-pr.yml` reescrito em **jobs paralelos**; (c) **testes afetados pelo diff** — `scripts/test-affected.mjs` (`vitest --changed` vs base do PR) e `scripts/e2e-affected.mjs` + manifesto `scripts/lib/e2e-affected-manifest.mjs`, fallback full em paths de risco (núcleo puro em `scripts/lib/test-affected-core.mjs`, pins unit); e2e entra bloqueante no PR; (d) `ci.yml` (main) virou rede de segurança completa (seed:minimal + build + e2e full); stage segue smoke; (e) fast gate DRY: scripts `gate:fast`/`gate:push`, `.husky/pre-push` roda `gate:push` (knip fora, escape `--no-verify`). diff --git a/docs/plans/insight-dobradinha-2026.md b/docs/plans/insight-dobradinha-2026.md index e1468863..ebd9c228 100644 --- a/docs/plans/insight-dobradinha-2026.md +++ b/docs/plans/insight-dobradinha-2026.md @@ -1,10 +1,19 @@ # Insight: oportunidades de dobradinha 2026 -Status: rascunho (gatilho externo: TSE publica candidaturas — pós-15/08) -Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios) +Status: **em execução** (Issue #27, claim 2026-07-31) — engenharia entra agora no estado "indisponível até 2026"; a ativação é automática quando a Fase 5 reconciliar `runningAgain2026` (gatilho externo: TSE publica candidaturas — pós-15/08) +Atualizado em: 2026-07-31 (freshness audit da execução — ver "Revisão 2026-07-31") Item do roadmap: [docs/roadmap.md](../roadmap.md) (Próximos — Demais itens abertos, A6) Responsável: — +> **Revisão 2026-07-31 (freshness audit, execução da #27):** +> +> 1. `src/lib/electionInsights.ts` **foi deletado** (Pass 2 W4a, 2026-07-25). A matemática nasce em módulo puro novo: `src/lib/ticketPartnerOpportunities.ts` (identificadores em inglês; "dobradinha" fica só na copy pt-BR — a vertical operacional já resolveu o mesmo problema chamando a entidade de `stateDeputy`). +> 2. `src/lib/electionAlliances.ts` **não é criado**. A taxonomia recomendada já existe no repo e se declarou para este uso: `electionPartySpectrum.ts` guarda a média contínua 0–10 de Bolognesi et al. (onda 2022) com o comentário "shared with future A6 tiers", e `campoParties.ts` guarda o campo PT/Solla curado por ano (2026 incluso, FE Brasil). O tier da A6 compõe os dois — sem terceiro mapa. +> 3. **Questões em aberto — resolvidas** (ver seção própria abaixo). +> 4. **Posição na UI:** o plano dizia "aba Visão geral", escrito antes das tabs pós-remodel. Hoje o detalhe tem aba **Eleições** dedicada aos derivados TSE (baseline + comparativo) — a lista de parceiros entra lá, streamed em Suspense próprio como as demais seções pesadas. +> 5. **Overview da lista — fora do escopo desta entrega.** "Agregar top parceiros por território filtrado" foi pensado para o overview territorial pré-remodel; hoje o overview da lista é um strip de métricas (`MunicipalityListOverview`), e um agregado que reage ao conjunto filtrado exigiria varredura estadual por request — padrão que o repo reserva a artefato CLI/build-time. Antes de 2026 carregado o agregado mostraria só o estado indisponível. Fica como follow-up possível depois que a Fase 5 popular 2026. +> 6. **Cache:** loader com `unstable_cache` sob a tag `election-tse`, como os irmãos. Quando a Fase 5 reconciliar `runningAgain2026` (script `scripts/reconcile-running-again.mjs`, ainda a criar), o runbook dele termina com `POST /api/revalidate?tag=election-tse` — mesmo passo pós-seed já documentado no AGENTS.md. + > **Revisão 2026-07-24:** desde a M4, as dobradinhas OPERACIONAIS já existem no produto — entidade `stateDeputy` + `municipality.stateDeputies` / `leadership.stateDeputies` + vertical `/campanha/dobradinhas`. A6 vira a **camada de insight TSE** sobre esse registro: sugerir candidatos 2026 (por força local 2022 + alinhamento) para o staff registrar/priorizar como `stateDeputy` — não criar um segundo registro paralelo. Referências antigas a "núcleo" abaixo foram renomeadas para município. ## Contexto @@ -25,39 +34,39 @@ Responsável: — - **Sem PII** — matching cross-ano via `identityKey` pública (já no plano baseline). - **i18n/naming** seguem o AGENTS.md. -## Questões em aberto +## Questões em aberto — RESOLVIDAS (2026-07-31) -- **Taxonomia de alinhamento político**: como classificar partidos/coligações em tiers (aliado / aliado histórico / neutro / adversário) relativos à chapa PT/Solla. Recomendação: mapear a **média contínua 0–10** de Bolognesi et al. (onda 2022; ver [insight-alavancagem-chapa.md](insight-alavancagem-chapa.md) / Dataverse `doi:10.7910/DVN/MFIXKW`) para tiers em `electionAlliances.ts`, em vez de inventar um segundo mapa. Decisão de produto pelos cortes de tier. -- **Fórmula do score**: como combinar alinhamento (ordinal) com força eleitoral (votos). Recomendação: score = `wAlign * tierWeight + wVotes * normalizedVotes`, com pesos `wAlign`/`wVotes` a definir com produto. -- Considerar só candidatos do mesmo cargo (dep. federal + estadual) ou também majoritários? Recomendação: só proporcionais (federal/estadual) para dobradinha com Solla. -- `lideranca` vê? Recomendação: sim, mas sem expor o tier de adversário (mostrar só aliados/neutros). +- **Taxonomia de alinhamento político** — RESOLVIDA. Tiers derivados dos dois módulos existentes, sem mapa novo: `aliado` = partido do campo 2026 (`isCampoParty(party, 2026)` — FE Brasil: PT/PCdoB/PV); `aliadoHistorico` = bucket `esquerda` do espectro Bolognesi fora do campo; `neutro` = bucket `centro` **ou partido desconhecido** (falha "para o meio", nunca para aliado nem para adversário); `adversario` = bucket `direita`. Cortes: os do espectro (esquerda ≤4,49 · centro 4,5–5,5 · direita ≥5,51), já pinados em `electionPartySpectrum.ts`. +- **Fórmula do score** — RESOLVIDA (default documentado, revisável com produto). `score = 0,6 * tierWeight + 0,4 * normalizedVotes`, com `tierWeight`: aliado 1,0 · aliadoHistórico 0,7 · neutro 0,35 · adversário 0,1, e `normalizedVotes = votos2022 / max(votos2022)` **dentro da geografia do município** — leitura relativa e local, nunca % estadual (kernel de `docs/research/`). Alinhamento pesa mais que força: dobradinha é primeiro decisão de confiança política, depois conta de votos. +- **Cargos** — RESOLVIDO: só proporcionais (`deputado_federal` + `deputado_estadual`, 1º turno). Solla (nº 1313, federal) é excluído — ele é a referência, não um parceiro. +- **`lideranca` vê?** — RESOLVIDO pelo remodel: `leader` é lockdown e a página do município já barra (`noLeader`). O insight é staff-only como todo dado eleitoral — não há variante de view model por papel. -## Abordagem proposta +## Abordagem proposta (atualizada 2026-07-31) ```mermaid flowchart LR Cand["electionCandidate
runningAgain2026=sim"] Votes["electionCandidateVote 2022
votos por geografia"] - Align["electionAlliances.ts
partido -> tier"] - Score["computeDobradinhaOpportunities(...)"] - Detail["Detalhe: lista ranqueada
nome, votos 2022, partido, tier"] - Overview["Overview: top parceiros
por território filtrado"] + Campo["campoParties.ts
campo 2026"] + Spect["electionPartySpectrum.ts
Bolognesi 2022"] + Score["computeTicketPartnerOpportunities(...)"] + Detail["Aba Eleições: lista ranqueada
nome, cargo, partido, votos 2022, tier"] Cand --> Score Votes --> Score - Align --> Score + Campo --> Score + Spect --> Score Score --> Detail - Score --> Overview ``` -- **Helper** `src/lib/electionAlliances.ts`: mapeamento partido→tier (versionado, com proveniência das coligações 2022/2026). -- **Helper** `src/lib/electionInsights.ts`: `computeDobradinhaOpportunities(candidates2026, votes2022, alliances, municipalityGeography)` → `Array<{ candidate, party, coalition, tier, votes2022, score }>` ordenado por score. -- **Componente** `src/components/campaign/DobradinhaOpportunities.tsx` (server). Overview: bloco agregado no `MunicipalityListOverview`. -- **Teste int** cenários: sem 2026 carregado (`desconhecido` → estado indisponível); só adversários; misto. +- **Puro** `src/lib/ticketPartnerOpportunities.ts`: tiers (campo + espectro), score, ordenação, labels pt-BR. Client-safe, testado em unit. +- **Loader** `src/utilities/municipality/municipalityTicketPartnerData.ts` (server-only): `assertCanReadElectionData` + `unstable_cache` tag `election-tse`. Três queries pequenas — sonda "2026 reconciliado?" (`payload.count` de `runningAgain2026 ∈ {sim,nao}`), votos 2022 proporcionais na geografia, registry `sim` só dos números com voto local. Sem sonda positiva → `{ status: 'pending2026' }` ("indisponível até a candidatura de 2026"). +- **Componente** `src/components/campaign/municipality/MunicipalityTicketPartnersCard.tsx` (server): estados pending2026 / vazio / lista top-10 + link para `/campanha/dobradinhas` (registro operacional — não cria segundo registro paralelo). +- **Testes:** unit do puro (tiers, score, ordenação, empates, zero votos) + int do loader (sem 2026 reconciliado → `pending2026`; só adversários; misto com ordem/score; votos de outra geografia fora; Solla fora). O int novo e `electionResultsImport.int.spec.ts` (que apaga TODA a tabela eleitoral) se serializam por lease advisory `election-collections` (`tests/helpers/testDatabaseLease.ts`). ## Arquivos a criar/alterar -- Criar: `src/lib/electionAlliances.ts`, `src/components/campaign/DobradinhaOpportunities.tsx`. -- Alterar: `src/lib/electionInsights.ts` (nova função), `municipios/[slug]/page.tsx` + `municipalityPageData.ts`, overview da lista. +- Criar: `src/lib/ticketPartnerOpportunities.ts`, `src/utilities/municipality/municipalityTicketPartnerData.ts`, `src/components/campaign/municipality/MunicipalityTicketPartnersCard.tsx`, `tests/unit/ticketPartnerOpportunities.unit.spec.ts`, `tests/int/municipalityTicketPartnerData.int.spec.ts`. +- Alterar: `municipios/[slug]/MunicipalityDetailTabs.tsx` (seção na aba Eleições), `tests/int/electionResultsImport.int.spec.ts` (lease compartilhado). ## Dependências diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx index dc700570..b9bd1690 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx @@ -6,6 +6,7 @@ import { MunicipalityGoalAccountCard } from '@/components/campaign/municipality/ import { MunicipalityLeadershipsPanel } from '@/components/campaign/municipality/MunicipalityLeadershipsPanel' import { MunicipalityPledgesPanel } from '@/components/campaign/municipality/MunicipalityPledgesPanel' import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard' +import { MunicipalityTicketPartnersCard } from '@/components/campaign/municipality/MunicipalityTicketPartnersCard' import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed' import { MunicipalityUpdateForm } from '@/components/campaign/municipality/MunicipalityUpdateForm' import { MunicipalityVisitEligibilityCard } from '@/components/campaign/municipality/MunicipalityVisitEligibilityCard' @@ -22,11 +23,15 @@ import { } from '@/utilities/municipality/municipalityCandidateComparison' import type { MunicipalityDetailSearchParams } from '@/utilities/municipality/municipalityDetailTabUi' import { loadMunicipalityDossierData } from '@/utilities/municipality/municipalityDossierData' -import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography' +import { + municipalityElectionGeographyForSlug, + type MunicipalityElectionGeography, +} from '@/utilities/municipality/municipalityElectionGeography' import { loadMunicipalityElectoralBaseline } from '@/utilities/municipality/municipalityElectoralBaseline' import { loadMunicipalityGoalAccount } from '@/utilities/municipality/municipalityGoalAccount' import type { getMunicipalityDetailViewModel } from '@/utilities/municipality/municipalityPageData' import { formatSilenceAgeLabel } from '@/utilities/municipality/municipalitySignal' +import { loadMunicipalityTicketPartners } from '@/utilities/municipality/municipalityTicketPartnerData' import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers' import { loadMunicipalityUpdatesFeed, @@ -276,10 +281,36 @@ export const ElectionsTab = async ({ ) : null} + {/* A6 — its own registry/votes reads, so it streams instead of holding + the baseline card and the comparison table above. */} + ) } +/** + * A6 — dobradinha opportunities for 2026: who runs again, ranked by alignment + * and local 2022 force. Lives on the Elections tab with the other TSE-derived + * reads; until the post-15/08 reconcile the card reports the pending state. + */ +const TicketPartnersSection = async ({ + geography, + user, +}: { + geography: MunicipalityElectionGeography | null + user: PayloadUser['user'] +}) => { + if (!geography) return null + const result = await loadMunicipalityTicketPartners(user, geography) + return +} + export const LeadershipsTab = async ({ municipalityID, payloadUser: { payload, user }, diff --git a/src/components/campaign/municipality/MunicipalityTicketPartnersCard.tsx b/src/components/campaign/municipality/MunicipalityTicketPartnersCard.tsx new file mode 100644 index 00000000..ec4fd66d --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityTicketPartnersCard.tsx @@ -0,0 +1,96 @@ +import Link from 'next/link' +import type { ComponentProps } from 'react' + +import { Badge } from '@/components/ui/Badge' +import { formatElectionNumber } from '@/lib/electionFormat' +import { + ticketPartnerOfficeLabels, + ticketPartnerTierLabels, + type TicketPartnerResult, + type TicketPartnerTier, +} from '@/lib/ticketPartnerOpportunities' + +const tierBadgeVariants: Record['variant']> = { + aliado: 'support-engaged', + aliadoHistorico: 'tse', + neutro: 'outline', + adversario: 'support-negative', +} + +const votesLabel = (votes: number) => + `${formatElectionNumber(votes)} ${votes === 1 ? 'voto' : 'votos'}` + +/** + * A6 — ranked dobradinha opportunities for 2026 in this municipality's + * geography. Until the TSE publishes the 2026 candidacies and the Fase 5 + * reconcile marks who runs again, the card says so instead of listing anyone. + * The write path stays in the operational vertical (`/campanha/dobradinhas`) — + * this insight suggests, it never registers. + */ +export const MunicipalityTicketPartnersCard = ({ result }: { result: TicketPartnerResult }) => ( +
+
+

+ Dobradinhas potenciais para 2026 +

+

+ Candidatos a deputado federal e estadual que concorrem de novo em 2026, ranqueados por + alinhamento com a chapa e pela força eleitoral aqui em 2022 (votos nominais TSE, 1º turno). +

+
+ + {result.status === 'pending2026' ? ( +

+ Indisponível até as candidaturas de 2026: quando o TSE publicar o registro (previsto para + depois de 15/08) e a conciliação marcar quem concorre de novo, a lista aparece aqui + automaticamente. +

+ ) : result.opportunities.length === 0 ? ( +

+ Nenhum candidato que retorna em 2026 teve votos nominais nesta geografia em 2022. +

+ ) : ( +
    + {result.opportunities.map((opportunity, index) => ( +
  1. + + {index + 1}º + +
    + + {opportunity.name} + {opportunity.elected2022 ? ( + · eleito em 2022 + ) : null} + + + {ticketPartnerOfficeLabels[opportunity.office]} ·{' '} + {opportunity.party ?? 'Sem partido'} · {votesLabel(opportunity.votes2022)} em 2022 + +
    + + {ticketPartnerTierLabels[opportunity.tier]} + +
  2. + ))} +
+ )} + +

+ Dobradinha fechada é decisão da coordenação — registre e priorize em{' '} + + Dobradinhas + + . +

+
+) diff --git a/src/lib/ticketPartnerOpportunities.ts b/src/lib/ticketPartnerOpportunities.ts new file mode 100644 index 00000000..c82fe183 --- /dev/null +++ b/src/lib/ticketPartnerOpportunities.ts @@ -0,0 +1,129 @@ +import { isCampoParty } from '@/lib/campoParties' +import { partySpectrum } from '@/lib/electionPartySpectrum' +import type { ElectionOffice } from '@/lib/electionResults' + +/** + * A6 — dobradinha opportunities: proportional candidates running again in 2026 + * (`runningAgain2026 === 'sim'`), ranked for one municipality geography by + * political alignment with the PT/Solla ticket and local 2022 electoral force. + * + * The alignment taxonomy composes the two existing party maps instead of + * inventing a third one: the curated 2026 field (`campoParties.ts`, FE Brasil) + * and the Bolognesi et al. 2022 ideological spectrum (`electionPartySpectrum.ts` + * — the continuous means were kept there explicitly "shared with future A6 + * tiers"). Unknown parties fail to the MIDDLE (`neutro`), never to `aliado` + * (would invent trust) nor to `adversario` (would invent hostility). + */ + +export const TICKET_PARTNER_OFFICES = ['deputado_federal', 'deputado_estadual'] as const +export type TicketPartnerOffice = (typeof TICKET_PARTNER_OFFICES)[number] + +export const isTicketPartnerOffice = (office: ElectionOffice): office is TicketPartnerOffice => + (TICKET_PARTNER_OFFICES as readonly ElectionOffice[]).includes(office) + +export type TicketPartnerTier = 'aliado' | 'aliadoHistorico' | 'neutro' | 'adversario' + +export const ticketPartnerTierLabels: Record = { + aliado: 'Aliado', + aliadoHistorico: 'Aliado histórico', + neutro: 'Neutro', + adversario: 'Adversário', +} + +export const ticketPartnerOfficeLabels: Record = { + deputado_federal: 'Dep. Federal', + deputado_estadual: 'Dep. Estadual', +} + +/** Campo year read for the tier lookup — FE Brasil carries over per campoParties. */ +const TICKET_PARTNER_CAMPO_YEAR = 2026 + +export const ticketPartnerTierForParty = (party: string | null | undefined): TicketPartnerTier => { + if (isCampoParty(party, TICKET_PARTNER_CAMPO_YEAR)) return 'aliado' + const spectrum = partySpectrum(party) + switch (spectrum) { + case 'esquerda': + return 'aliadoHistorico' + case 'centro': + return 'neutro' + case 'direita': + return 'adversario' + case null: + return 'neutro' + default: { + const exhaustive: never = spectrum + throw new Error(`Unhandled party spectrum: ${String(exhaustive)}`) + } + } +} + +/** + * Score weights (default documented in docs/plans/insight-dobradinha-2026.md, + * revisable with product): alignment outweighs force — a dobradinha is first a + * political-trust decision, then a vote count. + */ +const TICKET_PARTNER_ALIGNMENT_WEIGHT = 0.6 +const TICKET_PARTNER_VOTES_WEIGHT = 0.4 + +const TICKET_PARTNER_TIER_WEIGHTS: Record = { + aliado: 1, + aliadoHistorico: 0.7, + neutro: 0.35, + adversario: 0.1, +} + +export const TICKET_PARTNER_LIST_LIMIT = 10 + +export type TicketPartnerInput = { + office: TicketPartnerOffice + candidateNumber: number + name: string + party: string | null + elected2022: boolean + votes2022: number +} + +export type TicketPartnerOpportunity = TicketPartnerInput & { + tier: TicketPartnerTier + score: number +} + +export type TicketPartnerResult = + | { status: 'pending2026' } + | { status: 'ready'; opportunities: TicketPartnerOpportunity[] } + +/** + * Rank candidates by `0.6 * tierWeight + 0.4 * normalizedVotes`, where votes + * are normalized by the strongest candidate INSIDE the municipality set — a + * relative, local reading, never a statewide share (docs/research kernel). + * Ties break on raw votes, then name. + */ +export const computeTicketPartnerOpportunities = ( + candidates: readonly TicketPartnerInput[], + limit = TICKET_PARTNER_LIST_LIMIT, +): TicketPartnerOpportunity[] => { + const maxVotes = Math.max(0, ...candidates.map((candidate) => Math.max(0, candidate.votes2022))) + + const scored = candidates.map((candidate) => { + const tier = ticketPartnerTierForParty(candidate.party) + const normalizedVotes = maxVotes > 0 ? Math.max(0, candidate.votes2022) / maxVotes : 0 + const score = + Math.round( + (TICKET_PARTNER_ALIGNMENT_WEIGHT * TICKET_PARTNER_TIER_WEIGHTS[tier] + + TICKET_PARTNER_VOTES_WEIGHT * normalizedVotes) * + 1000, + ) / 1000 + return { ...candidate, tier, score } + }) + + scored.sort( + (a, b) => + b.score - a.score || + b.votes2022 - a.votes2022 || + a.name.localeCompare(b.name, 'pt-BR') || + // Same-name urna candidates are a real TSE phenomenon — keep the order + // deterministic across requests and cache entries. + a.candidateNumber - b.candidateNumber, + ) + return scored.slice(0, limit) +} diff --git a/src/utilities/municipality/municipalityTicketPartnerData.ts b/src/utilities/municipality/municipalityTicketPartnerData.ts new file mode 100644 index 00000000..8cd5334c --- /dev/null +++ b/src/utilities/municipality/municipalityTicketPartnerData.ts @@ -0,0 +1,164 @@ +import 'server-only' + +import configPromise from '@payload-config' +import { unstable_cache } from 'next/cache' +import { getPayload, type Payload, type Where } from 'payload' + +import { + BASELINE_TICKET_2022, + ELECTION_YEAR_2022, + FEDERAL_DEPUTY_OFFICE, +} from '@/lib/electionResults' +import { + computeTicketPartnerOpportunities, + isTicketPartnerOffice, + TICKET_PARTNER_OFFICES, + type TicketPartnerInput, + type TicketPartnerOffice, + type TicketPartnerResult, +} from '@/lib/ticketPartnerOpportunities' +import type { CampaignUser, User } from '@/payload-types' +import { assertCanReadElectionData } from '@/utilities/campaignAccess' +import { ELECTION_TSE_CACHE_TAG } from '@/utilities/electionCache' +import { + municipalityGeographyWhere, + type MunicipalityElectionGeography, +} from '@/utilities/municipality/municipalityElectionGeography' + +/** + * A6 — dobradinha opportunities for one municipality geography (detail, + * Elections tab). Module policy mirrors the sibling election loaders: the + * public loader checks the actor once (`assertCanReadElectionData`), and the + * cached core reads with `overrideAccess: true` — an admin bypass justified by + * the audience (any authenticated campaign/admin user) and by public TSE data + * carrying no row-level ACL. Entries live until the `election-tse` tag is + * busted; the Fase 5 reconcile of `runningAgain2026` (post-15/08) must end + * with the same `POST /api/revalidate?tag=election-tse` step as a re-seed. + */ + +/** Cross-request cached core — the geography argument is part of the cache key. */ +const loadMunicipalityTicketPartnersCached = unstable_cache( + async (geography: MunicipalityElectionGeography): Promise => { + const payload = await getPayload({ config: configPromise }) + return queryMunicipalityTicketPartners(payload, geography) + }, + ['municipality-ticket-partners'], + { tags: [ELECTION_TSE_CACHE_TAG] }, +) + +export const loadMunicipalityTicketPartners = ( + user: CampaignUser | User, + geography: MunicipalityElectionGeography, +): Promise => { + assertCanReadElectionData(user) + return loadMunicipalityTicketPartnersCached(geography) +} + +const votesKey = (office: TicketPartnerOffice, candidateNumber: number) => + `${office}:${candidateNumber}` + +/** + * Uncached core, exported so the int spec exercises the real geography join — + * `unstable_cache` needs the Next server runtime and cannot run under vitest. + * Three small queries instead of a statewide scan: an EXISTS probe for the + * 2026 reconciliation, the 2022 proportional votes inside the geography, and + * the registry read only for the numbers that actually voted there. + */ +export const queryMunicipalityTicketPartners = async ( + payload: Payload, + geography: MunicipalityElectionGeography, +): Promise => { + const proportionalClauses: Where[] = [ + { year: { equals: ELECTION_YEAR_2022 } }, + { office: { in: [...TICKET_PARTNER_OFFICES] } }, + { turn: { equals: '1' } }, + ] + + // Fase 5 flips `runningAgain2026` off 'desconhecido' once the TSE publishes + // the 2026 candidacies; until then the insight is unavailable everywhere. + const reconciled = await payload.count({ + collection: 'electionCandidate', + where: { and: [...proportionalClauses, { runningAgain2026: { in: ['sim', 'nao'] } }] }, + overrideAccess: true, + }) + if (reconciled.totalDocs === 0) return { status: 'pending2026' } + + const votes = await payload.find({ + collection: 'electionCandidateVote', + where: { + and: [ + ...proportionalClauses, + { voteType: { equals: 'nominal' } }, + municipalityGeographyWhere(geography), + ], + }, + depth: 0, + limit: 0, + pagination: false, + select: { office: true, candidateNumber: true, votes: true }, + overrideAccess: true, + }) + if (votes.docs.length === 0) return { status: 'ready', opportunities: [] } + + const votesByKey = new Map() + const numbersByOffice = new Map>() + for (const row of votes.docs) { + if (!isTicketPartnerOffice(row.office)) continue + const key = votesKey(row.office, row.candidateNumber) + votesByKey.set(key, (votesByKey.get(key) ?? 0) + (row.votes ?? 0)) + const numbers = numbersByOffice.get(row.office) ?? new Set() + numbers.add(row.candidateNumber) + numbersByOffice.set(row.office, numbers) + } + + const officeNumberClauses: Where[] = [...numbersByOffice.entries()].map( + ([office, numbers]): Where => ({ + and: [{ office: { equals: office } }, { candidateNumber: { in: [...numbers] } }], + }), + ) + if (officeNumberClauses.length === 0) return { status: 'ready', opportunities: [] } + + const registry = await payload.find({ + collection: 'electionCandidate', + where: { + and: [ + ...proportionalClauses, + { runningAgain2026: { equals: 'sim' } }, + { or: officeNumberClauses }, + ], + }, + depth: 0, + limit: 0, + pagination: false, + select: { + office: true, + candidateNumber: true, + urnaName: true, + party: true, + elected: true, + }, + overrideAccess: true, + }) + + const candidates: TicketPartnerInput[] = [] + for (const candidate of registry.docs) { + if (!isTicketPartnerOffice(candidate.office)) continue + // Solla is the reference the dobradinhas orbit — never a partner row. + if ( + candidate.office === FEDERAL_DEPUTY_OFFICE && + candidate.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber + ) { + continue + } + candidates.push({ + office: candidate.office, + candidateNumber: candidate.candidateNumber, + name: candidate.urnaName, + party: candidate.party ?? null, + elected2022: candidate.elected ?? false, + votes2022: votesByKey.get(votesKey(candidate.office, candidate.candidateNumber)) ?? 0, + }) + } + + return { status: 'ready', opportunities: computeTicketPartnerOpportunities(candidates) } +} diff --git a/tests/helpers/testDatabaseLease.ts b/tests/helpers/testDatabaseLease.ts index 4114f5bd..a01dac67 100644 --- a/tests/helpers/testDatabaseLease.ts +++ b/tests/helpers/testDatabaseLease.ts @@ -53,6 +53,12 @@ type ConsentSnapshot = { export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent' export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent' export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent' +/** + * Serializes spec files that treat the election collections as test-owned: + * `electionResultsImport.int.spec.ts` wipes ALL election rows, which would + * delete another election spec's fixtures mid-assertion without the lease. + */ +export const ELECTION_COLLECTIONS_LEASE_KEY = 'election-collections' const CAMPAIGN_INVITE_CONSENT_KEY = 'lideranca-autopreenchimento' /** A consent row shared across spec files, protected by an advisory-lock lease. */ diff --git a/tests/int/electionResultsImport.int.spec.ts b/tests/int/electionResultsImport.int.spec.ts index 32291a27..d144622e 100644 --- a/tests/int/electionResultsImport.int.spec.ts +++ b/tests/int/electionResultsImport.int.spec.ts @@ -7,6 +7,11 @@ import { BASELINE_TICKET_2022 } from '@/lib/electionResults' import { FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild' import config from '@/payload.config' import { buildImportBundles, importElectionBundles } from '@/utilities/electionResultsImport' +import { + acquireTestDatabaseLease, + ELECTION_COLLECTIONS_LEASE_KEY, + type TestDatabaseLease, +} from '../helpers/testDatabaseLease' import { loadTseFixtureResults, loadTseFixtureResultsForYear, @@ -14,6 +19,7 @@ import { } from '../helpers/tseFixtures' let payload: Payload +let electionCollectionsLease: TestDatabaseLease const deleteAllElectionData = async () => { for (const collection of [ @@ -39,14 +45,24 @@ const deleteAllElectionData = async () => { } describe('election results import', () => { + // The lease acquisition blocks until the other election spec finishes its + // whole file — far past the 10s default hook timeout under pool contention. beforeAll(async () => { payload = await getPayload({ config: await config }) + // This file deletes EVERY election row; the lease keeps that wipe from + // deleting another election spec's fixtures mid-run (48-way file + // parallelism, one Postgres). + electionCollectionsLease = await acquireTestDatabaseLease( + payload, + ELECTION_COLLECTIONS_LEASE_KEY, + ) await deleteAllElectionData() - }) + }, 120_000) afterAll(async () => { await deleteAllElectionData() - }) + await electionCollectionsLease.release() + }, 120_000) it('imports fixture totals, winners, elected flags, and is idempotent', async () => { const built = loadTseFixtureResults() diff --git a/tests/int/municipalityTicketPartnerData.int.spec.ts b/tests/int/municipalityTicketPartnerData.int.spec.ts new file mode 100644 index 00000000..9cc14f0a --- /dev/null +++ b/tests/int/municipalityTicketPartnerData.int.spec.ts @@ -0,0 +1,266 @@ +// @vitest-environment node + +import { getPayload, type Payload } from 'payload' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' + +import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults' +import { municipalityCatalog } from '@/lib/municipalityCatalog' +import type { ElectionCandidate } from '@/payload-types' +import config from '@/payload.config' +import { + municipalityElectionGeography, + type MunicipalityElectionGeography, +} from '@/utilities/municipality/municipalityElectionGeography' +import { queryMunicipalityTicketPartners } from '@/utilities/municipality/municipalityTicketPartnerData' + +import { + acquireTestDatabaseLease, + ELECTION_COLLECTIONS_LEASE_KEY, + type TestDatabaseLease, +} from '../helpers/testDatabaseLease' + +/** + * A6 — dobradinha opportunities loader. Election collections are test-owned in + * the int suite (electionResultsImport wipes them), so this spec holds the + * shared lease across the whole file. Marker candidate numbers live at + * 900001+ — outside any real TSE number range (4–5 digits) — plus Solla's own + * 1313, which the exclusion test must use by definition; both marker sets are + * deleted before and after the file, so a full-seed dev database loses at + * most those rows (the import spec already wipes everything). + */ +const MARKER_CANDIDATE_NUMBERS = [ + 900001, + 900002, + 900003, + 900004, + 900005, + 900006, + 900007, + BASELINE_TICKET_2022.candidate.candidateNumber, +] + +const geographyOf = (index: number): MunicipalityElectionGeography & { cityName: string } => { + const entry = municipalityCatalog[index] + if (!entry) throw new Error(`municipalityCatalog[${index}] is unavailable.`) + return { ...municipalityElectionGeography(entry), cityName: entry.name } +} + +const geographyA = geographyOf(0) +const geographyB = geographyOf(1) + +let payload: Payload +let lease: TestDatabaseLease + +const deleteMarkerRows = async () => { + await payload.delete({ + collection: 'electionCandidateVote', + where: { candidateNumber: { in: MARKER_CANDIDATE_NUMBERS } }, + depth: 0, + overrideAccess: true, + }) + await payload.delete({ + collection: 'electionCandidate', + where: { candidateNumber: { in: MARKER_CANDIDATE_NUMBERS } }, + depth: 0, + overrideAccess: true, + }) + // Any reconciled row (sim/nao) flips the pending2026 probe statewide. The + // int suite treats election data as disposable, so reset the flag to keep + // the first scenario deterministic on every machine. + await payload.update({ + collection: 'electionCandidate', + where: { runningAgain2026: { in: ['sim', 'nao'] } }, + data: { runningAgain2026: 'desconhecido' }, + overrideAccess: true, + }) +} + +type CandidateInput = { + office: ElectionCandidate['office'] + candidateNumber: number + urnaName: string + party: string + runningAgain2026: ElectionCandidate['runningAgain2026'] + elected?: boolean +} + +const createCandidate = (input: CandidateInput) => + payload.create({ + collection: 'electionCandidate', + data: { + year: ELECTION_YEAR_2022, + turn: '1', + state: 'BA', + coalition: null, + elected: input.elected ?? false, + ...input, + }, + depth: 0, + overrideAccess: true, + }) + +const createVotes = ( + geography: MunicipalityElectionGeography & { cityName: string }, + office: ElectionCandidate['office'], + candidateNumber: number, + candidateName: string, + votes: number, +) => + payload.create({ + collection: 'electionCandidateVote', + data: { + year: ELECTION_YEAR_2022, + office, + turn: '1', + state: 'BA', + cityCode: geography.cityCode, + cityName: geography.cityName, + zoneNumber: geography.zones[0]!, + candidateNumber, + candidateName, + voteType: 'nominal', + votes, + }, + depth: 0, + overrideAccess: true, + }) + +describe('municipality ticket partner data (A6)', () => { + // The lease acquisition blocks until the other election spec finishes its + // whole file — far past the 10s default hook timeout under pool contention. + beforeAll(async () => { + payload = await getPayload({ config: await config }) + lease = await acquireTestDatabaseLease(payload, ELECTION_COLLECTIONS_LEASE_KEY) + await deleteMarkerRows() + }, 120_000) + + afterEach(async () => { + await deleteMarkerRows() + }) + + afterAll(async () => { + await deleteMarkerRows() + await lease.release() + }, 120_000) + + it('stays unavailable while 2026 is not reconciled, even with local 2022 votes', async () => { + await createCandidate({ + office: 'deputado_federal', + candidateNumber: 900001, + urnaName: 'Candidato Desconhecido', + party: 'PT', + runningAgain2026: 'desconhecido', + }) + await createVotes(geographyA, 'deputado_federal', 900001, 'Candidato Desconhecido', 500) + + const result = await queryMunicipalityTicketPartners(payload, geographyA) + expect(result).toEqual({ status: 'pending2026' }) + }) + + it('lists only adversaries when every returning candidate is one', async () => { + await createCandidate({ + office: 'deputado_federal', + candidateNumber: 900002, + urnaName: 'Adversário Federal', + party: 'PL', + runningAgain2026: 'sim', + }) + await createCandidate({ + office: 'deputado_estadual', + candidateNumber: 900003, + urnaName: 'Adversário Estadual', + party: 'MDB', + runningAgain2026: 'sim', + }) + await createVotes(geographyA, 'deputado_federal', 900002, 'Adversário Federal', 800) + await createVotes(geographyA, 'deputado_estadual', 900003, 'Adversário Estadual', 500) + + const result = await queryMunicipalityTicketPartners(payload, geographyA) + expect(result.status).toBe('ready') + if (result.status !== 'ready') return + expect(result.opportunities.map((opportunity) => opportunity.name)).toEqual([ + 'Adversário Federal', + 'Adversário Estadual', + ]) + expect(result.opportunities.every((opportunity) => opportunity.tier === 'adversario')).toBe( + true, + ) + expect(result.opportunities[0]?.votes2022).toBe(800) + expect(result.opportunities[1]?.votes2022).toBe(500) + }) + + it('ranks a mixed field, excludes Solla/nao, and ignores votes from other geographies', async () => { + await createCandidate({ + office: 'deputado_estadual', + candidateNumber: 900004, + urnaName: 'Aliada Estadual', + party: 'PT', + runningAgain2026: 'sim', + elected: true, + }) + await createCandidate({ + office: 'deputado_federal', + candidateNumber: 900005, + urnaName: 'Histórico Federal', + party: 'PSOL', + runningAgain2026: 'sim', + }) + await createCandidate({ + office: 'deputado_federal', + candidateNumber: 900006, + urnaName: 'Adversário Federal', + party: 'PL', + runningAgain2026: 'sim', + }) + // Solla himself: reference, never a partner row. + await createCandidate({ + office: 'deputado_federal', + candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber, + urnaName: 'Jorge Solla', + party: 'PT', + runningAgain2026: 'sim', + }) + // Reconciled as NOT running again: excluded. + await createCandidate({ + office: 'deputado_federal', + candidateNumber: 900007, + urnaName: 'Aposentado Federal', + party: 'PV', + runningAgain2026: 'nao', + }) + + await createVotes(geographyA, 'deputado_estadual', 900004, 'Aliada Estadual', 100) + await createVotes(geographyB, 'deputado_estadual', 900004, 'Aliada Estadual', 500) + await createVotes(geographyA, 'deputado_federal', 900005, 'Histórico Federal', 9000) + await createVotes(geographyA, 'deputado_federal', 900006, 'Adversário Federal', 9000) + await createVotes( + geographyA, + 'deputado_federal', + BASELINE_TICKET_2022.candidate.candidateNumber, + 'Jorge Solla', + 9999, + ) + await createVotes(geographyA, 'deputado_federal', 900007, 'Aposentado Federal', 9999) + + const result = await queryMunicipalityTicketPartners(payload, geographyA) + expect(result.status).toBe('ready') + if (result.status !== 'ready') return + + expect(result.opportunities.map((opportunity) => opportunity.name)).toEqual([ + 'Histórico Federal', + 'Aliada Estadual', + 'Adversário Federal', + ]) + expect(result.opportunities.map((opportunity) => opportunity.tier)).toEqual([ + 'aliadoHistorico', + 'aliado', + 'adversario', + ]) + // Geography B votes do not leak into the municipality total. + const aliada = result.opportunities.find( + (opportunity) => opportunity.candidateNumber === 900004, + ) + expect(aliada?.votes2022).toBe(100) + expect(aliada?.elected2022).toBe(true) + }) +}) diff --git a/tests/unit/ticketPartnerOpportunities.unit.spec.ts b/tests/unit/ticketPartnerOpportunities.unit.spec.ts new file mode 100644 index 00000000..d01aea63 --- /dev/null +++ b/tests/unit/ticketPartnerOpportunities.unit.spec.ts @@ -0,0 +1,139 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + computeTicketPartnerOpportunities, + isTicketPartnerOffice, + TICKET_PARTNER_LIST_LIMIT, + ticketPartnerTierForParty, + type TicketPartnerInput, +} from '@/lib/ticketPartnerOpportunities' + +const candidate = (overrides: Partial): TicketPartnerInput => ({ + office: 'deputado_federal', + candidateNumber: 9001, + name: 'Candidato Teste', + party: 'PT', + elected2022: false, + votes2022: 0, + ...overrides, +}) + +describe('ticketPartnerTierForParty', () => { + it('marks the curated 2026 field (FE Brasil) as aliado', () => { + expect(ticketPartnerTierForParty('PT')).toBe('aliado') + expect(ticketPartnerTierForParty('PV')).toBe('aliado') + expect(ticketPartnerTierForParty('PC do B')).toBe('aliado') + }) + + it('marks esquerda-spectrum parties outside the field as aliadoHistorico', () => { + expect(ticketPartnerTierForParty('PSOL')).toBe('aliadoHistorico') + expect(ticketPartnerTierForParty('PDT')).toBe('aliadoHistorico') + expect(ticketPartnerTierForParty('PSB')).toBe('aliadoHistorico') + }) + + it('marks the centro bucket as neutro', () => { + // AVANTE (4,8) is the one 2022 centro-bucket entry in the survey means. + expect(ticketPartnerTierForParty('AVANTE')).toBe('neutro') + }) + + it('marks the direita bucket as adversario', () => { + expect(ticketPartnerTierForParty('PL')).toBe('adversario') + expect(ticketPartnerTierForParty('MDB')).toBe('adversario') + expect(ticketPartnerTierForParty('UNIÃO')).toBe('adversario') + }) + + it('fails to the middle on unknown or missing parties — never inventing trust or hostility', () => { + expect(ticketPartnerTierForParty('SIGLA INEXISTENTE')).toBe('neutro') + expect(ticketPartnerTierForParty(null)).toBe('neutro') + expect(ticketPartnerTierForParty(undefined)).toBe('neutro') + expect(ticketPartnerTierForParty('')).toBe('neutro') + }) +}) + +describe('isTicketPartnerOffice', () => { + it('accepts only the proportional offices', () => { + expect(isTicketPartnerOffice('deputado_federal')).toBe(true) + expect(isTicketPartnerOffice('deputado_estadual')).toBe(true) + expect(isTicketPartnerOffice('presidente')).toBe(false) + expect(isTicketPartnerOffice('governador')).toBe(false) + }) +}) + +describe('computeTicketPartnerOpportunities', () => { + it('scores alignment over force: a small-vote aliado beats a strong-vote neutro', () => { + const [first, second] = computeTicketPartnerOpportunities([ + candidate({ name: 'Neutro Forte', party: 'AVANTE', votes2022: 1000 }), + candidate({ name: 'Aliado Fraco', party: 'PT', votes2022: 100 }), + ]) + // aliado: 0.6*1 + 0.4*0.1 = 0.64 · neutro: 0.6*0.35 + 0.4*1 = 0.61 + expect(first?.name).toBe('Aliado Fraco') + expect(first?.score).toBe(0.64) + expect(second?.name).toBe('Neutro Forte') + expect(second?.score).toBe(0.61) + }) + + it('normalizes votes by the strongest candidate inside the municipality set', () => { + const [first, second] = computeTicketPartnerOpportunities([ + candidate({ name: 'A', party: 'PT', votes2022: 2000 }), + candidate({ name: 'B', party: 'PV', votes2022: 500 }), + ]) + expect(first?.score).toBe(1) // 0.6 + 0.4*1 + expect(second?.score).toBe(0.7) // 0.6 + 0.4*0.25 + }) + + it('breaks score ties on raw votes, then on name', () => { + const ordered = computeTicketPartnerOpportunities([ + candidate({ name: 'Zuleica', party: 'PSOL', votes2022: 300 }), + candidate({ name: 'Ana', party: 'PSOL', votes2022: 300 }), + candidate({ name: 'Beto', party: 'PSOL', votes2022: 900 }), + ]) + expect(ordered.map((opportunity) => opportunity.name)).toEqual(['Beto', 'Ana', 'Zuleica']) + }) + + it('sinks an adversary below equally-strong allies, and breaks cross-tier ties on votes', () => { + const sameVotes = computeTicketPartnerOpportunities([ + candidate({ name: 'Adversário', party: 'PL', votes2022: 1000 }), + candidate({ name: 'Aliado', party: 'PT', votes2022: 1000 }), + candidate({ name: 'Histórico', party: 'PSOL', votes2022: 1000 }), + ]) + expect(sameVotes.map((opportunity) => opportunity.tier)).toEqual([ + 'aliado', + 'aliadoHistorico', + 'adversario', + ]) + + // A much stronger adversary TIES with a weak historic ally (0.46 = 0.46) + // and wins the vote tie-break: the badge, not the rank, carries the warning. + const mixed = computeTicketPartnerOpportunities([ + candidate({ name: 'Adversário Forte', party: 'PL', votes2022: 5000 }), + candidate({ name: 'Aliado', party: 'PT', votes2022: 1000 }), + candidate({ name: 'Histórico Fraco', party: 'PSOL', votes2022: 500 }), + ]) + expect(mixed.map((opportunity) => [opportunity.name, opportunity.score])).toEqual([ + ['Aliado', 0.68], + ['Adversário Forte', 0.46], + ['Histórico Fraco', 0.46], + ]) + }) + + it('handles an all-zero-vote set without NaN scores', () => { + const [only] = computeTicketPartnerOpportunities([ + candidate({ name: 'Sem Votos', party: 'PT', votes2022: 0 }), + ]) + expect(only?.score).toBe(0.6) + expect(Number.isNaN(only?.score)).toBe(false) + }) + + it('caps the list at the configured limit', () => { + const candidates = Array.from({ length: TICKET_PARTNER_LIST_LIMIT + 2 }, (_, index) => + candidate({ name: `Candidato ${index}`, candidateNumber: 9000 + index, votes2022: index }), + ) + expect(computeTicketPartnerOpportunities(candidates)).toHaveLength(TICKET_PARTNER_LIST_LIMIT) + }) + + it('returns an empty list for an empty set', () => { + expect(computeTicketPartnerOpportunities([])).toEqual([]) + }) +})