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.') + }) +})