A6 — Dobradinha 2026 automática quando o TSE publicar candidaturas (pós-15/08) (#82)

* docs(A6): freshness audit da #27 no plano de dobradinhas 2026

electionInsights.ts foi deletado no Pass 2 W4a (modulo puro novo),
electionAlliances.ts nao nasce (campoParties + electionPartySpectrum ja
carregam a taxonomia), questoes em aberto resolvidas (tiers, score,
proporcionais, lockdown de lideranca), card na aba Eleicoes, overview da
lista fora de escopo, cache election-tse com bust na conciliacao Fase 5.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* feat(A6): insight de dobradinhas 2026 na aba Eleicoes do municipio

Lista ranqueada de candidatos proporcionais que concorrem de novo em 2026
(runningAgain2026=sim) por geografia do municipio: score 0,6 alinhamento
(campo 2026 + espectro Bolognesi) + 0,4 forca local normalizada pelo maximo
do conjunto. Estado pendente ate a conciliacao pos-15/08; Solla excluido
(referencia, nao parceiro); leitura derivada sem escrita; loader cacheado
sob a tag election-tse como os irmaos; link para /campanha/dobradinhas
como caminho de registro operacional.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* test(A6): cobertura unit/int do insight + lease das colecoes eleitorais

Unit: tiers (campo/espectro/falha-para-o-meio), score, ordenacao, empates,
limite, zero votos. Int: pending2026 sem conciliacao (mesmo com votos
locais), so adversarios, misto com exclusao de Solla/nao e geografia
alheia. Lease advisory election-collections serializa os dois specs que
tratam as colecoes eleitorais como test-owned (o import wipe-all apagaria
as fixtures do novo spec no meio do assert, 48-way paralelo).

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* refactor(A6): fixes do /simplify (3 reviewers paralelos)

P1: campo coalition morto removido do input/select/mapping (sem consumidor).
P1: hooks do lease com timeout explicito de 120s nos dois specs eleitorais
(o wait advisory passa dos 10s default do Vitest sob contencao — flake em
ambas as direcoes). P2: sonda de conciliacao via payload.count (idioma da
casa), desempate final por candidateNumber (ordem deterministica), geography
como prop na secao (sem lookup duplicado), casing Dep. Federal/Estadual
alinhado, marcador 1313 via BASELINE_TICKET_2022, entrada no CHANGELOG.

Declinados com justificativa: zones como tupla nao-vazia (churn em tipo
compartilhado por assert provada no catalogo) e early-start da promise
antes do Promise.all (loader-dentro-do-Suspense e o idioma da casa; ganho
so em cache frio).

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Francisco Solla 2026-07-31 00:14:27 -03:00 committed by GitHub
parent 15c6a96e1c
commit 8dd60cf962
10 changed files with 881 additions and 23 deletions

View file

@ -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-<uuid>` — 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`).

View file

@ -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<br/>runningAgain2026=sim"]
Votes["electionCandidateVote 2022<br/>votos por geografia"]
Align["electionAlliances.ts<br/>partido -> tier"]
Score["computeDobradinhaOpportunities(...)"]
Detail["Detalhe: lista ranqueada<br/>nome, votos 2022, partido, tier"]
Overview["Overview: top parceiros<br/>por território filtrado"]
Campo["campoParties.ts<br/>campo 2026"]
Spect["electionPartySpectrum.ts<br/>Bolognesi 2022"]
Score["computeTicketPartnerOpportunities(...)"]
Detail["Aba Eleições: lista ranqueada<br/>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

View file

@ -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 ({
<MunicipalityCandidateComparisonTable rows={comparisonRows} />
) : null}
</section>
{/* A6 — its own registry/votes reads, so it streams instead of holding
the baseline card and the comparison table above. */}
<Suspense
fallback={
<div aria-hidden="true" className="h-56 animate-pulse rounded-xl border bg-muted/40" />
}
>
<TicketPartnersSection geography={geography} user={user} />
</Suspense>
</div>
)
}
/**
* 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 <MunicipalityTicketPartnersCard result={result} />
}
export const LeadershipsTab = async ({
municipalityID,
payloadUser: { payload, user },

View file

@ -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<TicketPartnerTier, ComponentProps<typeof Badge>['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 }) => (
<section
aria-labelledby="municipality-ticket-partners-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="municipality-ticket-partners-title" className="text-base font-medium">
Dobradinhas potenciais para 2026
</h2>
<p className="text-sm text-muted-foreground">
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).
</p>
</div>
{result.status === 'pending2026' ? (
<p className="text-sm text-muted-foreground">
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.
</p>
) : result.opportunities.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhum candidato que retorna em 2026 teve votos nominais nesta geografia em 2022.
</p>
) : (
<ol className="flex flex-col">
{result.opportunities.map((opportunity, index) => (
<li
key={`${opportunity.office}-${opportunity.candidateNumber}`}
className="flex items-center gap-3 border-t border-border/60 py-2.5 first:border-t-0 first:pt-0 last:pb-0"
>
<span className="w-6 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
{index + 1}º
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">
{opportunity.name}
{opportunity.elected2022 ? (
<span className="font-normal text-muted-foreground"> · eleito em 2022</span>
) : null}
</span>
<span className="text-xs text-muted-foreground">
{ticketPartnerOfficeLabels[opportunity.office]} ·{' '}
{opportunity.party ?? 'Sem partido'} · {votesLabel(opportunity.votes2022)} em 2022
</span>
</div>
<Badge variant={tierBadgeVariants[opportunity.tier]}>
{ticketPartnerTierLabels[opportunity.tier]}
</Badge>
</li>
))}
</ol>
)}
<p className="border-t border-border/60 pt-3 text-xs text-muted-foreground">
Dobradinha fechada é decisão da coordenação — registre e priorize em{' '}
<Link
href="/campanha/dobradinhas"
className="text-primary underline-offset-4 hover:underline"
>
Dobradinhas
</Link>
.
</p>
</section>
)

View file

@ -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<TicketPartnerTier, string> = {
aliado: 'Aliado',
aliadoHistorico: 'Aliado histórico',
neutro: 'Neutro',
adversario: 'Adversário',
}
export const ticketPartnerOfficeLabels: Record<TicketPartnerOffice, string> = {
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<TicketPartnerTier, number> = {
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)
}

View file

@ -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<TicketPartnerResult> => {
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<TicketPartnerResult> => {
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<TicketPartnerResult> => {
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<string, number>()
const numbersByOffice = new Map<TicketPartnerOffice, Set<number>>()
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<number>()
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) }
}

View file

@ -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. */

View file

@ -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()

View file

@ -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)
})
})

View file

@ -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>): 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([])
})
})