teqo-backup/tests/unit/buildChartFromData.unit.spec.ts
Francisco Solla 03f1a80ee1 feat(C237-C239): colunas emparelhadas e motion determinístico no gerador de gráficos
- C237 (CENA 02F): --paired desenha duas colunas zero-based por categoria
  (inicial cinza, recente vermelha, valência positiva compartilhada), com
  legenda de períodos, valor sobre cada barra e rótulo sob o par; até 4
  categorias; design DEGRADED com sign-off humano de 2026-09-29.
- C238/C239 (CENAS 02G/02H): --motion gera MP4 H.264 local (6s @ 30fps,
  frames determinísticos por seek + ffmpeg do repo) para o par emparelhado,
  a coluna de 2 períodos e o número-âncora; C238 com sign-off humano de
  2026-09-30 e C239 aguardando gate.
- Superfície: CLI, SKILL.md, hi-fi e specs novos (chartMotion/
  chartMotionRender; SCRIPTS_SPEC_PINNED sincronizado).

Design tier: DEGRADED (inline, tier 4 — frontier sem quota).
2026-09-29 22:13:10 -03:00

679 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment node
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { main } from '../../scripts/build-chart-from-data.mjs'
// C191-runtime: the entry used to run `main()` at import and call
// `process.exit` on failure, so the unit suite could not touch it. The seam is
// injectable effects + an `isMain` guard: every path below runs without
// Chromium and without killing the worker.
const tempDir = () => mkdtemp(join(tmpdir(), 'c191-entry-'))
const throwingDie = (message: string): never => {
throw new Error(message)
}
const launchTracker = () => {
const close = vi.fn()
const launchBrowser = vi.fn(async () => ({ close }))
return { launchBrowser, close }
}
const screenshotStub = () =>
vi.fn(async (_browser: unknown, _options: { html: string; width: number; height: number }) => ({
size: 2048,
}))
const writeInput = async (dir: string, body: string) => {
const path = join(dir, 'dados.txt')
await writeFile(path, body)
return path
}
let dir: string
let stdout: string[]
beforeEach(async () => {
dir = await tempDir()
stdout = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdout.push(String(chunk))
return true
})
// Vitest owns the console, so the log line is captured here (not through the
// stream spy above).
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
stdout.push(args.map(String).join(' '))
})
})
afterEach(async () => {
vi.restoreAllMocks()
await rm(dir, { recursive: true, force: true })
})
describe('main — inspect and question paths', () => {
it('prints the parsed dataset for --inspect without launching a browser', async () => {
const input = await writeInput(dir, 'Ilhéus 84\nItabuna 68\n')
const { launchBrowser } = launchTracker()
await main({ argv: [`--in=${input}`, '--inspect'], repoRoot: dir, launchBrowser })
expect(launchBrowser).not.toHaveBeenCalled()
expect(JSON.parse(stdout.join(''))).toEqual({
rows: [
{ label: 'Ilhéus', value: 84 },
{ label: 'Itabuna', value: 68 },
],
format: 'txt',
issues: [],
})
})
it('emits needsQuestion and dies instead of drawing when a value is missing', async () => {
const input = await writeInput(dir, 'Ilhéus 84\nItabuna ???\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${input}`, '--headline=X', '--source=TSE'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/pergunte/)
expect(launchBrowser).not.toHaveBeenCalled()
const payload = JSON.parse(stdout.join(''))
expect(payload.needsQuestion).toBe(true)
expect(payload.issues.join(' ')).toContain('Itabuna')
})
it('requires --in when there is no --spec', async () => {
await expect(main({ argv: ['--headline=X'], repoRoot: dir, die: throwingDie })).rejects.toThrow(
/--in=/,
)
})
it('fails closed when --in cannot be read', async () => {
await expect(
main({
argv: [`--in=${join(dir, 'nao-existe.csv')}`],
repoRoot: dir,
die: throwingDie,
}),
).rejects.toThrow(/falha ao ler/)
})
it('fails closed when --spec is invalid JSON', async () => {
const specPath = join(dir, 'quebrado.json')
await writeFile(specPath, '{')
await expect(
main({ argv: [`--spec=${specPath}`], repoRoot: dir, die: throwingDie }),
).rejects.toThrow(/falha ao ler --spec/)
})
it('refuses to render without --source', async () => {
const input = await writeInput(dir, 'Ilhéus 84\nItabuna 68\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${input}`, '--headline=X'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/source/)
expect(launchBrowser).not.toHaveBeenCalled()
})
})
describe('main — spec assembly, replay and the per-size log', () => {
const fiveRows = 'A 5\nB 4\nC 3\nD 2\nE 1\n'
it.each([
['feed', 1080, 1350, 7],
['square', 1080, 1080, 7],
['story', 1080, 1920, 5],
])('assembles the spec and logs the %s canvas (cap %i)', async (size, width, height, cap) => {
const input = await writeInput(dir, fiveRows)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const outPath = join(dir, `${size}.png`)
await main({
argv: [
`--in=${input}`,
'--headline=Ranking de teste',
'--source=TSE 2022',
'--type=bar',
`--size=${size}`,
'--highlight=B',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(screenshot).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ width, height, outPath }),
)
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(join(dir, 'data/graficos-instagram/ranking-de-teste.chart-spec.json'), 'utf8'),
)
expect(spec).toMatchObject({
chartType: 'bar',
size,
headline: 'Ranking de teste',
source: 'TSE 2022',
highlight: 'B',
})
expect(spec.rows).toHaveLength(5)
const log = stdout.join('')
expect(log).toContain(`(${width}×${height}`)
expect(log).toContain(`≤ ${cap}`)
expect(log).toContain('tipo=bar')
})
it('embeds the official kit mark even with a temp repoRoot (C203)', async () => {
const input = await writeInput(dir, fiveRows)
const { launchBrowser } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [
`--in=${input}`,
'--headline=Ranking de teste',
'--source=TSE 2022',
'--type=bar',
`--out=${join(dir, 'brand.png')}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
// The asset resolves from the module, never from the cwd: `repoRoot` is a
// temp dir here and the official mark still reaches the HTML.
const html = screenshot.mock.calls[0][1].html
expect(html).toContain('data:image/png;base64,')
expect(html).not.toContain('MANDATO DEPUTADO FEDERAL')
})
it('assembles the delta spec from a table with the two periods', async () => {
const input = await writeInput(
dir,
'Tipo\t2020\t2026\nESF · Saúde da Família\t58\t63\nENASF-AB · Ampliado\t5\t5\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const outPath = join(dir, 'delta.png')
await main({
argv: [
`--in=${input}`,
'--type=delta',
'--headline=Quatro dos sete seguem sem ampliação',
'--source=Ministério da Saúde — CNES',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(screenshot).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ width: 1080, height: 1350, outPath }),
)
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/quatro-dos-sete-seguem-sem-ampliacao.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ chartType: 'delta', startLabel: '2020', endLabel: '2026' })
expect(spec.rows).toHaveLength(2)
expect(stdout.join('')).toContain('tipo=delta')
})
it('assembles the paired columns from the delta table with --paired', async () => {
const input = await writeInput(
dir,
'Tipo\t2006\t2026\nCirurgia geral\t592\t1433\nOncologia\t0\t134\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [
`--in=${input}`,
'--paired',
'--headline=Ortopedia cresce 4,1 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${join(dir, 'par.png')}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/ortopedia-cresce-4-1-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({
chartType: 'delta',
pairedColumns: true,
startLabel: '2006',
endLabel: '2026',
})
expect(stdout.join('')).toContain('tipo=delta')
})
it('renders the paired columns as an MP4 with --motion and skips the still screenshot', async () => {
const input = await writeInput(
dir,
'Tipo\t2006\t2026\nCirurgia geral\t592\t1433\nOncologia\t0\t134\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const renderMotion = vi.fn(async (_options: { outPath: string }) => ({
size: 4096,
source: 'empacotado',
}))
const outPath = join(dir, 'motion.mp4')
await main({
argv: [
`--in=${input}`,
'--paired',
'--motion',
'--headline=Ortopedia cresce 4,1 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
renderMotion,
})
expect(screenshot).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledOnce()
expect(renderMotion).toHaveBeenCalledWith(
expect.objectContaining({ outPath, width: 1080, height: 1350 }),
)
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/ortopedia-cresce-4-1-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ motion: true, pairedColumns: true, chartType: 'delta' })
expect(stdout.join('')).toContain('MP4')
expect(stdout.join('')).toContain('motion 6s @ 30fps')
})
it('refuses --motion outside the paired variant without launching the browser', async () => {
const input = await writeInput(dir, 'Ilhéus 84\nItabuna 68\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${input}`, '--motion', '--headline=X', '--source=TSE'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/colunas emparelhadas/)
expect(launchBrowser).not.toHaveBeenCalled()
})
it('renders the two-period column as an MP4 with --motion (C239)', async () => {
const input = await writeInput(dir, 'Ano\tLeitos de UTI\n2005\t179\n2026\t1536\n')
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const renderMotion = vi.fn(async (_options: { outPath: string }) => ({
size: 4096,
source: 'empacotado',
}))
const outPath = join(dir, 'uti.mp4')
await main({
argv: [
`--in=${input}`,
'--motion',
'--headline=UTIs crescem quase 9 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
renderMotion,
})
expect(screenshot).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/utis-crescem-quase-9-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ motion: true, chartType: 'column' })
expect(stdout.join('')).toContain('MP4')
})
it('assembles a vertical category comparison with the relation kicker and the unit', async () => {
const input = await writeInput(
dir,
'Hospital;Moradores da cidade (%)\nMunicipal (Esaú Matos);60.57\nEstadual (CHVC);54.67\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [
`--in=${input}`,
'--type=column',
'--unit=%',
'--headline=Maioria e da cidade',
'--source=Ministério da Saúde — SIH/SUS',
`--out=${join(dir, 'coluna.png')}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/maioria-e-da-cidade.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ chartType: 'column', kicker: 'Comparação', unit: '%' })
expect(stdout.join('')).toContain('tipo=column')
})
it('assembles the neutral piece with --no-highlight and refuses mixing both flags', async () => {
const input = await writeInput(
dir,
'Hospital;Moradores da cidade\nHospital municipal;60.57\nHospital estadual;54.67\n',
)
const { launchBrowser } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [
`--in=${input}`,
'--type=column',
'--unit=%',
'--no-highlight',
'--headline=Neutro',
'--source=Ministério da Saúde — SIH/SUS',
`--out=${join(dir, 'neutro.png')}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
const spec = JSON.parse(
await readFile(join(dir, 'data/graficos-instagram/neutro.chart-spec.json'), 'utf8'),
)
expect(spec.noHighlight).toBe(true)
expect(spec.highlight).toBeUndefined()
await expect(
main({
argv: [
`--in=${input}`,
'--type=column',
'--no-highlight',
'--highlight=Hospital municipal',
'--headline=Conflito',
'--source=TSE',
],
repoRoot: dir,
launchBrowser,
screenshot,
}),
).rejects.toThrow(/exclusivos/)
})
it('assembles the two-positive pair with --dual-positive', async () => {
const input = await writeInput(
dir,
'Hospital;Moradores da cidade\nMunicipal;60.57\nEstadual;54.67\n',
)
const { launchBrowser } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [
`--in=${input}`,
'--type=column',
'--unit=%',
'--dual-positive',
'--headline=Duas boas noticias',
'--source=Ministério da Saúde — SIH/SUS',
`--out=${join(dir, 'positivo.png')}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/duas-boas-noticias.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({
chartType: 'column',
unit: '%',
dualPositive: true,
kicker: 'Comparação',
})
})
it('assembles the three-series spec with the informed triad (C205)', async () => {
const input = await writeInput(
dir,
'Ano,Estadual,Municipal,Privada\n2015,235,93,832\n2016,235,93,724\n2017,256,105,707\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const outPath = join(dir, 'triple.png')
await main({
argv: [
`--in=${input}`,
'--type=line',
'--headline=Redes públicas ampliam leitos; a privada recua',
'--source=Ministério da Saúde — CNES',
'--good=Estadual',
'--neutral=Municipal',
'--neutral-dark=Privada',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(screenshot).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ width: 1080, height: 1350, outPath }),
)
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(
dir,
'data/graficos-instagram/redes-publicas-ampliam-leitos-a-privada-recua.chart-spec.json',
),
'utf8',
),
)
expect(
spec.series.map((serie: { name: string; tone: string }) => [serie.name, serie.tone]),
).toEqual([
['Estadual', 'good'],
['Municipal', 'neutral'],
['Privada', 'neutral-dark'],
])
expect(stdout.join('')).toContain('tipo=line')
expect(stdout.join('')).toContain('3 séries × 3')
})
it('assembles the projected three-series spec (C207)', async () => {
const input = await writeInput(
dir,
'Ano,Estadual,Privada,Municipal\n2024,300,100,200\n2025,400,120,150\n2026,500,121,100\n',
)
const { launchBrowser } = launchTracker()
const screenshot = screenshotStub()
const outPath = join(dir, 'triple-projected.png')
await main({
argv: [
`--in=${input}`,
'--type=line',
'--headline=Produção estadual cresce e municipal cai',
'--source=Ministério da Saúde — SIA/SUS',
'--good=Estadual',
'--neutral=Privada',
'--neutral-dark=Municipal',
'--projected=2026',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(screenshot).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ width: 1080, height: 1350, outPath }),
)
const spec = JSON.parse(
await readFile(
join(
dir,
'data/graficos-instagram/producao-estadual-cresce-e-municipal-cai.chart-spec.json',
),
'utf8',
),
)
expect(spec).toMatchObject({ chartType: 'line', size: 'feed', projectedLabel: '2026' })
expect(stdout.join('')).toContain('3 séries × 3')
})
it('refuses an incomplete triad and a 2-series tone on the three-series line', async () => {
const input = await writeInput(dir, 'Ano,Estadual,Municipal,Privada\n2015,1,2,3\n2016,2,2,2\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${input}`, '--headline=X', '--source=Y', '--good=Estadual'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/juntos/)
await expect(
main({
argv: [`--in=${input}`, '--headline=X', '--source=Y', '--good=Estadual', '--bad=Municipal'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/par de duas séries/)
expect(launchBrowser).not.toHaveBeenCalled()
})
it('retires --medium and keeps the vocabularies apart', async () => {
const tripleInput = await writeInput(
dir,
'Ano,Estadual,Municipal,Privada\n2015,1,2,3\n2016,2,2,2\n',
)
const pairInput = await writeInput(dir, 'Ano,Estadual,Municipal\n2017,1,2\n2018,2,1\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${tripleInput}`, '--headline=X', '--source=Y', '--medium=Estadual'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/saiu na revisão C205/)
await expect(
main({
argv: [`--in=${pairInput}`, '--headline=X', '--source=Y', '--neutral=Estadual'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/trio de três séries/)
expect(launchBrowser).not.toHaveBeenCalled()
})
it('replays a --spec without reparsing the input and closes the browser', async () => {
const specPath = join(dir, 'replay.chart-spec.json')
await writeFile(
specPath,
JSON.stringify({
chartType: 'anchor',
size: 'square',
headline: 'Um número',
source: 'TSE 2022',
rows: [{ label: 'Votos', value: 84 }],
}),
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [`--spec=${specPath}`, `--out=${join(dir, 'replay.png')}`],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(screenshot).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ width: 1080, height: 1080 }),
)
expect(close).toHaveBeenCalledOnce()
expect(stdout.join('')).toContain('1080×1080')
})
})