teqo/tests/unit/ticketPartnerOpportunities.unit.spec.ts
Francisco Solla 8dd60cf962 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>
2026-07-31 00:14:27 -03:00

139 lines
5.5 KiB
TypeScript

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