teqo-backup/tests/unit/cityReportBlocks.unit.spec.ts
Francisco Solla 321ac7b872
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
feat(relatorio-cidade): foca o relatório na mobilização do 2º turno
A federal foi decidida em 04/10; o documento de cidade passa a servir
inteiro à eleição de Lula. Saem conta eleitoral/cadeira de Solla,
concorrentes e pré-candidatos, emendas e entregas (sem fetch do Portal
da Transparência no builder), acervo de falas, panorama regional e
sinais/demandas; demografia, economia e transporte ficam como contexto
da visita.

A página 1 vira só mobilização: KPIs oficiais do artefato TSE 2026
(Lula/Δ, X₁/X₂, Solla), papel determinístico da cidade, quem mobiliza,
movimento local do 2º turno, riscos e o painel "O que pedir e falar"
(pedido Lula 13; guardrail de votos em jogo no lugar do defeso).

O checklist cai de 10 para 8 itens (emendas_web e quem_investe saem;
relacao_campo/disputa_local reancorados no 2º turno); listas
preCandidates/investments/emendasIndicators saem do contrato (arquivos
antigos seguem aceitos em replay). O snapshot enxuga para rede +
conjuntura + demografia (cityReportTerritory.mjs removido).

Prova: unit 5.232 verdes, int do snapshot sem PII e sem os grupos
aposentados, lint/format/typecheck/knip limpos e rebuild da fixture de
Cruz das Almas (9 seções, 16 páginas contra 21, página 1 fechando sem
fallback).
2026-10-10 16:57:20 -03:00

578 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest'
import { buildCityReport } from '../../scripts/lib/cityReportBlocks.mjs'
import {
normalizeResearchInput,
RESEARCH_CHECKLIST_IDS,
} from '../../scripts/lib/cityReportResearch.mjs'
import { SUMMARY_POINTER_COPY } from '../../scripts/lib/reportText.mjs'
const generatedAt = 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 research = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) =>
id === 'prefeito'
? validItem(id, {
extraSources: [
{
label: 'Polo regional — Teixeira de Freitas',
url: 'https://exemplo.test/polo',
date: '2026-09-01',
},
],
})
: validItem(id),
),
news: [
{
title: 'Matéria local',
url: 'https://jornal.test/materia',
publishedAt: '2026-09-01T00:00:00.000Z',
outlet: 'Jornal Local',
},
],
approach: [
{
persona: 'Ciência política',
topic: 'Saúde',
suggestion: 'Ancorar a agenda na defesa do SUS e no SAMU.',
sourceUrl: 'https://exemplo.test/abordagem',
sourceDate: '2026-09-10',
},
],
leaders: [
{
name: 'Ex-Prefeito',
role: 'Prefeito',
party: 'PSDB',
period: '2017–2024',
sourceUrl: 'https://exemplo.test/lideranca',
sourceDate: '2026-03-05',
},
],
leaderAgenda: [
{
name: 'Ex-Prefeito',
field: 'Mesmo campo',
topics: 'Saúde (hospital municipal)',
sourceUrl: 'https://exemplo.test/pauta',
sourceDate: '2026-09-01',
},
],
demography: [
{
topic: 'Cor/raça',
detail: 'Parda 62%, branca 25%.',
sourceUrl: 'https://exemplo.test/cor-raca',
sourceDate: '2026-09-10',
},
],
economy: [
{
topic: 'PIB',
detail: 'Serviços e agropecuária lideram.',
sourceUrl: 'https://exemplo.test/pib',
sourceDate: '2026-09-10',
},
],
transport: [
{
topic: 'Rodovias',
detail: 'BR-101 corta o município; trecho em obra.',
sourceUrl: 'https://exemplo.test/br101',
sourceDate: '2026-09-10',
},
],
opposition: [
{
topic: 'Obra parada no campo adversário',
detail: 'Anúncio sem entrega na região.',
sourceUrl: 'https://exemplo.test/oposicao',
sourceDate: '2026-09-10',
},
],
alliances: [
{
topic: 'Deputada Estadual (PT)',
detail: 'Dobradinha estadual indicada com Solla na cidade.',
sourceUrl: 'https://exemplo.test/dobradinha',
sourceDate: '2026-09-10',
},
],
gaps: [],
},
{ now: generatedAt },
)
const snapshot = {
meta: {
source: 'Base Teqo (produção, leitura read-only)',
readAt: '2026-09-15T10:30:00.000Z',
readOnly: true,
database: '127.0.0.1:5433/teqo_1313',
codeSha: 'abc1234',
actorId: 3,
actorName: 'Seed Candidato',
actorRole: 'candidate',
actorRoleLabel: 'Candidato',
},
municipality: {
id: 42,
slug: 'feira-de-santana',
name: 'Feira de Santana',
kind: 'municipio',
city: 'Feira de Santana',
region: 'Portal do Sertão',
ibgeCode: '2910800',
},
pledges: {
declaredTotal: 15_000,
lastPledgeAt: '2026-08-30T10:00:00.000Z',
},
leaderships: {
totalCount: 9,
rows: [
{
id: 1,
name: 'Liderança Um',
supportStatus: 'engajado',
organizationNames: ['Associação X'],
hasAppAccess: true,
updatedAt: '2026-09-10T10:00:00.000Z',
},
],
},
advisors: [{ id: 2, name: 'Seed Assessor' }],
conjuncture: {
priority: 'alta',
politicalTrend: {
status: 'favoravel',
note: 'Prefeito sinaliza apoio',
recordedByName: 'Seed Coordenador',
recordedAt: '2026-08-20T10:00:00.000Z',
},
engagementLevel: 'n3',
levelNote: 'Rede ativa',
strengths: ['Rede de saúde forte'],
risks: ['Oposição com rádio local'],
dobradinhaNotes: 'Dobradinha com a deputada estadual',
nextSteps: 'Agendar reunião com o prefeito',
stateDeputies: [{ id: 5, name: 'Deputada Estadual', party: 'PT' }],
},
demographics: {
population: 600_000,
ageBands: { '0-17': 150_000, '18-29': 120_000, '30-59': 250_000, '60+': 80_000 },
sexShareFemale: 0.52,
medianAge: 34,
},
}
type ReportItem = {
label?: string
text?: string
value?: string
hint?: string | null
url?: string | null
title?: string
body?: string
source?: { url?: string | null; date?: string | null }
}
type ReportRow = {
label: string
value: string
source?: { url?: string | null; date?: string | null }
}
type TableRow = Record<string, string>
type ReportBlock = {
kind: string
title?: string
tone?: string
items?: ReportItem[]
rows?: ReportRow[]
columns?: Array<{ key: string; label: string; width?: number }>
cells?: Array<{ title: string; blocks: ReportBlock[] }>
body?: string[]
note?: string
remaining?: number
sources?: Array<{ kind: string; url?: string | null }>
}
type ReportShape = {
page1: { blocks: ReportBlock[] }
sections: Array<{ id: string; title: string; blocks: ReportBlock[] }>
}
const asReport = (value: unknown) => value as ReportShape
const byId = (report: ReportShape, id: string) =>
report.sections.find((section) => section.id === id)!
describe('buildCityReport', () => {
const report = asReport(buildCityReport({ snapshot, research, generatedAt }))
it('builds the nine 2º-turno sections in the product order', () => {
expect(report.sections).toHaveLength(9)
expect(report.sections.map((section) => section.id)).toEqual([
'segundo-turno',
'rede',
'conjuntura',
'demografia',
'economia',
'transporte',
'noticias',
'abordagem',
'fontes',
])
expect(report.sections[0].title).toBe('1. 2º turno no município (Lula × Flávio)')
expect(report.sections[1].title).toBe('2. Rede e lideranças')
expect(report.sections[8].title).toBe('9. Fontes e limites')
})
it('retires the mandate-era sections from the report', () => {
const ids = report.sections.map((section) => section.id)
for (const retired of [
'conta-eleitoral',
'concorrentes',
'falas',
'regiao',
'sinais',
'demandas',
]) {
expect(ids).not.toContain(retired)
}
const serialized = JSON.stringify(report)
for (const copy of [
'Concorrentes',
'O que Solla entregou',
'Acervo de falas',
'Panorama regional',
'Emendas',
]) {
expect(serialized).not.toContain(copy)
}
})
it('brings the 2º turno artifact to the dedicated section', () => {
const section = byId(report, 'segundo-turno')
const callout = section.blocks.find((block) => block.kind === 'callout')!
expect(callout.title).toContain('Defesa e comparecimento')
expect(callout.body!.join(' ')).toContain('Captura do adversário')
const table = section.blocks.find((block) => block.kind === 'table')!
const rows = table.rows as unknown as Array<Record<string, string>>
expect(rows[0]).toEqual(
expect.objectContaining({
label: 'Lula (PT)',
y22: '211.813',
y26: '194.272',
delta: '−17.541',
}),
)
// The local movement of the research feeds the section; sem fonte não entra.
expect(section.blocks.some((block) => block.title?.includes('movimento local'))).toBe(true)
})
it('gaps the 2º turno research when the researcher did not answer it', () => {
const researchWithout = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'segundo_turno').map((id) =>
validItem(id),
),
gaps: [],
},
{ now: generatedAt },
)
const withoutReport = asReport(
buildCityReport({ snapshot, research: researchWithout, generatedAt }),
)
const section = byId(withoutReport, 'segundo-turno')
const gap = section.blocks.find((block) => block.kind === 'callout' && block.tone === 'gap')!
expect(gap.body!.join(' ')).toContain('Não pesquisado.')
const pageMovement = withoutReport.page1.blocks.find(
(block) => block.title === 'Movimento local do 2º turno (pesquisa)',
)!
expect(pageMovement.tone).toBe('gap')
})
it('keeps page 1 in the contract order — mobilization only', () => {
const kinds = report.page1.blocks.map((block) => block.kind)
expect(kinds).toEqual(['strip', 'grid', 'callout', 'cards', 'panel', 'footerNote'])
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
expect(grid.cells!.map((cell) => cell.title)).toEqual([
'2º turno — Lula × Flávio',
'Quem mobiliza',
])
const ask = report.page1.blocks.find((block) => block.kind === 'panel')!
expect(ask.title).toBe('O que pedir e falar')
expect(ask.tone).toBe('decision')
expect(ask.note).toContain('votos em jogo')
expect(ask.items!.some((item) => item.text!.includes('Lula 13'))).toBe(true)
})
it('shows the official 2º-turno KPIs and the deterministic role on page 1', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const cell = grid.cells![0]
const kpis = cell.blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items!.map((item) => item.label)).toEqual([
'Lula em 2026',
'Votos fora do 2º turno',
'Solla 2026',
])
expect(kpis.items![0].hint).toContain('Δ')
expect(kpis.items![1].value).toMatch(/^X₂ = /)
expect(kpis.items![1].hint).toContain('X₁ = ')
const role = cell.blocks.find((block) => block.kind === 'callout')!
expect(role.title).toContain('Papel no 2º turno')
})
it('reads the relative lens (share and delta), never the statewide absolute', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const kpis = grid.cells![0].blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items![0].value).toMatch(/%$/)
expect(kpis.items![0].hint).toContain('dos válidos')
})
it('puts research answers in Quem mobiliza with compact sources and URLs in the sources section', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const who = grid.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe('Resposta de prefeito')
expect(prefeito.source!.url).toBeNull()
expect(prefeito.source!.date).toBe('2026-09-10')
const labels = who.rows!.map((row) => row.label)
for (const label of [
'Vice',
'Relação com o campo',
'Lideranças',
'Dobradinhas',
'Vereadores/dobradas',
]) {
expect(labels).toContain(label)
}
const fontes = byId(report, 'fontes').blocks.find((block) => block.kind === 'sources')!
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true)
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/polo')).toBe(true)
})
it('describes the local movement from the research with its date', () => {
const movement = report.page1.blocks.find(
(block) => block.title === 'Movimento local do 2º turno (pesquisa)',
)!
expect(movement.kind).toBe('callout')
expect(movement.body!.join(' ')).toContain('Resposta de segundo_turno')
expect(movement.body!.join(' ')).toContain('10/09/2026')
})
it('adds the researched demography, economy and transport data', () => {
const demografia = byId(report, 'demografia').blocks[0]
const rows = demografia.rows as unknown as TableRow[]
expect(rows[0].indicator).toBe('População')
expect(rows[0].source).toBe('base Teqo')
expect(rows.some((row) => row.indicator === 'Cor/raça' && row.source === '10/09/2026')).toBe(
true,
)
expect(
demografia.sources!.some((source) => source.url === 'https://exemplo.test/cor-raca'),
).toBe(true)
const economia = byId(report, 'economia').blocks[0]
expect((economia.rows as unknown as TableRow[])[0].detail).toContain('Serviços')
const transporte = byId(report, 'transporte').blocks[0]
expect((transporte.rows as unknown as TableRow[])[0].detail).toContain('BR-101')
expect(transporte.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/br101' }),
)
})
it('renders the opposition front inside the conjuncture section, when researched', () => {
const conjuntura = byId(report, 'conjuntura')
const opposition = conjuntura.blocks.find(
(block) => block.title === 'Frente de oposição — o que usar com fonte (pesquisa)',
)!
const rows = opposition.rows as unknown as TableRow[]
expect(rows[0].topic).toBe('Obra parada no campo adversário')
expect(rows[0].detail).toContain('Anúncio sem entrega')
expect(opposition.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/oposicao' }),
)
})
it('lists the researched city leaders, their agenda and the dobradinhas in the network section', () => {
const rede = byId(report, 'rede')
const leadersBlock = rede.blocks.find(
(block) => block.title === 'Lideranças da cidade (pesquisa)',
)!
const rows = leadersBlock.rows as unknown as TableRow[]
expect(rows[0].name).toBe('Ex-Prefeito')
expect(rows[0].party).toBe('PSDB')
expect(leadersBlock.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/lideranca' }),
)
const agenda = rede.blocks.find((block) => block.title === 'Pauta das lideranças (pesquisa)')!
expect((agenda.rows as unknown as TableRow[])[0].topics).toContain('hospital')
const dobradinhas = rede.blocks.find((block) => block.title === 'Dobradinhas (pesquisa)')!
expect((dobradinhas.rows as unknown as TableRow[])[0].topic).toContain('Deputada Estadual')
})
it('renders the persona approach section and its sources', () => {
const abordagem = byId(report, 'abordagem').blocks[0]
const rows = abordagem.rows as unknown as TableRow[]
expect(rows[0].topic).toContain('Ciência política')
expect(rows[0].suggestion).toContain('SAMU')
expect(abordagem.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/abordagem' }),
)
})
it('keeps the full page-1 answer (no "…") and preserves the integral in the deep dive (C188)', () => {
const longAnswer = 'palavra '.repeat(20).trim()
const longResearch = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id, { answer: longAnswer })),
gaps: [],
},
{ now: generatedAt },
)
const capped = asReport(buildCityReport({ snapshot, research: longResearch, generatedAt }))
const who = capped.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe(longAnswer)
expect(prefeito.value!.includes('…')).toBe(false)
const appendix = byId(capped, 'fontes').blocks.find(
(block) => block.title === 'Pesquisa — respostas integrais (texto completo)',
)!
const rows = appendix.rows as unknown as TableRow[]
expect(rows).toHaveLength(RESEARCH_CHECKLIST_IDS.length)
expect(rows[0].answer).toContain(longAnswer)
})
it('prefers the researcher summary on page 1 when present (C188)', () => {
const longAnswer = 'palavra '.repeat(20).trim()
const summary = 'Resumo curto e completo do prefeito.'
const summarized = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id, { answer: longAnswer, summary })),
gaps: [],
},
{ now: generatedAt },
)
const capped = asReport(buildCityReport({ snapshot, research: summarized, generatedAt }))
const who = capped.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe(summary)
})
it('falls back to a pointer line (never "…") when the full text does not fit (C188)', () => {
const longAnswer = 'palavra '.repeat(20).trim()
const longResearch = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id, { answer: longAnswer })),
gaps: [],
},
{ now: generatedAt },
)
const pointer = asReport(
buildCityReport({
snapshot,
research: longResearch,
generatedAt,
textFallback: 'pointer',
}),
)
const who = pointer.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe(SUMMARY_POINTER_COPY)
expect(prefeito.value!.includes('…')).toBe(false)
})
it('shows the dobradinhas remainder when the list is capped', () => {
const manyDeputies = asReport(
buildCityReport({
snapshot: {
...snapshot,
conjuncture: {
...snapshot.conjuncture,
stateDeputies: [
{ id: 5, name: 'Deputada Estadual', party: 'PT' },
{ id: 6, name: 'Deputado Dois', party: 'PCdoB' },
{ id: 7, name: 'Deputado Três', party: 'PT' },
],
},
},
research,
generatedAt,
}),
)
const many = manyDeputies.page1.blocks.find((block) => block.kind === 'grid')!.cells![1]
.blocks[0]
const dobradinhas = many.rows!.find((row) => row.label === 'Dobradinhas')!
expect(dobradinhas.value).toContain('e mais 1')
expect(dobradinhas.value).not.toContain('Deputado Três')
})
it('tolerates snapshots from the pre-refactor shape (extra groups ignored)', () => {
const legacySnapshot = {
...snapshot,
electoral: {
series: [{ year: 2022, votes: 1 }],
rank2022: { rank: 1, votes: 1, share: 1, totalUnits: 435 },
},
goal: { suggestedGoal: 1, goalCoverage: {}, territorialClass: { class: 'expansao' } },
signals: { totalCount: 1, rows: [] },
activities: { upcoming: [], recent: [] },
speeches: { totalCount: 0, rows: [] },
demands: { totalCount: 0, rows: [] },
competitors: { referenceYear: 2022, federal: [], state: [] },
}
const legacy = asReport(buildCityReport({ snapshot: legacySnapshot, research, generatedAt }))
expect(legacy.sections).toHaveLength(9)
expect(legacy.page1.blocks.map((block) => block.kind)).toEqual([
'strip',
'grid',
'callout',
'cards',
'panel',
'footerNote',
])
})
it('shows a gap instead of an empty 2º-turno cell when the unit is outside the artifact', () => {
const outside = asReport(
buildCityReport({
snapshot: {
...snapshot,
municipality: { ...snapshot.municipality, slug: 'nao-existe-no-artefato' },
},
research,
generatedAt,
}),
)
const cell = outside.page1.blocks.find((block) => block.kind === 'grid')!.cells![0]
const gap = cell.blocks.find((block) => block.kind === 'callout' && block.tone === 'gap')!
expect(gap.body!.join(' ')).toContain('build:second-round')
const section = byId(outside, 'segundo-turno')
expect(section.blocks.some((block) => block.kind === 'callout' && block.tone === 'gap')).toBe(
true,
)
})
})