teqo-backup/tests/unit/briefingContent.unit.spec.ts
Francisco Solla c6620c2511 feat(C214): briefing 100% recorte — 4 folhas do recorte + âncora item-level (Closes #1284)
Replaneja o Briefing de capacitação C210: saem capa/objetivo/trajetória e as
recomendações genéricas (roteiro de 5 passos, justificativa científica, evitar,
conferir, limites); as 4 folhas passam a ser todas do recorte — defesas
(princípios + posições com fonte), essencial (fatos-âncora com fase + pedido
literal e uma linha de plano), qa 1/2 e qa 2/2.

- briefingContent: contrato novo (plan no lugar de script.steps; avoid/checklist
  removidos; shed qa → defesas) e âncora item-level por sourceUrl — o id de
  checklist repete e o ledger resolvia pelo primeiro item; par errado falha
  fechado (normalizeQa não repassava o sourceUrl, corrigido).
- briefingRender: 4 folhas novas (defesas/essencial/qa/qa-2) e companion .md;
  CSS portado do design (principles-copy/request-block/request-plan).
- build-dossie-solla-briefing: âncoras e comentários do fluxo novo.
- Design hi-fi refeito (4 cenas do recorte), skill e agente autor atualizados.
- Specs briefingContent/briefingRender reescritos; gate:fast verde (lint,
  typecheck, 389 arquivos / 4310 testes).
2026-09-23 13:17:37 -03:00

401 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
BRIEFING_LABEL,
BRIEFING_MINIMUMS,
BRIEFING_QA_SIDES,
briefingAnchorFact,
normalizeBriefingContent,
trimBriefing,
} from '../../scripts/lib/briefingContent.mjs'
import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs'
import {
mergeDossierResearch,
normalizeDossierResearchInput,
} from '../../scripts/lib/dossieResearch.mjs'
import { MUNICIPALITY_UNIT, THEME_UNIT } from '../../scripts/lib/dossieUnit.mjs'
// C210 (replanejado 2026-09-23): the authored briefing may only anchor on the
// dossiê ledger (facts with source) and is 100% recorte — princípios/crenças,
// defesas, fatos-âncora, uma linha de plano e Q&A. These tests pin the
// fail-closed validation, the deny-list and the deterministic shed order that
// keeps the four-sheet PDF honest.
const generatedAt = new Date('2026-09-22T12:00:00.000Z')
const snapshot = {
meta: { readAt: '2026-09-16T10:00:00.000Z', database: 'teqo_test' },
municipality: { slug: 'ilheus', name: 'Ilhéus', region: 'Litoral Sul', ibgeCode: '2913606' },
}
const research = mergeDossierResearch([
normalizeDossierResearchInput({
municipalitySlug: 'ilheus',
era: 'B',
researchedAt: '2026-09-16T10:00:00.000Z',
items: [
{
id: 'era_b_equipamentos',
answer: 'Ambulância e atenção domiciliar no município',
brief: { title: 'Ambulância e atenção domiciliar', note: 'Registro de 2012.' },
sourceUrl: 'https://saude.test/ambulancia',
sourceDate: '2012-01-25',
},
{
id: 'era_b_programas',
answer: 'Saúde em Movimento com menção direta',
sourceUrl: 'https://saude.test/programas',
sourceDate: '2013-05-10',
},
],
news: [],
gaps: [],
}),
normalizeDossierResearchInput({
municipalitySlug: 'ilheus',
era: 'C',
researchedAt: '2026-09-16T10:00:00.000Z',
items: [
{
id: 'era_c_atuacao',
answer: 'Articulação regional pela UFBA',
sourceUrl: 'https://camara.test/ufba',
sourceDate: '2023-09-22',
},
],
news: [],
gaps: [],
}),
])
const report = buildDossierReport({ snapshot, research, generatedAt })
const facts = report.bulletinFacts
const validRaw = (overrides: Record<string, unknown> = {}) => ({
unitId: 'municipality',
municipalitySlug: 'ilheus',
generatedAt: generatedAt.toISOString(),
subtitle: 'Litoral Sul · consulta antes e durante o contato',
lede: 'Universidade pública não é gasto: é projeto de país.',
defenses: [
{ factId: 'era_c_atuacao', title: 'Ensino superior', note: 'Defesa regional.' },
{ factId: 'era_b_programas', title: 'Saúde da Família', note: 'Defesa local.' },
],
essential: [
{ factId: 'era_b_equipamentos', title: 'Ambulância', note: 'Registro local de 2012.' },
{ factId: 'era_b_programas', title: 'Saúde em Movimento', note: 'Menção direta.' },
{ gapReason: 'sem fala própria localizada', title: 'Sem fala local', note: 'Anote a lacuna.' },
],
plan: 'Combine onde, quando e como votar e registre o compromisso nomeado.',
qa: [
{
side: 'direita',
question: 'Não voto no PT.',
acknowledge: 'Entendo que a escolha pesa.',
answer: 'Há registro nominal na saúde.',
close: 'Posso contar com o 1313?',
factId: 'era_b_equipamentos',
},
{
side: 'esquerda',
question: 'Por que ainda falta tanto?',
acknowledge: 'A cobrança é legítima.',
answer: 'O levantamento registra lacunas.',
close: 'Posso contar com o 1313?',
gapReason: 'sem registro localizado',
},
{
side: 'entrega',
question: 'Ele prometeu o quê?',
acknowledge: 'Promessa precisa estar registrada.',
answer: 'Não foi localizada promessa nominal.',
close: 'Pelo trabalho comprovado, vota 1313?',
gapReason: 'sem promessa nominal',
},
{
side: 'direita',
question: 'E o governo de Jerônimo?',
acknowledge: 'É justo cobrar o que chegou.',
answer: 'O briefing registra outros períodos.',
close: 'Posso pedir seu voto 1313?',
gapReason: 'sem entrega do governo atual',
},
{
side: 'esquerda',
question: 'Por que falar de Jacobina?',
acknowledge: 'Você tem razão em separar cidade e região.',
answer: 'As iniciativas aparecem como regionais.',
close: 'Posso contar com o 1313?',
factId: 'era_c_atuacao',
},
],
...overrides,
})
const normalize = (raw: Record<string, unknown> = validRaw()) =>
normalizeBriefingContent(raw, { unit: MUNICIPALITY_UNIT, slug: 'ilheus', facts })
describe('normalizeBriefingContent', () => {
it('resolves every anchor against the sourced ledger and keeps the lacuna explicit', () => {
const content = normalize()
expect(content.essential).toHaveLength(3)
expect(content.essential[0].fact?.sourceUrl).toBe('https://saude.test/ambulancia')
expect(content.essential[2].gapReason).toBe('sem fala própria localizada')
expect(content.defenses).toHaveLength(2)
expect(content.defenses[0].fact?.sourceUrl).toBe('https://camara.test/ufba')
expect(content.qa).toHaveLength(5)
expect(content.plan).toMatch(/compromisso nomeado/)
expect(content.warnings).toEqual([])
})
it('rejects a unitId or slug from another recorte', () => {
expect(() => normalize(validRaw({ unitId: 'theme' }))).toThrow(/unitId/)
expect(() => normalize(validRaw({ municipalitySlug: 'itabuna' }))).toThrow(/não é "ilheus"/)
})
it('requires generatedAt, a lede and the one-line plan', () => {
expect(() => normalize(validRaw({ generatedAt: 'ontem' }))).toThrow(/generatedAt inválido/)
expect(() => normalize(validRaw({ lede: '' }))).toThrow(/"lede" obrigatório/)
expect(() => normalize(validRaw({ plan: '' }))).toThrow(/"plan" obrigatório/)
})
it('demands exactly one of factId | gapReason per item', () => {
const both = validRaw()
const essential = (both.essential as Record<string, unknown>[]).map((item, index) =>
index === 0 ? { ...item, gapReason: 'também lacuna' } : item,
)
expect(() => normalize({ ...both, essential })).toThrow(
/exatamente um de "factId" \| "gapReason"/,
)
const neither = (both.essential as Record<string, unknown>[]).map((item, index) =>
index === 0 ? { title: 'x', note: 'y' } : item,
)
expect(() => normalize({ ...both, essential: neither })).toThrow(/exatamente um/)
})
it('fails closed when the factId does not resolve in a sourced fact', () => {
const raw = validRaw()
const essential = (raw.essential as Record<string, unknown>[]).map((item, index) =>
index === 0 ? { ...item, factId: 'era_z_inexistente' } : item,
)
expect(() => normalize({ ...raw, essential })).toThrow(/não resolve em fato com fonte/)
expect(() =>
normalizeBriefingContent(validRaw(), {
unit: MUNICIPALITY_UNIT,
slug: 'ilheus',
facts: [{ id: 'era_b_equipamentos' }],
}),
).toThrow(/não resolve em fato com fonte/)
})
it('blocks staff-only/scenario keys recursively', () => {
expect(() => normalize(validRaw({ scenario: { estimatedVotes: 1234 } }))).toThrow(
/cenário\/estimativa é staff-only/,
)
expect(() =>
normalize(
validRaw({
essential: [{ factId: 'era_b_equipamentos', title: 'x', note: 'y', estimatedVotes: 10 }],
}),
),
).toThrow(/proibida/)
})
it('covers both sides and enforces the list minimums', () => {
const raw = validRaw()
expect(() =>
normalize({
...raw,
qa: (raw.qa as Record<string, unknown>[]).map((item) =>
item.side === 'esquerda' ? { ...item, side: 'direita' } : item,
),
}),
).toThrow(/dois lados/)
expect(() => normalize({ ...raw, qa: [] })).toThrow(/qa: mínimo de 4/)
expect(() => normalize({ ...raw, essential: [] })).toThrow(/essential: mínimo de 3/)
})
it('reports over-cap text as a warning, never as a hard failure', () => {
const raw = validRaw()
const content = normalize({ ...raw, lede: 'a'.repeat(500), plan: 'b'.repeat(300) })
expect(content.warnings.join('\n')).toMatch(/lede: 500 chars/)
expect(content.warnings.join('\n')).toMatch(/plan: 300 chars/)
})
it('resolves anchors through the unit slugField (theme seam)', () => {
const themeFacts = ['era_b_equipamentos', 'era_b_programas', 'era_c_atuacao'].map((id) => ({
id,
sourceUrl: `https://x.test/${id}`,
sourceDate: '2011-08-30',
}))
const raw = validRaw({ unitId: 'theme' })
expect(() =>
normalizeBriefingContent(raw, { unit: THEME_UNIT, slug: 'educacao', facts: themeFacts }),
).toThrow(/themeSlug/)
const content = normalizeBriefingContent(
{ ...raw, themeSlug: 'educacao' },
{ unit: THEME_UNIT, slug: 'educacao', facts: themeFacts },
)
expect(content.slug).toBe('educacao')
expect(THEME_UNIT.briefingNoun).toBe('tema')
})
it('refuses the acervo sample (sourcePanel) as an anchor', () => {
const panelFacts = [
{
id: 'fala-1',
headline: 'Pronunciamento',
sourceUrl: 'https://x.test/fala',
sourcePanel: true,
},
]
expect(briefingAnchorFact(panelFacts, 'fala-1')).toBeNull()
expect(() =>
normalizeBriefingContent(validRaw(), {
unit: MUNICIPALITY_UNIT,
slug: 'ilheus',
facts: panelFacts,
}),
).toThrow(/não resolve em fato com fonte/)
})
it('resolves a repeated id through the item sourceUrl and fails closed on a wrong pair', () => {
const duplicated = [
{
id: 'era_b_equipamentos',
sourceUrl: 'https://saude.test/ambulancia-2',
sourceDate: '2019-01-01',
},
...facts,
]
const raw = validRaw({
essential: [
{
factId: 'era_b_equipamentos',
sourceUrl: 'https://saude.test/ambulancia-2',
title: 'Ambulância nova',
note: 'Registro de 2019.',
},
{ factId: 'era_b_programas', title: 'Saúde em Movimento', note: 'Menção direta.' },
{
gapReason: 'sem fala própria localizada',
title: 'Sem fala local',
note: 'Anote a lacuna.',
},
],
})
const content = normalizeBriefingContent(raw, {
unit: MUNICIPALITY_UNIT,
slug: 'ilheus',
facts: duplicated,
})
expect(content.essential[0].fact?.sourceDate).toBe('2019-01-01')
const wrongPair = (raw.essential as Record<string, unknown>[]).map((item, index) =>
index === 0 ? { ...item, sourceUrl: 'https://saude.test/nao-existe' } : item,
)
expect(() =>
normalizeBriefingContent(
{ ...raw, essential: wrongPair },
{ unit: MUNICIPALITY_UNIT, slug: 'ilheus', facts: duplicated },
),
).toThrow(/não resolve em fato com fonte/)
})
it('reports qa caps with the real field names, once each', () => {
const raw = validRaw()
const qa = (raw.qa as Record<string, unknown>[]).map((item, index) =>
index === 0 ? { ...item, question: 'q'.repeat(300), answer: 'a'.repeat(700) } : item,
)
const content = normalize({ ...raw, qa })
expect(content.warnings.join('\n')).toContain('qa[0].question: 300 chars')
expect(content.warnings.join('\n')).toContain('qa[0].answer: 700 chars')
expect(content.warnings).toHaveLength(2)
})
it('keeps the label literal and the qa sides pinned for the renderer', () => {
expect(BRIEFING_LABEL).toBe('Insumo interno de capacitação — não publicar')
expect(BRIEFING_QA_SIDES).toEqual(['direita', 'esquerda', 'entrega'])
})
})
describe('briefingAnchorFact', () => {
it('returns the ledger fact or null', () => {
expect(briefingAnchorFact(facts, 'era_b_programas')?.sourceUrl).toBe(
'https://saude.test/programas',
)
expect(briefingAnchorFact(facts, 'não-existe')).toBeNull()
})
it('disambiguates a repeated id by the exact sourceUrl', () => {
const duplicated = [
{
id: 'era_b_programas',
sourceUrl: 'https://saude.test/programas',
sourceDate: '2013-05-10',
},
{
id: 'era_b_programas',
sourceUrl: 'https://saude.test/programas-2',
sourceDate: '2020-01-01',
},
]
expect(briefingAnchorFact(duplicated, 'era_b_programas')?.sourceDate).toBe('2013-05-10')
expect(
briefingAnchorFact(duplicated, 'era_b_programas', 'https://saude.test/programas-2')
?.sourceDate,
).toBe('2020-01-01')
expect(briefingAnchorFact(duplicated, 'era_b_programas', 'https://nope.test')).toBeNull()
})
})
describe('trimBriefing (deterministic shed order)', () => {
const content = normalize()
it('drops qa first, then defenses, then stops', () => {
const first = trimBriefing(content)!
expect(first.qa).toHaveLength(BRIEFING_MINIMUMS.qa)
expect(first.shed).toEqual({ qa: 1, defenses: 0 })
const second = trimBriefing(first)!
expect(second.defenses).toHaveLength(1)
expect(second.shed).toEqual({ qa: 1, defenses: 1 })
const third = trimBriefing(second)!
expect(third.defenses).toHaveLength(0)
expect(third.shed).toEqual({ qa: 1, defenses: 2 })
expect(trimBriefing(third)).toBeNull()
})
it('never silences a required side when shedding qa', () => {
const base = normalize()
const qa = [
{ ...base.qa[1], side: 'esquerda' },
{ ...base.qa[2], side: 'esquerda' },
{ ...base.qa[3], side: 'entrega' },
{ ...base.qa[4], side: 'esquerda' },
{ ...base.qa[0], side: 'direita' },
]
const trimmed = trimBriefing({ ...base, qa })!
expect(trimmed.qa).toHaveLength(BRIEFING_MINIMUMS.qa)
expect(trimmed.shed.qa).toBe(1)
const sides = new Set(trimmed.qa.map((item: { side: string }) => item.side))
expect(sides.has('direita')).toBe(true)
expect(sides.has('esquerda')).toBe(true)
// The unique "direita" (last item) survives — the shed picked an "esquerda".
expect(trimmed.qa.at(-1)?.side).toBe('direita')
})
it('never touches the essential, the principles copy or the plan', () => {
let current = content
let next = trimBriefing(current)
let guard = 0
while (next && guard < 50) {
current = next
next = trimBriefing(current)
guard += 1
}
expect(current.qa).toHaveLength(BRIEFING_MINIMUMS.qa)
expect(current.defenses).toHaveLength(0)
expect(current.essential).toEqual(content.essential)
expect(current.lede).toBe(content.lede)
expect(current.plan).toBe(content.plan)
expect(next).toBeNull()
})
})