mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
15c6a96e1c
commit
8dd60cf962
10 changed files with 881 additions and 23 deletions
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
129
src/lib/ticketPartnerOpportunities.ts
Normal file
129
src/lib/ticketPartnerOpportunities.ts
Normal 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)
|
||||
}
|
||||
164
src/utilities/municipality/municipalityTicketPartnerData.ts
Normal file
164
src/utilities/municipality/municipalityTicketPartnerData.ts
Normal 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) }
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
266
tests/int/municipalityTicketPartnerData.int.spec.ts
Normal file
266
tests/int/municipalityTicketPartnerData.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
139
tests/unit/ticketPartnerOpportunities.unit.spec.ts
Normal file
139
tests/unit/ticketPartnerOpportunities.unit.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue