diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md index 1ab3091b..9678a417 100644 --- a/.agents/skills/dossie-solla-cidade/SKILL.md +++ b/.agents/skills/dossie-solla-cidade/SKILL.md @@ -150,8 +150,12 @@ cruza para ele. "items": [ { "id": "era_c_emendas", // id do checklist da era - "answer": "R$ 1,2 milhão para equipamentos de saúde", + "answer": "registro integral; aceita {{fonte}} / {{fonte:N}} — vai para o .md e para o lastro", "details": "opcional; aceita {{fonte}} / {{fonte:N}}", + "brief": { // obrigatório no item publicado: copy reformulada, curta + "title": "≤80 chars — manchete (o quê + onde)", + "note": "≤120 chars — 1 frase de contexto; opcional" + }, "sphere": "municipio", // municipio | regiao | polo (default municipio) "numbers": [ // opcional { "label": "Saúde", "value": "R$ 1,2 mi", "year": "2024", "phase": "empenhado" } @@ -183,6 +187,17 @@ Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira (default `nao_informado`). O `bulletinFacts` (ledger do boletim) só é populado por item **com fonte** — o boletim não introduz fato novo. +**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa: +em vez de cortar o texto com `…`, cada item publicado traz um `brief` **reescrito +para caber** (`title` ≤80 / `note` ≤120) preservando o essencial (o quê, onde, +valor + fase) — nunca inventa fato, nunca sugere exclusividade municipal para +item `regiao`/`polo` e nunca troca a fase (empenho ≠ pagamento). O cartão/linha +do PDF imprime `brief.title`/`brief.note`; o `answer`/`details` integrais ficam +no registro e no companion `.md`. Sem `brief`, o renderer cai no texto integral e +a guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabelas +longas (lacunas, notícias) não usam `brief`: são **paginadas** em folhas de +continuação com o texto inteiro. + ## Conteúdo do dossiê - **Capa** — série municipal, "INSUMO INTERNO", território, região/polo diff --git a/.opencode/agent/dossie-solla-cidade.md b/.opencode/agent/dossie-solla-cidade.md index 090f5db1..f366ba46 100644 --- a/.opencode/agent/dossie-solla-cidade.md +++ b/.opencode/agent/dossie-solla-cidade.md @@ -20,7 +20,9 @@ slug e coordena as demais eras/cidades em paralelo. (ex.: `ilheus.c.research.json`) — item sem `sourceUrl`/`sourceDate` não vai para o arquivo (vira lacuna explícita). Use o checklist da era e marque `sphere` (`municipio`/`regiao`/`polo`); região/polo nunca é somado ao - município. + município. Cada item publicado traz **`brief`** (`title` ≤80 / `note` ≤120): + copy **reformulada** para caber no A4, sem `…`, sem fato novo e sem trocar a + fase — o `answer`/`details` integrais continuam no arquivo (registro/.md). 3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill): `slug`, `era`, `status`, `researchPath`, `researchedAt`, `itemCount`, `gapCount`, `newsCount`, `gaps`, `failureReason?`. **Nunca** devolva o corpo diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs index 86cdc5d7..4bc51303 100644 --- a/scripts/lib/dossieBlocks.mjs +++ b/scripts/lib/dossieBlocks.mjs @@ -20,10 +20,11 @@ import { stripInlineSources } from './reportText.mjs' const MAX_DELIVERIES_PAGE_ONE = 3 const MAX_HOOKS_PAGE_ONE = 2 const MAX_PENDING_PAGE_ONE = 4 -const MAX_ERA_NUMBERS = 12 -const MAX_ERA_ACTIONS = 4 -const MAX_REGION_ITEMS = 4 -const MAX_SCOPE_LIST = 5 +const MAX_ERA_NUMBERS = 6 +/** Curated research actions are all kept (the renderer paginates them); only the generic Câmara list is capped. */ +const MAX_ERA_CAMARA_ACTIONS = 4 +const MAX_REGION_ITEMS = 3 +const MAX_SCOPE_LIST = 3 const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' } export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere @@ -171,6 +172,7 @@ export const buildDossierReport = ({ year: item.numbers?.[0]?.year ?? null, title: item.answer, detail: item.details, + brief: item.brief ?? null, sourceUrl: item.sourceUrl, sourceDate: item.sourceDate, })) @@ -184,10 +186,17 @@ export const buildDossierReport = ({ : 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 honors = + era.id === 'C' + ? items + .filter((item) => item.id === 'era_c_titulos') + .map((item) => ({ + text: item.answer, + brief: item.brief ?? null, + sourceUrl: item.sourceUrl, + })) + : [] + const actions = [...ownActions, ...limit(extraActions, MAX_ERA_CAMARA_ACTIONS)] if (numbers.length === 0 && actions.length === 0) return null return { ...era, @@ -215,6 +224,7 @@ export const buildDossierReport = ({ year: item.numbers?.[0]?.year ?? null, title: item.answer, detail: item.details, + brief: item.brief ?? null, value: item.numbers?.[0]?.value ?? null, phase: item.numbers?.[0]?.phase ?? null, sourceUrl: item.sourceUrl, @@ -227,6 +237,7 @@ export const buildDossierReport = ({ [...municipalItems, ...regionalItems].map((item) => ({ topic: item.area, angle: item.answer, + brief: item.brief ?? null, sourceUrl: item.sourceUrl, })), MAX_HOOKS_PAGE_ONE, @@ -249,6 +260,7 @@ export const buildDossierReport = ({ item: item.answer, sphere: item.sphere, evidence: item.details ?? item.label, + brief: item.brief ?? null, sourceUrl: item.sourceUrl, })), MAX_REGION_ITEMS, @@ -264,6 +276,12 @@ export const buildDossierReport = ({ area: item.area, headline: stripInlineSources(item.answer), detail: item.details ? stripInlineSources(item.details) : null, + brief: item.brief + ? { + title: stripInlineSources(item.brief.title), + note: item.brief.note ? stripInlineSources(item.brief.note) : null, + } + : null, value: item.numbers?.[0]?.value ?? null, numberLabel: item.numbers?.[0]?.label ?? null, year: item.numbers?.[0]?.year ?? null, diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs index bc2bc1e1..c1ee9588 100644 --- a/scripts/lib/dossieBulletin.mjs +++ b/scripts/lib/dossieBulletin.mjs @@ -44,14 +44,14 @@ export const buildBulletin = ({ const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({ eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere)}`, number: fact.value, - title: fact.headline, - note: fact.detail ?? null, + title: fact.brief?.title ?? fact.headline, + note: fact.brief?.note ?? fact.detail, })) const moreItems = ordered .slice(BULLETIN_HIGHLIGHT_LIMIT, BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT) .map((fact) => ({ - label: fact.headline, - detail: fact.detail ?? fact.value ?? fact.area, + label: fact.brief?.title ?? fact.headline, + detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area, })) return { diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs index d24d93a7..2c3aa1ef 100644 --- a/scripts/lib/dossieBulletinRender.mjs +++ b/scripts/lib/dossieBulletinRender.mjs @@ -21,7 +21,7 @@ const renderHighlight = (highlight) => ` ` const renderMoreItem = (item) => - `

${htmlEscape(item.label)}: ${htmlEscape(item.detail)}

` + `

${htmlEscape(item.label)}

` const PRINT_CSS = ` * { box-sizing: border-box; } diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs index dbcbee39..1540a7b4 100644 --- a/scripts/lib/dossieRender.mjs +++ b/scripts/lib/dossieRender.mjs @@ -30,8 +30,15 @@ const formatShortDateBr = (value) => { return parts.length === 3 ? `${parts[0]}/${parts[1]}/${parts[2].slice(-2)}` : full } -/** Research text without the inline `{{fonte}}` markers — the row's own link carries the source. */ -const cleanText = (value) => htmlEscape(stripInlineSources(value)) +/** + * Print surfaces prefer the reformulated `brief` (short headline + note that fit + * the fixed A4 with no ellipsis) and fall back to the full `answer`/`details` + * record when an item has none — the builder's A4 fit guard then fails closed + * instead of silently dropping information. + */ +const copyHtml = (value) => htmlEscape(stripInlineSources(value)) +const briefOr = (brief, fallback) => brief?.title ?? fallback +const noteOr = (brief, fallback) => brief?.note ?? fallback const phaseBadge = (phase) => `${htmlEscape(dossierPhaseLabel(phase))}` @@ -52,8 +59,15 @@ const assetBox = (title, lines) => 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 +/** Gaps are paginated, never truncated: 7 full-text rows per A4 sheet. */ +const GAPS_PER_PAGE = 7 + +/** News are paginated, never truncated: 10 full-title rows per A4 sheet. */ +const NEWS_PER_PAGE = 10 + +/** Era "Atuação registrada" cards: 2 on the first sheet, 6 per continuation sheet. */ +const ERA_ACTIONS_FIRST_SHEET = 2 +const ERA_ACTIONS_PER_SHEET = 6 const chunkGaps = (gaps) => { const list = Array.isArray(gaps) ? gaps : [] @@ -65,6 +79,31 @@ const chunkGaps = (gaps) => { return chunks } +const chunkNews = (news) => { + const list = Array.isArray(news) ? news : [] + const chunks = [] + for (let index = 0; index < list.length; index += NEWS_PER_PAGE) { + chunks.push(list.slice(index, index + NEWS_PER_PAGE)) + } + return chunks +} + +/** + * Era action cards, paginated so no sourced fact is dropped: the first sheet + * carries the design's 2 cards (with method + numbers + honors), and the rest + * flow to continuation sheets. + */ +const chunkEraActions = (era) => { + const actions = Array.isArray(era.actions) ? era.actions : [] + const first = actions.slice(0, ERA_ACTIONS_FIRST_SHEET) + const rest = actions.slice(ERA_ACTIONS_FIRST_SHEET) + const chunks = [first] + for (let index = 0; index < rest.length; index += ERA_ACTIONS_PER_SHEET) { + chunks.push(rest.slice(index, index + ERA_ACTIONS_PER_SHEET)) + } + return chunks +} + const timelineGrid = (rows) => `
${rows @@ -165,8 +204,8 @@ const renderDeliveries = (report) => { (row) => `
${scopeBadge(row.sphere)}

Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}

-

${cleanText(row.title)}

- ${row.detail ? `

${cleanText(row.detail)}

` : ''} +

${copyHtml(briefOr(row.brief, row.title))}

+ ${noteOr(row.brief, row.detail) ? `

${copyHtml(noteOr(row.brief, row.detail))}

` : ''}

${valueCell(row.value, 'Valor não informado')}

@@ -194,7 +233,7 @@ const renderHooksAndPending = (report) => {
    ${hooks .map( (hook) => - `
  • Tema: ${htmlEscape(hook.topic)}. Ângulo: ${cleanText(hook.angle)} ${sourceLink(hook.sourceUrl)}
  • `, + `
  • Tema: ${htmlEscape(hook.topic)}. Ângulo: ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}
  • `, ) .join('')}
` @@ -221,11 +260,34 @@ const renderSummary = (report, pageNo) => ` ${sheetFooter(report, 'Resumo', pageNo)} ` -const renderEra = (report, era, pageNo) => ` -
- ${sheetHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)} +const renderActionGrid = (actions) => ` +
+ ${actions + .map( + (action) => `
+
${scopeBadge(action.sphere)}${htmlEscape(action.year ?? '—')}
+

${copyHtml(briefOr(action.brief, action.title))}

+ ${noteOr(action.brief, action.detail) ? `

${copyHtml(noteOr(action.brief, action.detail))}

` : ''} + ${sourceLink(action.sourceUrl)} +
`, + ) + .join('')} +
` -
+const renderEraPage = (report, era, actions, index, pageNo) => { + const first = index === 0 + const slug = era.id.toLowerCase() + const anchor = first ? `era-${slug}` : `era-${slug}-${index + 1}` + const subtitle = first + ? `${era.period} · ${era.subtitle}` + : `Atuação registrada · continuação ${index + 1}` + return ` +
+ ${sheetHeader(report, era.label, subtitle, pageNo)} + + ${ + first + ? `

Recorte e método

${htmlEscape(era.method)}

@@ -256,39 +318,32 @@ const renderEra = (report, era, pageNo) => ` ) .join('')} -

Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.

` : '' + }` + : '' } ${ - era.actions.length + actions.length ? `

Atuação registrada

O que fez — item, alcance e lastro

-
- ${era.actions - .map( - (action) => `
-
${scopeBadge(action.sphere)}${htmlEscape(action.year ?? '—')}
-

${cleanText(action.title)}

- ${action.detail ? `

${cleanText(action.detail)}

` : ''} - ${sourceLink(action.sourceUrl)} -
`, - ) - .join('')} -
+ ${renderActionGrid(actions)}
` : '' } ${ - era.honors.length + first && era.honors.length ? `

Títulos, honrarias e vínculos locais

    ${era.honors - .map((honor) => `
  • ${cleanText(honor.text)} ${sourceLink(honor.sourceUrl)}
  • `) + .map( + (honor) => + `
  • ${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}
  • `, + ) .join('')}
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])} @@ -298,6 +353,7 @@ const renderEra = (report, era, pageNo) => ` ${sheetFooter(report, era.label, pageNo)}
` +} const renderScopeLists = (report) => `
@@ -307,7 +363,10 @@ const renderScopeLists = (report) => `
${scopeBadge('municipio')}${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte
    ${report.region.municipal.items - .map((item) => `
  • ${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}
  • `) + .map( + (item) => + `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, + ) .join( '', )}${report.region.municipal.items.length === 0 ? '
  • Nenhum item municipal com fonte.
  • ' : ''}
@@ -316,7 +375,10 @@ const renderScopeLists = (report) => `
${scopeBadge('regiao', 'região / polo')}SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}
    ${report.region.regional.items - .map((item) => `
  • ${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}
  • `) + .map( + (item) => + `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, + ) .join( '', )}${report.region.regional.items.length === 0 ? '
  • Nenhum item regional com fonte.
  • ' : ''}
@@ -340,7 +402,7 @@ const renderRegion = (report, pageNo) => ` report.region.context.length ? `

Contexto municipal (IBGE): ${report.region.context .map( - (item) => `${htmlEscape(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`, + (item) => `${copyHtml(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`, ) .join(' · ')} — leitura relativa, nunca % estadual absoluto.

` : '' @@ -359,9 +421,9 @@ const renderRegion = (report, pageNo) => ` ${report.region.items .map( (item) => ` - ${cleanText(item.item)} + ${copyHtml(briefOr(item.brief, item.item))} ${scopeBadge(item.sphere)} - ${cleanText(item.evidence)} + ${copyHtml(noteOr(item.brief, item.evidence))} ${sourceLink(item.sourceUrl)} `, ) @@ -376,7 +438,7 @@ const renderRegion = (report, pageNo) => ` ? `
${ report.region.hook - ? `

Gancho possível

Tema: ${htmlEscape(report.region.hook.topic)}. Ângulo: ${cleanText(report.region.hook.angle)} ${sourceLink(report.region.hook.sourceUrl)}

` + ? `

Gancho possível

Tema: ${htmlEscape(report.region.hook.topic)}. Ângulo: ${copyHtml(briefOr(report.region.hook.brief, report.region.hook.angle))} ${sourceLink(report.region.hook.sourceUrl)}

` : '
' } ${ @@ -420,34 +482,35 @@ const renderGapsPage = (report, chunk, index, pageNo) => {
` } +const renderNewsPage = (report, chunk, index, pageNo) => ` +
+ ${sheetHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)} +
+

Lista clicável

+

Notícias e documentos consultados

+ + + + + ${chunk + .map( + (row) => ` + + + + + `, + ) + .join('')} +
Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%
DataVeículoTítuloLink
${htmlEscape(formatShortDateBr(row.date))}${htmlEscape(row.outlet)}${htmlEscape(row.title)}${sourceLink(row.url, '(fonte)')}
+
+ ${sheetFooter(report, 'Notícias e documentos consultados · INSUMO INTERNO', pageNo)} +
` + const renderSourcesPage = (report, pageNo) => `
${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)} - ${ - report.news.length - ? `
-

Lista clicável

-

Notícias e documentos consultados

- - - - - ${report.news - .map( - (row) => ` - - - - - `, - ) - .join('')} -
Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%
DataVeículoTítuloLink
${htmlEscape(formatShortDateBr(row.date))}${htmlEscape(row.outlet)}${htmlEscape(row.title)}${sourceLink(row.url, '(fonte)')}
-
` - : '' - } -

Limites de cobertura

    ${report.limits.coverage .map((item) => `
  • ${htmlEscape(item)}
  • `) @@ -578,16 +641,24 @@ const PRINT_CSS = ` export const renderDossierHtml = (report) => { const gapChunks = chunkGaps(report.gaps) - report.meta.pageTotal = SHEET_COUNT_BASE + report.eras.length + gapChunks.length + const newsChunks = chunkNews(report.news) + const eraSheets = report.eras.map((era) => ({ era, chunks: chunkEraActions(era) })) + const eraSheetCount = eraSheets.reduce((sum, entry) => sum + entry.chunks.length, 0) + report.meta.pageTotal = SHEET_COUNT_BASE + eraSheetCount + gapChunks.length + newsChunks.length let page = 0 const parts = [] parts.push(renderCover(report)) page += 1 parts.push(renderTrajectory(report, ++page)) parts.push(renderSummary(report, ++page)) - for (const era of report.eras) parts.push(renderEra(report, era, ++page)) + for (const { era, chunks } of eraSheets) { + chunks.forEach((actions, index) => + parts.push(renderEraPage(report, era, actions, index, ++page)), + ) + } parts.push(renderRegion(report, ++page)) gapChunks.forEach((chunk, index) => parts.push(renderGapsPage(report, chunk, index, ++page))) + newsChunks.forEach((chunk, index) => parts.push(renderNewsPage(report, chunk, index, ++page))) parts.push(renderSourcesPage(report, ++page)) return ` diff --git a/scripts/lib/dossieResearch.mjs b/scripts/lib/dossieResearch.mjs index f7f56622..7bb52429 100644 --- a/scripts/lib/dossieResearch.mjs +++ b/scripts/lib/dossieResearch.mjs @@ -14,6 +14,16 @@ const DOSSIER_SPHERES = ['municipio', 'regiao', 'polo'] /** Execution phases (empenho ≠ pagamento). Unknown/absent degrades to "não informada". */ const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos', 'nao_informado'] +/** + * Copy budget for a printed `brief` (reformulated headline + note that must fit + * the fixed A4 surfaces with no ellipsis). The full `answer`/`details` stay in + * the record and the companion `.md`; the `brief` is the reformulated, shorter + * version the PDF prints. Items without a `brief` fall back to the full text and + * the builder's A4 fit guard fails closed if it overflows. + */ +export const DOSSIER_BRIEF_TITLE_MAX = 80 +export const DOSSIER_BRIEF_NOTE_MAX = 120 + /** Checklist per era — the dossiê's research checklist. `area` labels the boletim card. */ const DOSSIER_RESEARCH_CHECKLIST = [ { @@ -116,6 +126,27 @@ const normalizePhase = (value) => { return DOSSIER_PHASES.includes(phase) ? phase : 'nao_informado' } +const normalizeBrief = (entry) => { + const title = isNonEmptyString(entry?.title) ? entry.title.trim() : null + if (!title) return null + return { title, note: isNonEmptyString(entry?.note) ? entry.note.trim() : null } +} + +/** Non-fatal: an over-budget brief is kept, but the A4 fit guard will likely fail — surface it early. */ +const warnIfBriefOverBudget = (id, brief) => { + if (!brief) return + if (brief.title.length > DOSSIER_BRIEF_TITLE_MAX) { + console.warn( + `[dossie-research] brief.title de ${id} tem ${brief.title.length} chars (teto ${DOSSIER_BRIEF_TITLE_MAX}).`, + ) + } + if ((brief.note?.length ?? 0) > DOSSIER_BRIEF_NOTE_MAX) { + console.warn( + `[dossie-research] brief.note de ${id} tem ${brief.note.length} chars (teto ${DOSSIER_BRIEF_NOTE_MAX}).`, + ) + } +} + const normalizeNumbers = (entry) => { const numbers = [] for (const number of Array.isArray(entry?.numbers) ? entry.numbers : []) { @@ -208,6 +239,8 @@ export const normalizeDossierResearchInput = (raw) => { }) continue } + const brief = normalizeBrief(entry.brief) + warnIfBriefOverBudget(id, brief) items.push({ id, era, @@ -215,6 +248,7 @@ export const normalizeDossierResearchInput = (raw) => { area: checklistItem.area, answer, details: isNonEmptyString(entry.details) ? entry.details.trim() : null, + brief, sphere, numbers: normalizeNumbers(entry), sourceUrl, diff --git a/tests/unit/dossieRender.unit.spec.ts b/tests/unit/dossieRender.unit.spec.ts index f123b518..4dc67b12 100644 --- a/tests/unit/dossieRender.unit.spec.ts +++ b/tests/unit/dossieRender.unit.spec.ts @@ -60,6 +60,12 @@ const research = mergeDossierResearch([ sourceUrl: 'https://portaldatransparencia.test/emendas', sourceDate: '2026-09-15', }, + { + id: 'era_c_titulos', + answer: 'Título de cidadão honorário', + sourceUrl: 'https://camara.test/titulo', + sourceDate: '2026-09-10', + }, ], news: [ { @@ -120,6 +126,89 @@ describe('renderDossierHtml', () => { it('prints the defeso note', () => { expect(html).toContain('Nota de defeso eleitoral 2026') }) + + it('renders the local honors only on their own era sheet (never on another era)', () => { + expect((html.match(/Títulos, honrarias e vínculos locais/g) ?? []).length).toBe(1) + }) +}) + +describe('renderDossierHtml — reformulated brief (no ellipsis)', () => { + const briefedResearch = mergeDossierResearch([ + normalizeDossierResearchInput({ + municipalitySlug: 'ilheus', + era: 'C', + researchedAt: '2026-09-16T10:00:00.000Z', + items: [ + { + id: 'era_c_emendas', + answer: 'Resposta integral bem longa que não deve aparecer no cartão impresso do dossiê', + details: 'Detalhe integral que também não deve aparecer no cartão impresso.', + brief: { title: 'Manchete reformulada para caber', note: 'Nota reformulada curta.' }, + numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }], + sourceUrl: 'https://portaldatransparencia.test/brief', + sourceDate: '2026-09-15', + }, + ], + news: Array.from({ length: 12 }, (_value, index) => ({ + title: `Notícia ${index}`, + url: `https://jornal.test/${index}`, + publishedAt: '2024-06-01', + outlet: 'Jornal Local', + })), + gaps: [], + }), + ]) + const briefReport = buildDossierReport({ snapshot, research: briefedResearch, generatedAt }) + const html = renderDossierHtml(briefReport) + const md = renderDossierMd(briefReport) + + it('prints the short brief instead of the full record', () => { + expect(html).toContain('Manchete reformulada para caber') + expect(html).toContain('Nota reformulada curta.') + expect(html).not.toContain('Resposta integral bem longa') + }) + + it('never truncates with an ellipsis', () => { + expect(html).not.toContain('…') + }) + + it('keeps the full record in the .md companion', () => { + expect(md).toContain('Resposta integral bem longa') + }) + + it('paginates long news lists into continuation sheets', () => { + expect(html).toContain('data-page="noticias"') + expect(html).toContain('data-page="noticias-2"') + }) +}) + +describe('renderDossierHtml — era continuation sheets', () => { + const manyActions = mergeDossierResearch([ + normalizeDossierResearchInput({ + municipalitySlug: 'ilheus', + era: 'C', + researchedAt: '2026-09-16T10:00:00.000Z', + items: ['discursos', 'proposicoes', 'emendas', 'titulos', 'atuacao'].map((suffix, index) => ({ + id: `era_c_${suffix}`, + answer: `Ação ${index}`, + brief: { title: `Ação curta ${index}`, note: `Nota ${index}` }, + sourceUrl: `https://exemplo.test/acao-${index}`, + sourceDate: '2026-09-10', + })), + news: [], + gaps: [], + }), + ]) + const report = buildDossierReport({ snapshot, research: manyActions, generatedAt }) + const html = renderDossierHtml(report) + + it('paginates era action cards into continuation sheets instead of dropping them', () => { + expect(html).toContain('data-page="era-c"') + expect(html).toContain('data-page="era-c-2"') + for (let index = 0; index < 5; index += 1) { + expect(html).toContain(`Ação curta ${index}`) + } + }) }) describe('renderDossierMd', () => { diff --git a/tests/unit/dossieResearch.unit.spec.ts b/tests/unit/dossieResearch.unit.spec.ts index 23a398fa..6a647da4 100644 --- a/tests/unit/dossieResearch.unit.spec.ts +++ b/tests/unit/dossieResearch.unit.spec.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { + DOSSIER_BRIEF_NOTE_MAX, + DOSSIER_BRIEF_TITLE_MAX, DOSSIER_RESEARCH_CHECKLIST_IDS, dossierChecklistForEra, dossierResearchReceipt, @@ -41,6 +43,41 @@ describe('normalizeDossierResearchInput', () => { expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true) }) + it('normalizes the reformulated brief and drops it when it has no title', () => { + const research = normalizeDossierResearchInput( + eraResearch('C', { + items: [ + validItem('era_c_emendas', { brief: { title: 'Manchete curta', note: 'Nota curta' } }), + validItem('era_c_discursos', { brief: { note: 'sem título' } }), + ], + }), + ) + const emendas = research.items.find((item) => item.id === 'era_c_emendas') + const discursos = research.items.find((item) => item.id === 'era_c_discursos') + expect(emendas?.brief).toEqual({ title: 'Manchete curta', note: 'Nota curta' }) + expect(discursos?.brief).toBeNull() + }) + + it('warns when a brief exceeds the print budget (kept, so the A4 guard catches it)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const research = normalizeDossierResearchInput( + eraResearch('C', { + items: [ + validItem('era_c_emendas', { + brief: { title: 'x'.repeat(DOSSIER_BRIEF_TITLE_MAX + 1) }, + }), + ], + }), + ) + expect(warn).toHaveBeenCalled() + expect(research.items[0].brief?.title.length).toBe(DOSSIER_BRIEF_TITLE_MAX + 1) + expect(DOSSIER_BRIEF_NOTE_MAX).toBeGreaterThan(0) + } finally { + warn.mockRestore() + } + }) + it('throws when the slug, the date or the era is missing', () => { expect(() => normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }))).toThrow( /municipalitySlug/,