teqo-backup/tests/unit/dossieInstitution.unit.spec.ts

437 lines
15 KiB
TypeScript
Raw 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 rows, `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. C209: sober redesign (analysis first, lists and
// tables, "O que Solla defende").
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' }),
item('era_c_defesas', 'C', {
position: 'Financiamento',
answer: 'Defende o financiamento das universidades federais',
}),
]),
{ 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('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('Valores localizados, sem consolidar fases')
})
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)')
})
it('reads the positions with lastro and the missing defenses', () => {
expect(report.defends.positions).toContainEqual(
expect.objectContaining({ label: 'Financiamento', era: 'C' }),
)
expect(report.defends.gaps.map((gap: { id: string }) => gap.id)).toEqual([
'era_a_defesas',
'era_b_defesas',
])
})
})
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',
'trajetoria',
'era-a',
'era-b',
'era-c',
'abrangencia',
'titulos',
'defende',
'lacunas',
'noticias',
'acervo',
'fontes',
]) {
expect(html).toContain(`data-page="${anchor}"`)
}
})
it('prints the synthesis lines and the between-eras reading, never the old 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('Leitura entre eras')
expect(html).not.toContain('data-page="graficos"')
expect(html).not.toContain('Gráficos consolidados')
expect(html).not.toContain('chart-grid')
expect(md).toContain('## Leitura entre eras')
})
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: [1], news: [1] },
})
expect(bigHtml).toContain('data-page="era-c"')
expect(bigHtml).toContain('data-page="era-c-2"')
expect(bigHtml).toContain('data-page="abrangencia"')
expect(bigHtml).toContain('data-page="abrangencia-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 sober classes: index, position list, tables and phase as text', () => {
expect(html).toContain('index-list')
expect(html).toContain('position-list')
expect(html).toContain('plain-list')
expect(html).toContain('document-table')
expect(html).toContain('phase-text')
expect(html).not.toContain('identity-badge')
expect(html).not.toContain('scope-cards')
expect(html).not.toContain('asset-box')
})
it('prints the non-summable rule and the defeso note', () => {
expect(html).toContain('Não somar.')
expect(html).toContain('Nota de defeso eleitoral 2026')
})
it('renders the honors scene with date, nature and the reading rule', () => {
expect(html).toContain('Títulos, honrarias e vínculos')
expect(html).toContain('Data')
expect(html).toContain('Natureza')
expect(html).toContain('Como ler:')
})
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('## Títulos, honrarias e vínculos')
expect(md).toContain('## Lacunas explícitas')
expect(md).toContain('[fonte](https://ufba.test/era_a_formacao)')
})
it('links every index entry to an anchor that exists in the companion', () => {
const links = [...md.matchAll(/\]\(#([^)]+)\)/g)].map((match) => match[1])
expect(links.length).toBeGreaterThan(0)
for (const anchor of links) expect(md).toContain(`<a id="${anchor}"></a>`)
})
})
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 the institution name', () => {
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('UFBA')
})
it('declares no sources and carries the defeso footer', () => {
expect(html).not.toContain('source-link')
expect(html).not.toContain('Fonte:')
expect(html).toContain('Defeso eleitoral')
expect(html).toContain('Não publicar sem revisão')
})
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
expect(html).toContain('empenhado')
expect(html).toContain('pago')
expect(html).toContain('bulletin-list')
})
it('renders the sparse variant without filling the page', () => {
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('A página termina com espaço')
expect(sparseHtml).toContain('note-line')
})
})