mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C163): relatório — frente de oposição, dobradinhas, investimentos e seções dinâmicas
- Frente de oposição: lista de pesquisa 'opposition' (fato publicado com URL+data, sem inferir culpa) renderizada na seção 2, com nota de guardrail e URLs na seção de fontes. - Dobradinhas: lista 'alliances' (com quem Solla soma na cidade) como bloco na seção de rede, insumo direto da abordagem. - Investimentos: lista 'investments' (Solla, estado e federal, município e região; empenho ≠ pagamento) como bloco na conjuntura. - Seções dinâmicas: numeração por posição; Sinais recentes e Demandas e visitas omitidas quando vazias (lacuna explícita só onde é produto). Skill, changelog e testes unit cobrindo validador e builder.
This commit is contained in:
parent
afd5f137ac
commit
1ed28906cd
8 changed files with 289 additions and 24 deletions
|
|
@ -119,6 +119,15 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
|
|||
"transport": [ // rodovias, aeroporto, portos, ferrovia
|
||||
{ "topic": "Rodovia federal (BR-101)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
|
||||
],
|
||||
"opposition": [ // o que a oposição fez de errado na região (fato com fonte)
|
||||
{ "topic": "Emenda sob investigação (PF)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
|
||||
],
|
||||
"alliances": [ // dobradinhas: com quem somar na cidade
|
||||
{ "topic": "Dobradinha estadual (PT)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
|
||||
],
|
||||
"investments": [ // investimentos/obras de Solla, do estado e do federal (município e região)
|
||||
{ "topic": "Obra federal — saúde", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
|
||||
],
|
||||
"gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe
|
||||
}
|
||||
```
|
||||
|
|
@ -151,6 +160,29 @@ ferrovia da região — FIOL/Porto Sul, deixando claro quando não serve
|
|||
diretamente a cidade). O enquadramento é "onde falta × onde o estado e a União
|
||||
têm acertado" — o candidato é da base.
|
||||
|
||||
**Frente de oposição (pesquisa):** `opposition` é uma lista de
|
||||
`{topic, detail, sourceUrl, sourceDate}` (sem fonte → lacuna) com **fatos
|
||||
publicados e datados sobre o que a oposição fez de errado na região** — PF/MP em
|
||||
emendas, obras paradas, anúncio sem entrega, escândalos do campo adversário.
|
||||
Regras: (1) cada linha é fato publicado, **nunca** inferência de culpa — atribua
|
||||
à fonte ("segundo a PF / o jornal") e diferencie acusação de condenação; (2)
|
||||
priorize o **local** (operadores da oposição no município/região, ex.: emendas
|
||||
investigadas) sobre o ataque frontal ao líder estadual; (3) marque temas de
|
||||
**mão dupla** (casos que também citam o campo da base) para a coordenação não
|
||||
entrar desprevenida; (4) **não** recomende ataque pessoal nem onde a marca do
|
||||
adversário domina — o relatório é insumo ancorado, não roteiro de ataque. O item
|
||||
entra na seção 2 (junto aos concorrentes) e a seção 14 lista as URLs.
|
||||
|
||||
**Dobradinhas e investimentos (pesquisa):** `alliances` é a lista de
|
||||
`{topic, detail, sourceUrl, sourceDate}` com **com quem Solla soma na cidade**
|
||||
(estadual/federal, chapa e lideranças que transferem voto) — insumo direto da
|
||||
`approach`, exibido como bloco "Dobradinhas (pesquisa)" na seção 3 (rede).
|
||||
`investments` lista **investimentos e obras de Solla, do governo do estado e do
|
||||
federal no município e na região**, com fase/valor quando a fonte informar
|
||||
(**empenho ≠ pagamento**), exibido como bloco "Investimentos e obras (pesquisa)"
|
||||
na seção 4 (conjuntura). Sem fonte, cada item vira lacuna; nenhuma das duas
|
||||
listas é obrigatória.
|
||||
|
||||
**Emendas oficiais:** sem `--emendas`, o builder consulta o Portal da
|
||||
Transparência (`PORTAL_TRANSPARENCIA_API_KEY` no ambiente; sem chave → lacuna).
|
||||
A API oficial **não filtra por município** (só UF/Nacional/Múltiplo na
|
||||
|
|
@ -170,7 +202,8 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
|||
anunciar, riscos e pontos sem leitura.
|
||||
- **Seções 2+:** `1. Conta eleitoral` · `2. Concorrentes no município (federal
|
||||
e estadual)` — top 5 por votos de 2022 na base TSE, com série 2014/2018/2022
|
||||
e os prováveis candidatos do campo do prefeito (pesquisa) · `3. Rede e
|
||||
e os prováveis candidatos do campo do prefeito (pesquisa), além da **frente de
|
||||
oposição** quando pesquisada (fatos com fonte) · `3. Rede e
|
||||
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
|
||||
vereadores mais votados, com partido) · `4. Conjuntura` · `5. Sinais` · `6.
|
||||
Demandas e visitas` · `7. Demografia` — IBGE Censo 2022 do artefato +
|
||||
|
|
@ -189,7 +222,14 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
|||
limites`.
|
||||
- **Links clicáveis:** todo URL no PDF é um link (`<a href>`): células de
|
||||
tabela, fontes por linha e a seção de fontes.
|
||||
- **Tabela da seção 11 (notícias) com larguras fixas:** `Data 9 · Veículo 14 ·
|
||||
- **Numeração dinâmica e omissão de seções vazias:** o builder numera as seções
|
||||
depois de montá-las (o `id` é estável; o número é posicional). As seções
|
||||
**Sinais recentes** e **Demandas e visitas** são **omitidas quando não há
|
||||
dado na base** (mostrar um callout de "nada aqui" só gastaria página); as
|
||||
seções de pesquisa (`opposition`, `alliances`, `investments`, demografia,
|
||||
economia, transporte) mantêm a lacuna explícita quando o item falta. Nunca
|
||||
referencie seção por número fixo — use o nome/`id`.
|
||||
- **Tabela de notícias com larguras fixas:** `Data 9 · Veículo 14 ·
|
||||
Título 49 · Link 28` (%). As larguras explícitas tornam a tabela `table-fixed`
|
||||
(a URL longa/crua quebra em vez de inflar a coluna) e dão ao **Título** — o
|
||||
texto mais longo — a maior fatia. Sem largura, a URL sem espaços domina o
|
||||
|
|
@ -211,6 +251,11 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
|||
- **Abordagem é análise ancorada**: as sugestões das personas (governo do PT na
|
||||
região, projetos futuros, prioridades locais) saem do `approach` com fonte por
|
||||
item — sem fonte, não entra; o PDF deixa claro que é análise, não fato novo.
|
||||
- **Frente de oposição é fato, não ataque**: `opposition` só recebe fato publicado
|
||||
com URL + data; o PDF **não** afirma culpa, **não** infere dolo e **não** orienta
|
||||
ataque pessoal. Prioriza o local (operadores na região) sobre o líder estadual e
|
||||
sinaliza temas de mão dupla; onde a marca do adversário domina, o insumo é para
|
||||
contraste, não para confronto frontal.
|
||||
- **Artefato gitignored**: o repo é público; o PDF/MD com dado interno nunca é
|
||||
commitado (só a skill/scripts/changelog).
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-15-c163-frente-oposicao.md
Normal file
1
docs/changelog/2026-09-15-c163-frente-oposicao.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C163 — Relatório de cidade: frente de oposição como pesquisa ancorada (2026-09-15):** nova lista de pesquisa `opposition` no contrato do relatório de cidade (`{topic, detail, sourceUrl, sourceDate}` — sem fonte vira lacuna, igual a `demography`/`economy`/`transport`). Ela responde a "o que a oposição fez de errado na região que dá para usar a favor da campanha": PF/MP em emendas, obras paradas, anúncio sem entrega, escândalos do campo adversário. O bloco entra na **seção 2 (Concorrentes)**, ao lado dos prováveis candidatos do campo do prefeito, como tabela Tema/O que a fonte diz/Fonte, e a seção 14 lista as URLs. Guardrails fixos no `note` do bloco, na seção 14 e na skill: cada linha é fato publicado com URL+data, **nunca** inferência de culpa (diferencie acusação de condenação e atribua à fonte); priorize o **local** (operadores da oposição na região) sobre o ataque frontal ao líder estadual; marque temas de **mão dupla**; e não oriente ataque pessoal onde a marca do adversário domina. Cobertura unit nova no validador (fonte mantida, sem fonte vira lacuna) e no builder (bloco renderizado com fonte), sem nova seção numerada (mantém as 14 seções).
|
||||
|
|
@ -0,0 +1 @@
|
|||
**C163 — Relatório de cidade: seções dinâmicas, dobradinhas e investimentos (2026-09-15):** o builder agora **numera as seções por posição** (o `id` é estável; o número é derivado) e **omite "Sinais recentes" e "Demandas e visitas" quando não há dado na base** — mostrar um callout de "nada aqui" só gastava página; as seções de pesquisa seguem com lacuna explícita quando o item falta. Duas listas de pesquisa novas, no mesmo contrato `{topic, detail, sourceUrl, sourceDate}` (sem fonte → lacuna): **`alliances`** (dobradinhas — com quem Solla soma na cidade: estadual/federal, chapa e lideranças que transferem voto), exibida na seção de rede como "Dobradinhas (pesquisa)" e insumo direto da `approach`; e **`investments`** (investimentos e obras de Solla, do governo do estado e do federal no município e na região, com fase/valor quando a fonte informar — empenho ≠ pagamento), exibida na seção de conjuntura como "Investimentos e obras (pesquisa)". A seção de fontes rotula as duas listas e a skill registra o contrato, os guardrails e a regra de numeração dinâmica/omissão. Cobertura unit nova: validador (fonte mantida, sem fonte vira lacuna) e builder (dobradinhas na rede, investimentos na conjuntura, omissão de 5/6 com renumeração contígua).
|
||||
|
|
@ -399,6 +399,32 @@ const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
|
|||
sources: [sourceTeqo('base Teqo — TSE 2014/2018/2022', readAt)],
|
||||
})
|
||||
|
||||
const buildOppositionBlock = (research) => {
|
||||
const entries = research.opposition ?? []
|
||||
if (!entries.length) return null
|
||||
return {
|
||||
kind: 'table',
|
||||
title: 'Oposição — o que usar com fonte (pesquisa)',
|
||||
columns: [
|
||||
{ key: 'topic', label: 'Tema', width: 22 },
|
||||
{ key: 'detail', label: 'O que a fonte diz', width: 62 },
|
||||
{ key: 'source', label: 'Fonte', width: 16 },
|
||||
],
|
||||
rows: entries.map((item) => ({
|
||||
topic: item.topic,
|
||||
detail: item.detail,
|
||||
source: formatDateBr(item.sourceDate),
|
||||
})),
|
||||
note: 'Fato publicado com fonte datada — não é condenação. Atribua à fonte e não infira culpa; tema nacional pode ser de mão dupla.',
|
||||
sources: entries.map((item) => ({
|
||||
kind: 'web',
|
||||
label: `Oposição — ${item.topic}`,
|
||||
url: item.sourceUrl,
|
||||
date: item.sourceDate,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const buildCompetitorsSection = ({ snapshot, research }) => {
|
||||
const competitors = snapshot.competitors ?? { referenceYear: 2022, federal: [], state: [] }
|
||||
const offices = [
|
||||
|
|
@ -458,6 +484,9 @@ const buildCompetitorsSection = ({ snapshot, research }) => {
|
|||
},
|
||||
)
|
||||
|
||||
const opposition = buildOppositionBlock(research)
|
||||
if (opposition) blocks.push(opposition)
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
|
|
@ -556,10 +585,18 @@ const buildNetworkSection = ({ snapshot, research }) => {
|
|||
})
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
...buildSourcedListBlocks({
|
||||
title: 'Dobradinhas (pesquisa)',
|
||||
note: 'Com quem Solla soma na cidade em 2026 (estadual/federal, chapa e lideranças que transferem voto) — insumo direto da abordagem sugerida; cada item com URL.',
|
||||
items: research?.alliances ?? [],
|
||||
}),
|
||||
)
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
const buildConjunctureSection = ({ snapshot }) => {
|
||||
const buildConjunctureSection = ({ snapshot, research }) => {
|
||||
const conjuncture = snapshot.conjuncture
|
||||
const trend = conjuncture?.politicalTrend
|
||||
const rows = [
|
||||
|
|
@ -632,6 +669,14 @@ const buildConjunctureSection = ({ snapshot }) => {
|
|||
})
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
...buildSourcedListBlocks({
|
||||
title: 'Investimentos e obras (pesquisa)',
|
||||
note: 'O que Solla, o governo do estado e o federal entregaram, anunciaram ou têm em obra no município e na região — com fase/valor quando a fonte informar; cada item com URL. Empenho ≠ pagamento.',
|
||||
items: research?.investments ?? [],
|
||||
}),
|
||||
)
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
|
|
@ -1095,11 +1140,19 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
|
|||
date: item.sourceDate,
|
||||
})
|
||||
}
|
||||
for (const listKey of ['demography', 'economy', 'transport']) {
|
||||
const listLabels = {
|
||||
demography: 'Demografia',
|
||||
economy: 'Economia',
|
||||
transport: 'Transporte',
|
||||
opposition: 'Oposição',
|
||||
alliances: 'Dobradinhas',
|
||||
investments: 'Investimentos e obras',
|
||||
}
|
||||
for (const listKey of Object.keys(listLabels)) {
|
||||
for (const item of research[listKey] ?? []) {
|
||||
items.push({
|
||||
kind: 'web',
|
||||
label: `${listKey === 'demography' ? 'Demografia' : listKey === 'economy' ? 'Economia' : 'Transporte'} — ${item.topic}`,
|
||||
label: `${listLabels[listKey]} — ${item.topic}`,
|
||||
url: item.sourceUrl,
|
||||
date: item.sourceDate,
|
||||
})
|
||||
|
|
@ -1119,6 +1172,7 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
|
|||
'Emendas são lidas da fonte oficial em tempo de geração e não são persistidas na base.',
|
||||
'Quando a fonte oficial não atribui emenda ao município, indícios web (município, região ou polo) entram como evidência datada — nunca somados como emenda da cidade.',
|
||||
'Abordagem sugerida é análise das personas ancorada nas fontes listadas item a item, não fato verificado além delas.',
|
||||
'Oposição: cada linha é fato publicado com URL + data; o PDF não afirma culpa nem orienta ataque pessoal — use como contraste ancorado.',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -1151,6 +1205,23 @@ const assertSnapshotShape = (snapshot) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections whose only content would be an empty-state callout are omitted
|
||||
* (sinais/demandas) so the report does not spend a page saying "nothing here".
|
||||
* Research sections keep their explicit gap callout — a missing source is a
|
||||
* product lacuna, not noise.
|
||||
*/
|
||||
const hasSectionData = (id, snapshot) => {
|
||||
if (id === 'sinais') return (snapshot.signals?.rows?.length ?? 0) > 0
|
||||
if (id === 'demandas') {
|
||||
return (
|
||||
(snapshot.demands?.rows?.length ?? 0) > 0 ||
|
||||
(snapshot.activities?.upcoming?.length ?? 0) + (snapshot.activities?.recent?.length ?? 0) > 0
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new Date() }) => {
|
||||
assertSnapshotShape(snapshot)
|
||||
const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt
|
||||
|
|
@ -1158,63 +1229,69 @@ export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new
|
|||
const sections = [
|
||||
{
|
||||
id: 'conta-eleitoral',
|
||||
title: '1. Conta eleitoral completa (2014/2018/2022)',
|
||||
title: 'Conta eleitoral completa (2014/2018/2022)',
|
||||
blocks: buildElectoralSection({ snapshot }),
|
||||
},
|
||||
{
|
||||
id: 'concorrentes',
|
||||
title: '2. Concorrentes no município (federal e estadual)',
|
||||
title: 'Concorrentes no município (federal e estadual)',
|
||||
blocks: buildCompetitorsSection({ snapshot, research }),
|
||||
},
|
||||
{
|
||||
id: 'rede',
|
||||
title: '3. Rede e lideranças',
|
||||
title: 'Rede e lideranças',
|
||||
blocks: buildNetworkSection({ snapshot, research }),
|
||||
},
|
||||
{ id: 'conjuntura', title: '4. Conjuntura', blocks: buildConjunctureSection({ snapshot }) },
|
||||
{ id: 'sinais', title: '5. Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
|
||||
{
|
||||
id: 'conjuntura',
|
||||
title: 'Conjuntura',
|
||||
blocks: buildConjunctureSection({ snapshot, research }),
|
||||
},
|
||||
{ id: 'sinais', title: 'Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
|
||||
{
|
||||
id: 'demandas',
|
||||
title: '6. Demandas e visitas',
|
||||
title: 'Demandas e visitas',
|
||||
blocks: buildDemandsVisitsSection({ snapshot }),
|
||||
},
|
||||
{
|
||||
id: 'demografia',
|
||||
title: '7. Demografia',
|
||||
title: 'Demografia',
|
||||
blocks: buildDemographicsSection({ snapshot, research }),
|
||||
},
|
||||
{
|
||||
id: 'economia',
|
||||
title: '8. Atividade econômica (pesquisa)',
|
||||
title: 'Atividade econômica (pesquisa)',
|
||||
blocks: buildEconomySection({ research }),
|
||||
},
|
||||
{
|
||||
id: 'transporte',
|
||||
title: '9. Transporte e conexões (pesquisa)',
|
||||
title: 'Transporte e conexões (pesquisa)',
|
||||
blocks: buildTransportSection({ research }),
|
||||
},
|
||||
{ id: 'falas', title: '10. Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
|
||||
{ id: 'falas', title: 'Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
|
||||
{
|
||||
id: 'noticias',
|
||||
title: '11. Notícias internas e imprensa local',
|
||||
title: 'Notícias internas e imprensa local',
|
||||
blocks: buildNewsSection({ research }),
|
||||
},
|
||||
{
|
||||
id: 'regiao',
|
||||
title: '12. Panorama regional (Território de Identidade)',
|
||||
title: 'Panorama regional (Território de Identidade)',
|
||||
blocks: buildRegionSection({ snapshot }),
|
||||
},
|
||||
{
|
||||
id: 'abordagem',
|
||||
title: '13. Abordagem sugerida (pesquisa)',
|
||||
title: 'Abordagem sugerida (pesquisa)',
|
||||
blocks: buildApproachSection({ research }),
|
||||
},
|
||||
{
|
||||
id: 'fontes',
|
||||
title: '14. Fontes e limites',
|
||||
title: 'Fontes e limites',
|
||||
blocks: buildSourcesSection({ snapshot, research, emendas }),
|
||||
},
|
||||
]
|
||||
.filter((section) => hasSectionData(section.id, snapshot))
|
||||
.map((section, index) => ({ ...section, title: `${index + 1}. ${section.title}` }))
|
||||
|
||||
return {
|
||||
meta: {
|
||||
|
|
|
|||
|
|
@ -267,7 +267,14 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
|
|||
})
|
||||
}
|
||||
|
||||
const sourcedLists = { demography: [], economy: [], transport: [] }
|
||||
const sourcedLists = {
|
||||
demography: [],
|
||||
economy: [],
|
||||
transport: [],
|
||||
opposition: [],
|
||||
alliances: [],
|
||||
investments: [],
|
||||
}
|
||||
for (const key of Object.keys(sourcedLists)) {
|
||||
for (const entry of Array.isArray(raw[key]) ? raw[key] : []) {
|
||||
const topic = isNonEmptyString(entry?.topic) ? entry.topic.trim() : null
|
||||
|
|
@ -311,6 +318,9 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
|
|||
demography: sourcedLists.demography,
|
||||
economy: sourcedLists.economy,
|
||||
transport: sourcedLists.transport,
|
||||
opposition: sourcedLists.opposition,
|
||||
alliances: sourcedLists.alliances,
|
||||
investments: sourcedLists.investments,
|
||||
gaps,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,30 @@ const research = normalizeResearchInput(
|
|||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
opposition: [
|
||||
{
|
||||
topic: 'Emenda sob investigação (PF)',
|
||||
detail: 'Operação apura desvio em emendas do campo adversário.',
|
||||
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',
|
||||
},
|
||||
],
|
||||
investments: [
|
||||
{
|
||||
topic: 'Obra federal no município',
|
||||
detail: 'Investimento do governo federal em saúde; em execução.',
|
||||
sourceUrl: 'https://exemplo.test/investimento',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
gaps: [],
|
||||
},
|
||||
{ now: generatedAt },
|
||||
|
|
@ -398,6 +422,65 @@ describe('buildCityReport', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('renders the opposition front as sourced contrast, when researched', () => {
|
||||
const concorrentes = report.sections.find((section) => section.id === 'concorrentes')!
|
||||
const opposition = concorrentes.blocks.find(
|
||||
(block) => block.title === 'Oposição — o que usar com fonte (pesquisa)',
|
||||
)!
|
||||
const rows = opposition.rows as unknown as TableRow[]
|
||||
expect(rows[0].topic).toBe('Emenda sob investigação (PF)')
|
||||
expect(rows[0].detail).toContain('desvio em emendas')
|
||||
expect(opposition.sources![0]).toEqual(
|
||||
expect.objectContaining({ url: 'https://exemplo.test/oposicao' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the dobradinhas research in the network section', () => {
|
||||
const rede = report.sections.find((section) => section.id === 'rede')!
|
||||
const dobradinhas = rede.blocks.find((block) => block.title === 'Dobradinhas (pesquisa)')!
|
||||
const rows = dobradinhas.rows as unknown as TableRow[]
|
||||
expect(rows[0].topic).toContain('Deputada Estadual')
|
||||
expect(dobradinhas.sources![0]).toEqual(
|
||||
expect.objectContaining({ url: 'https://exemplo.test/dobradinha' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the investments research in the conjuncture section', () => {
|
||||
const conjuntura = report.sections.find((section) => section.id === 'conjuntura')!
|
||||
const investments = conjuntura.blocks.find(
|
||||
(block) => block.title === 'Investimentos e obras (pesquisa)',
|
||||
)!
|
||||
const rows = investments.rows as unknown as TableRow[]
|
||||
expect(rows[0].detail).toContain('governo federal')
|
||||
expect(investments.sources![0]).toEqual(
|
||||
expect.objectContaining({ url: 'https://exemplo.test/investimento' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the empty signals/demands sections and renumbers the rest', () => {
|
||||
const emptyReport = asReport(
|
||||
buildCityReport({
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
signals: { totalCount: 0, rows: [] },
|
||||
demands: { totalCount: 0, rows: [] },
|
||||
activities: { upcoming: [], recent: [] },
|
||||
},
|
||||
research,
|
||||
emendas,
|
||||
generatedAt,
|
||||
}),
|
||||
)
|
||||
expect(emptyReport.sections.some((section) => section.id === 'sinais')).toBe(false)
|
||||
expect(emptyReport.sections.some((section) => section.id === 'demandas')).toBe(false)
|
||||
expect(
|
||||
emptyReport.sections.every((section, index) => section.title.startsWith(`${index + 1}. `)),
|
||||
).toBe(true)
|
||||
expect(emptyReport.sections.find((section) => section.id === 'demografia')!.title).toMatch(
|
||||
/^5\. /,
|
||||
)
|
||||
})
|
||||
|
||||
it('lists the researched city leaders in the network section', () => {
|
||||
const rede = report.sections.find((section) => section.id === 'rede')!
|
||||
const leadersBlock = rede.blocks.find(
|
||||
|
|
|
|||
|
|
@ -114,10 +114,12 @@ describe('renderReportMd', () => {
|
|||
it('carries the same sections as tables and headings', () => {
|
||||
expect(md).toContain('## 1. Conta eleitoral completa')
|
||||
expect(md).toContain('## 2. Concorrentes no município (federal e estadual)')
|
||||
expect(md).toContain('## 8. Atividade econômica (pesquisa)')
|
||||
expect(md).toContain('## 9. Transporte e conexões (pesquisa)')
|
||||
expect(md).toContain('## 13. Abordagem sugerida (pesquisa)')
|
||||
expect(md).toContain('## 14. Fontes e limites')
|
||||
expect(md).not.toContain('## 5. Sinais recentes')
|
||||
expect(md).not.toContain('Demandas e visitas')
|
||||
expect(md).toContain('## 6. Atividade econômica (pesquisa)')
|
||||
expect(md).toContain('## 7. Transporte e conexões (pesquisa)')
|
||||
expect(md).toContain('## 11. Abordagem sugerida (pesquisa)')
|
||||
expect(md).toContain('## 12. Fontes e limites')
|
||||
expect(md).toContain('| Ano | Votos |')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -177,4 +177,50 @@ describe('normalizeResearchInput', () => {
|
|||
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' }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue