teqo-backup/tests/unit/buildChartFromData.unit.spec.ts
Francisco Solla b226ebb88a feat(C191): barra de variação (delta) no gráfico de Instagram — base e extensão por categoria, sem valência
Skill, núcleo (chartData), renderer (deltaBody/CSS), design CENA 02C com sign-off humano e testes.
Entrega: docs/research/graficos-instagram/equipes-saude-vdc-2020-2026-feed.png (worktree work/20, sem claim).
2026-09-19 01:02:37 -03:00

257 lines
7.5 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: { 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('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('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')
})
})