teqo/tests/unit/municipalityPriorities.unit.spec.ts
Francisco Solla b354ad55d6 style(B186): simplify fixes — NaN tiebreak, janela simétrica, testes sem bomb-relógio
- byGravity estagnação: Infinity - Infinity (NaN) matava o tiebreak para dois
  municípios nunca-sinal (topo da lista) — comparação explícita + teste
- fronteira da janela: módulo puro inclusivo (>= cutoff) e query da tool em
  greater_than_equal (antes greater_than, assimétrico) + testes 30/31 dias
- ultimoSinalAtrasDias reporta a idade da atualização decisiva no balde sinal
  (E9 podia devolver null ao lado de evidência 'há 2 dias' em linha stale)
- fixtures da tool com datas relativas a Date.now() (absolutas apodreceriam
  em ~30 dias); assert de user: ctx.user no caminho do assessor
- options type em inglês (windowDays/reason/sortBy); MunicipalityPriorityReason
  não exportado (knip); agora passado da tool (um relógio)
- expectedVotes select estreito { central: true }; sort '-createdAt' removido
  (módulo re-deriva o decisivo); NARROW_SCOPE_HINT compartilhado em aiToolScope
- baseResponse unifica o shape (short-circuit + retorno); docblock órfão da
  extração removido; prompt: coordenação/candidatura
- impl plan: registro de execução + shape entregue
2026-08-10 08:37:49 -03:00

480 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { DAY_MS } from '@/lib/text'
import {
PRIORITY_POTENTIAL_TOP_N,
rankMunicipalityPriorities,
type MunicipalityPriorityItem,
type PriorityMunicipalityInput,
type PriorityUpdateSignalInput,
} from '@/utilities/municipality/municipalityPriorities'
const NOW = new Date('2026-08-10T12:00:00.000Z')
const daysAgo = (days: number): string => new Date(NOW.getTime() - days * DAY_MS).toISOString()
const mun = (overrides: Partial<PriorityMunicipalityInput>): PriorityMunicipalityInput => ({
id: 1,
name: 'Amargosa',
slug: 'amargosa',
region: 'Vale do Jiquiriçá',
city: 'Amargosa',
priority: 'normal',
engagementLevel: null,
expectedVotesCentral: null,
validVotes2022: null,
lastUpdateAt: null,
politicalTrendStatus: null,
...overrides,
})
const update = (
municipalityID: number,
createdAt: string,
overrides: Partial<PriorityUpdateSignalInput> = {},
): PriorityUpdateSignalInput => ({
municipalityID,
createdAt,
polarity: 'ruim',
urgent: false,
adversarySignal: false,
body: null,
...overrides,
})
const rank = (
municipalities: PriorityMunicipalityInput[],
options: {
janelaDias?: number
motivo?: 'sinal_desfavoravel' | 'estagnacao' | 'potencial'
ordenarPor?: 'gravidade' | 'potencial'
lastPledgeAtById?: ReadonlyMap<number, string | null>
updates?: PriorityUpdateSignalInput[]
} = {},
): MunicipalityPriorityItem[] =>
rankMunicipalityPriorities(
municipalities,
options.lastPledgeAtById ?? new Map(),
options.updates ?? [],
{
windowDays: options.janelaDias ?? 30,
reason: options.motivo,
sortBy: options.ordenarPor,
agora: NOW,
},
)
describe('rankMunicipalityPriorities buckets (B186)', () => {
it('atualização ruim na janela vira sinal_desfavoravel com evidência e trecho', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2) })], {
updates: [update(1, daysAgo(2), { body: ' prefeito fechou com o adversário ' })],
})
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
id: 1,
motivo: 'sinal_desfavoravel',
ultimoSinalAtrasDias: 2,
})
expect(result[0]!.evidencia).toBe(
'Atualização ruim há 2 dias: "prefeito fechou com o adversário"',
)
})
it('urgente manda sobre a polaridade boa (flags dominam)', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(1), { polarity: 'boa', urgent: true })],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
expect(result[0]!.evidencia).toContain('Atualização urgente há 1 dia')
})
it('alerta de adversário tem label próprio', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(3) })], {
updates: [update(1, daysAgo(3), { polarity: 'neutra', adversarySignal: true })],
})
expect(result[0]!.evidencia).toBe('Alerta de adversário há 3 dias')
})
it('exclui município com atualização recente favorável e sem sinais negativos', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(1), { polarity: 'boa' })],
})
expect(result).toHaveLength(0)
})
it('exclusão vale inclusive para o balde potencial (aceite literal)', () => {
const result = rank(
[
mun({
id: 1,
lastUpdateAt: daysAgo(1),
engagementLevel: 'n0',
expectedVotesCentral: 90000,
}),
],
{ updates: [update(1, daysAgo(1), { polarity: 'boa' })] },
)
expect(result).toHaveLength(0)
})
it('"última palavra decide": boa recente depois de ruim exclui', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(5)), update(1, daysAgo(1), { polarity: 'boa' })],
})
expect(result).toHaveLength(0)
})
it('"última palavra decide": ruim recente depois de boa é sinal', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(5), { polarity: 'boa' }), update(1, daysAgo(1))],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
})
it('atualização fora da janela não é sinal recente; vale a estagnação', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(45) })], {
updates: [update(1, daysAgo(45))],
})
expect(result[0]!.motivo).toBe('estagnacao')
expect(result[0]!.evidencia).toContain('sem sinal há 45 dias')
})
it('sem sinal nenhum é estagnação máxima ("nunca recebeu sinal")', () => {
const result = rank([mun({ id: 1 })])
expect(result[0]!.motivo).toBe('estagnacao')
expect(result[0]!.evidencia).toBe('nunca recebeu sinal')
expect(result[0]!.ultimoSinalAtrasDias).toBeNull()
})
it('pledge recente é sinal (E9): sem atualização mas com compromisso não é estagnação', () => {
const result = rank([mun({ id: 1, lastUpdateAt: null })], {
lastPledgeAtById: new Map([[1, daysAgo(2)]]),
})
expect(result).toHaveLength(0)
})
it('pledge antigo não salva da estagnação', () => {
const result = rank([mun({ id: 1, lastUpdateAt: null })], {
lastPledgeAtById: new Map([[1, daysAgo(60)]]),
})
expect(result[0]!.motivo).toBe('estagnacao')
expect(result[0]!.evidencia).toContain('sem sinal há 60 dias')
})
it('neutra recente não é estagnação nem sinal; entra no potencial se for top', () => {
const result = rank(
[
mun({
id: 1,
lastUpdateAt: daysAgo(1),
engagementLevel: 'n0',
expectedVotesCentral: 80000,
}),
],
{ updates: [update(1, daysAgo(1), { polarity: 'neutra' })] },
)
expect(result[0]!.motivo).toBe('potencial')
})
it('potencial: nível N0 com estimativa central alta entra com evidência rotulada', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n0', expectedVotesCentral: 52400 }),
])
expect(result[0]!.motivo).toBe('potencial')
expect(result[0]!.evidencia).toBe(
'Potencial alto (estimativa central de 52.400 votos) e nível N0 · Monitorar',
)
expect(result[0]).toMatchObject({
potencialEstimado: 52400,
fontePotencial: 'estimativa_2026',
nivelEngajamento: 'N0 · Monitorar',
})
})
it('potencial: sem nível definido entra e a evidência diz "sem nível definido"', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2), expectedVotesCentral: 40000 })])
expect(result[0]!.motivo).toBe('potencial')
expect(result[0]!.evidencia).toBe(
'Potencial alto (estimativa central de 40.000 votos) e sem nível definido',
)
expect(result[0]!.nivelEngajamento).toBe('Sem nível')
})
it('potencial: fallback nos válidos 2022 quando não há estimativa central', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n1', validVotes2022: 31000 }),
])
expect(result[0]!.motivo).toBe('potencial')
expect(result[0]!.evidencia).toBe(
'Potencial alto (31.000 válidos em 2022) e nível N1 · Presença de mandato',
)
expect(result[0]).toMatchObject({ fontePotencial: 'validos_2022' })
})
it('potencial: engajamento N2+ não é candidato', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n2', expectedVotesCentral: 90000 }),
])
expect(result).toHaveLength(0)
})
it('potencial: sem número algum de potencial não entra', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n0' })])
expect(result).toHaveLength(0)
})
it(`potencial: corte relativo top ${PRIORITY_POTENTIAL_TOP_N} do escopo`, () => {
const candidates = Array.from({ length: 8 }, (_, index) =>
mun({
id: index + 1,
name: `Cidade ${index}`,
slug: `cidade-${index}`,
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 10000 + index,
}),
)
const result = rank(candidates)
expect(result).toHaveLength(PRIORITY_POTENTIAL_TOP_N)
expect(result.map((item) => item.id)).toEqual([8, 7, 6, 5, 4])
})
})
describe('rankMunicipalityPriorities ordering (B186)', () => {
it('gravidade: sinal_desfavoravel > estagnacao > potencial', () => {
const result = rank(
[
mun({
id: 1,
lastUpdateAt: daysAgo(5),
engagementLevel: 'n0',
expectedVotesCentral: 90000,
}),
mun({ id: 2, lastUpdateAt: daysAgo(2) }),
mun({ id: 3, lastUpdateAt: daysAgo(40) }),
],
{
updates: [update(2, daysAgo(2))],
},
)
expect(result.map((item) => item.motivo)).toEqual([
'sinal_desfavoravel',
'estagnacao',
'potencial',
])
})
it('dentro do sinal: mais recente primeiro', () => {
const result = rank(
[
mun({ id: 1, name: 'A', lastUpdateAt: daysAgo(2) }),
mun({ id: 2, name: 'B', lastUpdateAt: daysAgo(1) }),
],
{ updates: [update(1, daysAgo(2)), update(2, daysAgo(1))] },
)
expect(result.map((item) => item.id)).toEqual([2, 1])
})
it('dentro da estagnação: mais frio primeiro, nunca-sinal no topo', () => {
const result = rank([
mun({ id: 1, name: 'A', lastUpdateAt: daysAgo(35) }),
mun({ id: 2, name: 'B', lastUpdateAt: null }),
mun({ id: 3, name: 'C', lastUpdateAt: daysAgo(90) }),
])
expect(result.map((item) => item.id)).toEqual([2, 3, 1])
})
it('dentro do potencial: maior potencial primeiro; prioridade alta desempata', () => {
const result = rank([
mun({
id: 1,
name: 'A',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
mun({
id: 2,
name: 'B',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
priority: 'alta',
}),
mun({
id: 3,
name: 'C',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 80000,
}),
])
expect(result.map((item) => item.id)).toEqual([3, 2, 1])
})
it('prioridade alta aparece na evidência', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(40), priority: 'alta' })])
expect(result[0]!.evidencia).toBe('sem sinal há 40 dias · prioritário')
})
it('tendência política desfavorável entra como cláusula secundária', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(40), politicalTrendStatus: 'desfavoravel' }),
])
expect(result[0]!.evidencia).toBe('sem sinal há 40 dias · tendência política desfavorável')
})
it('trecho longo é truncado com reticências', () => {
const longBody = 'x'.repeat(300)
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(1), { body: longBody })],
})
expect(result[0]!.evidencia).toMatch(/^Atualização ruim há 1 dia: "x{200}…"$/)
})
})
describe('rankMunicipalityPriorities options (B186)', () => {
it('motivo filtra para um único balde', () => {
const result = rank(
[
mun({ id: 1, lastUpdateAt: daysAgo(1) }),
mun({ id: 2, lastUpdateAt: daysAgo(40) }),
mun({
id: 3,
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
],
{
updates: [update(1, daysAgo(1))],
motivo: 'estagnacao',
},
)
expect(result).toHaveLength(1)
expect(result[0]!.id).toBe(2)
})
it('ordenarPor potencial reordena todos os baldes por potencial desc', () => {
const result = rank(
[
mun({ id: 1, name: 'A', lastUpdateAt: daysAgo(40), expectedVotesCentral: 30000 }),
mun({ id: 2, name: 'B', lastUpdateAt: daysAgo(1), expectedVotesCentral: 90000 }),
mun({ id: 3, name: 'C', lastUpdateAt: daysAgo(45), expectedVotesCentral: null }),
],
{
updates: [update(2, daysAgo(1))],
ordenarPor: 'potencial',
},
)
expect(result.map((item) => item.id)).toEqual([2, 1, 3])
})
it('janela custom muda a fronteira da estagnação e do sinal', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(40) })], {
janelaDias: 60,
updates: [update(1, daysAgo(40))],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
const result2 = rank([mun({ id: 2, lastUpdateAt: daysAgo(40) })], { janelaDias: 30 })
expect(result2[0]!.motivo).toBe('estagnacao')
})
it('fronteira exata da janela: atualização com exatamente 30 dias ainda é decisiva', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(30) })], {
updates: [update(1, daysAgo(30))],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
const result2 = rank([mun({ id: 2, lastUpdateAt: daysAgo(31) })], {
updates: [update(2, daysAgo(31))],
})
expect(result2[0]!.motivo).toBe('estagnacao')
})
it('estagnação: sinal com exatamente 30 dias está no limite (>= janela)', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(30) })])
expect(result[0]!.motivo).toBe('estagnacao')
})
})
describe('rankMunicipalityPriorities tiebreaks (B186)', () => {
it('dois municípios nunca-sinal: prioridade alta primeiro, depois nome (Infinity não vira NaN)', () => {
const result = rank([
mun({ id: 1, name: 'Abreu e Lima', slug: 'a', priority: 'normal' }),
mun({ id: 2, name: 'Belmonte', slug: 'b', priority: 'alta' }),
mun({ id: 3, name: 'Cairu', slug: 'c', priority: 'normal' }),
])
expect(result.map((item) => item.id)).toEqual([2, 1, 3])
})
it('candidatos empatados em potencial ordenam por nome', () => {
const result = rank([
mun({
id: 1,
name: 'Zé Ninguém',
slug: 'z',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
mun({
id: 2,
name: 'Alfa',
slug: 'a',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
mun({
id: 3,
name: 'Beta',
slug: 'b',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 90000,
}),
])
expect(result.map((item) => item.id)).toEqual([3, 2, 1])
})
it('exclusão vence a estagnação: atualização boa recente com pledge antigo fica fora', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2) })], {
lastPledgeAtById: new Map([[1, daysAgo(40)]]),
updates: [update(1, daysAgo(2), { polarity: 'boa' })],
})
expect(result).toHaveLength(0)
})
})