mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
* 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>
139 lines
5.5 KiB
TypeScript
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([])
|
|
})
|
|
})
|