mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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
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.
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
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('<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')
|
||
})
|
||
})
|