From 23456bb81d00c6c4ba03a5feeae5cb3991e021b7 Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Thu, 17 Sep 2026 16:21:29 -0300
Subject: [PATCH] =?UTF-8?q?refactor(C186):=20endurece=20o=20builder=20e=20?=
=?UTF-8?q?enxuga=20o=20modelo=20do=20dossi=C3=AA=20(review)=20(Related=20?=
=?UTF-8?q?#1132)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agents/skills/dossie-solla-cidade/SKILL.md | 2 +-
docs/changelog/2026-09-17-c186.md | 2 +-
scripts/build-dossie-solla-cidade.mjs | 39 ++++---
scripts/lib/dossieBlocks.mjs | 90 ++++-----------
scripts/lib/dossieCamara.mjs | 32 +++---
scripts/lib/dossieCareer.mjs | 2 +-
scripts/lib/dossieRender.mjs | 16 ++-
scripts/lib/dossieResearch.mjs | 9 +-
tests/unit/dossieBlocks.unit.spec.ts | 25 ++--
tests/unit/dossieBulletin.unit.spec.ts | 11 +-
tests/unit/dossieRender.unit.spec.ts | 119 +++++++++-----------
tests/unit/dossieResearch.unit.spec.ts | 35 +++---
tests/unit/reportText.unit.spec.ts | 21 ++++
13 files changed, 192 insertions(+), 211 deletions(-)
create mode 100644 tests/unit/reportText.unit.spec.ts
diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md
index 535d1a8c..1ab3091b 100644
--- a/.agents/skills/dossie-solla-cidade/SKILL.md
+++ b/.agents/skills/dossie-solla-cidade/SKILL.md
@@ -197,7 +197,7 @@ por item **com fonte** — o boletim não introduz fato novo.
duas listas lado a lado (município ≠ região/polo); itens regionais com
evidência de alcance; gancho e lacuna prioritária.
- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados
- (larguras fixas 9/14/49/28%); limites de cobertura; regras para uso editorial;
+ (larguras fixas 12/13/47/28%); limites de cobertura; regras para uso editorial;
nota de defeso eleitoral 2026.
## Conteúdo do Boletim modelo (1 página A4)
diff --git a/docs/changelog/2026-09-17-c186.md b/docs/changelog/2026-09-17-c186.md
index f3738d9a..7fa79eb8 100644
--- a/docs/changelog/2026-09-17-c186.md
+++ b/docs/changelog/2026-09-17-c186.md
@@ -1 +1 @@
-**C186 — Dossiê Solla por cidade + Boletim Informativo modelo (2026-09-17):** nova skill `/dossie-solla-cidade` (command + subagente researcher por era A/B/C) que reúne, com fonte e data, tudo o que Jorge Solla fez pela cidade/região ao longo da carreira e entrega dois artefatos internos por município: o **dossiê** (PDF A4 de 7 páginas + companion `.md` — capa/resumo/timeline/seções por era/região-polo/lacunas/fontes/defeso) e o **Boletim modelo** (1 página A4, linguagem de eleitor, sem fontes). Arquitetura: um único builder (`scripts/build-dossie-solla-cidade.mjs`) lê o snapshot read-only (mesmo extrator C163, serializado por cidade), a pesquisa por era (`dossieResearch.mjs`, checklist por era, esfera explícita `município|região|polo`, `phase` por valor) e as fontes oficiais em tempo de build (emendas 2015–2026 via Portal da Transparência, Câmara com janela ≤4 anos por limitação da API, contexto IBGE/SIDRA), monta o dossiê e deriva dele o ledger `bulletinFacts` — o boletim herda **apenas** fato com fonte, por construção. O contrato sólido de escape/`{{fonte}}` foi extraído de `cityReportRender.mjs` para o dono único `scripts/lib/reportText.mjs` (export aditivo equivalente em `cityReportResearch.mjs`; specs C163 seguem verdes). O builder emite os dois PDFs num Chromium só, cada um com sua guarda de fit (`data-page="resumo"` e `data-page="boletim"`), e cada era/cidade falha isolada (sucesso parcial). Sem schema, migration, escrita no DB, Consent ou URL pública — skills/scripts/testes apenas; PDF/MD/JSON gitignored (repo público). Novos specs: `dossieResearch`, `dossieBlocks`, `dossieRender`, `dossieBulletin`, `dossieCamara`, `dossieHealthData`, `dossieCareer`, `dossieBatchSkill` (+ `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (346 arquivos / 3607 testes). Issue: #1132.
+**C186 — Dossiê Solla por cidade + Boletim Informativo modelo (2026-09-17):** nova skill `/dossie-solla-cidade` (command + subagente researcher por era A/B/C) que reúne, com fonte e data, tudo o que Jorge Solla fez pela cidade/região ao longo da carreira e entrega dois artefatos internos por município: o **dossiê** (PDF A4 multi-página + companion `.md` — capa/trajetória/resumo/seções por era/região-polo/lacunas/fontes/defeso) e o **Boletim modelo** (1 página A4, linguagem de eleitor, sem fontes). Arquitetura: um único builder (`scripts/build-dossie-solla-cidade.mjs`) lê o snapshot read-only (mesmo extrator C163, serializado por cidade), a pesquisa por era (`dossieResearch.mjs`, checklist por era, esfera explícita `município|região|polo`, `phase` por valor) e as fontes oficiais em tempo de build (emendas 2015–2026 via Portal da Transparência, Câmara com janela ≤4 anos por limitação da API, contexto IBGE/SIDRA), monta o dossiê e deriva dele o ledger `bulletinFacts` — o boletim herda **apenas** fato com fonte, por construção. O contrato sólido de escape/`{{fonte}}` foi extraído de `cityReportRender.mjs` para o dono único `scripts/lib/reportText.mjs` (export aditivo equivalente em `cityReportResearch.mjs`; specs C163 seguem verdes). O builder emite os dois PDFs num Chromium só, cada um com sua guarda de fit (`data-page="resumo"` e `data-page="boletim"`), e cada era/cidade falha isolada (sucesso parcial). Sem schema, migration, escrita no DB, Consent ou URL pública — skills/scripts/testes apenas; PDF/MD/JSON gitignored (repo público). Novos specs: `dossieResearch`, `dossieBlocks`, `dossieRender`, `dossieBulletin`, `dossieCamara`, `dossieHealthData`, `dossieCareer`, `dossieBatchSkill` (+ `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (346 arquivos / 3607 testes). Issue: #1132.
diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs
index 132f51be..3bfe3917 100644
--- a/scripts/build-dossie-solla-cidade.mjs
+++ b/scripts/build-dossie-solla-cidade.mjs
@@ -43,8 +43,7 @@ const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
/** Rounding slack of the mm→px conversion; content must still fit the page. */
const PAGE_FIT_TOLERANCE_PX = 10
-const DOSSIER_PAGE_ONE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
-const BOLETIM_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
+const PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
loadCliEnv()
@@ -80,21 +79,29 @@ if (!slug) die('Snapshot sem "municipality.slug" — extração inválida.')
const readEraResearch = async (era) => {
const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`)
+ const assertMatches = (research) => {
+ if (research.municipalitySlug !== slug) {
+ die(
+ `Pesquisa da Era ${era} é de "${research.municipalitySlug}", não de "${slug}" (${path}) — pare e regenere.`,
+ )
+ }
+ if (research.era !== era) {
+ die(`Pesquisa em ${path} declara Era ${research.era}, não Era ${era} — pare e regenere.`)
+ }
+ return research
+ }
try {
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
- return normalizeDossierResearchInput(raw, { now: generatedAt })
+ return assertMatches(normalizeDossierResearchInput(raw))
} catch (error) {
if (error?.code === 'ENOENT') {
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
- return normalizeDossierResearchInput(
- {
- municipalitySlug: slug,
- era,
- researchedAt: generatedAt.toISOString(),
- items: [],
- },
- { now: generatedAt },
- )
+ return normalizeDossierResearchInput({
+ municipalitySlug: slug,
+ era,
+ researchedAt: generatedAt.toISOString(),
+ items: [],
+ })
}
die(
`Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`,
@@ -209,12 +216,12 @@ try {
Boolean(document.querySelector('[data-page="resumo"]')),
)
if (!hasSummary) die('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
- const dossierOverflows = await measureOverflows(page, DOSSIER_PAGE_ONE_BUDGET_PX)
+ const dossierOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
if (dossierOverflows.length > 0) {
die(
`Página(s) do dossiê estouraram o A4 (${dossierOverflows
.map((row) => `${row.page}: ${row.height}px`)
- .join(', ')} > ${DOSSIER_PAGE_ONE_BUDGET_PX}px úteis). ` +
+ .join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
}
@@ -231,12 +238,12 @@ try {
Boolean(document.querySelector('[data-page="boletim"]')),
)
if (!hasBulletin) die('Bloco do boletim ausente no HTML — renderer quebrado.')
- const bulletinOverflows = await measureOverflows(page, BOLETIM_PAGE_BUDGET_PX)
+ const bulletinOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
if (bulletinOverflows.length > 0) {
die(
`O boletim estourou (${bulletinOverflows
.map((row) => `${row.height}px`)
- .join(', ')} > ${BOLETIM_PAGE_BUDGET_PX}px úteis). ` +
+ .join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
'Corte itens/caps — o boletim TEM de caber em uma página.',
)
}
diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs
index 10ce7ad8..86cdc5d7 100644
--- a/scripts/lib/dossieBlocks.mjs
+++ b/scripts/lib/dossieBlocks.mjs
@@ -10,7 +10,6 @@
import { formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
import {
- CAREER_NOTES,
CAREER_TIMELINE,
DOSSIER_ERAS,
DOSSIER_SCOPE,
@@ -25,7 +24,6 @@ const MAX_ERA_NUMBERS = 12
const MAX_ERA_ACTIONS = 4
const MAX_REGION_ITEMS = 4
const MAX_SCOPE_LIST = 5
-const MAX_GAPS = 30
const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' }
export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere
@@ -59,7 +57,7 @@ const emendaNumberRows = (emendas) => {
if (!isPositive(row[key])) continue
rows.push({
object: row.functionName ?? row.type ?? 'Emenda',
- value: Number(row[key]),
+ value: formatMoneyCompact(row[key]),
year: row.year ? String(row.year) : null,
phase,
sphere: 'municipio',
@@ -109,16 +107,15 @@ const eraMethod = {
C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
}
-const healthNumberRows = (health) =>
- (health?.status === 'ok' ? (health.items ?? []) : []).map((item) => ({
- object: item.topic,
- value: item.detail,
- year: item.year,
- phase: 'nao_informado',
- sphere: 'municipio',
- sourceUrl: item.sourceUrl,
- sourceDate: item.sourceDate,
- }))
+const healthContext = (health) =>
+ health?.status === 'ok'
+ ? (health.items ?? []).map((item) => ({
+ topic: item.topic,
+ detail: item.detail,
+ sourceUrl: item.sourceUrl,
+ sourceDate: item.sourceDate,
+ }))
+ : []
const camaraItemsAsActions = (camara, era) =>
(camara?.status === 'ok' ? (camara.items ?? []) : []).map((item) => ({
@@ -164,7 +161,6 @@ export const buildDossierReport = ({
const regionalItems = items.filter((item) => item.sphere !== 'municipio')
const eraRows = emendaNumberRows(emendas)
- const healthRows = healthNumberRows(health)
const camaraActions = camaraItemsAsActions(camara, 'C')
const researchActionsByEra = (era) =>
@@ -182,33 +178,23 @@ export const buildDossierReport = ({
const eraSections = DOSSIER_ERAS.map((era) => {
const ownActions = researchActionsByEra(era.id)
const extraActions = era.id === 'C' ? camaraActions : []
- const numbers =
+ const numbers = limit(
era.id === 'C'
- ? limit(
- [...emendaNumberRows(emendas), ...itemNumberRows(items.filter((i) => i.era === 'C'))],
- MAX_ERA_NUMBERS,
- )
- : limit(
- [
- ...itemNumberRows(items.filter((i) => i.era === era.id)),
- ...(era.id === 'B' ? healthRows : []),
- ],
- MAX_ERA_NUMBERS,
- )
+ ? [...eraRows, ...itemNumberRows(items.filter((i) => i.era === 'C'))]
+ : itemNumberRows(items.filter((i) => i.era === era.id)),
+ MAX_ERA_NUMBERS,
+ )
const honors = items
.filter((item) => item.era === 'C' && item.id === 'era_c_titulos')
.map((item) => ({ text: item.answer, sourceUrl: item.sourceUrl }))
const actions = limit([...ownActions, ...extraActions], MAX_ERA_ACTIONS)
- const remainingActions = ownActions.length + extraActions.length - actions.length
if (numbers.length === 0 && actions.length === 0) return null
return {
...era,
method: eraMethod[era.id],
recovery: eraRecovery[era.id],
numbers,
- remainingNumbers: 0,
actions,
- remainingActions: Math.max(0, remainingActions),
honors,
}
}).filter(Boolean)
@@ -246,22 +232,18 @@ export const buildDossierReport = ({
MAX_HOOKS_PAGE_ONE,
)
- const gaps = limit(
- (research.gaps ?? []).map((gap) => ({
- label: gap.label ?? gap.id,
- reason: gap.reason,
- nextStep: 'Apurar em fonte primária.',
- })),
- MAX_GAPS,
- )
+ const gaps = (research.gaps ?? []).map((gap) => ({
+ label: gap.label ?? gap.id,
+ reason: gap.reason,
+ nextStep: 'Apurar em fonte primária.',
+ }))
const pending = limit(
(research.gaps ?? []).map((gap) => gap.label ?? gap.id),
MAX_PENDING_PAGE_ONE,
)
- const municipalNumbers = itemNumberRows(municipalItems)
- const regionalNumbers = itemNumberRows(regionalItems)
+ const context = healthContext(health)
const regionItems = limit(
regionalItems.map((item) => ({
item: item.answer,
@@ -298,7 +280,7 @@ export const buildDossierReport = ({
area: 'Emendas',
headline: row.object,
detail: null,
- value: formatMoneyCompact(row.value),
+ value: row.value,
numberLabel: row.object,
year: row.year,
phase: row.phase,
@@ -307,27 +289,6 @@ export const buildDossierReport = ({
})),
]
- const sources = []
- for (const item of items) {
- sources.push({ kind: 'web', label: item.label, date: item.sourceDate, url: item.sourceUrl })
- }
- for (const row of research.news ?? []) {
- sources.push({
- kind: 'web',
- label: row.outlet ? `${row.outlet}: ${row.title}` : row.title,
- date: row.publishedAt,
- url: row.url,
- })
- }
- if (emendas?.status === 'ok' && emendas.sourceUrl) {
- sources.push({
- kind: 'official',
- label: 'Portal da Transparência — emendas do autor',
- date: emendas.consultedAt,
- url: emendas.sourceUrl,
- })
- }
-
const generatedAtLabel = formatDateTimeBr(generatedAt)
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
@@ -339,8 +300,6 @@ export const buildDossierReport = ({
generatedAt,
generatedAtLabel,
readAtLabel,
- codeSha: snapshot.meta?.codeSha ?? null,
- database: snapshot.meta?.database ?? null,
},
cover: {
kicker: 'Comunicação · pesquisa documental',
@@ -371,17 +330,16 @@ export const buildDossierReport = ({
'Um equipamento de referência ou uma política regional pode atender moradores do município sem constituir entrega exclusiva para ele. Atribuição só entra com alcance documentado.',
municipal: {
label: 'município',
- numbers: municipalNumbers,
items: limit(municipalItems, MAX_SCOPE_LIST),
total: municipalItems.length,
},
regional: {
label: 'região / polo',
- numbers: regionalNumbers,
items: limit(regionalItems, MAX_SCOPE_LIST),
total: regionalItems.length,
},
items: regionItems,
+ context,
hook: hooks[0] ?? null,
priorityGap: pending[0] ?? null,
},
@@ -405,8 +363,6 @@ export const buildDossierReport = ({
'Valor sempre informa a fase de execução.',
],
},
- notes: CAREER_NOTES,
- sources,
bulletinFacts,
}
}
diff --git a/scripts/lib/dossieCamara.mjs b/scripts/lib/dossieCamara.mjs
index 1516afa6..0418d591 100644
--- a/scripts/lib/dossieCamara.mjs
+++ b/scripts/lib/dossieCamara.mjs
@@ -6,28 +6,28 @@
*/
import { getJsonWithBackoff, speechesUrl } from './camaraFetch.mjs'
+import { isNonEmptyString } from './cityReportResearch.mjs'
import { SOLLA_DEPUTY_ID } from './dossieCareer.mjs'
const CAMARA_API = 'https://dadosabertos.camara.leg.br/api/v2'
export const CAMARA_DEFAULT_FROM = '2015-01-01'
const DEFAULT_MAX_PAGES = 2
+const PAGE_SIZE = 100
const ITEMS_PER_LIST = 20
export const propositionsUrl = (deputyId, page) =>
- `${CAMARA_API}/proposicoes?idDeputadoAutor=${deputyId}&itens=100&ordem=DESC&ordenarPor=id&pagina=${page}`
+ `${CAMARA_API}/proposicoes?idDeputadoAutor=${deputyId}&itens=${PAGE_SIZE}&ordem=DESC&ordenarPor=id&pagina=${page}`
-const propositionSourceUrl = (id) =>
+const propositionSourceUrl = (id, deputyId) =>
id
? `https://www.camara.leg.br/proposicoesWeb/fichadetramitacao?idProposicao=${id}`
- : `https://www.camara.leg.br/deputados/${SOLLA_DEPUTY_ID}`
+ : `https://www.camara.leg.br/deputados/${deputyId}`
-const isNonEmpty = (value) => typeof value === 'string' && value.trim() !== ''
-
-export const normalizeProposition = (row) => {
- const sigla = isNonEmpty(row?.siglaTipo) ? row.siglaTipo.trim() : null
+export const normalizeProposition = (row, deputyId = SOLLA_DEPUTY_ID) => {
+ const sigla = isNonEmptyString(row?.siglaTipo) ? row.siglaTipo.trim() : null
const number = row?.numero ?? null
const year = row?.ano ?? null
- const ementa = isNonEmpty(row?.ementa) ? row.ementa.trim() : null
+ const ementa = isNonEmptyString(row?.ementa) ? row.ementa.trim() : null
if (!sigla && !ementa) return null
const identifier = [sigla, number && year ? `${number}/${year}` : number ? String(number) : null]
.filter(Boolean)
@@ -36,17 +36,17 @@ export const normalizeProposition = (row) => {
title: identifier || 'Proposição de autoria do deputado',
detail: ementa,
year: year ? String(year) : null,
- sourceUrl: propositionSourceUrl(row?.id),
+ sourceUrl: propositionSourceUrl(row?.id, deputyId),
}
}
export const normalizeSpeech = (row, deputyId) => {
- const sumario = isNonEmpty(row?.sumario) ? row.sumario.trim() : null
- const transcricao = isNonEmpty(row?.transcricao) ? row.transcricao.trim() : null
+ const sumario = isNonEmptyString(row?.sumario) ? row.sumario.trim() : null
+ const transcricao = isNonEmptyString(row?.transcricao) ? row.transcricao.trim() : null
const detail = (sumario ?? transcricao ?? '').slice(0, 280) || null
- if (!detail && !isNonEmpty(row?.dataHoraInicio)) return null
- const date = isNonEmpty(row?.dataHoraInicio) ? row.dataHoraInicio.slice(0, 10) : null
- const tipo = isNonEmpty(row?.tipoDiscurso) ? row.tipoDiscurso.trim() : 'Pronunciamento'
+ if (!detail && !isNonEmptyString(row?.dataHoraInicio)) return null
+ const date = isNonEmptyString(row?.dataHoraInicio) ? row.dataHoraInicio.slice(0, 10) : null
+ const tipo = isNonEmptyString(row?.tipoDiscurso) ? row.tipoDiscurso.trim() : 'Pronunciamento'
return {
title: `${tipo} em ${date ?? 'data não informada'}`,
detail,
@@ -109,8 +109,8 @@ export const fetchCamaraActivity = async ({
fetchImpl,
label,
})
- propositions.push(...rows.map(normalizeProposition).filter(Boolean))
- if (rows.length < 100) break
+ propositions.push(...rows.map((row) => normalizeProposition(row, deputyId)).filter(Boolean))
+ if (rows.length < PAGE_SIZE) break
}
for (const window of speechWindows(from, to)) {
if (speeches.length >= ITEMS_PER_LIST) break
diff --git a/scripts/lib/dossieCareer.mjs b/scripts/lib/dossieCareer.mjs
index 0abb6851..85420f07 100644
--- a/scripts/lib/dossieCareer.mjs
+++ b/scripts/lib/dossieCareer.mjs
@@ -5,7 +5,7 @@
* cannot drift.
*/
-export const SOLLA_DEPUTY_ID = 178857
+export { SOLLA_DEPUTY_ID } from './camaraSpeeches.mjs'
export const SOLLA_BIRTH_DATE = '1961-04-11'
export const SOLLA_BIRTH_PLACE = 'Salvador-BA'
export const SOLLA_OFFICIAL_SITE = 'https://www.camara.leg.br/deputados/178857'
diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs
index 1a93b2b2..dbcbee39 100644
--- a/scripts/lib/dossieRender.mjs
+++ b/scripts/lib/dossieRender.mjs
@@ -48,7 +48,9 @@ const assetBox = (title, lines) =>
.map((line) => htmlEscape(line))
.join('
')}
`
-const pageCount = (report) => report.meta.pageTotal ?? 5 + report.eras.length
+/** cover + trajetória + resumo + região + fontes (eras and gap sheets added separately). */
+const SHEET_COUNT_BASE = 5
+const pageCount = (report) => report.meta.pageTotal
/** Gaps are paginated, never truncated: 12 rows per A4 sheet. */
const GAPS_PER_PAGE = 12
@@ -334,6 +336,16 @@ const renderRegion = (report, pageNo) => `
+ ${
+ report.region.context.length
+ ? `Contexto municipal (IBGE): ${report.region.context
+ .map(
+ (item) => `${htmlEscape(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
+ )
+ .join(' · ')} — leitura relativa, nunca % estadual absoluto.
`
+ : ''
+ }
+
${renderScopeLists(report)}
${
@@ -566,7 +578,7 @@ const PRINT_CSS = `
export const renderDossierHtml = (report) => {
const gapChunks = chunkGaps(report.gaps)
- report.meta.pageTotal = 5 + report.eras.length + gapChunks.length
+ report.meta.pageTotal = SHEET_COUNT_BASE + report.eras.length + gapChunks.length
let page = 0
const parts = []
parts.push(renderCover(report))
diff --git a/scripts/lib/dossieResearch.mjs b/scripts/lib/dossieResearch.mjs
index f8f41360..f7f56622 100644
--- a/scripts/lib/dossieResearch.mjs
+++ b/scripts/lib/dossieResearch.mjs
@@ -120,7 +120,10 @@ const normalizeNumbers = (entry) => {
const numbers = []
for (const number of Array.isArray(entry?.numbers) ? entry.numbers : []) {
const label = isNonEmptyString(number?.label) ? number.label.trim() : null
- const value = isNonEmptyString(number?.value) ? number.value.trim() : null
+ const value =
+ number?.value === null || number?.value === undefined
+ ? null
+ : String(number.value).trim() || null
if (!label || !value) continue
numbers.push({
label,
@@ -139,7 +142,7 @@ const normalizeNumbers = (entry) => {
* operator fixes the file. Content failures (missing source, unknown item, item
* from another era) degrade to explicit gaps.
*/
-export const normalizeDossierResearchInput = (raw, { now = new Date() } = {}) => {
+export const normalizeDossierResearchInput = (raw) => {
if (!raw || typeof raw !== 'object') {
throw new Error('Pesquisa do dossiê inválida: o arquivo precisa ser um objeto JSON.')
}
@@ -157,7 +160,6 @@ export const normalizeDossierResearchInput = (raw, { now = new Date() } = {}) =>
}
const researchedAt = new Date(raw.researchedAt)
- const reference = now instanceof Date ? now : new Date(now)
const gaps = []
const items = []
const seen = new Set()
@@ -266,7 +268,6 @@ export const normalizeDossierResearchInput = (raw, { now = new Date() } = {}) =>
municipalitySlug: raw.municipalitySlug.trim(),
era,
researchedAt: researchedAt.toISOString(),
- reference,
items,
news,
gaps,
diff --git a/tests/unit/dossieBlocks.unit.spec.ts b/tests/unit/dossieBlocks.unit.spec.ts
index c0d3f793..9eeceab3 100644
--- a/tests/unit/dossieBlocks.unit.spec.ts
+++ b/tests/unit/dossieBlocks.unit.spec.ts
@@ -32,9 +32,7 @@ const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({
})
const research = mergeDossierResearch([
- normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista', { number: undefined })]), {
- now: generatedAt,
- }),
+ normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
normalizeDossierResearchInput(
eraFile('B', [
item('era_b_sesab', {
@@ -42,7 +40,6 @@ const research = mergeDossierResearch([
}),
item('era_b_obras', { sphere: 'regiao', answer: 'Hospital de referência regional' }),
]),
- { now: generatedAt },
),
normalizeDossierResearchInput(
eraFile('C', [
@@ -51,7 +48,6 @@ const research = mergeDossierResearch([
}),
item('era_c_titulos', { answer: 'Cidadão Ilheense' }),
]),
- { now: generatedAt },
),
])
@@ -116,14 +112,13 @@ describe('buildDossierReport', () => {
)
})
- it('includes the IBGE baseline as an Era B number', () => {
- const eraB = build().eras.find((era) => era?.id === 'B')
- expect(eraB?.numbers).toContainEqual(
- expect.objectContaining({
- object: 'População residente (Censo 2022)',
- phase: 'nao_informado',
- }),
+ it('surfaces the IBGE baseline as municipal context, not as an era number', () => {
+ const report = build()
+ expect(report.region.context).toContainEqual(
+ expect.objectContaining({ topic: 'População residente (Censo 2022)' }),
)
+ const eraB = report.eras.find((era) => era?.id === 'B')
+ expect(eraB?.numbers.some((row) => row.object.includes('População'))).toBe(false)
})
it('never sums região/polo into the município list', () => {
@@ -142,9 +137,9 @@ describe('buildDossierReport', () => {
it('omits an era that has no number and no action', () => {
const empty = mergeDossierResearch([
- normalizeDossierResearchInput(eraFile('A', []), { now: generatedAt }),
- normalizeDossierResearchInput(eraFile('B', []), { now: generatedAt }),
- normalizeDossierResearchInput(eraFile('C', []), { now: generatedAt }),
+ normalizeDossierResearchInput(eraFile('A', [])),
+ normalizeDossierResearchInput(eraFile('B', [])),
+ normalizeDossierResearchInput(eraFile('C', [])),
])
const report = buildDossierReport({
snapshot,
diff --git a/tests/unit/dossieBulletin.unit.spec.ts b/tests/unit/dossieBulletin.unit.spec.ts
index 37ee7c2c..f4aec34f 100644
--- a/tests/unit/dossieBulletin.unit.spec.ts
+++ b/tests/unit/dossieBulletin.unit.spec.ts
@@ -57,11 +57,14 @@ describe('buildBulletin', () => {
expect(bulletin.timeline.length).toBe(4)
})
- it('never drops the fact source from the ledger (boletim can only inherit sourced facts)', () => {
+ it('maps every highlight and "e mais" item to a ledger fact (never invents)', () => {
const facts = Array.from({ length: 25 }, (_value, index) => fact(index))
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
- const chosen = bulletin.highlights.length + bulletin.moreItems.length
- expect(chosen).toBe(BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT)
- expect(facts.every((row) => Boolean(row.sourceUrl))).toBe(true)
+ const headlines = new Set(facts.map((row) => row.headline))
+ expect(bulletin.highlights.length + bulletin.moreItems.length).toBe(
+ BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT,
+ )
+ expect(bulletin.highlights.every((row) => headlines.has(row.title))).toBe(true)
+ expect(bulletin.moreItems.every((row) => headlines.has(row.label))).toBe(true)
})
})
diff --git a/tests/unit/dossieRender.unit.spec.ts b/tests/unit/dossieRender.unit.spec.ts
index 0b46d00c..f123b518 100644
--- a/tests/unit/dossieRender.unit.spec.ts
+++ b/tests/unit/dossieRender.unit.spec.ts
@@ -16,70 +16,61 @@ const snapshot = {
}
const research = mergeDossierResearch([
- normalizeDossierResearchInput(
- {
- municipalitySlug: 'ilheus',
- era: 'A',
- researchedAt: '2026-09-16T10:00:00.000Z',
- items: [
- {
- id: 'era_a_conquista',
- answer: 'Gestão da saúde em Vitória da Conquista',
- details: 'Detalhe com {{fonte}}.',
- sourceUrl: 'https://exemplo.test/conquista',
- sourceDate: '2026-09-10',
- },
- ],
- news: [],
- gaps: [],
- },
- { now: generatedAt },
- ),
- normalizeDossierResearchInput(
- {
- municipalitySlug: 'ilheus',
- era: 'B',
- researchedAt: '2026-09-16T10:00:00.000Z',
- items: [
- {
- id: 'era_b_equipamentos',
- answer: '1 hospital regional',
- sphere: 'polo',
- sourceUrl: 'https://saude.test/hospital',
- sourceDate: '2026-09-10',
- },
- ],
- news: [],
- gaps: [],
- },
- { now: generatedAt },
- ),
- normalizeDossierResearchInput(
- {
- municipalitySlug: 'ilheus',
- era: 'C',
- researchedAt: '2026-09-16T10:00:00.000Z',
- items: [
- {
- id: 'era_c_emendas',
- answer: 'R$ 1,2 milhão para equipamentos',
- numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
- sourceUrl: 'https://portaldatransparencia.test/emendas',
- sourceDate: '2026-09-15',
- },
- ],
- news: [
- {
- title: 'Matéria local',
- url: 'https://jornal.test/materia',
- publishedAt: '2024-06-01',
- outlet: 'Jornal Local',
- },
- ],
- gaps: [],
- },
- { now: generatedAt },
- ),
+ normalizeDossierResearchInput({
+ municipalitySlug: 'ilheus',
+ era: 'A',
+ researchedAt: '2026-09-16T10:00:00.000Z',
+ items: [
+ {
+ id: 'era_a_conquista',
+ answer: 'Gestão da saúde em Vitória da Conquista',
+ details: 'Detalhe com {{fonte}}.',
+ sourceUrl: 'https://exemplo.test/conquista',
+ sourceDate: '2026-09-10',
+ },
+ ],
+ news: [],
+ gaps: [],
+ }),
+ normalizeDossierResearchInput({
+ municipalitySlug: 'ilheus',
+ era: 'B',
+ researchedAt: '2026-09-16T10:00:00.000Z',
+ items: [
+ {
+ id: 'era_b_equipamentos',
+ answer: '1 hospital regional',
+ sphere: 'polo',
+ sourceUrl: 'https://saude.test/hospital',
+ sourceDate: '2026-09-10',
+ },
+ ],
+ news: [],
+ gaps: [],
+ }),
+ normalizeDossierResearchInput({
+ municipalitySlug: 'ilheus',
+ era: 'C',
+ researchedAt: '2026-09-16T10:00:00.000Z',
+ items: [
+ {
+ id: 'era_c_emendas',
+ answer: 'R$ 1,2 milhão para equipamentos',
+ numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
+ sourceUrl: 'https://portaldatransparencia.test/emendas',
+ sourceDate: '2026-09-15',
+ },
+ ],
+ news: [
+ {
+ title: 'Matéria local',
+ url: 'https://jornal.test/materia',
+ publishedAt: '2024-06-01',
+ outlet: 'Jornal Local',
+ },
+ ],
+ gaps: [],
+ }),
])
const report = buildDossierReport({
diff --git a/tests/unit/dossieResearch.unit.spec.ts b/tests/unit/dossieResearch.unit.spec.ts
index c451c973..23a398fa 100644
--- a/tests/unit/dossieResearch.unit.spec.ts
+++ b/tests/unit/dossieResearch.unit.spec.ts
@@ -8,8 +8,6 @@ import {
normalizeDossierResearchInput,
} from '../../scripts/lib/dossieResearch.mjs'
-const now = new Date('2026-09-17T12:00:00.000Z')
-
const validItem = (id: string, overrides: Record = {}) => ({
id,
answer: `Resposta de ${id}`,
@@ -37,28 +35,26 @@ describe('normalizeDossierResearchInput', () => {
})
it('keeps fully sourced items and defaults the sphere to município', () => {
- const research = normalizeDossierResearchInput(eraResearch('C'), { now })
+ const research = normalizeDossierResearchInput(eraResearch('C'))
expect(research.items).toHaveLength(dossierChecklistForEra('C').length)
expect(research.gaps).toEqual([])
expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true)
})
it('throws when the slug, the date or the era is missing', () => {
- expect(() =>
- normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }), { now }),
- ).toThrow(/municipalitySlug/)
- expect(() =>
- normalizeDossierResearchInput(eraResearch('A', { researchedAt: null }), { now }),
- ).toThrow(/researchedAt/)
- expect(() => normalizeDossierResearchInput(eraResearch('A', { era: 'Z' }), { now })).toThrow(
- /era/,
+ expect(() => normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }))).toThrow(
+ /municipalitySlug/,
)
+ expect(() => normalizeDossierResearchInput(eraResearch('A', { researchedAt: null }))).toThrow(
+ /researchedAt/,
+ )
+ expect(() => normalizeDossierResearchInput(eraResearch('A', { era: 'Z' }))).toThrow(/era/)
})
it('degrades an unsourced item to an explicit gap', () => {
const items = dossierChecklistForEra('A').map((item) => validItem(item.id))
items[0] = validItem(items[0].id, { sourceUrl: null })
- const research = normalizeDossierResearchInput(eraResearch('A', { items }), { now })
+ const research = normalizeDossierResearchInput(eraResearch('A', { items }))
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: items[0].id, reason: expect.stringMatching(/Sem fonte/) }),
)
@@ -68,7 +64,6 @@ describe('normalizeDossierResearchInput', () => {
it('rejects an item that belongs to another era', () => {
const research = normalizeDossierResearchInput(
eraResearch('A', { items: [...eraResearch('A').items, validItem('era_c_emendas')] }),
- { now },
)
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'era_c_emendas', reason: expect.stringMatching(/Era C/) }),
@@ -76,7 +71,7 @@ describe('normalizeDossierResearchInput', () => {
})
it('marks unreached checklist items as gaps', () => {
- const research = normalizeDossierResearchInput(eraResearch('B', { items: [] }), { now })
+ const research = normalizeDossierResearchInput(eraResearch('B', { items: [] }))
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(
dossierChecklistForEra('B').length,
)
@@ -90,7 +85,7 @@ describe('normalizeDossierResearchInput', () => {
{ label: 'Sem fase', value: 'R$ 10 mil' },
],
})
- const research = normalizeDossierResearchInput(eraResearch('C', { items }), { now })
+ const research = normalizeDossierResearchInput(eraResearch('C', { items }))
const item = research.items.find((row) => row.id === items[0].id)
expect(item?.numbers).toHaveLength(2)
expect(item?.numbers[0]).toMatchObject({ label: 'Recurso', phase: 'empenhado' })
@@ -100,7 +95,7 @@ describe('normalizeDossierResearchInput', () => {
it('flags an invalid sphere as a gap instead of silently summing it', () => {
const items = dossierChecklistForEra('A').map((item) => validItem(item.id))
items[0] = validItem(items[0].id, { sphere: 'estado' })
- const research = normalizeDossierResearchInput(eraResearch('A', { items }), { now })
+ const research = normalizeDossierResearchInput(eraResearch('A', { items }))
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: items[0].id,
@@ -110,7 +105,7 @@ describe('normalizeDossierResearchInput', () => {
})
it('emits the short receipt with the era file path', () => {
- const receipt = dossierResearchReceipt(normalizeDossierResearchInput(eraResearch('B'), { now }))
+ const receipt = dossierResearchReceipt(normalizeDossierResearchInput(eraResearch('B')))
expect(receipt).toMatchObject({ slug: 'ilheus', era: 'B', status: 'ok' })
expect(receipt.researchPath).toContain('ilheus.b.research.json')
})
@@ -120,7 +115,7 @@ describe('mergeDossierResearch', () => {
it('merges the three eras ordered A→B→C', () => {
const merged = mergeDossierResearch(
['A', 'B', 'C'].map((era) =>
- normalizeDossierResearchInput(eraResearch(era as 'A' | 'B' | 'C'), { now }),
+ normalizeDossierResearchInput(eraResearch(era as 'A' | 'B' | 'C')),
),
)
expect(merged.eras).toEqual(['A', 'B', 'C'])
@@ -129,8 +124,8 @@ describe('mergeDossierResearch', () => {
})
it('fails closed when the slugs disagree', () => {
- const a = normalizeDossierResearchInput(eraResearch('A'), { now })
- const b = normalizeDossierResearchInput(eraResearch('B', { municipalitySlug: 'una' }), { now })
+ const a = normalizeDossierResearchInput(eraResearch('A'))
+ const b = normalizeDossierResearchInput(eraResearch('B', { municipalitySlug: 'una' }))
expect(() => mergeDossierResearch([a, b])).toThrow(/municípios diferentes/)
})
diff --git a/tests/unit/reportText.unit.spec.ts b/tests/unit/reportText.unit.spec.ts
new file mode 100644
index 00000000..9fc39631
--- /dev/null
+++ b/tests/unit/reportText.unit.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+
+import { stripInlineSources } from '../../scripts/lib/reportText.mjs'
+
+describe('stripInlineSources', () => {
+ it('drops a trailing token and the stray space before punctuation', () => {
+ expect(stripInlineSources('Pagamento pendente de confirmação {{fonte}}.')).toBe(
+ 'Pagamento pendente de confirmação.',
+ )
+ })
+
+ it('drops indexed tokens and collapses the leftover spaces', () => {
+ expect(stripInlineSources('Fato {{fonte:2}} e outro {{fonte:3}} aqui.')).toBe(
+ 'Fato e outro aqui.',
+ )
+ })
+
+ it('leaves text without tokens untouched', () => {
+ expect(stripInlineSources('Sem marcador de fonte.')).toBe('Sem marcador de fonte.')
+ })
+})