TS394 linhas15.493 bytes
apps/worker/tests/premiacao.ts
SHA-256
c255759cd70af4901c60b2d5cb834c46fd17962ff9391435a3bc7508e0322e94
Somente leiturafonte-e58fe007b6ee
/**
* Testes da apuração do Prêmio Leg 57.
*
* Roda sobre SQLite em memória — não toca no banco do runtime.
* pnpm --filter worker test:premiacao
*/
import Database from 'better-sqlite3'
import assert from 'node:assert/strict'
import type { Env, IdsScore } from '../src/types'
import { SqliteD1 } from '../src/runtime/node/d1-sqlite'
import {
apurarPremiacao,
setSnapshotFixado,
regiaoDaUf,
REGIOES,
MIN_LINHAS_SNAPSHOT,
MESES_MIN_VITORIA,
PremiacaoIndisponivel,
} from '../src/services/premiacao'
const SCHEMA = `
CREATE TABLE ranking_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
computed_at TEXT NOT NULL, senador_cod TEXT NOT NULL, nome TEXT NOT NULL,
partido TEXT, uf TEXT, bloco TEXT, foto_url TEXT,
data_inicio_exercicio TEXT, meses_ativos INTEGER DEFAULT 0,
auxilio_moradia INTEGER DEFAULT 0, imovel_funcional INTEGER DEFAULT 0,
ids_total REAL NOT NULL, dim_produtividade REAL NOT NULL, dim_efetividade REAL NOT NULL,
dim_participacao REAL NOT NULL, dim_fiscalizacao REAL NOT NULL, dim_ceap REAL NOT NULL,
dim_transparencia REAL NOT NULL,
autorias_total INTEGER DEFAULT 0, autorias_aprovadas INTEGER DEFAULT 0,
votacoes_presentes INTEGER DEFAULT 0, votacoes_total INTEGER DEFAULT 0,
votacoes_comissao_presentes INTEGER DEFAULT 0, votacoes_comissao_total INTEGER DEFAULT 0,
relatorias_total INTEGER DEFAULT 0, discursos_total INTEGER DEFAULT 0, apartes_total INTEGER DEFAULT 0,
ceap_total_ano REAL DEFAULT 0, ceap_divulgacao REAL DEFAULT 0, ceap_escritorio REAL DEFAULT 0,
ceap_locomocao REAL DEFAULT 0, ceap_consultoria REAL DEFAULT 0, ceap_outros REAL DEFAULT 0,
pct_divulgacao REAL DEFAULT 0, escritorios_count INTEGER DEFAULT 0,
cargos_lideranca REAL DEFAULT 0, cargos_titulos TEXT DEFAULT '[]',
status TEXT, confianca TEXT, ids_total_bruto REAL,
em_exercicio INTEGER DEFAULT 1, data_fim_exercicio TEXT, elegivel_premio INTEGER DEFAULT 1
);
CREATE TABLE premiacao_config (
chave TEXT PRIMARY KEY, valor TEXT NOT NULL, atualizado_em TEXT NOT NULL
);
`
const TODAS_UFS = Object.values(REGIOES).flatMap((r) => r.ufs)
interface Linha {
cod: string
nome: string
uf: string
ids: number
meses?: number
elegivel?: boolean
dims?: Partial<Record<string, number>>
brutos?: Partial<Record<string, number>>
}
function criarEnv() {
const sqlite = new Database(':memory:')
sqlite.exec(SCHEMA)
const env = {
SENADO_DB: new SqliteD1(sqlite) as unknown as Env['SENADO_DB'],
} as unknown as Env
return { env, sqlite }
}
function inserir(sqlite: Database.Database, computedAt: string, linhas: Linha[]) {
const stmt = sqlite.prepare(`
INSERT INTO ranking_snapshots (
computed_at, senador_cod, nome, partido, uf, bloco, foto_url,
data_inicio_exercicio, meses_ativos, ids_total,
dim_produtividade, dim_efetividade, dim_participacao,
dim_fiscalizacao, dim_ceap, dim_transparencia,
autorias_total, autorias_aprovadas, votacoes_presentes, votacoes_total,
relatorias_total, discursos_total, apartes_total,
ids_total_bruto, elegivel_premio
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
for (const l of linhas) {
const d = l.dims ?? {}
const b = l.brutos ?? {}
stmt.run(
computedAt, l.cod, l.nome, 'PART', l.uf, 'BLOCO', '',
'2023-02-01', l.meses ?? 43, l.ids,
d.produtividade ?? 50, d.efetividade ?? 50, d.participacao ?? 50,
d.fiscalizacao ?? 50, d.ceap ?? 50, d.transparencia ?? 50,
b.autoriasTotal ?? 10, b.autoriasAprovadas ?? 5,
b.votacoesPresentes ?? 80, b.votacoesTotal ?? 100,
b.relatoriasTotal ?? 4, b.discursosTotal ?? 20, b.apartesTotal ?? 10,
l.ids, l.elegivel === false ? 0 : 1,
)
}
}
/** Coorte sintética: 4 elegíveis por UF, IDS decrescente e previsível. */
function coorteCompleta(): Linha[] {
const linhas: Linha[] = []
TODAS_UFS.forEach((uf, i) => {
for (let k = 0; k < 4; k++) {
linhas.push({
cod: `${uf}${k}`,
nome: `Sen ${uf} ${k}`,
uf,
// IDS único por senador; o 1º de cada UF é sempre o melhor da UF.
ids: 90 - i * 2 - k * 0.5,
})
}
})
return linhas
}
const testes: [string, () => Promise<void> | void][] = []
const teste = (nome: string, fn: () => Promise<void> | void) => testes.push([nome, fn])
teste('o mapa de regiões cobre as 27 UFs, sem repetição', () => {
assert.equal(TODAS_UFS.length, 27)
assert.equal(new Set(TODAS_UFS).size, 27)
for (const uf of TODAS_UFS) assert.ok(regiaoDaUf(uf), `${uf} sem região`)
assert.equal(regiaoDaUf('XX'), undefined)
assert.equal(regiaoDaUf('sp'), 'SE', 'deve aceitar UF minúscula')
})
teste('apura 12 categorias nos 3 eixos', async () => {
const { env, sqlite } = criarEnv()
inserir(sqlite, '2026-09-20T03:00:00.000Z', coorteCompleta())
const p = await apurarPremiacao(env)
assert.equal(p.categorias.length, 12)
assert.equal(p.categorias.filter((c) => c.eixo === 'grande_premio').length, 1)
assert.equal(p.categorias.filter((c) => c.eixo === 'regional').length, 5)
assert.equal(p.categorias.filter((c) => c.eixo === 'tematico').length, 6)
assert.equal(new Set(p.categorias.map((c) => c.id)).size, 12)
for (const c of p.categorias) {
assert.ok(c.vencedor, `${c.id} sem vencedor`)
assert.equal(c.podio[0].posicao, 1)
assert.ok(c.podio.length <= 3)
}
})
teste('prêmio regional só contempla senadores da própria região', async () => {
const { env, sqlite } = criarEnv()
inserir(sqlite, '2026-09-20T03:00:00.000Z', coorteCompleta())
const p = await apurarPremiacao(env)
const regionais = p.categorias.filter((c) => c.eixo === 'regional')
let soma = 0
for (const c of regionais) {
soma += c.elegiveis
for (const q of c.podio) {
assert.equal(regiaoDaUf(q.uf), c.regiao, `${q.uf} não pertence a ${c.regiao}`)
}
assert.equal(c.elegiveis, REGIOES[c.regiao!].ufs.length * 4)
}
assert.equal(soma, 27 * 4, 'as regiões devem particionar a coorte')
assert.equal(soma, p.coorte.concorrentes, 'a partição cobre todos os concorrentes')
})
teste('inelegíveis ficam fora de todas as categorias', async () => {
const { env, sqlite } = criarEnv()
const linhas = coorteCompleta()
// Um inelegível com IDS acima de todo mundo não pode vencer nada.
linhas.push({ cod: 'INEL', nome: 'Inelegível', uf: 'SP', ids: 999, elegivel: false })
inserir(sqlite, '2026-09-20T03:00:00.000Z', linhas)
const p = await apurarPremiacao(env)
assert.equal(p.coorte.avaliados, 27 * 4 + 1)
assert.equal(p.coorte.elegiveis, 27 * 4)
for (const c of p.categorias) {
assert.ok(
!c.podio.some((q) => q.senadorCod === 'INEL'),
`inelegível apareceu em ${c.id}`,
)
}
})
teste('acúmulo é permitido: o Grande Prêmio também vence a própria região', async () => {
const { env, sqlite } = criarEnv()
inserir(sqlite, '2026-09-20T03:00:00.000Z', coorteCompleta())
const p = await apurarPremiacao(env)
const geral = p.categorias.find((c) => c.id === 'grande_premio')!.vencedor
const suaRegiao = p.categorias.find(
(c) => c.eixo === 'regional' && c.regiao === regiaoDaUf(geral.uf),
)!
assert.equal(suaRegiao.vencedor.senadorCod, geral.senadorCod)
})
teste('empate na nota é decidido pelo indicador da própria dimensão', async () => {
const { env, sqlite } = criarEnv()
const linhas = coorteCompleta()
const dims = { produtividade: 100 }
// A é melhor no conjunto (IDS maior); B fez mais daquilo que a categoria mede.
// A categoria é sobre a dimensão, então B vence apesar do IDS menor.
linhas.push({ cod: 'A', nome: 'Melhor no conjunto', uf: 'SP', ids: 90, dims, brutos: { autoriasTotal: 10 } })
linhas.push({ cod: 'B', nome: 'Melhor na dimensão', uf: 'RJ', ids: 70, dims, brutos: { autoriasTotal: 99 } })
inserir(sqlite, '2026-09-20T03:00:00.000Z', linhas)
const p = await apurarPremiacao(env)
const cat = p.categorias.find((c) => c.id === 'produtividade')!
assert.equal(cat.houveDesempate, true)
assert.equal(cat.vencedor.senadorCod, 'B', 'indicador da dimensão vem antes do IDS geral')
assert.equal(cat.podio[1].senadorCod, 'A')
})
teste('empate na nota e no indicador cai no IDS total', async () => {
const { env, sqlite } = criarEnv()
const linhas = coorteCompleta()
const dims = { produtividade: 100 }
const brutos = { autoriasTotal: 50 }
linhas.push({ cod: 'A', nome: 'Menor IDS', uf: 'SP', ids: 70, dims, brutos })
linhas.push({ cod: 'B', nome: 'Maior IDS', uf: 'RJ', ids: 71, dims, brutos })
inserir(sqlite, '2026-09-20T03:00:00.000Z', linhas)
const p = await apurarPremiacao(env)
const cat = p.categorias.find((c) => c.id === 'produtividade')!
assert.equal(cat.vencedor.senadorCod, 'B', 'IDS total é o 2º critério')
})
teste('o indicador de uma categoria por IDS decompõe as dimensões', async () => {
const { env, sqlite } = criarEnv()
inserir(sqlite, '2026-09-20T03:00:00.000Z', coorteCompleta())
const p = await apurarPremiacao(env)
const geral = p.categorias.find((c) => c.id === 'grande_premio')!
assert.equal(geral.vencedor.indicador.composicao?.length, 6)
// Categoria temática mostra o dado contado, não a decomposição.
const tem = p.categorias.find((c) => c.id === 'fiscalizacao')!
assert.equal(tem.vencedor.indicador.composicao, undefined)
assert.ok(tem.vencedor.indicador.rotulo.length > 0)
})
teste('empate total cai na ordem alfabética — sempre determinístico', async () => {
const resultados = new Set<string>()
for (let i = 0; i < 5; i++) {
const { env, sqlite } = criarEnv()
const linhas = coorteCompleta()
const iguais = { ids: 70, dims: { produtividade: 100 }, brutos: { autoriasTotal: 10 }, meses: 43 }
// Ordem de inserção diferente a cada rodada
const nomes = i % 2 === 0 ? ['Zulmira', 'Aline'] : ['Aline', 'Zulmira']
nomes.forEach((n, k) => linhas.push({ cod: `X${k}`, nome: n, uf: 'SP', ...iguais }))
inserir(sqlite, '2026-09-20T03:00:00.000Z', linhas)
const p = await apurarPremiacao(env)
resultados.add(p.categorias.find((c) => c.id === 'produtividade')!.vencedor.nome)
}
assert.deepEqual([...resultados], ['Aline'], 'desempate deve ser estável e alfabético')
})
teste('snapshot truncado é recusado em vez de premiar coorte parcial', async () => {
const { env, sqlite } = criarEnv()
inserir(sqlite, '2026-08-23T03:00:00.000Z', coorteCompleta().slice(0, MIN_LINHAS_SNAPSHOT - 1))
await assert.rejects(
() => apurarPremiacao(env),
(e: unknown) =>
e instanceof PremiacaoIndisponivel && e.motivo === 'snapshot_incompleto',
)
})
teste('sem nenhum snapshot, recusa com motivo próprio', async () => {
const { env } = criarEnv()
await assert.rejects(
() => apurarPremiacao(env),
(e: unknown) => e instanceof PremiacaoIndisponivel && e.motivo === 'sem_snapshot',
)
})
teste('congelado: um novo snapshot não muda o vencedor', async () => {
const { env, sqlite } = criarEnv()
const antigo = '2026-09-20T03:00:00.000Z'
inserir(sqlite, antigo, coorteCompleta())
const antes = await apurarPremiacao(env)
assert.equal(antes.congelado, false, 'sem fixar, segue o mais recente')
await setSnapshotFixado(env, antigo)
// Cron de domingo: novo snapshot com o ranking invertido.
const invertido = coorteCompleta().map((l) => ({ ...l, ids: 100 - l.ids }))
inserir(sqlite, '2026-09-27T03:00:00.000Z', invertido)
const depois = await apurarPremiacao(env)
assert.equal(depois.congelado, true)
assert.equal(depois.snapshot, antigo)
assert.equal(
depois.categorias.find((c) => c.id === 'grande_premio')!.vencedor.senadorCod,
antes.categorias.find((c) => c.id === 'grande_premio')!.vencedor.senadorCod,
)
// Descongelar volta a seguir o mais recente.
await setSnapshotFixado(env, null)
const solto = await apurarPremiacao(env)
assert.equal(solto.congelado, false)
assert.equal(solto.snapshot, '2026-09-27T03:00:00.000Z')
assert.notEqual(
solto.categorias.find((c) => c.id === 'grande_premio')!.vencedor.senadorCod,
antes.categorias.find((c) => c.id === 'grande_premio')!.vencedor.senadorCod,
)
})
teste('snapshot fixado inexistente recusa em vez de cair no mais recente', async () => {
const { env, sqlite } = criarEnv()
inserir(sqlite, '2026-09-20T03:00:00.000Z', coorteCompleta())
await setSnapshotFixado(env, '2020-01-01T00:00:00.000Z')
await assert.rejects(
() => apurarPremiacao(env),
(e: unknown) => e instanceof PremiacaoIndisponivel && e.motivo === 'sem_snapshot',
)
})
teste('categorias temáticas carregam o peso da dimensão no IDS', async () => {
const { env, sqlite } = criarEnv()
inserir(sqlite, '2026-09-20T03:00:00.000Z', coorteCompleta())
const p = await apurarPremiacao(env)
const tematicas = p.categorias.filter((c) => c.eixo === 'tematico')
const soma = tematicas.reduce((a, c) => a + (c.peso ?? 0), 0)
assert.ok(Math.abs(soma - 1) < 0.001, `pesos das 6 dimensões somam ${soma}`)
for (const c of p.categorias.filter((x) => x.eixo !== 'tematico')) {
assert.equal(c.peso, undefined, `${c.id} não deveria ter peso`)
}
})
teste('a régua da vitória é mais exigente que a da coorte pontuada', () => {
assert.ok(
MESES_MIN_VITORIA >= 12,
'vencer não pode exigir menos que entrar na coorte do IDS',
)
})
teste('quem está entre as duas réguas é avaliado mas não disputa', async () => {
const { env, sqlite } = criarEnv()
const linhas = coorteCompleta()
// IDS acima de todos, mas abaixo da régua da vitória: não pode vencer nada.
linhas.push({
cod: 'CURTO',
nome: 'Mandato Curto',
uf: 'SP',
ids: 999,
meses: MESES_MIN_VITORIA - 1,
})
inserir(sqlite, '2026-09-20T03:00:00.000Z', linhas)
const p = await apurarPremiacao(env)
assert.equal(p.coorte.elegiveis, 27 * 4 + 1, 'segue na coorte pontuada')
assert.equal(p.coorte.concorrentes, 27 * 4, 'mas fora da disputa')
assert.equal(p.coorte.mesesMinimosVitoria, MESES_MIN_VITORIA)
for (const c of p.categorias) {
assert.ok(
!c.podio.some((q) => q.senadorCod === 'CURTO'),
`mandato curto apareceu em ${c.id}`,
)
}
})
teste('exatamente na régua da vitória, concorre', async () => {
const { env, sqlite } = criarEnv()
const linhas = coorteCompleta()
linhas.push({
cod: 'LIMITE',
nome: 'No Limite',
uf: 'SP',
ids: 999,
meses: MESES_MIN_VITORIA,
})
inserir(sqlite, '2026-09-20T03:00:00.000Z', linhas)
const p = await apurarPremiacao(env)
assert.equal(
p.categorias.find((c) => c.id === 'grande_premio')!.vencedor.senadorCod,
'LIMITE',
'o limiar deve ser inclusivo (>=), não exclusivo',
)
})
teste('sem ninguém acima da régua da vitória, recusa em vez de premiar', async () => {
const { env, sqlite } = criarEnv()
const curtos = coorteCompleta().map((l) => ({ ...l, meses: MESES_MIN_VITORIA - 1 }))
inserir(sqlite, '2026-09-20T03:00:00.000Z', curtos)
await assert.rejects(
() => apurarPremiacao(env),
(e: unknown) => e instanceof PremiacaoIndisponivel && e.motivo === 'sem_elegiveis',
)
})
async function main() {
let falhas = 0
for (const [nome, fn] of testes) {
try {
await fn()
console.log(`ok ${nome}`)
} catch (err) {
falhas++
console.log(`FALHA ${nome}`)
console.log(` ${err instanceof Error ? err.message : String(err)}`)
}
}
console.log(`\n${testes.length - falhas}/${testes.length} testes passaram`)
process.exit(falhas === 0 ? 0 : 1)
}
main()