import { describe, expect, it } from 'vitest' import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs' import { buildBulletin } from '../../scripts/lib/dossieBulletin.mjs' import { renderBulletinHtml } from '../../scripts/lib/dossieBulletinRender.mjs' import { renderDossierHtml, renderDossierMd } from '../../scripts/lib/dossieRender.mjs' import { mergeDossierResearch, normalizeDossierResearchInput, } from '../../scripts/lib/dossieResearch.mjs' import { INSTITUTION_UNIT } from '../../scripts/lib/dossieUnit.mjs' // C187: institution content model + render. Grounds the institutional recorte: // identity badges, `instituicao|setor|rede` never summed, per-row phase/source, // empty-era state instead of silent omission, and a one-page boletim that // inherits only sourced facts. const generatedAt = new Date('2026-09-17T12:00:00.000Z') const snapshot = { meta: { readAt: '2026-09-17T11:00:00.000Z' }, institution: { slug: 'ufba', name: 'UFBA', kind: 'universidade', kindLabel: 'Universidade', sphere: 'federal', sphereLabel: 'Federal', scope: 'BA', scopeLabel: 'Abrangência BA', aliases: ['Universidade Federal da Bahia'], topics: ['educacao', 'ciencia-tecnologia'], }, speeches: { topics: ['ciencia-tecnologia'], totalCount: 1, rows: [ { id: 11, speechAt: '2024-05-10T00:00:00.000Z', summary: 'Defesa do financiamento das universidades federais.', officialTextUrl: 'https://camara.test/discurso-ufba', mentionExcerpt: 'a UFBA e as demais universidades federais', }, ], }, } const item = (id: string, _era: 'A' | 'B' | 'C', overrides: Record = {}) => ({ id, answer: `Ação ${id} documentada`, sphere: 'instituicao', sourceUrl: `https://ufba.test/${id}`, sourceDate: '2024-06-01', ...overrides, }) const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({ institutionSlug: 'ufba', era, researchedAt: '2026-09-17T10:00:00.000Z', items, news: [ { title: 'Notícia institucional', outlet: 'UFBA', publishedAt: '2024-05-01', url: 'https://ufba.test/noticia', }, ], }) const research = mergeDossierResearch( [ normalizeDossierResearchInput( eraFile('A', [ item('era_a_formacao', 'A'), item('era_a_vinculo', 'A', { sphere: 'setor' }), item('era_a_sesab', 'A', { sphere: 'rede' }), ]), { unit: INSTITUTION_UNIT }, ), normalizeDossierResearchInput( eraFile('B', [ item('era_b_sesab', 'B', { numbers: [{ label: 'Investimento', value: 'R$ 2,0 mi', year: '2012', phase: 'pago' }], }), ]), { unit: INSTITUTION_UNIT }, ), normalizeDossierResearchInput( eraFile('C', [ item('era_c_emendas', 'C', { numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }], }), item('era_c_titulos', 'C', { answer: 'Título de cidadão honorário' }), ]), { unit: INSTITUTION_UNIT }, ), ], { unit: INSTITUTION_UNIT }, ) const report = buildDossierReport({ snapshot, research, generatedAt, unit: INSTITUTION_UNIT, }) describe('institution era without evidence', () => { const emptyReport = buildDossierReport({ snapshot, research: mergeDossierResearch( (['A', 'B', 'C'] as const).map((era) => normalizeDossierResearchInput(eraFile(era, []), { unit: INSTITUTION_UNIT }), ), { unit: INSTITUTION_UNIT }, ), generatedAt, unit: INSTITUTION_UNIT, }) it('marks an evidence-less era as an explicit gap, never a silent blank', () => { expect(emptyReport.eras.every((era) => era.empty)).toBe(true) const html = renderDossierHtml(emptyReport) expect(html).toContain('Lacuna explícita · não é “zero”') expect(html).toContain('Nenhuma fonte suficiente foi localizada para esta era.') expect(html).toContain('empty-panel') }) }) describe('buildDossierReport (institution unit)', () => { it('carries the institution identity and badges', () => { expect(report.unit.id).toBe('institution') expect(report.meta.subjectName).toBe('UFBA') expect(report.meta.identityBadges).toEqual(['Universidade', 'Federal', 'Abrangência BA']) }) it('keeps all three eras, marking evidence-less ones as explicit gaps', () => { expect(report.eras.map((era) => era.id)).toEqual(['A', 'B', 'C']) expect(report.eras.every((era) => typeof era.empty === 'boolean')).toBe(true) }) it('never sums setor/rede into the instituicao list', () => { const listFor = (key: string) => report.reach.lists.find((list) => list.key === key) expect(listFor('institution')?.items.every((row) => row.sphere === 'instituicao')).toBe(true) expect(listFor('institution')?.items.some((row) => row.id === 'era_a_vinculo')).toBe(false) expect(listFor('sector')?.total).toBe(1) expect(listFor('network')?.total).toBe(1) }) it('declares overflow instead of truncating silently (C188 rule)', () => { for (const list of report.reach.lists) { expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) }) } expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0) }) it('shows every sourced item — institution lists are never capped', () => { const manyItems = [ item('era_c_emendas', 'C', { sphere: 'setor' }), item('era_c_atuacao', 'C', { sphere: 'setor' }), item('era_c_discursos', 'C', { sphere: 'setor' }), ] const fullReport = buildDossierReport({ snapshot, research: mergeDossierResearch( [normalizeDossierResearchInput(eraFile('C', manyItems), { unit: INSTITUTION_UNIT })], { unit: INSTITUTION_UNIT }, ), generatedAt, unit: INSTITUTION_UNIT, }) const list = fullReport.reach.lists.find((row) => row.key === 'sector') expect(list).toMatchObject({ total: 3, omitted: 0 }) expect(list?.items).toHaveLength(3) const fullHtml = renderDossierHtml(fullReport) expect(fullHtml).not.toContain('e mais') for (const row of manyItems) expect(fullHtml).toContain(row.answer) }) it('consolidates money by phase and never counts non-money numbers as money', () => { const synthesis = report.synthesis expect(synthesis).toBeTruthy() if (!synthesis) return const moneyItem = item('era_b_equipamentos', 'B', { numbers: [{ label: 'Convênio', value: 'R$ 1,2 mi', year: '2012', phase: 'pago' }], }) const countItem = item('era_b_obras', 'B', { numbers: [{ label: 'Leitos', value: '175', year: '2010', phase: 'nao_informado' }], }) const moneyReport = buildDossierReport({ snapshot, research: mergeDossierResearch( [ normalizeDossierResearchInput(eraFile('B', [moneyItem, countItem]), { unit: INSTITUTION_UNIT, }), ], { unit: INSTITUTION_UNIT }, ), generatedAt, unit: INSTITUTION_UNIT, }) const moneySynthesis = moneyReport.synthesis expect(moneySynthesis).toBeTruthy() if (!moneySynthesis) return expect(moneySynthesis.totals.money).toBe(1) expect(moneySynthesis.moneyTotals.executed).toBe(1_200_000) const paid = moneySynthesis.moneyByPhase.find((row: { key: string }) => row.key === 'pago') expect(paid).toMatchObject({ count: 1, amount: 1_200_000 }) const html = renderDossierHtml(moneyReport) expect(html).toContain('R$ 1,2 mi') expect(html).toContain('Recursos com execução por ano') }) it('derives bulletinFacts only from sourced items', () => { expect(report.bulletinFacts.length).toBeGreaterThan(0) expect(report.bulletinFacts.every((fact) => Boolean(fact.sourceUrl))).toBe(true) }) it('consumes the read-only acervo rows as sourced evidence (never silent)', () => { expect(report.acervo.items.length).toBe(1) expect(report.acervo.items[0]).toMatchObject({ period: '2024', sourceUrl: expect.any(String) }) expect(report.bulletinFacts.some((fact) => fact.id === 'fala-11')).toBe(true) expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)') }) }) describe('renderDossierHtml/Md (institution unit)', () => { const html = renderDossierHtml(report) const md = renderDossierMd(report) it('anchors every institution page', () => { for (const anchor of [ 'capa', 'resumo', 'sintese', 'graficos', 'era-a', 'era-b', 'era-c', 'abrangencia', 'abrangencia-instituicao', 'abrangencia-setor', 'abrangencia-rede', 'titulos', 'lacunas', 'fontes', ]) { expect(html).toContain(`data-page="${anchor}"`) } }) it('prints the synthesis lines and the consolidated charts', () => { const synthesis = report.synthesis expect(synthesis).toBeTruthy() if (!synthesis) return expect(synthesis.totals.items).toBeGreaterThan(0) const eraTotal = synthesis.byEra.reduce( (sum: number, row: { count: number }) => sum + row.count, 0, ) expect(eraTotal).toBe(synthesis.totals.items) const sphereTotal = synthesis.bySphere.reduce( (sum: number, row: { count: number }) => sum + row.count, 0, ) expect(sphereTotal).toBe(synthesis.totals.items) expect(html).toContain('data-page="sintese"') expect(html).toContain('data-page="graficos"') expect(html).toContain('Síntese do que foi localizado') expect(html).toContain('Gráficos consolidados') expect(html).toContain('chart-grid') expect(md).toContain('## Síntese do que foi localizado') }) it('flows across continuation sheets when the pack plan splits a section', () => { const bigReport = buildDossierReport({ snapshot, research: mergeDossierResearch( [ normalizeDossierResearchInput( { institutionSlug: 'ufba', era: 'C', researchedAt: '2026-09-17T10:00:00.000Z', items: [ 'era_c_discursos', 'era_c_proposicoes', 'era_c_emendas', 'era_c_titulos', 'era_c_atuacao', 'era_c_parcerias', ].map((id) => item(id, 'C', { sphere: 'setor' })), news: Array.from({ length: 12 }, (_, index) => ({ title: `Notícia ${index + 1}`, outlet: 'UFBA', publishedAt: '2024-05-01', url: `https://ufba.test/noticia-${index + 1}`, })), }, { unit: INSTITUTION_UNIT }, ), ], { unit: INSTITUTION_UNIT }, ), generatedAt, unit: INSTITUTION_UNIT, }) const bigHtml = renderDossierHtml(bigReport, { pack: { 'era:C': [1], 'scope:setor': [1], news: [1] }, }) expect(bigHtml).toContain('data-page="era-c"') expect(bigHtml).toContain('data-page="era-c-2"') expect(bigHtml).toContain('data-page="abrangencia-setor"') expect(bigHtml).toContain('data-page="abrangencia-setor-2"') expect(bigHtml).toContain('data-page="noticias"') expect(bigHtml).toContain('data-page="noticias-2"') expect(bigHtml).toContain('continuação') const anchors = [...bigHtml.matchAll(/data-page="([^"]+)"/g)].map((match) => match[1]) expect(new Set(anchors).size, 'every sheet anchor must be unique').toBe(anchors.length) expect(anchors.length).toBe(bigReport.meta.pageTotal) }) it('ports the institutional classes and identity badges', () => { expect(html).toContain('identity-badge') expect(html).toContain('scope-sector') expect(html).toContain('scope-network') expect(html).toContain('phase-paid') expect(html).toContain('phase-pending') }) it('prints the non-summable rule and the defeso note', () => { expect(html).toContain('Setor/rede não é a instituição') expect(html).toContain('Nota de defeso eleitoral 2026') }) it('renders the honors scene with date, nature and the reading panels', () => { expect(html).toContain('Data, natureza e fonte primária') expect(html).toContain('Como ler') expect(html).toContain('Vínculo documentado') expect(html).toContain('Honraria documentada') }) it('escapes untrusted research text', () => { const hostile = buildDossierReport({ snapshot, research: mergeDossierResearch( [ normalizeDossierResearchInput( eraFile('C', [item('era_c_emendas', 'C', { answer: '' })]), { unit: INSTITUTION_UNIT }, ), ], { unit: INSTITUTION_UNIT }, ), generatedAt, unit: INSTITUTION_UNIT, }) const hostileHtml = renderDossierHtml(hostile) expect(hostileHtml).not.toContain('') expect(hostileHtml).toContain('<script>') }) it('mirrors the institution sections in markdown', () => { expect(md).toContain('# Dossiê Solla por instituição — UFBA') expect(md).toContain('## Abrangência: instituição × setor × rede') expect(md).toContain('## Lacunas explícitas') expect(md).toContain('[fonte](https://ufba.test/era_a_formacao)') }) }) describe('buildBulletin (institution unit)', () => { const bulletin = buildBulletin({ facts: report.bulletinFacts, identity: { name: report.meta.subjectName, badges: report.meta.identityBadges, }, unit: INSTITUTION_UNIT, generatedAt, }) const html = renderBulletinHtml(bulletin) it('is a single A4 page with the model label and identity pills', () => { expect(html).toContain('data-page="boletim"') expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1) expect(html).toContain('Modelo — insumo interno') expect(html).toContain('identity-pill') expect(html).toContain('UFBA') }) it('declares no sources and carries the defeso band', () => { expect(html).not.toContain('source-link') expect(html).not.toContain('Fonte:') expect(html).toContain('defeso-band') expect(html).toContain('sem CTA') }) it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => { expect(html).toContain('highlight-phase') expect(html).toContain('phase-pending') expect(html).toContain('empenhado') expect(html).toContain('phase-paid') expect(html).toContain('pago') }) it('renders the sparse variant when there are fewer than three facts', () => { const sparse = buildBulletin({ facts: [report.bulletinFacts[0]], identity: { name: 'UFBA', badges: ['Universidade'] }, unit: INSTITUTION_UNIT, generatedAt, }) const sparseHtml = renderBulletinHtml(sparse) expect(sparse.sparse).toBe(true) expect(sparseHtml).toContain('lacuna-panel') expect(sparseHtml).toContain('A página termina com espaço') }) })