mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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).
401 lines
14 KiB
TypeScript
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()
|
|
})
|
|
})
|