teqo-backup/tests/unit/dossieInstitution.unit.spec.ts
Francisco Solla 5644a9f947
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
feat(C186/C187): dossiê corrente sem caps + redação, síntese e gráficos (paridade cidade × instituição)
Revisão de produto a partir do uso real (UFBA): as seções deixam de ser
capadas e fluem por quantas folhas precisarem, com packing medido
(probe → grow/shrink por folha, scripts/lib/dossiePack.mjs) e guarda de A4
no emit; o dossiê ganha redação de abertura (narrative.json escrito por
sub-agente e auditado pelo orquestrador), parágrafo de consolidação por
era, páginas de síntese e gráficos (recursos com fase, abrangência,
trajetória) e acervo como amostra declarada. O dono (dossieBlocks/
dossieRender/dossieUnit/buildPdf) foi generalizado e o ramo cidade adotou
as mesmas decisões; skills, comandos, agentes redatores, specs e designs
hi-fi re-sincronizados.
2026-09-17 23:00:08 -03:00

422 lines
15 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 { 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<string, unknown> = {}) => ({
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: '<script>alert(1)</script>' })]),
{ unit: INSTITUTION_UNIT },
),
],
{ unit: INSTITUTION_UNIT },
),
generatedAt,
unit: INSTITUTION_UNIT,
})
const hostileHtml = renderDossierHtml(hostile)
expect(hostileHtml).not.toContain('<script>alert(1)</script>')
expect(hostileHtml).toContain('&lt;script&gt;')
})
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')
})
})