From 92075538636fced8fc9da7e0b5df2bbd60b24494 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 18 Sep 2026 18:46:31 -0300 Subject: [PATCH] =?UTF-8?q?fix(C190):=20boletim=20de=20uma=20p=C3=A1gina?= =?UTF-8?q?=20cabe=20por=20medi=C3=A7=C3=A3o=20(painel=20de=20acervo=20+?= =?UTF-8?q?=20fit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O run real (Educação) entregava o dossiê mas o boletim media 3502px vs 1133px úteis: os 15 fatos do acervo (sphere area) dominavam os destaques com o summary integral e não havia contador nem fit no one-pager. - dossieBlocks marca as falas do acervo com sourcePanel: true; buildBulletin as mantém contadas no "e mais N" e fora das vagas impressas (destaques/E mais ficam com findings com fonte) - buildBulletin ganha printLimit; emitHtmlPairPdf ganha onBulletinOverflow (espelho do onDossierOverflow do C188) e o builder do tema re-renderiza com 2 fatos a menos até caber, fail-closed - boletim subject (tema/instituição) ganha o contador C188 que só o município tinha, com copy.remaining por descritor - contrato do tema documenta brief/summary (C188) na skill e no subagente researcher --- .agents/skills/dossie-solla-tema/SKILL.md | 21 ++++++- .opencode/agent/dossie-solla-tema.md | 5 +- docs/changelog/2026-09-18-c190-boletim-fit.md | 1 + scripts/build-dossie-solla-tema.mjs | 41 ++++++++----- scripts/lib/buildPdf.mjs | 57 +++++++++++++++---- scripts/lib/dossieBlocks.mjs | 18 +++++- scripts/lib/dossieBulletin.mjs | 36 +++++++----- scripts/lib/dossieBulletinRender.mjs | 6 ++ scripts/lib/dossieUnit.mjs | 2 + tests/unit/dossieTheme.unit.spec.ts | 45 +++++++++++++++ 10 files changed, 188 insertions(+), 44 deletions(-) create mode 100644 docs/changelog/2026-09-18-c190-boletim-fit.md diff --git a/.agents/skills/dossie-solla-tema/SKILL.md b/.agents/skills/dossie-solla-tema/SKILL.md index 2399ab73..f83beddb 100644 --- a/.agents/skills/dossie-solla-tema/SKILL.md +++ b/.agents/skills/dossie-solla-tema/SKILL.md @@ -194,7 +194,12 @@ consolidação determinística. { "id": "era_c_emendas", // id do checklist da era "answer": "R$ 5,0 milhões para a rede de ensino", + "summary": "opcional — versão curta, completa e auto-contida do answer, redigida para caber no resumo/boletim (C188)", "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": "area", // area | segmento | rede (default area) "numbers": [ // opcional { "label": "Emenda", "value": "R$ 5,0 mi", "year": "2024", "phase": "empenhado" } @@ -229,6 +234,17 @@ vira lacuna. `phase` ∈ `autorizado|empenhado|liquidado|pago|restos` (default `nao_informado`; empenho **não** é pagamento). O `bulletinFacts` (ledger do boletim) só é populado por item **com fonte** — o boletim não introduz fato novo. +**`brief` (copy reformulada, sem reticências) e `summary` (C188).** As páginas +A4 têm altura fixa: o `brief` é a copy que o PDF imprime nas páginas de era, nas +listas de abrangência e nos cartões do boletim (o `answer`/`details` integrais +ficam no `.md` e no lastro); sem ele o builder imprime o texto integral e a +página pode estourar. Escreva `brief.title` ≤ 80 e `brief.note` ≤ 120 chars para +**todo item publicado**; o `summary` alimenta as superfícies de resumo. O +boletim de uma página **mede e reduz por medição**: o painel do acervo (falas +com link) entra no contador "e mais N fatos com fonte", nunca nos destaques; se +ainda não couber, o builder re-renderiza com menos fatos impressos e declara o +resto — sem truncar texto e sem descarte silencioso. + `.narrative.json` (opcional; escrito pelo redator, auditado pelo orquestrador): @@ -299,7 +315,10 @@ nenhum número, data, nome ou órgão novo. abrangência", número em destaque, título curto, nota); timeline de 4 passos da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com controle editorial e defeso. Com **poucos fatos**, usa a variação de lacuna (não cria - cards vazios para chegar a seis). + cards vazios para chegar a seis). O painel do acervo **não ocupa vaga de + destaque**: entra no contador "e mais N fatos com fonte"; se os fatos + impressos ainda estourarem a folha, o builder mede e reduz o conjunto + impresso até caber, declarando o resto — nunca corta texto. - **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem CTA de campanha; herda **apenas** fatos com fonte do dossiê. diff --git a/.opencode/agent/dossie-solla-tema.md b/.opencode/agent/dossie-solla-tema.md index aa0b58e0..8f58f372 100644 --- a/.opencode/agent/dossie-solla-tema.md +++ b/.opencode/agent/dossie-solla-tema.md @@ -18,7 +18,10 @@ slug da área na taxonomia e coordena as demais eras/áreas em paralelo. 2. Fazer a **pesquisa web datada** da era e escrever `data/dossie-solla-tema/..research.json` no contrato da skill (ex.: `educacao.c.research.json`) — item sem `sourceUrl`/`sourceDate` não vai - para o arquivo (vira lacuna explícita). Use o checklist da era e marque + para o arquivo (vira lacuna explícita). Todo item publicado leva `brief` + (`title` ≤ 80, `note` ≤ 120 chars) e, quando ajudar o resumo, `summary` + curto e auto-contido (C188): é a copy que o PDF e o boletim imprimem; sem ela + o texto integral pode estourar a página. Use o checklist da era e marque `sphere` (`area`/`segmento`/`rede`); segmento e rede **nunca** são somados à área. 3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill): diff --git a/docs/changelog/2026-09-18-c190-boletim-fit.md b/docs/changelog/2026-09-18-c190-boletim-fit.md new file mode 100644 index 00000000..58cd2109 --- /dev/null +++ b/docs/changelog/2026-09-18-c190-boletim-fit.md @@ -0,0 +1 @@ +**C190-fit — boletim de uma página cabe por medição (2026-09-18):** o primeiro run real do `/dossie-solla-tema` (Educação) entregou o dossiê mas **não** o boletim: com o acervo real (15 falas) o one-pager media 3502px contra 1133px úteis. Três correções no dono, sem geminar pipeline: (1) **acervo é painel de fontes, não achado** — os fatos de fala entram no `bulletinFacts` marcados `sourcePanel: true` (`dossieBlocks`) e o `buildBulletin` os mantém **contados** no "e mais N fatos com fonte" mas **fora das vagas impressas** (destaques e "E mais"), que passam a ser só findings com fonte; (2) **fit por medição** — `emitHtmlPairPdf` ganha `onBulletinOverflow` (espelho do `onDossierOverflow` do C188), e o builder do tema re-renderiza com `printLimit` dois fatos menor até a folha caber, sempre declarando o resto (fail-closed se o callback não encolher); (3) **contador C188 no boletim `subject`** — tema/instituição não exibiam o "e mais N" que o C188 exige (o renderer de município já tinha); o rodapé ganha `copy.remaining` por descritor ("no dossiê da área/da instituição"). O contrato do tema (skill + subagente) passa a documentar `brief` (title ≤80 / note ≤120, obrigatório no item publicado) e `summary` (C188) — a copy impressa que faltava deixava o `answer`/`details` integrais irem para as páginas. Specs novos em `dossieTheme.unit.spec.ts` (painel contado fora dos destaques; `printLimit` encolhe e declara o resto); specs existentes de município/instituição seguem verdes (testes 3772/3772). Sem schema, migration, URL pública, Consent ou LGPD — scripts/skill/testes apenas. diff --git a/scripts/build-dossie-solla-tema.mjs b/scripts/build-dossie-solla-tema.mjs index bb82e4fe..4199733f 100644 --- a/scripts/build-dossie-solla-tema.mjs +++ b/scripts/build-dossie-solla-tema.mjs @@ -148,25 +148,29 @@ const report = buildDossierReport({ unit: THEME_UNIT, narrative, }) -const bulletin = buildBulletin({ - facts: report.bulletinFacts, - identity: { - name: report.meta.subjectName, - badges: report.meta.identityBadges, - value: report.meta.identity?.value ?? null, - taxonomyNote: report.meta.identity?.taxonomyNote ?? null, - }, - unit: THEME_UNIT, - generatedAt, -}) +const bulletinIdentity = { + name: report.meta.subjectName, + badges: report.meta.identityBadges, + value: report.meta.identity?.value ?? null, + taxonomyNote: report.meta.identity?.taxonomyNote ?? null, +} +const buildThemeBulletin = (printLimit = null) => + buildBulletin({ + facts: report.bulletinFacts, + identity: bulletinIdentity, + unit: THEME_UNIT, + generatedAt, + printLimit, + }) + +let bulletin = buildThemeBulletin() +let bulletinHtml = renderBulletinHtml(bulletin) const dossierMd = renderDossierMd(report) -const bulletinHtml = renderBulletinHtml(bulletin) const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}` await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true }) -await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml) await mkdir(resolve(ROOT, outDir), { recursive: true }) const dossierMdFile = join(outDir, `${baseName}-dossie.md`) @@ -229,7 +233,18 @@ try { bulletinHtml, dossierPdf: resolve(ROOT, dossierPdfFile), bulletinPdf: resolve(ROOT, bulletinPdfFile), + // One-page fit by measurement (C188/C190): while the boletim overflows, + // rebuild it with two fewer printed facts — the source panel and the facts + // beyond the cap stay counted in the "e mais N" line, never truncated. + onBulletinOverflow: async () => { + const next = bulletin.factsPrinted - 2 + if (bulletin.factsPrinted === 0 || next < 0) return null + bulletin = buildThemeBulletin(next) + bulletinHtml = renderBulletinHtml(bulletin) + return bulletinHtml + }, }) + await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml) } catch (error) { die(error instanceof Error ? error.message : String(error)) } finally { diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs index 0f2909ec..e5385066 100644 --- a/scripts/lib/buildPdf.mjs +++ b/scripts/lib/buildPdf.mjs @@ -19,6 +19,14 @@ export const A4_WIDTH_PX = Math.round(210 * MM_TO_PX) export const PAGE_FIT_TOLERANCE_PX = 10 export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX +/** + * Bounded re-renders of the one-page boletim while an `onBulletinOverflow` + * callback keeps handing back a shorter document (C190). Each pass drops at + * least one printed fact, so the cap only exists to fail closed on a callback + * that stops shrinking. + */ +const MAX_BULLETIN_FIT_ATTEMPTS = 24 + export const launchPdfBrowser = () => chromium.launch() /** Every `[data-page]` taller than the budget, in document order. */ @@ -166,13 +174,19 @@ const printA4Pdf = (page, outPath) => /** * One browser run, two HTML documents: sets each document, asserts its anchor - * exists, guards the fit and emits the PDF. Shared by the C186 (city) and C187 - * (institution) builders — the pages differ, the emit contract does not. + * exists, guards the fit and emits the PDF. Shared by the C186 (city), C187 + * (institution) and C190 (theme) builders — the pages differ, the emit + * contract does not. * * C188 fit fallback: when `onDossierOverflow` is given, only the page-1 sheet * (`resumoOnly`) is measured first; if it overflows, the callback rebuilds the * document (pointer lines, never "…") and the fit is checked again — still * fail-closed if even the pointer does not fit. + * + * C190 bulletin fit: the one-pager is measured and, when `onBulletinOverflow` + * is given, the callback rebuilds it with fewer printed facts (the remainder + * is declared in the "e mais N" counter) until the A4 holds — still fail-closed + * when the callback cannot shrink any further. */ export const emitHtmlPairPdf = async ( browser, @@ -182,6 +196,7 @@ export const emitHtmlPairPdf = async ( dossierPdf, bulletinPdf, onDossierOverflow = null, + onBulletinOverflow = null, resumoOnly = false, }, ) => { @@ -226,17 +241,35 @@ export const emitHtmlPairPdf = async ( } await printA4Pdf(page, dossierPdf) - await page.setContent(bulletinHtml, { waitUntil: 'load' }) + let currentBulletinHtml = bulletinHtml + await page.setContent(currentBulletinHtml, { waitUntil: 'load' }) await page.emulateMedia({ media: 'print' }) - const hasBulletin = await page.evaluate(() => - Boolean(document.querySelector('[data-page="boletim"]')), - ) - if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.') - assertPageFits( - await measurePageOverflows(page, A4_PAGE_BUDGET_PX), - 'O boletim', - 'Corte itens/caps — o boletim TEM de caber em uma página.', - ) + for (let attempt = 0; ; attempt += 1) { + const hasBulletin = await page.evaluate(() => + Boolean(document.querySelector('[data-page="boletim"]')), + ) + if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.') + const overflows = await measurePageOverflows(page, A4_PAGE_BUDGET_PX) + if (overflows.length === 0) break + if (!onBulletinOverflow || attempt >= MAX_BULLETIN_FIT_ATTEMPTS) { + assertPageFits( + overflows, + 'O boletim', + 'Corte itens/caps — o boletim TEM de caber em uma página.', + ) + } + const rebuilt = await onBulletinOverflow() + if (!rebuilt) { + assertPageFits( + overflows, + 'O boletim', + 'Corte itens/caps — o boletim TEM de caber em uma página.', + ) + } + currentBulletinHtml = rebuilt + await page.setContent(currentBulletinHtml, { waitUntil: 'load' }) + await page.emulateMedia({ media: 'print' }) + } await printA4Pdf(page, bulletinPdf) await page.close() diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs index da9e87da..21e62218 100644 --- a/scripts/lib/dossieBlocks.mjs +++ b/scripts/lib/dossieBlocks.mjs @@ -419,7 +419,11 @@ const buildMunicipalityReport = ({ sourceUrl: row.sourceUrl, sourceDate: row.sourceDate, })), - ...speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) })), + ...speechFacts.map((fact) => ({ + ...fact, + headline: stripInlineSources(fact.headline), + sourcePanel: true, + })), ] const generatedAtLabel = formatDateTimeBr(generatedAt) @@ -557,7 +561,9 @@ const uncapped = (list) => ({ items: list, total: list.length, omitted: 0 }) /** * Speeches from the internal acervo (read-only snapshot) that the research did * not already carry: they are sourced evidence for the recorte, so they feed - * the bulletin ledger too — never a second, unsourced fact. + * the bulletin ledger too — never a second, unsourced fact. They enter as a + * declared source panel (`sourcePanel: true`): counted in the one-pager, never + * displacing a finding from the printed slots. */ const speechFactsFromRows = (snapshot, sphere) => (snapshot.speeches?.rows ?? []) @@ -1068,7 +1074,13 @@ const buildSubjectReport = (params) => { sourceUrl: item.sourceUrl, sourceDate: item.sourceDate, })) - .concat(speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) }))) + .concat( + speechFacts.map((fact) => ({ + ...fact, + headline: stripInlineSources(fact.headline), + sourcePanel: true, + })), + ) const generatedAtLabel = formatDateTimeBr(generatedAt) const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—' diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs index 96fd2a94..85470607 100644 --- a/scripts/lib/dossieBulletin.mjs +++ b/scripts/lib/dossieBulletin.mjs @@ -30,12 +30,13 @@ const rankFacts = (facts, unit) => { /** * @param {{ - * facts?: Array<{ id: string, era: string, sphere: string, area: string, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string }>, + * facts?: Array<{ id: string, era: string, sphere: string, area: string, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string, sourcePanel?: boolean }>, * municipality?: string, * region?: string | null, * identity?: { name: string, badges?: string[] } | null, * unit?: any, * generatedAt?: Date, + * printLimit?: number | null, * }} params */ export const buildBulletin = ({ @@ -45,11 +46,20 @@ export const buildBulletin = ({ identity = null, unit, generatedAt = new Date(), + printLimit = null, }) => { const resolvedUnit = resolveDossierUnit(unit) const ordered = rankFacts(facts, resolvedUnit) + // The acervo sample is a source panel, not a finding (C190): it is counted in + // the one-pager but never takes a printed slot from a sourced finding. + const printable = ordered.filter((fact) => !fact.sourcePanel) + const target = + printLimit === null + ? BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT) + : Math.max(0, printLimit) + const printed = printable.slice(0, target) const subjectName = municipality ?? identity?.name ?? '—' - const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => { + const highlights = printed.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => { // Theme-only fallback: a fact without money value shows its year; without // either, the card is textual and prints no number slot at all (never zero). const number = resolvedUnit.bulletinNumberFallback @@ -65,20 +75,15 @@ export const buildBulletin = ({ phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null, } }) - const moreItems = ordered - .slice( - BULLETIN_HIGHLIGHT_LIMIT, - BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT), - ) - .map((fact) => ({ - label: fact.brief?.title ?? fact.headline, - detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area, - })) + const moreItems = printed.slice(BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({ + label: fact.brief?.title ?? fact.headline, + detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area, + })) const shown = highlights.length + moreItems.length return { unit: resolvedUnit, - sparse: ordered.length < 3, + sparse: printable.length < 3, meta: { title: resolvedUnit.bulletinTitle, kicker: resolvedUnit.bulletinKicker ?? 'Boletim informativo · atuação pública', @@ -95,9 +100,12 @@ export const buildBulletin = ({ }, highlights, moreItems, - // Never a silent drop (C188): the facts beyond the two caps are counted so - // the one-page boletim states how much of the sourced set it left out. + // Never a silent drop (C188): the facts beyond the two caps — and the whole + // source panel — are counted so the one-page boletim states how much of the + // sourced set it left out. factsTotal: ordered.length, + factsPrintable: printable.length, + factsPrinted: shown, factsRemaining: ordered.length - shown, timeline: BULLETIN_TIMELINE_STEPS, } diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs index 163df737..f3a70cd5 100644 --- a/scripts/lib/dossieBulletinRender.mjs +++ b/scripts/lib/dossieBulletinRender.mjs @@ -229,6 +229,7 @@ const SUBJECT_BULLETIN_CSS = ` .bulletin-timeline { margin-top: 2.5mm; } .more { margin-top: 2.5mm; } .empty-box { min-height: 30mm; } + .more-counter { margin-top: 1.8mm; color: #435264; font-size: 7.6pt; font-weight: 600; } .lacuna-panel .warn-icon { width: 5mm; height: 5mm; flex-shrink: 0; margin-top: .4mm; color: #8a5a18; } .highlight-phase { margin: 1.2mm 0 0; } .highlight-card--textual { border-top-color: #435264; } @@ -341,6 +342,11 @@ const renderSubjectBulletinHtml = (bulletin) => { ? `
${more.map(renderMoreItem).join('')}
` : `

Sem itens adicionais com fonte além dos destaques.

${htmlEscape(copy.moreEmpty)}

` } + ${ + bulletin.factsRemaining > 0 + ? `

${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} ${htmlEscape(copy.remaining)}

` + : '' + } ${ sparse diff --git a/scripts/lib/dossieUnit.mjs b/scripts/lib/dossieUnit.mjs index 3ae62731..a4127137 100644 --- a/scripts/lib/dossieUnit.mjs +++ b/scripts/lib/dossieUnit.mjs @@ -206,6 +206,7 @@ export const INSTITUTION_UNIT = { defeso: 'fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.', orig: 'Conteúdo selecionado do dossiê institucional.', + remaining: 'no dossiê da instituição — o boletim de uma página não os exibe.', sparseRule: 'Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.', title: 'Uma trajetória de compromisso com o serviço público', @@ -411,6 +412,7 @@ export const THEME_UNIT = { defeso: 'fase acompanha cada valor · área, segmento e rede não são somados · sem percentual estadual absoluto.', orig: 'Conteúdo selecionado do dossiê da área.', + remaining: 'no dossiê da área — o boletim de uma página não os exibe.', sparseRule: 'Não repetir item, não ampliar efeito e não preencher com ação de segmento ou rede como se fosse entrega exclusiva da área.', title: 'Uma trajetória de compromisso com o serviço público', diff --git a/tests/unit/dossieTheme.unit.spec.ts b/tests/unit/dossieTheme.unit.spec.ts index 78d0a68f..e8859aa8 100644 --- a/tests/unit/dossieTheme.unit.spec.ts +++ b/tests/unit/dossieTheme.unit.spec.ts @@ -516,3 +516,48 @@ describe('buildBulletin (theme unit)', () => { expect(sparseHtml).toContain('entrega exclusiva da área') }) }) + +describe('buildBulletin (theme unit, one-page fit)', () => { + const identity = { + name: report.meta.subjectName, + badges: report.meta.identityBadges, + value: report.meta.identity?.value ?? null, + taxonomyNote: report.meta.identity?.taxonomyNote ?? null, + } + const fala = report.bulletinFacts.find((fact) => String(fact.id).startsWith('fala-')) + + it('marks the acervo as a declared source panel, out of the printed slots', () => { + expect(fala).toMatchObject({ sourcePanel: true }) + const bulletin = buildBulletin({ + facts: fala ? [fala] : [], + identity, + unit: THEME_UNIT, + generatedAt, + }) + expect(bulletin.highlights).toHaveLength(0) + expect(bulletin.moreItems).toHaveLength(0) + expect(bulletin.factsTotal).toBe(1) + expect(bulletin.factsPrinted).toBe(0) + expect(bulletin.factsRemaining).toBe(1) + expect(bulletin.sparse).toBe(true) + const html = renderBulletinHtml(bulletin) + expect(html).not.toContain(fala?.headline ?? '') + expect(html).toContain('e mais 1 fato com fonte no dossiê da área') + }) + + it('shrinks the printed set on printLimit and declares every remaining fact', () => { + const facts = report.bulletinFacts.filter((fact) => !fact.sourcePanel) + const full = buildBulletin({ facts, identity, unit: THEME_UNIT, generatedAt }) + const fitted = buildBulletin({ facts, identity, unit: THEME_UNIT, generatedAt, printLimit: 2 }) + expect(full.highlights).toHaveLength(6) + expect(full.moreItems).toHaveLength(0) + expect(fitted.highlights).toHaveLength(2) + expect(fitted.moreItems).toHaveLength(0) + expect(fitted.factsPrinted).toBe(2) + expect(fitted.factsPrintable).toBe(facts.length) + expect(fitted.factsRemaining).toBeGreaterThan(full.factsRemaining) + expect(renderBulletinHtml(fitted)).toContain( + `e mais ${fitted.factsRemaining} fatos com fonte no dossiê da área`, + ) + }) +})