O coropleto do Inicio era quase monocromatico porque 0-100% dos validos comprime uma disputa cujo topo estadual e 4,71%. Tres escalas relativas ao proprio candidato, todas em classes discretas: quantis (novo default), padrao proprio (LQ) e posicao no municipio, ao lado de "% dos validos" (B11) e "Total (votos)". Artefato v3: federalRankByIbgeCode com a colocacao entre todos os candidatos com votos > 0 na geografia (2014/2018/2022), chaveado por ibgeCode porque e o que o mapa desenha. Fica no artefato commitado, nao em query de request, que varreria ~100 mil linhas por ano para um dado imutavel. 534 -> 623 KB (orcamento 700 KB). LQ virou 5 classes nas ancoras do E10, nao diverging: o plano pedia as duas coisas e se contradizia. Vermelho x azul continua significando "Solla x adversario" e segue exclusivo do compare. computeAggregateTerritorialClass classifica um grupo de slugs como um territorio so, somando insumos e rodando o mesmo classificador (LQ e razao; a media de razoes nao e a razao das somas) - o E12 herda o helper. TERRITORIAL_CLASS_ANCHORS mudou para src/lib/territorialClassAnchors.ts para nao arrastar o artefato TSE ao bundle do cliente. Quantis sobre o escopo de acesso do usuario, degradando para 3 classes abaixo de 10 municipios com a legenda declarando a contagem efetiva. Rank indisponivel em 2026 (dado TSE); LQ em 2026 reusa o padrao estadual de 2022. Bolhas opcionais por validos projetados (area proporcional ao valor, cor da classe E10, interactive: false). 417 marcadores medidos antes de otimizar - nao derrubaram o frame, entao o corte para top-K nao foi feito. Sem migration, sem collection, sem server action; bundle client de /campanha byte a byte igual.
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import {
|
||
buildCompetitiveRankClassing,
|
||
buildLqClassing,
|
||
buildQuantileClassing,
|
||
fillsForClassing,
|
||
QUANTILE_CLASSES,
|
||
QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT,
|
||
} from '@/lib/mapScaleClasses'
|
||
import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
|
||
|
||
const valuesFrom = (numbers: number[]): Record<string, number> =>
|
||
Object.fromEntries(numbers.map((value, index) => [`k${index}`, value]))
|
||
|
||
describe('buildQuantileClassing', () => {
|
||
it('cuts a large set into five classes ordered weakest to strongest', () => {
|
||
const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1))
|
||
const classing = buildQuantileClassing(values)
|
||
|
||
expect(classing.classes).toHaveLength(QUANTILE_CLASSES)
|
||
expect(classing.reduced).toBe(false)
|
||
expect(classing.classIndexByKey.k0).toBe(0)
|
||
expect(classing.classIndexByKey.k99).toBe(QUANTILE_CLASSES - 1)
|
||
})
|
||
|
||
it('splits evenly: each quantile holds about a fifth of the features', () => {
|
||
const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1))
|
||
const classing = buildQuantileClassing(values)
|
||
|
||
const counts = new Array(QUANTILE_CLASSES).fill(0)
|
||
for (const index of Object.values(classing.classIndexByKey)) counts[index] += 1
|
||
|
||
expect(counts).toEqual([20, 20, 20, 20, 20])
|
||
})
|
||
|
||
it('degrades to three classes below the minimum feature count', () => {
|
||
const values = valuesFrom(
|
||
Array.from({ length: QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT - 1 }, (_, i) => (i + 1) * 10),
|
||
)
|
||
const classing = buildQuantileClassing(values)
|
||
|
||
expect(classing.classes).toHaveLength(3)
|
||
expect(classing.reduced).toBe(true)
|
||
})
|
||
|
||
it('collapses classes rather than repeating a break when values tie heavily', () => {
|
||
const classing = buildQuantileClassing(valuesFrom([5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 900]))
|
||
|
||
// Eleven features share one value: quantiles 1-4 all end at 5, so the
|
||
// legend gets two honest classes instead of five with duplicate bounds.
|
||
expect(classing.classes).toHaveLength(2)
|
||
expect(classing.reduced).toBe(true)
|
||
expect(classing.classes[0].label).toBe('5')
|
||
expect(classing.classes[1].label).toBe('900')
|
||
})
|
||
|
||
it('excludes zero and negative values from the classing', () => {
|
||
const classing = buildQuantileClassing({ a: 0, b: -5, c: 10, d: 20 })
|
||
|
||
expect(classing.classIndexByKey).not.toHaveProperty('a')
|
||
expect(classing.classIndexByKey).not.toHaveProperty('b')
|
||
expect(Object.keys(classing.classIndexByKey)).toEqual(['c', 'd'])
|
||
})
|
||
|
||
it('returns nothing to paint when no feature has a positive value', () => {
|
||
const classing = buildQuantileClassing({ a: 0, b: 0 })
|
||
|
||
expect(classing.classes).toEqual([])
|
||
expect(classing.classIndexByKey).toEqual({})
|
||
})
|
||
|
||
it('labels each class with its real extremes, never an invented gap', () => {
|
||
const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900]))
|
||
|
||
expect(classing.classes.map((entry) => entry.label)).toEqual(['1–3', '40–60', '700–900'])
|
||
})
|
||
|
||
it('labels a single-value class with that value alone', () => {
|
||
const classing = buildQuantileClassing({ a: 42 })
|
||
|
||
expect(classing.classes).toHaveLength(1)
|
||
expect(classing.classes[0].label).toBe('42')
|
||
})
|
||
})
|
||
|
||
describe('buildLqClassing', () => {
|
||
const classOf = (lq: number) => buildLqClassing({ a: lq }).classIndexByKey.a
|
||
|
||
it('cuts on E10 anchors, so the map and the list agree on "reduto"', () => {
|
||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq - 0.01)).toBe(0)
|
||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq)).toBe(1)
|
||
expect(classOf(AT_STANDARD_LQ.min)).toBe(2)
|
||
expect(classOf(AT_STANDARD_LQ.max)).toBe(3)
|
||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq)).toBe(4)
|
||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq + 10)).toBe(4)
|
||
})
|
||
|
||
it('always offers the five named bands, even when only one is populated', () => {
|
||
const classing = buildLqClassing({ a: 1 })
|
||
|
||
expect(classing.classes).toHaveLength(5)
|
||
expect(classing.reduced).toBe(false)
|
||
})
|
||
|
||
it('spells the band edges without rounding 0,95 into 1', () => {
|
||
const labels = buildLqClassing({ a: 1 }).classes.map((entry) => entry.label)
|
||
|
||
expect(labels).toEqual([
|
||
'menos de 0,5×',
|
||
'0,5×–0,95×',
|
||
'no padrão (≈1×)',
|
||
'1,15×–2×',
|
||
'2× ou mais',
|
||
])
|
||
})
|
||
|
||
it('skips municípios with no measurable standard', () => {
|
||
expect(buildLqClassing({ a: 0 }).classIndexByKey).toEqual({})
|
||
})
|
||
})
|
||
|
||
describe('buildCompetitiveRankClassing', () => {
|
||
const classOf = (rank: number) => buildCompetitiveRankClassing({ a: rank }).classIndexByKey.a
|
||
|
||
it('puts the strongest placement in the darkest class', () => {
|
||
expect(classOf(1)).toBe(2)
|
||
expect(classOf(2)).toBe(1)
|
||
expect(classOf(3)).toBe(1)
|
||
expect(classOf(4)).toBe(0)
|
||
expect(classOf(120)).toBe(0)
|
||
})
|
||
|
||
it('reads weakest to strongest, like every other legend', () => {
|
||
const labels = buildCompetitiveRankClassing({ a: 1 }).classes.map((entry) => entry.label)
|
||
|
||
expect(labels).toEqual(['4º ou pior', '2º–3º', '1º'])
|
||
})
|
||
|
||
it('ignores an impossible placement instead of painting it', () => {
|
||
expect(buildCompetitiveRankClassing({ a: 0 }).classIndexByKey).toEqual({})
|
||
})
|
||
})
|
||
|
||
describe('fillsForClassing', () => {
|
||
it('maps every classed key to its class fill', () => {
|
||
const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900]))
|
||
const fills = fillsForClassing(classing)
|
||
|
||
expect(Object.keys(fills)).toHaveLength(9)
|
||
expect(fills.k0).toBe(classing.classes[0].fill)
|
||
expect(fills.k8).toBe(classing.classes[2].fill)
|
||
})
|
||
|
||
it('gives each class a distinguishable fill', () => {
|
||
const classing = buildQuantileClassing(
|
||
valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1)),
|
||
)
|
||
|
||
expect(new Set(classing.classes.map((entry) => entry.fill)).size).toBe(QUANTILE_CLASSES)
|
||
})
|
||
})
|