mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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.
This commit is contained in:
parent
82b4fdf5fb
commit
89b7515fe1
12 changed files with 1360 additions and 0 deletions
45
src/app/(frontend)/api/potencial/municipios/route.ts
Normal file
45
src/app/(frontend)/api/potencial/municipios/route.ts
Normal file
|
|
@ -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<NextResponse> => {
|
||||
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)
|
||||
}
|
||||
54
src/app/(frontend)/api/potencial/secao/route.ts
Normal file
54
src/app/(frontend)/api/potencial/secao/route.ts
Normal file
|
|
@ -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<NextResponse> => {
|
||||
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)
|
||||
}
|
||||
21
src/lib/schemas/sectionPotentialQuery.ts
Normal file
21
src/lib/schemas/sectionPotentialQuery.ts
Normal file
|
|
@ -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),
|
||||
})
|
||||
299
src/lib/sectionPotential.ts
Normal file
299
src/lib/sectionPotential.ts
Normal file
|
|
@ -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/<UF>.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<string, unknown>
|
||||
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<string, unknown>
|
||||
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),
|
||||
)
|
||||
}
|
||||
456
src/lib/sectionStoryRender.ts
Normal file
456
src/lib/sectionStoryRender.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
|
|||
'jingles',
|
||||
'conteudos',
|
||||
'fotos',
|
||||
'potencial',
|
||||
'api',
|
||||
'admin',
|
||||
'noticia',
|
||||
|
|
|
|||
31
src/utilities/potencialSections.ts
Normal file
31
src/utilities/potencialSections.ts
Normal file
|
|
@ -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<string, Promise<PotentialUfShard | null>>()
|
||||
|
||||
export const loadPotentialUfShard = (uf: string): Promise<PotentialUfShard | null> => {
|
||||
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
|
||||
}
|
||||
103
tests/int/potencialApi.int.spec.ts
Normal file
103
tests/int/potencialApi.int.spec.ts
Normal file
|
|
@ -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<string, string> = {}): 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
206
tests/unit/sectionPotential.unit.spec.ts
Normal file
206
tests/unit/sectionPotential.unit.spec.ts
Normal file
|
|
@ -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> = {}): 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([])
|
||||
})
|
||||
})
|
||||
139
tests/unit/sectionStoryRender.unit.spec.ts
Normal file
139
tests/unit/sectionStoryRender.unit.spec.ts
Normal file
|
|
@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -57,6 +57,7 @@ describe('shareLink slug', () => {
|
|||
'jingles',
|
||||
'mandato-no-whatsapp',
|
||||
'noticia',
|
||||
'potencial',
|
||||
'privacidade',
|
||||
].sort(),
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue