teqo/tests/unit/mapScaleClasses.unit.spec.ts
Francisco Ignácio Fontoura Solla 66feecd66b simplify(B13): corrige classe agregada, bolhas em pane proprio e registra debitos
O /simplify da escala relativa do mapa achou um bug semantico real em
computeAggregateTerritorialClass — somava o headroom dos slugs e comparava
contra a mediana de UMA unidade, classificando um grupo grande como expansao
quando ele e marginal. A mediana agora escala por slugs.length, com teste.

As bolhas sairam do centro do bounding box para o centroide real e ganharam
pane proprio (z-index 450), porque o bringToFront do hover as escondia atras
do poligono. Legenda e pintura passaram a compartilhar o mesmo predicado,
a copy dos quantis parou de prometer faixas de igual largura, e o subtitulo
do painel parou de dizer "em 2026" com 2022 selecionado (defeito do B11).

Fonte unica onde havia duas: NO_DATA_FILL, o raio das bolhas e
formatPlacementOrdinal (ex-formatMunicipalityVoteRank, agora client-safe em
electionFormat.ts).

Os quatro debitos maiores que o cleanup viraram o fill-in B13+
(docs/plans/escala-dry-pos-b13.md); a flakiness dos e2e, reproduzida em main
limpo, foi para o ledger com a causa afinada.
2026-07-26 05:12:37 -03:00

158 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest'
import {
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.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)
})
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.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)
})
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)
})
})