From 62b674cfba383d174400f1873271db10c860bc52 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 7 Oct 2026 19:04:34 -0300 Subject: [PATCH] =?UTF-8?q?feat(potencial):=20c=C3=A1lculo=20puro,=20loade?= =?UTF-8?q?r=20e=20rotas=20p=C3=BAblicas=20da=20se=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api/potencial/municipios/route.ts | 45 ++ .../(frontend)/api/potencial/secao/route.ts | 54 +++ src/lib/schemas/sectionPotentialQuery.ts | 21 + src/lib/sectionPotential.ts | 299 ++++++++++++ src/lib/sectionStoryRender.ts | 456 ++++++++++++++++++ src/lib/shareLink.ts | 1 + src/utilities/potencialSections.ts | 31 ++ tests/int/potencialApi.int.spec.ts | 103 ++++ tests/unit/codebaseConventions.unit.spec.ts | 4 + tests/unit/sectionPotential.unit.spec.ts | 206 ++++++++ tests/unit/sectionStoryRender.unit.spec.ts | 139 ++++++ tests/unit/shareLink.unit.spec.ts | 1 + 12 files changed, 1360 insertions(+) create mode 100644 src/app/(frontend)/api/potencial/municipios/route.ts create mode 100644 src/app/(frontend)/api/potencial/secao/route.ts create mode 100644 src/lib/schemas/sectionPotentialQuery.ts create mode 100644 src/lib/sectionPotential.ts create mode 100644 src/lib/sectionStoryRender.ts create mode 100644 src/utilities/potencialSections.ts create mode 100644 tests/int/potencialApi.int.spec.ts create mode 100644 tests/unit/sectionPotential.unit.spec.ts create mode 100644 tests/unit/sectionStoryRender.unit.spec.ts diff --git a/src/app/(frontend)/api/potencial/municipios/route.ts b/src/app/(frontend)/api/potencial/municipios/route.ts new file mode 100644 index 00000000..d89bb951 --- /dev/null +++ b/src/app/(frontend)/api/potencial/municipios/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' + +import { potentialMunicipiosQuerySchema } from '@/lib/schemas/sectionPotentialQuery' +import { listPotentialMunicipalities } from '@/lib/sectionPotential' +import { + checkContentEventRateLimit, + contentEventClientKey, +} from '@/utilities/content/contentEventRateLimit' +import { loadPotentialUfShard } from '@/utilities/potencialSections' +import { isSameOriginRequest } from '@/utilities/sameOriginRequest' + +export const dynamic = 'force-dynamic' + +/** + * S46 — the municípios of one UF for the title form's combobox. Public read of + * committed TSE geography: same-origin, rate-limited like the other anonymous + * JSON routes, strict query. The answer is lean (`code` + `name`), alphabetical + * and cacheable for a day — the artifact only changes with a deploy. + */ +const json = (body: unknown, status = 200, cacheable = false): NextResponse => + NextResponse.json(body, { + status, + headers: cacheable + ? { 'Cache-Control': 'public, max-age=86400' } + : { 'Cache-Control': 'no-store' }, + }) + +export const GET = async (request: Request): Promise => { + if (!isSameOriginRequest(request)) return json({ ok: false, error: 'origin' }, 403) + + const clientKey = contentEventClientKey(request.headers) + if (!checkContentEventRateLimit(clientKey && `potencial:${clientKey}`)) { + return json({ ok: false, error: 'rate-limited' }, 429) + } + + const parsed = potentialMunicipiosQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ) + if (!parsed.success) return json({ ok: false, error: 'query' }, 400) + + const shard = await loadPotentialUfShard(parsed.data.uf) + if (!shard) return json({ ok: false, error: 'not-found' }, 404) + + return json({ ok: true, municipalities: listPotentialMunicipalities(shard) }, 200, true) +} diff --git a/src/app/(frontend)/api/potencial/secao/route.ts b/src/app/(frontend)/api/potencial/secao/route.ts new file mode 100644 index 00000000..9a668e66 --- /dev/null +++ b/src/app/(frontend)/api/potencial/secao/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' + +import { potentialSectionQuerySchema } from '@/lib/schemas/sectionPotentialQuery' +import { findPotentialSection } from '@/lib/sectionPotential' +import { + checkContentEventRateLimit, + contentEventClientKey, +} from '@/utilities/content/contentEventRateLimit' +import { loadPotentialUfShard } from '@/utilities/potencialSections' +import { isSameOriginRequest } from '@/utilities/sameOriginRequest' + +export const dynamic = 'force-dynamic' + +/** + * S46 — the raw numbers of one polling section (TSE 1st round 2026): identity + * plus aptos/comparecimento/Lula/Flávio/válidos. The percentages are computed + * by the shared pure module on the client, so the panel and the story image + * can never drift. Unknown UF or section answers 404 — the page's honest + * "não encontramos essa seção" state; a section with no valid votes is a 200 + * whose `no-data` verdict belongs to the pure calculator. + */ +const json = (body: unknown, status = 200, cacheable = false): NextResponse => + NextResponse.json(body, { + status, + headers: cacheable + ? { 'Cache-Control': 'public, max-age=86400' } + : { 'Cache-Control': 'no-store' }, + }) + +export const GET = async (request: Request): Promise => { + if (!isSameOriginRequest(request)) return json({ ok: false, error: 'origin' }, 403) + + const clientKey = contentEventClientKey(request.headers) + if (!checkContentEventRateLimit(clientKey && `potencial:${clientKey}`)) { + return json({ ok: false, error: 'rate-limited' }, 429) + } + + const parsed = potentialSectionQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ) + if (!parsed.success) return json({ ok: false, error: 'query' }, 400) + + const shard = await loadPotentialUfShard(parsed.data.uf) + if (!shard) return json({ ok: false, error: 'not-found' }, 404) + + const section = findPotentialSection(shard, { + municipalityCode: parsed.data.codigo, + zone: parsed.data.zona, + section: parsed.data.secao, + }) + if (!section) return json({ ok: false, error: 'not-found' }, 404) + + return json({ ok: true, section }, 200, true) +} diff --git a/src/lib/schemas/sectionPotentialQuery.ts b/src/lib/schemas/sectionPotentialQuery.ts new file mode 100644 index 00000000..53eca537 --- /dev/null +++ b/src/lib/schemas/sectionPotentialQuery.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +import { POTENTIAL_UFS } from '@/lib/sectionPotential' + +/** + * S46 — wire contract of the public `/api/potencial` reads. Strict objects: + * an extra query key is malformed, never silently ignored. The section numbers + * follow the title's shapes (zona 2–4 dígitos, seção até 4) and are normalized + * by the callers; the município is the TSE code the combobox picked. + */ +export const potentialMunicipiosQuerySchema = z.strictObject({ + uf: z.enum(POTENTIAL_UFS), +}) + +export const potentialSectionQuerySchema = z.strictObject({ + uf: z.enum(POTENTIAL_UFS), + /** Código TSE do município (`CD_MUNICIPIO`) — não é slug de URL. */ + codigo: z.coerce.number().int().positive(), + zona: z.coerce.number().int().min(1).max(9999), + secao: z.coerce.number().int().min(1).max(9999), +}) diff --git a/src/lib/sectionPotential.ts b/src/lib/sectionPotential.ts new file mode 100644 index 00000000..b5e5b215 --- /dev/null +++ b/src/lib/sectionPotential.ts @@ -0,0 +1,299 @@ +/** + * S46 — the pure core of the public `/potencial` story: parse the committed + * per-UF TSE shard, find one section and compute the approved scenario. + * + * The shard (`public/dados/potencial-secao-2026/.json`, built by + * `scripts/build-potencial-secoes-2026.mjs`) stores one row per polling + * section — `[zona, seção, aptos, comparecimento, lula, flavio, válidos]` — + * sorted by (zona, seção) inside each município, municípios sorted by code. + * The numbers are raw; every percentage is computed here so the result panel + * and the story image can never drift. + * + * Scenario (aprovado no plano de intenção, corte "todos os votos disponíveis"): + * - 1º turno = Lula ÷ válidos × 100; + * - X₁ = comparecimento − Lula − Flávio (nulos + brancos + TODOS os terceiros); + * - X₂ = X₁ + faltantes = aptos − Lula − Flávio; + * - 2º turno = (Lula + X) ÷ (Lula + X + Flávio) × 100, para X₁ e X₂; + * - ganho = 2º − 1º, em pontos percentuais (nunca arredondado antes de subtrair). + * + * Fail-closed: a section without valid votes (or with impossible counts) is + * `no-data`, never a fabricated estimate. + */ + +import { slugify } from '@/lib/slug' +import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter' + +export const POTENTIAL_SHARD_VERSION = 1 + +export const POTENTIAL_UFS = [ + 'AC', + 'AL', + 'AP', + 'AM', + 'BA', + 'CE', + 'DF', + 'ES', + 'GO', + 'MA', + 'MT', + 'MS', + 'MG', + 'PA', + 'PB', + 'PR', + 'PE', + 'PI', + 'RJ', + 'RN', + 'RS', + 'RO', + 'RR', + 'SC', + 'SP', + 'SE', + 'TO', +] as const + +export type PotentialUf = (typeof POTENTIAL_UFS)[number] + +/** `[zona, seção, aptos, comparecimento, votos Lula, votos Flávio, válidos]`. */ +type PotentialSectionRow = readonly [number, number, number, number, number, number, number] + +type PotentialMunicipality = { + /** TSE `CD_MUNICIPIO`. */ + code: number + name: string + sections: PotentialSectionRow[] +} + +export type PotentialUfShard = { + version: number + uf: PotentialUf + municipalities: PotentialMunicipality[] +} + +/** The identity + raw numbers of one found section — the API's wire shape. */ +export type PotentialSectionNumbers = { + uf: PotentialUf + municipalityCode: number + municipalityName: string + zone: number + section: number + aptos: number + comparecimento: number + lula: number + flavio: number + validos: number +} + +export type SectionPotential = { + /** 1º turno: Lula ÷ votos válidos × 100. */ + firstRoundPct: number + /** 2º turno só com X₁ (votos disponíveis já no 1º turno). */ + immediatePct: number + /** 2º turno com X₂ (X₁ + faltantes). */ + totalPct: number + gainImmediatePp: number + gainTotalPp: number + x1: number + x2: number +} + +export type SectionPotentialResult = + | { available: true; potential: SectionPotential } + | { available: false; reason: 'no-data' } + +const isCount = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + +const isPotentialUf = (value: string): value is PotentialUf => + (POTENTIAL_UFS as readonly string[]).includes(value) + +/** + * Structural validation of one shard. Runs once per UF per process (the loader + * caches the parsed result) and fails closed: a malformed row — or one out of + * the (code) / (zona, seção) order the binary searches assume — rejects the + * whole shard instead of leaking a partial one. + */ +export const parsePotentialShard = (value: unknown): PotentialUfShard | null => { + if (typeof value !== 'object' || value === null) return null + const shard = value as Record + if (shard.version !== POTENTIAL_SHARD_VERSION) return null + if (typeof shard.uf !== 'string' || !isPotentialUf(shard.uf)) return null + if (!Array.isArray(shard.municipalities)) return null + + const municipalities: PotentialMunicipality[] = [] + let lastCode = -1 + for (const entry of shard.municipalities) { + if (typeof entry !== 'object' || entry === null) return null + const municipality = entry as Record + if (!isCount(municipality.code) || typeof municipality.name !== 'string') return null + if (municipality.code <= lastCode) return null + lastCode = municipality.code + if (!Array.isArray(municipality.sections)) return null + const sections: PotentialSectionRow[] = [] + let lastZone = -1 + let lastSection = -1 + for (const row of municipality.sections) { + if (!Array.isArray(row) || row.length !== 7 || !row.every(isCount)) return null + const [zone, section] = row as unknown as PotentialSectionRow + if (zone < lastZone || (zone === lastZone && section <= lastSection)) return null + lastZone = zone + lastSection = section + sections.push(row as unknown as PotentialSectionRow) + } + municipalities.push({ code: municipality.code, name: municipality.name, sections }) + } + + return { version: POTENTIAL_SHARD_VERSION, uf: shard.uf, municipalities } +} + +/** Município lookup by TSE code — the shard is sorted by code (binary search). */ +const findPotentialMunicipality = ( + shard: PotentialUfShard, + code: number, +): PotentialMunicipality | null => { + const municipalities = shard.municipalities + let low = 0 + let high = municipalities.length - 1 + while (low <= high) { + const middle = (low + high) >> 1 + const current = municipalities[middle] + if (current.code === code) return current + if (current.code < code) low = middle + 1 + else high = middle - 1 + } + return null +} + +/** Section row lookup inside one município — rows sorted by (zona, seção). */ +const findPotentialSectionRow = ( + municipality: PotentialMunicipality, + zone: number, + section: number, +): PotentialSectionRow | null => { + const rows = municipality.sections + let low = 0 + let high = rows.length - 1 + while (low <= high) { + const middle = (low + high) >> 1 + const row = rows[middle] + if (row[0] === zone && row[1] === section) return row + if (row[0] < zone || (row[0] === zone && row[1] < section)) low = middle + 1 + else high = middle - 1 + } + return null +} + +/** The full query: shard → município → section → raw numbers, or `null`. */ +export const findPotentialSection = ( + shard: PotentialUfShard, + query: { municipalityCode: number; zone: number; section: number }, +): PotentialSectionNumbers | null => { + const municipality = findPotentialMunicipality(shard, query.municipalityCode) + if (!municipality) return null + const row = findPotentialSectionRow(municipality, query.zone, query.section) + if (!row) return null + return { + uf: shard.uf, + municipalityCode: municipality.code, + municipalityName: municipality.name, + zone: row[0], + section: row[1], + aptos: row[2], + comparecimento: row[3], + lula: row[4], + flavio: row[5], + validos: row[6], + } +} + +/** + * The approved scenario over one section's raw numbers. `no-data` is the only + * alternative to a computed result: zero valid votes or impossible counts + * (Flávio + Lula above the comparecimento, válidos above it, turnout above the + * electorate) never render a percentage. + */ +export const computeSectionPotential = ( + numbers: Pick< + PotentialSectionNumbers, + 'aptos' | 'comparecimento' | 'lula' | 'flavio' | 'validos' + >, +): SectionPotentialResult => { + const { aptos, comparecimento, lula, flavio, validos } = numbers + if (validos <= 0) return { available: false, reason: 'no-data' } + if ( + comparecimento > aptos || + validos > comparecimento || + lula + flavio > comparecimento || + lula > validos || + flavio > validos + ) { + return { available: false, reason: 'no-data' } + } + + const x1 = comparecimento - lula - flavio + const x2 = aptos - lula - flavio + const firstRoundPct = (lula / validos) * 100 + // O denominador do 2º turno é (Lula + X + Flávio): X₁ fecha no comparecimento + // e X₂ fecha no eleitorado — a conta do cenário "só Lula × Flávio". + const immediatePct = ((lula + x1) / comparecimento) * 100 + const totalPct = ((lula + x2) / aptos) * 100 + + return { + available: true, + potential: { + firstRoundPct, + immediatePct, + totalPct, + gainImmediatePp: immediatePct - firstRoundPct, + gainTotalPp: totalPct - firstRoundPct, + x1, + x2, + }, + } +} + +const percentFormatter = new Intl.NumberFormat('pt-BR', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +}) + +/** `66,923…` → `66,9%` (one decimal, pt-BR). */ +export const formatPotentialPercent = (value: number): string => + `${percentFormatter.format(value)}%` + +/** `6,864…` → `+6,9 p.p.`; the sign is derived, never hardcoded. */ +export const formatPotentialPoints = (value: number): string => + `${value < 0 ? '−' : '+'}${percentFormatter.format(Math.abs(value))} p.p.` + +/** The downloadable PNG name: legible, ASCII, deterministic. */ +export const sectionStoryFileName = (numbers: PotentialSectionNumbers): string => + `potencial-lula-${numbers.uf.toLowerCase()}-${slugify(numbers.municipalityName)}-z${numbers.zone}-s${numbers.section}.png` + +/** The municípios of one shard, lean and alphabetical — the combobox source. */ +export type PotentialMunicipalityOption = { code: number; name: string } + +export const listPotentialMunicipalities = ( + shard: PotentialUfShard, +): PotentialMunicipalityOption[] => + shard.municipalities + .map(({ code, name }) => ({ code, name })) + .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) + +/** + * The municípios whose normalized name matches `term` at a word start + * (accent/punctuation-insensitive — o mesmo normalizador dos pickers do repo); + * below three letters the list stays closed (design: "digite ao menos 3"). + */ +export const filterPotentialMunicipalities = ( + municipalities: PotentialMunicipalityOption[], + term: string, +): PotentialMunicipalityOption[] => { + const needle = normalizeSearchPhrase(term) + if (needle.length < 3) return [] + return municipalities.filter((municipality) => + matchesNormalizedAtWordStart(normalizeSearchPhrase(municipality.name), needle), + ) +} diff --git a/src/lib/sectionStoryRender.ts b/src/lib/sectionStoryRender.ts new file mode 100644 index 00000000..ed6ec328 --- /dev/null +++ b/src/lib/sectionStoryRender.ts @@ -0,0 +1,456 @@ +/** + * S46 — the pure canvas renderer of the story 1080×1920. The design artifact + * (`docs/plans/potencial-secao-story-ui-design.html`, cena 4) defines the + * geometry in container-query units over a 1080-wide story; every constant + * below is that value × 10.8. The caller passes the numbers already formatted + * by `sectionPotential`, so the image and the page can never disagree. + * + * The context is a structural subset of `CanvasRenderingContext2D` so unit + * tests drive a fake recorder instead of a real canvas. + */ + +export const STORY_WIDTH = 1080 +export const STORY_HEIGHT = 1920 + +/** + * The story's own copy, shared with the page where the sentence is the same + * (the headline and the hypothesis): a single source, so the screen and the + * image can never disagree on what the campaign is saying. + */ +export const SECTION_STORY_TITLE = 'Quanto o Lula pode crescer na sua seção?' +export const SECTION_STORY_HYPOTHESIS = + 'todos os votos disponíveis vão para o Lula e o Flávio mantém os votos dele.' + +const COLORS = { + background: '#f7f6f3', + ink: '#1b1b1b', + title: '#141414', + muted: '#57534c', + kicker: '#8c8880', + rowNumber: '#a39e95', + rowSmall: '#837e76', + legend: '#6e6a63', + hairline: '#dcd9d2', + linkBorder: '#cbc7bf', +} as const + +/** cqw → px on the 1080-wide story (1cqw = 10.8px). */ +const PX = { + padX: 84, + padTop: 118, + padBottom: 108, + kickerSize: 23, + kickerSpacing: 2.5, + titleSize: 58, + titleLineHeight: 1.16, + titleSpacing: -0.5, + titleMarginTop: 26, + subSize: 27, + subLineHeight: 1.45, + subMarginTop: 22, + fieldsMarginTop: 39, + fieldPaddingY: 14, + fieldGap: 20, + numWidth: 38, + numSize: 27, + nameSize: 29, + nameLineHeight: 1.18, + smallSize: 21, + smallLineHeight: 1.3, + smallMarginTop: 3, + valueSize: 34, + valueSmallSize: 27, + valueHeroSize: 48, + formulaMarginTop: 48, + formulaTitleSize: 22, + formulaTitleSpacing: 2, + calcSize: 25, + calcLineHeight: 1.45, + calcMarginTop: 13, + legendMarginTop: 26, + legendSize: 21, + legendLineHeight: 1.5, + legendPaddingTop: 18, + linkHeight: 100, + linkRadius: 10, + hairline: 1.5, +} as const + +export type SectionStoryDrawContext = { + font: string + fillStyle: string | CanvasGradient | CanvasPattern + strokeStyle: string | CanvasGradient | CanvasPattern + lineWidth: number + textAlign: CanvasTextAlign + textBaseline: CanvasTextBaseline + letterSpacing: string + fillRect(x: number, y: number, width: number, height: number): void + fillText(text: string, x: number, y: number): void + measureText(text: string): { width: number } + save(): void + restore(): void + beginPath(): void + moveTo(x: number, y: number): void + lineTo(x: number, y: number): void + stroke(): void + setLineDash(segments: number[]): void + roundRect(x: number, y: number, width: number, height: number, radii?: number | number[]): void +} + +export type SectionStoryInput = { + municipalityName: string + uf: string + zone: number + section: number + /** Já formatados por `sectionPotential` (1 casa, pt-BR). */ + firstRound: string + immediate: string + total: string + gainImmediate: string + gainTotal: string + fontFamily: string +} + +type StoryWord = { text: string; weight: number; color: string } + +const setFont = (ctx: SectionStoryDrawContext, weight: number, size: number, family: string) => { + ctx.font = `${weight} ${size}px ${family}` +} + +const wordsOf = (text: string, weight: number, color: string): StoryWord[] => + text.split(' ').map((word) => ({ text: word, weight, color })) + +const richWords = (segments: { text: string; weight?: number; color?: string }[], color: string) => + segments.flatMap((segment) => + wordsOf(segment.text, segment.weight ?? 400, segment.color ?? color), + ) + +/** Greedy word wrap over styled words; the caller sets the size/family. */ +const wrapWords = ( + ctx: SectionStoryDrawContext, + words: StoryWord[], + maxWidth: number, + size: number, + family: string, +): StoryWord[][] => { + const lines: StoryWord[][] = [] + let line: StoryWord[] = [] + let lineWidth = 0 + const space = () => { + setFont(ctx, 400, size, family) + return ctx.measureText(' ').width + } + for (const word of words) { + setFont(ctx, word.weight, size, family) + const width = ctx.measureText(word.text).width + const next = line.length === 0 ? width : lineWidth + space() + width + if (line.length > 0 && next > maxWidth) { + lines.push(line) + line = [word] + lineWidth = width + } else { + line.push(word) + lineWidth = next + } + } + if (line.length > 0) lines.push(line) + return lines +} + +const drawWords = ( + ctx: SectionStoryDrawContext, + words: StoryWord[], + x: number, + y: number, + size: number, + family: string, +) => { + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + let cursor = x + for (const [index, word] of words.entries()) { + setFont(ctx, word.weight, size, family) + ctx.fillStyle = word.color + if (index > 0) cursor += ctx.measureText(' ').width + ctx.fillText(word.text, cursor, y) + cursor += ctx.measureText(word.text).width + } +} + +const drawWrapped = ( + ctx: SectionStoryDrawContext, + words: StoryWord[], + x: number, + y: number, + maxWidth: number, + size: number, + lineHeight: number, + family: string, +): number => { + const lines = wrapWords(ctx, words, maxWidth, size, family) + for (const [index, line] of lines.entries()) { + drawWords(ctx, line, x, y + index * size * lineHeight, size, family) + } + return y + lines.length * size * lineHeight +} + +/** Truncates a wrapped text to `maxLines`, adding an ellipsis to the last one. */ +const drawWrappedClamped = ( + ctx: SectionStoryDrawContext, + words: StoryWord[], + x: number, + y: number, + maxWidth: number, + size: number, + lineHeight: number, + maxLines: number, + family: string, +): number => { + const lines = wrapWords(ctx, words, maxWidth, size, family) + const visible = lines.slice(0, maxLines) + if (lines.length > maxLines && visible.length > 0) { + const last = visible[visible.length - 1] + setFont(ctx, 400, size, family) + let text = last.map((word) => word.text).join(' ') + while (text.length > 0 && ctx.measureText(`${text}…`).width > maxWidth) { + text = text.slice(0, -1).trimEnd() + } + visible[visible.length - 1] = [{ ...last[0], text: `${text}…` }] + } + for (const [index, line] of visible.entries()) { + drawWords(ctx, line, x, y + index * size * lineHeight, size, family) + } + return y + visible.length * size * lineHeight +} + +const drawHairline = (ctx: SectionStoryDrawContext, y: number, x0: number, x1: number) => { + ctx.save() + ctx.beginPath() + ctx.strokeStyle = COLORS.hairline + ctx.lineWidth = PX.hairline + ctx.setLineDash([]) + ctx.moveTo(x0, y) + ctx.lineTo(x1, y) + ctx.stroke() + ctx.restore() +} + +type StoryField = { + number: string + name: string + small: string + value: string + valueKind: 'small' | 'default' | 'hero' +} + +/** + * Draws the whole story on a 1080×1920 context. Pure: no DOM, no fonts API — + * the caller must have loaded `fontFamily` (weights 400/500/600/700) first. + */ +export const drawSectionStory = (ctx: SectionStoryDrawContext, input: SectionStoryInput): void => { + const family = input.fontFamily + const left = PX.padX + const right = STORY_WIDTH - PX.padX + const maxWidth = right - left + + ctx.save() + ctx.fillStyle = COLORS.background + ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT) + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + + let y: number = PX.padTop + + ctx.letterSpacing = `${PX.kickerSpacing}px` + setFont(ctx, 500, PX.kickerSize, family) + ctx.fillStyle = COLORS.kicker + ctx.fillText('2º TURNO · 25/10', left, y) + y += PX.kickerSize * 1.3 + + ctx.letterSpacing = `${PX.titleSpacing}px` + y += PX.titleMarginTop + y = drawWrapped( + ctx, + wordsOf(SECTION_STORY_TITLE, 600, COLORS.title), + left, + y, + maxWidth, + PX.titleSize, + PX.titleLineHeight, + family, + ) + ctx.letterSpacing = '0px' + + y += PX.subMarginTop + y = drawWrapped( + ctx, + richWords( + [ + { text: 'Cenário só ' }, + { text: 'Lula × Flávio', weight: 600, color: '#2b2b2b' }, + { text: ` nesta seção: ${SECTION_STORY_HYPOTHESIS}` }, + ], + COLORS.muted, + ), + left, + y, + maxWidth, + PX.subSize, + PX.subLineHeight, + family, + ) + + const fields: StoryField[] = [ + { + number: '1.', + name: 'Sua seção', + small: `${input.municipalityName} · ${input.uf}`, + value: `Zona ${input.zone} · Seção ${input.section}`, + valueKind: 'small', + }, + { + number: '2.', + name: 'Lula no 1º turno', + small: 'dos votos válidos na seção', + value: input.firstRound, + valueKind: 'default', + }, + { + number: '3.', + name: 'Lula no 2º turno', + small: 'só votos imediatos (X₁)', + value: input.immediate, + valueKind: 'default', + }, + { + number: '4.', + name: 'Lula no 2º turno', + small: 'todos os votos (X₂)', + value: input.total, + valueKind: 'default', + }, + { + number: '5.', + name: 'Ganho imediato', + small: '2º − 1º, em pontos percentuais', + value: input.gainImmediate, + valueKind: 'default', + }, + { + number: '6.', + name: 'Ganho total', + small: '2º − 1º, em pontos percentuais', + value: input.gainTotal, + valueKind: 'hero', + }, + ] + + y += PX.fieldsMarginTop + drawHairline(ctx, y, left, right) + + const labelX = left + PX.numWidth + PX.fieldGap + const labelMaxWidth = right - labelX - 220 + for (const field of fields) { + const valueSize = + field.valueKind === 'hero' + ? PX.valueHeroSize + : field.valueKind === 'small' + ? PX.valueSmallSize + : PX.valueSize + const valueWeight = field.valueKind === 'hero' ? 700 : field.valueKind === 'small' ? 500 : 600 + + const rowTop = y + PX.fieldPaddingY + setFont(ctx, 500, PX.numSize, family) + ctx.fillStyle = COLORS.rowNumber + ctx.fillText(field.number, left, rowTop + 2) + + const nameBottom = drawWrapped( + ctx, + wordsOf(field.name, 500, COLORS.ink), + labelX, + rowTop, + labelMaxWidth, + PX.nameSize, + PX.nameLineHeight, + family, + ) + const smallBottom = drawWrappedClamped( + ctx, + wordsOf(field.small, 400, COLORS.rowSmall), + labelX, + nameBottom + PX.smallMarginTop, + labelMaxWidth, + PX.smallSize, + PX.smallLineHeight, + 2, + family, + ) + + setFont(ctx, valueWeight, valueSize, family) + ctx.fillStyle = COLORS.title + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + const valueMiddle = rowTop + Math.max(nameBottom - rowTop, PX.nameSize * PX.nameLineHeight) / 2 + ctx.fillText(field.value, right, valueMiddle) + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + + const rowHeight = Math.max(smallBottom - rowTop, valueSize) + 2 * PX.fieldPaddingY + y += rowHeight + drawHairline(ctx, y, left, right) + } + + y += PX.formulaMarginTop + ctx.letterSpacing = `${PX.formulaTitleSpacing}px` + setFont(ctx, 500, PX.formulaTitleSize, family) + ctx.fillStyle = COLORS.kicker + ctx.fillText('A CONTA (COMO NO TSE: % = VOTOS ÷ VOTOS VÁLIDOS)', left, y) + ctx.letterSpacing = '0px' + y += PX.formulaTitleSize * 1.4 + + for (const line of [ + '2º turno (imediato) = (LULA + X₁) ÷ (LULA + X₁ + FLÁVIO) × 100', + '2º turno (total) = (LULA + X₂) ÷ (LULA + X₂ + FLÁVIO) × 100', + 'ganho = 2º − 1º', + ]) { + y += PX.calcMarginTop + setFont(ctx, 400, PX.calcSize, family) + ctx.fillStyle = COLORS.ink + ctx.fillText(line, left, y) + y += PX.calcSize * PX.calcLineHeight + } + + y += PX.legendMarginTop + drawHairline(ctx, y, left, right) + y += PX.legendPaddingTop + drawWrapped( + ctx, + richWords( + [ + { text: 'X₁', weight: 600, color: COLORS.ink }, + { text: ' = nulos + brancos + votos de terceiros · ' }, + { text: 'X₂', weight: 600, color: COLORS.ink }, + { text: ' = X₁ + faltantes' }, + ], + COLORS.legend, + ), + left, + y, + maxWidth, + PX.legendSize, + PX.legendLineHeight, + family, + ) + + const linkTop = STORY_HEIGHT - PX.padBottom - PX.linkHeight + ctx.save() + ctx.beginPath() + ctx.strokeStyle = COLORS.linkBorder + ctx.lineWidth = PX.hairline + ctx.setLineDash([8, 8]) + ctx.roundRect(left, linkTop, maxWidth, PX.linkHeight, PX.linkRadius) + ctx.stroke() + ctx.restore() + + ctx.restore() +} diff --git a/src/lib/shareLink.ts b/src/lib/shareLink.ts index 6b50d23f..2044639a 100644 --- a/src/lib/shareLink.ts +++ b/src/lib/shareLink.ts @@ -16,6 +16,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [ 'jingles', 'conteudos', 'fotos', + 'potencial', 'api', 'admin', 'noticia', diff --git a/src/utilities/potencialSections.ts b/src/utilities/potencialSections.ts new file mode 100644 index 00000000..c0dda56a --- /dev/null +++ b/src/utilities/potencialSections.ts @@ -0,0 +1,31 @@ +import 'server-only' + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { parsePotentialShard, type PotentialUfShard } from '@/lib/sectionPotential' + +/** + * S46 — the server-side reader of the committed TSE shards. The artifact lives + * under `public/` (copied into the standalone image by the Dockerfile) and is + * immutable per deploy, so one parse per UF per process is enough; a missing or + * malformed file answers `null` and every caller fails closed. + */ +const SHARD_DIR = join(process.cwd(), 'public', 'dados', 'potencial-secao-2026') + +const shardCache = new Map>() + +export const loadPotentialUfShard = (uf: string): Promise => { + const normalized = uf.trim().toUpperCase() + if (!/^[A-Z]{2}$/.test(normalized)) return Promise.resolve(null) + + const cached = shardCache.get(normalized) + if (cached) return cached + + const promise = readFile(join(SHARD_DIR, `${normalized}.json`), 'utf8') + .then((text) => parsePotentialShard(JSON.parse(text))) + .catch(() => null) + + shardCache.set(normalized, promise) + return promise +} diff --git a/tests/int/potencialApi.int.spec.ts b/tests/int/potencialApi.int.spec.ts new file mode 100644 index 00000000..1b42d118 --- /dev/null +++ b/tests/int/potencialApi.int.spec.ts @@ -0,0 +1,103 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { GET as getMunicipios } from '@/app/(frontend)/api/potencial/municipios/route' +import { GET as getSecao } from '@/app/(frontend)/api/potencial/secao/route' + +/** + * S46 — the two public reads of `/potencial` over the committed TSE artifact: + * the município list of a UF and the raw numbers of one real section + * (Serrinha/BA ZE 150 seção 50, conferida contra o CSV do TSE). Guards: + * same-origin, strict query, rate limit and the honest 404. + */ +const request = (path: string, headers: Record = {}): Request => + new Request(`http://localhost${path}`, { headers }) + +describe('GET /api/potencial/municipios', () => { + it('lists the municípios of a UF, alphabetically, with a day cache', async () => { + const response = await getMunicipios(request('/api/potencial/municipios?uf=BA')) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toContain('max-age=86400') + const body = (await response.json()) as { + ok: boolean + municipalities: { code: number; name: string }[] + } + expect(body.ok).toBe(true) + expect(body.municipalities).toHaveLength(417) + expect(body.municipalities).toContainEqual({ code: 39098, name: 'Serrinha' }) + const names = body.municipalities.map((municipality) => municipality.name) + expect([...names].sort((left, right) => left.localeCompare(right, 'pt-BR'))).toEqual(names) + }) + + it('rejects a malformed query and a cross-origin request', async () => { + expect((await getMunicipios(request('/api/potencial/municipios?uf=ba'))).status).toBe(400) + expect((await getMunicipios(request('/api/potencial/municipios'))).status).toBe(400) + expect((await getMunicipios(request('/api/potencial/municipios?uf=BA&extra=1'))).status).toBe( + 400, + ) + expect( + ( + await getMunicipios( + request('/api/potencial/municipios?uf=BA', { origin: 'https://evil.example' }), + ) + ).status, + ).toBe(403) + }) +}) + +describe('GET /api/potencial/secao', () => { + it('answers the raw numbers of a real section', async () => { + const response = await getSecao( + request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toContain('max-age=86400') + expect(await response.json()).toEqual({ + ok: true, + section: { + uf: 'BA', + municipalityCode: 39098, + municipalityName: 'Serrinha', + zone: 150, + section: 50, + aptos: 376, + comparecimento: 289, + lula: 174, + flavio: 76, + validos: 260, + }, + }) + }) + + it('answers 404 for an unknown section and 400 for a malformed query', async () => { + expect( + (await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=9999'))) + .status, + ).toBe(404) + expect( + (await getSecao(request('/api/potencial/secao?uf=BA&codigo=1&zona=150&secao=50'))).status, + ).toBe(404) + expect( + (await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=0&secao=50'))) + .status, + ).toBe(400) + expect( + (await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=150'))).status, + ).toBe(400) + }) + + it('throttles a client that exceeds the window budget', async () => { + const headers = { 'x-forwarded-for': '203.0.113.9' } + for (let index = 0; index < 120; index += 1) { + const response = await getSecao( + request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50', headers), + ) + expect(response.status).toBe(200) + } + const throttled = await getSecao( + request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50', headers), + ) + expect(throttled.status).toBe(429) + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 6ef7e567..590084b5 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -553,6 +553,10 @@ describe('src/utilities top-level is pinned', () => { 'payloadTransaction.ts', 'postgresTransactionLocks.ts', 'posts.ts', + // S46 — server-side reader of the committed TSE section shards under + // `public/dados/potencial-secao-2026` (fs + parse cache, fail-closed); + // the sibling of `electionCache.ts` for the `/potencial` public route. + 'potencialSections.ts', 'recentVisits.ts', 'revalidateRequest.ts', 'sameOriginRequest.ts', diff --git a/tests/unit/sectionPotential.unit.spec.ts b/tests/unit/sectionPotential.unit.spec.ts new file mode 100644 index 00000000..3116b20c --- /dev/null +++ b/tests/unit/sectionPotential.unit.spec.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' + +import { + computeSectionPotential, + filterPotentialMunicipalities, + findPotentialSection, + formatPotentialPercent, + formatPotentialPoints, + parsePotentialShard, + sectionStoryFileName, + type PotentialSectionNumbers, + type PotentialUfShard, +} from '@/lib/sectionPotential' + +const numbers = (overrides: Partial = {}): PotentialSectionNumbers => ({ + uf: 'BA', + municipalityCode: 39098, + municipalityName: 'Serrinha', + zone: 150, + section: 50, + aptos: 376, + comparecimento: 289, + lula: 174, + flavio: 76, + validos: 260, + ...overrides, +}) + +describe('computeSectionPotential', () => { + it('refuses the plan illustrative reference (válidos above the comparecimento)', () => { + // O exemplo do plano ("Seção 0051 Curitiba/PR: Lula 91, Flávio 81, válidos + // 190, X₁=13, X₂=69") é uma referência de forma, marcada como "não usar na + // UI como dado real" — e é aritmeticamente impossível: comp = Lula+Flávio+X₁ + // = 185 < válidos = 190. O cálculo falha fechado em vez de renderizar %. + const result = computeSectionPotential({ + aptos: 241, + comparecimento: 185, + lula: 91, + flavio: 81, + validos: 190, + }) + expect(result).toEqual({ available: false, reason: 'no-data' }) + }) + + it('computes the real Serrinha/BA ZE 150 section 50 row', () => { + const result = computeSectionPotential(numbers()) + expect(result.available).toBe(true) + if (!result.available) return + // X₁ = 289 − 174 − 76 = 39 (nulos + brancos + todos os terceiros); + // X₂ = 376 − 174 − 76 = 126 (X₁ + faltantes). + expect(result.potential.x1).toBe(39) + expect(result.potential.x2).toBe(126) + expect(formatPotentialPercent(result.potential.firstRoundPct)).toBe('66,9%') + expect(formatPotentialPercent(result.potential.immediatePct)).toBe('73,7%') + expect(formatPotentialPercent(result.potential.totalPct)).toBe('79,8%') + expect(formatPotentialPoints(result.potential.gainImmediatePp)).toBe('+6,8 p.p.') + expect(formatPotentialPoints(result.potential.gainTotalPp)).toBe('+12,9 p.p.') + }) + + it('never fabricates a percentage for a section without valid votes', () => { + expect( + computeSectionPotential({ aptos: 100, comparecimento: 0, lula: 0, flavio: 0, validos: 0 }), + ).toEqual({ available: false, reason: 'no-data' }) + }) + + it.each([ + [ + 'Lula + Flávio above the comparecimento', + { aptos: 400, comparecimento: 100, lula: 60, flavio: 60, validos: 100 }, + ], + [ + 'válidos above the comparecimento', + { aptos: 400, comparecimento: 100, lula: 40, flavio: 40, validos: 120 }, + ], + [ + 'turnout above the electorate', + { aptos: 90, comparecimento: 100, lula: 40, flavio: 40, validos: 80 }, + ], + [ + 'a candidate above the valid votes', + { aptos: 400, comparecimento: 300, lula: 250, flavio: 40, validos: 200 }, + ], + ])('fails closed on impossible counts: %s', (_label, row) => { + expect(computeSectionPotential(row)).toEqual({ available: false, reason: 'no-data' }) + }) + + it('proves the gain is never negative under the approved hypothesis', () => { + let seed = 7 + const random = (max: number) => { + seed = (seed * 1103515245 + 12345) % 2147483648 + return seed % (max + 1) + } + for (let index = 0; index < 500; index += 1) { + const aptos = 50 + random(950) + const comparecimento = random(aptos) + const validos = random(comparecimento) + const lula = random(validos) + const flavio = random(validos - lula) + const result = computeSectionPotential({ aptos, comparecimento, lula, flavio, validos }) + if (!result.available) continue + const { gainImmediatePp, gainTotalPp, firstRoundPct, immediatePct, totalPct } = + result.potential + expect(gainImmediatePp).toBeGreaterThanOrEqual(0) + expect(gainTotalPp).toBeGreaterThanOrEqual(gainImmediatePp) + expect(firstRoundPct).toBeGreaterThanOrEqual(0) + expect(totalPct).toBeLessThanOrEqual(100) + expect(immediatePct).toBeLessThanOrEqual(100) + } + }) +}) + +describe('parsePotentialShard', () => { + const valid = { + version: 1, + uf: 'BA', + municipalities: [ + { code: 39098, name: 'Serrinha', sections: [[150, 50, 376, 289, 174, 76, 260]] }, + ], + } + + it('accepts a well-formed shard', () => { + expect(parsePotentialShard(valid)).toEqual(valid) + }) + + it.each([ + ['a wrong version', { ...valid, version: 2 }], + ['an unknown UF', { ...valid, uf: 'XX' }], + ['a municipality without a name', { ...valid, municipalities: [{ code: 1, sections: [] }] }], + [ + 'a row with the wrong width', + { ...valid, municipalities: [{ code: 1, name: 'X', sections: [[1, 2, 3]] }] }, + ], + [ + 'a row with a negative count', + { ...valid, municipalities: [{ code: 1, name: 'X', sections: [[1, 2, -3, 4, 5, 6, 7]] }] }, + ], + ['a non-object', null], + ])('rejects %s', (_label, value) => { + expect(parsePotentialShard(value)).toBeNull() + }) +}) + +describe('findPotentialSection', () => { + const shard: PotentialUfShard = { + version: 1, + uf: 'BA', + municipalities: [ + { code: 39098, name: 'Serrinha', sections: [[150, 50, 376, 289, 174, 76, 260]] }, + { code: 40000, name: 'Outro', sections: [[1, 1, 10, 8, 4, 2, 7]] }, + ], + } + + it('finds a section through the binary searches', () => { + expect( + findPotentialSection(shard, { municipalityCode: 39098, zone: 150, section: 50 }), + ).toEqual({ + uf: 'BA', + municipalityCode: 39098, + municipalityName: 'Serrinha', + zone: 150, + section: 50, + aptos: 376, + comparecimento: 289, + lula: 174, + flavio: 76, + validos: 260, + }) + }) + + it('answers null for an unknown município or section', () => { + expect(findPotentialSection(shard, { municipalityCode: 1, zone: 1, section: 1 })).toBeNull() + expect(findPotentialSection(shard, { municipalityCode: 39098, zone: 1, section: 1 })).toBeNull() + }) +}) + +describe('formatters and helpers', () => { + it('formats percentages with one pt-BR decimal', () => { + expect(formatPotentialPercent(66.923)).toBe('66,9%') + expect(formatPotentialPercent(0)).toBe('0,0%') + }) + + it('derives the sign of the gain instead of hardcoding "+"', () => { + expect(formatPotentialPoints(6.864)).toBe('+6,9 p.p.') + expect(formatPotentialPoints(0)).toBe('+0,0 p.p.') + expect(formatPotentialPoints(-1.24)).toBe('−1,2 p.p.') + }) + + it('names the story file from the section identity', () => { + expect(sectionStoryFileName(numbers())).toBe('potencial-lula-ba-serrinha-z150-s50.png') + }) + + it('filters municípios accent-insensitively from three letters', () => { + const municipalities = [ + { code: 1, name: 'Serrinha' }, + { code: 2, name: 'São Gonçalo dos Campos' }, + { code: 3, name: 'Feira de Santana' }, + ] + expect(filterPotentialMunicipalities(municipalities, 'ser')).toEqual([ + { code: 1, name: 'Serrinha' }, + ]) + expect(filterPotentialMunicipalities(municipalities, 'sao goncalo')).toEqual([ + { code: 2, name: 'São Gonçalo dos Campos' }, + ]) + expect(filterPotentialMunicipalities(municipalities, 'se')).toEqual([]) + }) +}) diff --git a/tests/unit/sectionStoryRender.unit.spec.ts b/tests/unit/sectionStoryRender.unit.spec.ts new file mode 100644 index 00000000..5a730e7a --- /dev/null +++ b/tests/unit/sectionStoryRender.unit.spec.ts @@ -0,0 +1,139 @@ +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 => ({ + 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) + }) +}) diff --git a/tests/unit/shareLink.unit.spec.ts b/tests/unit/shareLink.unit.spec.ts index 867564b0..6a4ba062 100644 --- a/tests/unit/shareLink.unit.spec.ts +++ b/tests/unit/shareLink.unit.spec.ts @@ -57,6 +57,7 @@ describe('shareLink slug', () => { 'jingles', 'mandato-no-whatsapp', 'noticia', + 'potencial', 'privacidade', ].sort(), )