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

552 lines
20 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 { SPEECH_TOPICS } from '@/lib/speechFacets'
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 { THEME_UNIT, themeSubjectPhrase } from '../../scripts/lib/dossieUnit.mjs'
// C190: theme/area content model + render. Grounds the third recorte: area
// identity (label + canonical value), `area|segmento|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.
const generatedAt = new Date('2026-09-18T12:00:00.000Z')
const snapshot = {
meta: { readAt: '2026-09-18T11:00:00.000Z' },
theme: {
slug: 'educacao',
value: 'educacao',
label: 'Educação',
taxonomyNote: 'Taxonomia do acervo',
topics: ['educacao'],
},
speeches: {
topics: ['educacao'],
totalCount: 1,
rows: [
{
id: 21,
speechAt: '2024-05-10T00:00:00.000Z',
summary: 'Defesa do financiamento da educação básica.',
officialTextUrl: 'https://camara.test/discurso-educacao',
},
],
},
}
const item = (id: string, era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
id,
answer: `Ação ${id} documentada`,
sphere: 'area',
sourceUrl: `https://educacao.test/${id}`,
sourceDate: '2024-06-01',
era,
...overrides,
})
const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({
themeSlug: 'educacao',
era,
researchedAt: '2026-09-18T10:00:00.000Z',
items,
news: [
{
title: 'Notícia sobre educação',
outlet: 'Educação Hoje',
publishedAt: '2024-05-01',
url: 'https://educacao.test/noticia',
},
],
})
const research = mergeDossierResearch(
[
normalizeDossierResearchInput(
eraFile('A', [
item('era_a_formacao', 'A'),
item('era_a_vinculo', 'A', { sphere: 'segmento' }),
item('era_a_sesab', 'A', { sphere: 'rede' }),
]),
{ unit: THEME_UNIT },
),
normalizeDossierResearchInput(
eraFile('B', [
item('era_b_sesab', 'B', {
numbers: [{ label: 'Investimento', value: 'R$ 2,0 mi', year: '2012', phase: 'pago' }],
}),
]),
{ unit: THEME_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: 'Homenagem da área' }),
item('era_c_defesas', 'C', {
position: 'Educação básica',
answer: 'Defende o financiamento da educação básica',
}),
]),
{ unit: THEME_UNIT },
),
],
{ unit: THEME_UNIT },
)
const report = buildDossierReport({
snapshot,
research,
generatedAt,
unit: THEME_UNIT,
})
describe('theme era without evidence', () => {
const emptyReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
(['A', 'B', 'C'] as const).map((era) =>
normalizeDossierResearchInput(eraFile(era, []), { unit: THEME_UNIT }),
),
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_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('evidência da área não localizada')
expect(html).toContain('empty-panel')
})
})
describe('buildDossierReport (theme unit)', () => {
it('maps the canonical area labels to the right subject preposition', () => {
expect(themeSubjectPhrase('Saúde')).toBe('pela Saúde')
expect(themeSubjectPhrase('Esporte')).toBe('pelo Esporte')
expect(themeSubjectPhrase('Direitos Humanos e Assistência Social')).toBe(
'pelos Direitos Humanos e Assistência Social',
)
expect(themeSubjectPhrase('Mulheres e Gênero')).toBe('pelas Mulheres e Gênero')
expect(themeSubjectPhrase('Fora da taxonomia')).toBe('pela área de Fora da taxonomia')
})
it('has a mapped phrase for every canonical topic label (no silent fallback)', () => {
for (const { label } of SPEECH_TOPICS) {
expect(themeSubjectPhrase(label), label).not.toBe(`pela área de ${label}`)
}
})
it('carries the area identity and canonical value', () => {
expect(report.unit.id).toBe('theme')
expect(report.meta.subjectName).toBe('Educação')
expect(report.meta.identity?.value).toBe('educacao')
expect(report.meta.identityBadges).toEqual(['Educação'])
})
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 segmento/rede into the area list', () => {
const listFor = (key: string) => report.reach.lists.find((list) => list.key === key)
expect(listFor('area')?.items.every((row) => row.sphere === 'area')).toBe(true)
expect(listFor('area')?.items.some((row) => row.id === 'era_a_vinculo')).toBe(false)
expect(listFor('segment')?.total).toBe(1)
expect(listFor('network')?.total).toBe(1)
})
it('shows every sourced item — theme lists are never capped', () => {
const manyItems = [
item('era_c_emendas', 'C', { sphere: 'segmento' }),
item('era_c_programas', 'C', { sphere: 'segmento' }),
item('era_c_relatorias', 'C', { sphere: 'segmento' }),
]
const fullReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[normalizeDossierResearchInput(eraFile('C', manyItems), { unit: THEME_UNIT })],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_UNIT,
})
const list = fullReport.reach.lists.find((row) => row.key === 'segment')
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_investimentos', 'B', {
numbers: [{ label: 'Convênio', value: 'R$ 1,2 mi', year: '2012', phase: 'pago' }],
})
const countItem = item('era_b_politicas', 'B', {
numbers: [{ label: 'Escolas', value: '175', year: '2010', phase: 'nao_informado' }],
})
const moneyReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[normalizeDossierResearchInput(eraFile('B', [moneyItem, countItem]), { unit: THEME_UNIT })],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_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 })
expect(moneySynthesis.lines.join(' ')).toContain('R$ 1,2 mi pago')
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-21')).toBe(true)
expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)')
})
})
describe('renderDossierHtml/Md (theme unit)', () => {
const html = renderDossierHtml(report)
const md = renderDossierMd(report)
it('anchors every theme page', () => {
for (const anchor of [
'capa',
'resumo',
'sintese',
'trajetoria',
'era-a',
'era-b',
'era-c',
'abrangencia',
'defende',
'lacunas',
'noticias',
'acervo',
'fontes',
]) {
expect(html).toContain(`data-page="${anchor}"`)
}
// Theme has no separate honors sheet: títulos render inline in the era.
expect(html).not.toContain('data-page="titulos"')
expect(html).toContain('Homenagem da área')
})
it('prints the theme acervo scene with the canonical columns and the scope alert', () => {
expect(html).toContain('Tema canônico')
expect(html).toContain('Trecho/contexto')
// The editorial label carries the column; the canonical token lives in the
// identification table, never concatenated to the label.
expect(html).toContain('>Educação<')
expect(html).toContain('<dt>Token</dt><dd>educacao</dd>')
expect(html).not.toContain('Educação educacao')
expect(html).toContain(
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega',
)
expect(html).not.toContain('acervo-stat-grid')
})
it('prints the theme reach scene with the five columns and the ledger year', () => {
expect(html).toContain('Era / ano')
expect(html).toContain('Valor / fase')
expect(html).toContain('Evidência temática')
expect(html).toContain('Era A')
})
it('prints the theme gap scene with era, status and the attribution alert', () => {
expect(html).toContain('Já consultado')
expect(html).toContain('Próxima busca')
expect(html).toContain('prioridade')
expect(html).toContain('aberta')
expect(html).toContain(
'Portais que não filtram por área não autorizam atribuição. Sem fonte temática, a linha permanece lacuna — nunca zero.',
)
})
it('prints the theme source hierarchy above the limits', () => {
expect(html).toContain('Hierarquia de fontes')
expect(html).toContain('Fontes primárias')
expect(html).toContain('Atos, diários oficiais e documentos contemporâneos')
expect(html).toContain('Sem documento, não entra')
expect(html).toContain('Não filtram por área')
})
it('uses the theme subject phrase on the cover and the bulletin', () => {
expect(html).toContain('O que Jorge Solla fez pela Educação')
expect(html).toContain('pela Educação ao longo da carreira')
})
it('prints 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(
{
themeSlug: 'educacao',
era: 'C',
researchedAt: '2026-09-18T10:00:00.000Z',
items: [
'era_c_discursos',
'era_c_proposicoes',
'era_c_relatorias',
'era_c_emendas',
'era_c_programas',
'era_c_audiencias',
].map((id) => item(id, 'C', { sphere: 'segmento' })),
news: Array.from({ length: 12 }, (_, index) => ({
title: `Notícia ${index + 1}`,
outlet: 'Educação Hoje',
publishedAt: '2024-05-01',
url: `https://educacao.test/noticia-${index + 1}`,
})),
},
{ unit: THEME_UNIT },
),
],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_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 theme vocabulary and the sober classes', () => {
expect(html).toContain('Dossiê temático')
expect(html).toContain('index-list')
expect(html).toContain('position-list')
expect(html).toContain('phase-text')
expect(html).toContain('Uma ação dirigida a um segmento ou a uma rede')
expect(html).toContain('Segmento e rede não são somados à área')
expect(html).not.toContain('identity-badge')
expect(html).not.toContain('scope-cards')
})
it('prints the defeso note', () => {
expect(html).toContain('Nota de defeso eleitoral 2026')
})
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: THEME_UNIT },
),
],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_UNIT,
})
const hostileHtml = renderDossierHtml(hostile)
expect(hostileHtml).not.toContain('<script>alert(1)</script>')
expect(hostileHtml).toContain('&lt;script&gt;')
})
it('mirrors the theme sections in markdown', () => {
expect(md).toContain('# Dossiê Solla por tema/área — Educação')
expect(md).toContain('## Abrangência: área × segmento × rede')
expect(md).toContain('## Lacunas explícitas')
expect(md).toContain('[fonte](https://educacao.test/era_a_formacao)')
})
})
describe('buildBulletin (theme unit)', () => {
const identity = {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
}
const bulletin = buildBulletin({
facts: report.bulletinFacts,
identity,
unit: THEME_UNIT,
generatedAt,
})
const html = renderBulletinHtml(bulletin)
it('is a single A4 page with the model label and the subject phrase', () => {
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('O que Jorge Solla fez pela <span>Educação</span>')
})
it('prints the defense block and the period list', () => {
expect(html).toContain('bulletin-defense-list')
expect(html).toContain('Defende o financiamento da educação básica')
expect(html).toContain('period-list')
})
it('falls back to the year and never prints a fake zero', () => {
const fact = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
era: 'C',
sphere: 'area',
area: 'Educação',
headline: `Fato ${id}`,
detail: null,
value: null,
brief: null,
year: null,
phase: null,
sourceUrl: `https://educacao.test/${id}`,
sourceDate: '2024-06-01',
...overrides,
})
const fallback = buildBulletin({
facts: [fact('a-ano', { year: '2019' }), fact('b-texto')],
identity,
unit: THEME_UNIT,
generatedAt,
})
const fallbackHtml = renderBulletinHtml(fallback)
expect(fallback.highlights[0]).toMatchObject({ number: '2019' })
expect(fallback.highlights[1]).toMatchObject({ number: null })
expect(fallbackHtml).toContain('2019')
expect(fallbackHtml).not.toContain('>0<')
})
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('área, segmento e rede não são somados')
})
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
expect(html).toContain('empenhado')
expect(html).toContain('pago')
})
it('renders the sparse variant without filling the page', () => {
const sparse = buildBulletin({
facts: [report.bulletinFacts[0]],
identity,
unit: THEME_UNIT,
generatedAt,
})
const sparseHtml = renderBulletinHtml(sparse)
expect(sparse.sparse).toBe(true)
expect(sparseHtml).toContain('A página termina com espaço')
expect(sparseHtml).toContain('entrega exclusiva da área')
})
})
describe('buildBulletin (theme unit, one-page fit)', () => {
const identity = {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
}
const fala = report.bulletinFacts.find((fact) => String(fact.id).startsWith('fala-'))
it('marks the acervo as a declared source panel, out of the printed slots', () => {
expect(fala).toMatchObject({ sourcePanel: true })
const bulletin = buildBulletin({
facts: fala ? [fala] : [],
identity,
unit: THEME_UNIT,
generatedAt,
})
expect(bulletin.highlights).toHaveLength(0)
expect(bulletin.moreItems).toHaveLength(0)
expect(bulletin.factsTotal).toBe(1)
expect(bulletin.factsPrinted).toBe(0)
expect(bulletin.factsRemaining).toBe(1)
expect(bulletin.sparse).toBe(true)
const html = renderBulletinHtml(bulletin)
expect(html).not.toContain(fala?.headline ?? '')
expect(html).toContain('e mais 1 fato com fonte no dossiê da área')
})
it('shrinks the printed set on printLimit and declares every remaining fact', () => {
const facts = report.bulletinFacts.filter((fact) => !fact.sourcePanel)
const full = buildBulletin({ facts, identity, unit: THEME_UNIT, generatedAt })
const fitted = buildBulletin({ facts, identity, unit: THEME_UNIT, generatedAt, printLimit: 2 })
expect(full.highlights).toHaveLength(6)
expect(full.moreItems).toHaveLength(0)
expect(fitted.highlights).toHaveLength(2)
expect(fitted.moreItems).toHaveLength(0)
// Two printed highlights plus the one defense (never dropped by the cap).
expect(fitted.factsPrinted).toBe(3)
expect(fitted.factsPrintable).toBe(facts.length)
expect(fitted.factsRemaining).toBeGreaterThan(full.factsRemaining)
expect(renderBulletinHtml(fitted)).toContain(
`e mais ${fitted.factsRemaining} fatos com fonte no dossiê da área`,
)
})
})