teqo-backup/tests/unit/sectionStoryRender.unit.spec.ts
Francisco Solla 62b674cfba feat(potencial): cálculo puro, loader e rotas públicas da seção
sectionPotential.ts parseia/valida o shard (ordem fail-closed), busca por
seção e computa o cenário aprovado — válidos = nominais, X₁ = comparecimento
− Lula − Flávio (todos os terceiros; o bulk tem 13 candidaturas) e X₂ =
aptos − Lula − Flávio — com formatadores pt-BR e o nome do PNG. O loader
server-only lê os shards por fs com cache por processo; as rotas públicas
/api/potencial/{municipios,secao} aplicam same-origin, rate-limit e zod
strict, com 404 honesto e cache de um dia. O story 1080×1920 é desenhado
por um renderer puro com contexto estrutural. 'potencial' entra nos slugs
reservados e o módulo novo no pin do top-level de utilities.
2026-10-07 19:47:19 -03:00

139 lines
4.2 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.

import { describe, expect, it } from 'vitest'
import {
drawSectionStory,
STORY_HEIGHT,
STORY_WIDTH,
type SectionStoryDrawContext,
type SectionStoryInput,
} from '@/lib/sectionStoryRender'
type FillCall = { text: string; x: number; y: number; font: string; fillStyle: string }
type Recorder = SectionStoryDrawContext & {
texts: FillCall[]
rects: { x: number; y: number; width: number; height: number; fillStyle: string }[]
strokes: { dash: number[]; strokeStyle: string }[]
lineDash: number[]
}
const createRecorder = () => {
const texts: FillCall[] = []
const rects: { x: number; y: number; width: number; height: number; fillStyle: string }[] = []
const strokes: { dash: number[]; strokeStyle: string }[] = []
const ctx: Recorder = {
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
textAlign: 'left',
textBaseline: 'top',
letterSpacing: '',
fillRect(x, y, width, height) {
rects.push({ x, y, width, height, fillStyle: String(ctx.fillStyle) })
},
fillText(text, x, y) {
texts.push({ text, x, y, font: ctx.font, fillStyle: String(ctx.fillStyle) })
},
// Largura proporcional ao tamanho da fonte corrente — suficiente para o wrap.
measureText(text) {
const size = Number(/(\d+(?:\.\d+)?)px/.exec(ctx.font)?.[1] ?? '16')
return { width: text.length * size * 0.52 }
},
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {
strokes.push({ dash: ctx.lineDash, strokeStyle: String(ctx.strokeStyle) })
},
setLineDash(segments) {
ctx.lineDash = [...segments]
},
roundRect() {},
lineDash: [] as number[],
texts,
rects,
strokes,
}
return ctx
}
const input = (overrides: Partial<SectionStoryInput> = {}): SectionStoryInput => ({
municipalityName: 'Serrinha',
uf: 'BA',
zone: 150,
section: 50,
firstRound: '66,9%',
immediate: '73,7%',
total: '79,8%',
gainImmediate: '+6,8 p.p.',
gainTotal: '+12,9 p.p.',
fontFamily: 'Arimo, Arial, sans-serif',
...overrides,
})
describe('drawSectionStory', () => {
it('paints the 1080×1920 off-white background', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
expect(ctx.rects[0]).toEqual({
x: 0,
y: 0,
width: STORY_WIDTH,
height: STORY_HEIGHT,
fillStyle: '#f7f6f3',
})
})
it('draws the section identity, the three readings and both gains', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
const drawn = ctx.texts.map((call) => call.text)
const joined = drawn.join(' ')
expect(joined).toContain('2º TURNO · 25/10')
expect(joined).toContain('Quanto o Lula pode crescer na sua seção?')
expect(joined).toContain('Serrinha · BA')
expect(joined).toContain('Zona 150 · Seção 50')
expect(joined).toContain('66,9%')
expect(joined).toContain('73,7%')
expect(joined).toContain('79,8%')
expect(joined).toContain('+6,8 p.p.')
expect(joined).toContain('+12,9 p.p.')
expect(joined).toContain('X₁')
expect(joined).toContain('X₂')
})
it('draws the hero gain with the 700 48px face and keeps every text inside the canvas', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
const hero = ctx.texts.find((call) => call.text === '+12,9 p.p.')
expect(hero?.font).toContain('700 48px')
for (const call of ctx.texts) {
expect(call.y).toBeGreaterThanOrEqual(0)
expect(call.y).toBeLessThan(STORY_HEIGHT)
expect(call.x).toBeGreaterThanOrEqual(0)
expect(call.x).toBeLessThanOrEqual(STORY_WIDTH)
}
})
it('reserves the bottom link area with a dashed border', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
expect(ctx.strokes.some((stroke) => stroke.dash.length === 2)).toBe(true)
})
it('clamps a long município name to two lines with an ellipsis', () => {
const ctx = createRecorder()
drawSectionStory(
ctx,
input({
municipalityName:
'São José do Vale do Rio Preto do Norte do Sertão do Cariri Paraibano da Serra do Mar e do Vale do Rio Doce de Minas Gerais do Oeste Baiano',
}),
)
expect(ctx.texts.some((call) => call.text.endsWith('…'))).toBe(true)
})
})