diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index cb302ccf..e9d253e5 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -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/.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/.emendas.json` para replay. limites`. - **Links clicáveis:** todo URL no PDF é um link (``): 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/.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). diff --git a/docs/changelog/2026-09-15-c163-frente-oposicao.md b/docs/changelog/2026-09-15-c163-frente-oposicao.md new file mode 100644 index 00000000..5f51e083 --- /dev/null +++ b/docs/changelog/2026-09-15-c163-frente-oposicao.md @@ -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). diff --git a/docs/changelog/2026-09-15-c163-secoes-dinamicas-dobradinhas-investimentos.md b/docs/changelog/2026-09-15-c163-secoes-dinamicas-dobradinhas-investimentos.md new file mode 100644 index 00000000..eacf9768 --- /dev/null +++ b/docs/changelog/2026-09-15-c163-secoes-dinamicas-dobradinhas-investimentos.md @@ -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). diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index 49580974..c44873c6 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -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: { diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index 90ad7c5e..3fb79af4 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -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, } } diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts index 4ba153b9..f0ff8024 100644 --- a/tests/unit/cityReportBlocks.unit.spec.ts +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -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( diff --git a/tests/unit/cityReportRender.unit.spec.ts b/tests/unit/cityReportRender.unit.spec.ts index d0d467ba..a86e3785 100644 --- a/tests/unit/cityReportRender.unit.spec.ts +++ b/tests/unit/cityReportRender.unit.spec.ts @@ -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 |') }) diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts index 1bd8cdfd..d5f0c2cf 100644 --- a/tests/unit/cityReportResearch.unit.spec.ts +++ b/tests/unit/cityReportResearch.unit.spec.ts @@ -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' })) + }) })