teqo-backup/tests/unit/cityReportResearch.unit.spec.ts
Francisco Solla 9fae859c0d feat(relatorio-cidade): leva o 2º turno (Lula × Flávio) ao relatório
Artefato oficial por unidade (município e ZE de Salvador): TSE 2026
presidente por seção + Solla 2026 e a fatia de 2022 da análise
2º turno; cada número validado contra o baseline TSE e os totais da BA
pinados (pnpm build:second-round).

O relatório ganha a seção "2º turno no município (Lula × Flávio)" com
X₁/X₂ pela mesma fórmula do /potencial, o papel determinístico da cidade
(prioridade/mobilizacao/defesa/expansao) e o novo item de pesquisa
segundo_turno (movimento local com fonte); a seção fica fora da página 1
porque é determinística e a página 1 falha fechada por medição.
2026-10-09 04:01:20 -03:00

295 lines
9.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
normalizeResearchInput,
RESEARCH_CHECKLIST_IDS,
} from '../../scripts/lib/cityReportResearch.mjs'
const now = new Date('2026-09-15T12:00:00.000Z')
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
answer: `Resposta de ${id}`,
sourceUrl: `https://exemplo.test/${id}`,
sourceDate: '2026-09-10',
...overrides,
})
const baseResearch = (overrides: Record<string, unknown> = {}) => ({
municipalitySlug: 'teixeira-de-freitas',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)),
news: [],
gaps: [],
...overrides,
})
describe('normalizeResearchInput', () => {
it('includes the 2º turno local movement in the checklist', () => {
expect(RESEARCH_CHECKLIST_IDS).toContain('segundo_turno')
})
it('keeps fully sourced items', () => {
const research = normalizeResearchInput(baseResearch(), { now })
expect(research.items).toHaveLength(RESEARCH_CHECKLIST_IDS.length)
expect(research.gaps).toEqual([])
})
it('normalizes the optional per-item summary without changing the source contract (C188)', () => {
const research = normalizeResearchInput(
baseResearch({
items: RESEARCH_CHECKLIST_IDS.map((id) =>
id === 'prefeito'
? validItem(id, { summary: ' Prefeito X, do PSD, em primeiro mandato. ' })
: id === 'vice'
? validItem(id, { summary: ' ' })
: validItem(id),
),
}),
{ now },
)
const prefeito = research.items.find((item) => item.id === 'prefeito')!
expect(prefeito.summary).toBe('Prefeito X, do PSD, em primeiro mandato.')
expect(prefeito.answer).toBe('Resposta de prefeito')
expect(research.items.find((item) => item.id === 'vice')!.summary).toBeNull()
expect(research.items.find((item) => item.id === 'vereadores')!.summary).toBeNull()
expect(research.gaps).toEqual([])
})
it('throws when the research is not dated', () => {
expect(() => normalizeResearchInput(baseResearch({ researchedAt: null }), { now })).toThrow(
/researchedAt/,
)
})
it('throws when the slug is missing', () => {
expect(() => normalizeResearchInput(baseResearch({ municipalitySlug: '' }), { now })).toThrow(
/municipalitySlug/,
)
})
it('degrades an item without source to an explicit gap', () => {
const research = normalizeResearchInput(
baseResearch({
items: [
validItem('prefeito', { sourceUrl: null }),
...RESEARCH_CHECKLIST_IDS.slice(1).map((id) => validItem(id)),
],
}),
{ now },
)
expect(research.items.find((item) => item.id === 'prefeito')).toBeUndefined()
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'prefeito', reason: expect.stringMatching(/Sem fonte/) }),
)
})
it('marks checklist items never researched as gaps', () => {
const research = normalizeResearchInput(baseResearch({ items: [validItem('prefeito')] }), {
now,
})
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(
RESEARCH_CHECKLIST_IDS.length - 1,
)
})
it('treats an explicit gap as researched, without a duplicate "Não pesquisado"', () => {
const research = normalizeResearchInput(
baseResearch({
items: RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'emendas_web').map((id) =>
validItem(id),
),
gaps: [{ id: 'emendas_web', label: 'Emendas na web', reason: 'Fonte oficial respondeu.' }],
}),
{ now },
)
const emendasGaps = research.gaps.filter((gap) => gap.id === 'emendas_web')
expect(emendasGaps).toHaveLength(1)
expect(emendasGaps[0].reason).toBe('Fonte oficial respondeu.')
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(0)
})
it('keeps item extra sources with URL + date and drops ones without', () => {
const research = normalizeResearchInput(
baseResearch({
items: [
validItem('emendas_web', {
extraSources: [
{ label: 'Polo regional', url: 'https://exemplo.test/polo', date: '2026-09-01' },
{ label: 'Sem data', url: 'https://exemplo.test/sem-data' },
],
}),
...RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'emendas_web').map((id) => validItem(id)),
],
}),
{ now },
)
const item = research.items.find((entry) => entry.id === 'emendas_web')!
expect(item.extraSources).toHaveLength(1)
expect(item.extraSources[0].url).toBe('https://exemplo.test/polo')
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'fonte_extra', reason: expect.stringMatching(/URL ou data/) }),
)
})
it('keeps sourced emenda indications and gaps those without author, esfera or source', () => {
const research = normalizeResearchInput(
baseResearch({
emendasIndicators: [
{
author: 'Zé Neto',
sphere: 'municipio',
value: 'R$ 1 mi',
purpose: 'Ambulância do TFD',
year: '2026',
sourceUrl: 'https://exemplo.test/ze-neto',
sourceDate: '2026-09-01',
},
{
author: 'Sem esfera',
sphere: 'capital',
sourceUrl: 'https://exemplo.test/capital',
sourceDate: '2026-09-01',
},
{ author: 'Sem fonte', sphere: 'regiao' },
],
}),
{ now },
)
expect(research.emendasIndicators).toHaveLength(1)
expect(research.emendasIndicators[0]).toEqual(
expect.objectContaining({
author: 'Zé Neto',
sphere: 'municipio',
value: 'R$ 1 mi',
purpose: 'Ambulância do TFD',
}),
)
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'emenda_indicio_incompleto' }),
)
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'emenda_indicio_sem_fonte',
reason: expect.stringMatching(/URL e data/),
}),
)
})
it('keeps sourced approach suggestions and gaps ones without source', () => {
const research = normalizeResearchInput(
baseResearch({
approach: [
{
persona: 'Coordenação',
topic: 'Saúde',
suggestion: 'Agenda no hospital regional.',
sourceUrl: 'https://exemplo.test/saude',
sourceDate: '2026-09-01',
},
{
persona: 'Ciência política',
topic: 'Sem fonte',
suggestion: 'Não entra.',
sourceUrl: null,
sourceDate: null,
},
],
}),
{ now },
)
expect(research.approach).toHaveLength(1)
expect(research.approach[0].persona).toBe('Coordenação')
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'abordagem_sem_fonte',
reason: expect.stringMatching(/URL e data/),
}),
)
})
it('drops news outside the 90-day window and without source', () => {
const research = normalizeResearchInput(
baseResearch({
news: [
{
title: 'Matéria válida',
url: 'https://jornal.test/materia',
publishedAt: '2026-09-01T00:00:00.000Z',
outlet: 'Jornal Local',
},
{
title: 'Velha',
url: 'https://jornal.test/velha',
publishedAt: '2026-01-01T00:00:00.000Z',
},
{ title: 'Sem URL', publishedAt: '2026-09-01T00:00:00.000Z' },
],
}),
{ now },
)
expect(research.news).toHaveLength(1)
expect(research.news[0].title).toBe('Matéria válida')
expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'noticia_fora_da_janela' }))
expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'noticia_sem_fonte' }))
})
it('rejects unknown checklist ids', () => {
const research = normalizeResearchInput(
baseResearch({
items: [...RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)), validItem('inventado')],
}),
{ now },
)
expect(research.items.find((item) => item.id === 'inventado')).toBeUndefined()
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'inventado', reason: expect.stringMatching(/checklist/) }),
)
})
it('keeps sourced opposition facts and gaps entries without source', () => {
const research = normalizeResearchInput(
baseResearch({
opposition: [
{
topic: 'Emenda sob investigação (PF)',
detail: 'Operação apura desvio em emendas.',
sourceUrl: 'https://exemplo.test/oposicao',
sourceDate: '2026-09-10',
},
{ topic: 'Sem fonte', detail: 'Não entra.' },
],
}),
{ now },
)
expect(research.opposition).toHaveLength(1)
const [firstOpposition] = research.opposition as unknown as Array<{ topic: string }>
expect(firstOpposition.topic).toBe('Emenda sob investigação (PF)')
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'opposition_sem_fonte',
reason: expect.stringMatching(/URL e data/),
}),
)
})
it('keeps sourced dobradinhas and investments, gapping those without source', () => {
const research = normalizeResearchInput(
baseResearch({
alliances: [
{
topic: 'Dobradinha estadual',
detail: 'Deputada X (PT) indicada com Solla.',
sourceUrl: 'https://exemplo.test/dobradinha',
sourceDate: '2026-09-10',
},
],
investments: [{ topic: 'Sem fonte', detail: 'Não entra.' }],
}),
{ now },
)
expect(research.alliances).toHaveLength(1)
expect(research.investments).toHaveLength(0)
expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'investments_sem_fonte' }))
})
})