mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import {
|
||
CHART_COLORS,
|
||
barChart,
|
||
columnChart,
|
||
dualLineChart,
|
||
lineChart,
|
||
proportionalPercent,
|
||
stackedColumnChart,
|
||
tripleLineChart,
|
||
valueList,
|
||
} from '../../scripts/lib/chartPrimitives.mjs'
|
||
|
||
// C191: the chart primitives were extracted from dossieRender.mjs so the
|
||
// Instagram generator (second consumer) never grows a twin. These snapshots
|
||
// freeze the dossier emission byte-for-byte — a change here is a change to the
|
||
// published dossiê, not a cosmetic tweak.
|
||
|
||
describe('chartPrimitives — dossier emitters (byte-identical)', () => {
|
||
it('barChart', () => {
|
||
expect(
|
||
barChart({
|
||
rows: [
|
||
{ label: 'A', count: 3 },
|
||
{ label: 'B', count: 1 },
|
||
],
|
||
}),
|
||
).toMatchInlineSnapshot(`
|
||
"<svg class="chart" viewBox="0 0 330 42" role="img">
|
||
<text x="0" y="14" class="chart-label">A</text>
|
||
<rect x="118" y="5" width="186" height="12" rx="1" fill="#315c75" />
|
||
<text x="308" y="14" class="chart-value">3</text><text x="0" y="33" class="chart-label">B</text>
|
||
<rect x="118" y="24" width="62" height="12" rx="1" fill="#315c75" />
|
||
<text x="184" y="33" class="chart-value">1</text>
|
||
</svg>"
|
||
`)
|
||
})
|
||
|
||
it('columnChart', () => {
|
||
expect(
|
||
columnChart({
|
||
rows: [
|
||
{ key: '2020', value: 2 },
|
||
{ key: '2021', value: 4 },
|
||
],
|
||
format: (row: { value: number }) => String(row.value),
|
||
}),
|
||
).toMatchInlineSnapshot(`
|
||
"<svg class="chart" viewBox="0 0 330 152" role="img">
|
||
<rect x="6" y="65" width="156" height="49" fill="#315c75" />
|
||
<text x="84.5" y="62" class="chart-value" text-anchor="middle">2</text>
|
||
<text x="84.5" y="144" class="chart-label" text-anchor="middle">20</text><rect x="167" y="16" width="156" height="98" fill="#315c75" />
|
||
<text x="245.5" y="13" class="chart-value" text-anchor="middle">4</text>
|
||
<text x="245.5" y="144" class="chart-label" text-anchor="middle">21</text>
|
||
</svg>"
|
||
`)
|
||
})
|
||
|
||
it('stackedColumnChart', () => {
|
||
expect(
|
||
stackedColumnChart({
|
||
rows: [
|
||
{
|
||
key: '2020',
|
||
value: 3,
|
||
segments: [
|
||
{ key: 'pago', amount: 2 },
|
||
{ key: 'empenhado', amount: 1 },
|
||
],
|
||
},
|
||
],
|
||
format: (row: { value: number }) => String(row.value),
|
||
}),
|
||
).toMatchInlineSnapshot(`
|
||
"<svg class="chart" viewBox="0 0 330 152" role="img">
|
||
<rect x="6" y="49" width="317" height="65" fill="#285338" /><rect x="6" y="16" width="317" height="33" fill="#6b4918" />
|
||
<text x="165" y="13" class="chart-value" text-anchor="middle">3</text>
|
||
<text x="165" y="144" class="chart-label" text-anchor="middle">20</text>
|
||
</svg>"
|
||
`)
|
||
})
|
||
|
||
it('valueList', () => {
|
||
expect(
|
||
valueList({
|
||
rows: [
|
||
{ label: 'A', value: 2 },
|
||
{ label: 'B', value: 1 },
|
||
],
|
||
format: (row: { value: number }) => String(row.value),
|
||
}),
|
||
).toMatchInlineSnapshot(`
|
||
"<ul class="value-list">
|
||
<li>
|
||
<span>A</span>
|
||
<span class="value-list-bar"><span style="width:100%"></span></span>
|
||
<span class="value-list-value">2</span>
|
||
</li><li>
|
||
<span>B</span>
|
||
<span class="value-list-bar"><span style="width:50%"></span></span>
|
||
<span class="value-list-value">1</span>
|
||
</li>
|
||
</ul>"
|
||
`)
|
||
})
|
||
|
||
it('escapes untrusted labels', () => {
|
||
expect(barChart({ rows: [{ label: '<b>x</b>', count: 1 }] })).toContain('<b>')
|
||
})
|
||
|
||
it('keeps the dossier domain palette as the default', () => {
|
||
expect(CHART_COLORS.default).toBe('#315c75')
|
||
expect(CHART_COLORS.pago).toBe('#285338')
|
||
})
|
||
})
|
||
|
||
describe('proportionalPercent — honest scale, zero stays zero', () => {
|
||
it('maps value/max to 0..100 and floors a present value at 2%', () => {
|
||
expect(proportionalPercent(84, 84)).toBe(100)
|
||
expect(proportionalPercent(42, 84)).toBe(50)
|
||
expect(proportionalPercent(1, 84)).toBe(2)
|
||
expect(proportionalPercent(0, 84)).toBe(0)
|
||
expect(proportionalPercent(5, 0)).toBe(100)
|
||
})
|
||
})
|
||
|
||
describe('lineChart — series with one highlighted end', () => {
|
||
const rows = [
|
||
{ label: '2020', value: 2 },
|
||
{ label: '2021', value: 4 },
|
||
]
|
||
const svg = lineChart({ rows, format: (row: { value: number }) => String(row.value) })
|
||
|
||
it('draws every label and the last value', () => {
|
||
expect(svg).toContain('>2020<')
|
||
expect(svg).toContain('>2021<')
|
||
expect(svg).toContain('>4<')
|
||
})
|
||
|
||
it('marks the last point with the Solla red', () => {
|
||
expect(svg).toContain('fill="#e4102f"')
|
||
})
|
||
})
|
||
|
||
describe('dualLineChart — the approved two-series time line', () => {
|
||
const annual = (values: number[]) =>
|
||
values.map((value, index) => ({ label: `${2017 + index}`, value }))
|
||
const series = [
|
||
{
|
||
name: 'Estadual',
|
||
tone: 'good',
|
||
rows: annual([9807, 10636, 11807, 10929, 13119, 14767, 14501, 20495, 21637, 23294]),
|
||
},
|
||
{
|
||
name: 'Municipal',
|
||
tone: 'bad',
|
||
rows: annual([24002, 24438, 22269, 20305, 21562, 20608, 19983, 19726, 18588, 15948]),
|
||
},
|
||
]
|
||
const colors = {
|
||
good: '#e4102f',
|
||
bad: '#78716c',
|
||
neutralA: '#1c1917',
|
||
neutralB: '#78716c',
|
||
grid: '#a8a29e',
|
||
paper: '#faf9f7',
|
||
ink: '#1c1917',
|
||
change: '#57534e',
|
||
}
|
||
const valence = { good: '↑ amplia', bad: '↓ recua' }
|
||
const format = (row: { value: number }) =>
|
||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value)
|
||
const svg = dualLineChart({
|
||
series,
|
||
size: 'feed',
|
||
projected: true,
|
||
crossingLabel: '2024',
|
||
colors,
|
||
valence,
|
||
format,
|
||
})
|
||
|
||
it('draws the good path in the mandate red and the bad one neutral', () => {
|
||
expect(svg).toContain('class="dual-series dual-series-good"')
|
||
expect(svg).toContain('stroke="#e4102f"')
|
||
expect(svg).toContain('class="dual-series dual-series-bad"')
|
||
expect(svg).toContain('stroke="#78716c"')
|
||
expect(svg).toContain('<g class="dual-markers-good" fill="#e4102f"><circle')
|
||
expect(svg).toContain('<g class="dual-markers-bad" fill="#78716c"><rect')
|
||
})
|
||
|
||
it('labels each line with name, value, valence word and observed change', () => {
|
||
expect(svg).toContain('>Estadual<')
|
||
expect(svg).toContain('>23.294<')
|
||
expect(svg).toContain('>↑ amplia<')
|
||
expect(svg).toContain('>+121% até 2025<')
|
||
expect(svg).toContain('>Municipal<')
|
||
expect(svg).toContain('>15.948<')
|
||
expect(svg).toContain('>↓ recua<')
|
||
expect(svg).toContain('>−23% até 2025<')
|
||
})
|
||
|
||
it('marks only the last segment as a projection', () => {
|
||
expect(svg.match(/stroke-dasharray="18 12"/g)).toHaveLength(2)
|
||
expect(svg).toContain('class="dual-projection-band"')
|
||
expect(svg).toContain('>projeção<')
|
||
expect(svg).toContain('class="dual-marker-projected dual-marker-projected-good"')
|
||
expect(svg).toContain('class="dual-marker-projected dual-marker-projected-bad"')
|
||
})
|
||
|
||
it('annotates the confirmed crossing with ring, guide and box', () => {
|
||
expect(svg).toContain('class="dual-crossing-guide"')
|
||
expect(svg).toContain('class="dual-crossing-ring"')
|
||
expect(svg).toContain('class="dual-crossing-box"')
|
||
expect(svg).toContain('class="dual-crossing-year"')
|
||
expect(svg).toContain('>2024<')
|
||
expect(svg).toContain('class="dual-crossing-copy"')
|
||
expect(svg).toContain('>ultrapassa<')
|
||
})
|
||
|
||
it('keeps the crossing out when it was not confirmed', () => {
|
||
const withoutCrossing = dualLineChart({
|
||
series,
|
||
size: 'feed',
|
||
projected: true,
|
||
colors,
|
||
valence,
|
||
format,
|
||
})
|
||
expect(withoutCrossing).not.toContain('dual-crossing')
|
||
expect(withoutCrossing).not.toContain('ultrapassa')
|
||
})
|
||
|
||
it('keeps the honest zero base and the shared legible ceiling', () => {
|
||
expect(svg).toContain('viewBox="0 0 880 620"')
|
||
expect(svg).toContain('y1="544"')
|
||
expect(svg).toContain('>0<')
|
||
expect(svg.match(/class="dual-grid-line"/g)).toHaveLength(2)
|
||
})
|
||
|
||
it('alternates the ten annual axis labels, always keeping first and last', () => {
|
||
for (const label of ['2017', '2019', '2021', '2023', '2026']) {
|
||
expect(svg).toContain(`>${label}<`)
|
||
}
|
||
for (const label of ['2018', '2020', '2022', '2025']) {
|
||
expect(svg).not.toContain(`>${label}<`)
|
||
}
|
||
})
|
||
|
||
it('adapts the geometry to square and to Stories', () => {
|
||
const square = dualLineChart({
|
||
series,
|
||
size: 'square',
|
||
projected: true,
|
||
colors,
|
||
valence,
|
||
format,
|
||
})
|
||
expect(square).toContain('viewBox="0 0 880 440"')
|
||
expect(square).toContain('stroke-dasharray="15 11"')
|
||
expect(square).toContain('font-size="32"')
|
||
const story = dualLineChart({ series, size: 'story', projected: true, colors, valence, format })
|
||
expect(story).toContain('viewBox="0 0 880 700"')
|
||
expect(story).toContain('stroke-dasharray="18 12"')
|
||
expect(story).toContain('font-size="36"')
|
||
})
|
||
|
||
it('compares neutrally without valence or red when no tone is informed', () => {
|
||
const neutral = dualLineChart({
|
||
series: series.map(({ name, rows }) => ({ name, rows })),
|
||
size: 'feed',
|
||
projected: false,
|
||
colors,
|
||
valence,
|
||
format,
|
||
})
|
||
expect(neutral).toContain('class="dual-series dual-series-a"')
|
||
expect(neutral).toContain('class="dual-series dual-series-b"')
|
||
expect(neutral).not.toContain('#e4102f')
|
||
expect(neutral).not.toContain('amplia')
|
||
expect(neutral).not.toContain('recua')
|
||
expect(neutral).not.toContain('dual-projection-band')
|
||
})
|
||
|
||
it('escapes untrusted series names', () => {
|
||
const evil = dualLineChart({
|
||
series: [{ ...series[0], name: '<b>x</b>' }, series[1]],
|
||
size: 'feed',
|
||
projected: false,
|
||
colors,
|
||
valence,
|
||
format,
|
||
})
|
||
expect(evil).toContain('<b>x</b>')
|
||
})
|
||
})
|
||
|
||
describe('tripleLineChart — the approved three-series extension (C205, feed)', () => {
|
||
const annual = (values: number[]) =>
|
||
values.map((value, index) => ({ label: `${2015 + index}`, value }))
|
||
const series = [
|
||
{
|
||
name: 'Hospital Estadual',
|
||
tone: 'good',
|
||
rows: annual([235, 235, 256, 256, 256, 256, 256, 256, 369, 369, 369, 369]),
|
||
},
|
||
{
|
||
name: 'Rede municipal',
|
||
tone: 'neutral',
|
||
rows: annual([93, 93, 105, 93, 93, 93, 93, 93, 113, 113, 113, 113]),
|
||
},
|
||
{
|
||
name: 'Rede privada',
|
||
tone: 'neutral-dark',
|
||
rows: annual([832, 724, 707, 644, 521, 496, 459, 489, 508, 536, 500, 510]),
|
||
},
|
||
]
|
||
const colors = {
|
||
good: '#e4102f',
|
||
neutral: '#78716c',
|
||
'neutral-dark': '#1c1917',
|
||
grid: '#a8a29e',
|
||
ink: '#1c1917',
|
||
}
|
||
const valence = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' }
|
||
const format = (row: { value: number }) =>
|
||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value)
|
||
const svg = tripleLineChart({ series, colors, valence, format })
|
||
|
||
it('draws the triad with color, word and one marker shape per tone', () => {
|
||
expect(svg).toContain('class="triple-series triple-series-good"')
|
||
expect(svg).toContain('stroke="#e4102f"')
|
||
expect(svg).toContain('class="triple-series triple-series-neutral"')
|
||
expect(svg).toContain('stroke="#78716c"')
|
||
expect(svg).toContain('class="triple-series triple-series-neutral-dark"')
|
||
expect(svg).toContain('stroke="#1c1917"')
|
||
expect(svg).toContain('<g class="triple-markers-good" fill="#e4102f"><circle')
|
||
expect(svg).toContain('<g class="triple-markers-neutral" fill="#78716c"><rect')
|
||
expect(svg).toContain('transform="rotate(45 52 499)"')
|
||
expect(svg).toContain('<g class="triple-markers-neutral-dark" fill="#1c1917"><path')
|
||
expect(svg).toContain('>↑ amplia<')
|
||
expect(svg).toContain('>↗ cresce<')
|
||
expect(svg).toContain('>↘ diminui<')
|
||
expect(svg).not.toContain('#184e92')
|
||
})
|
||
|
||
it('stacks the three-line end blocks by final point, metric and valence', () => {
|
||
expect(svg).toContain('>Rede privada<')
|
||
expect(svg).toContain('>510 · −39%<')
|
||
expect(svg).toContain('>Hospital Estadual<')
|
||
expect(svg).toContain('>369 · +57%<')
|
||
expect(svg).toContain('>Rede municipal<')
|
||
expect(svg).toContain('>113 · +22%<')
|
||
expect(svg.indexOf('>Rede privada<')).toBeLessThan(svg.indexOf('>Hospital Estadual<'))
|
||
expect(svg.indexOf('>Hospital Estadual<')).toBeLessThan(svg.indexOf('>Rede municipal<'))
|
||
})
|
||
|
||
it('keeps the wider data box, the shared zero base and the leader band', () => {
|
||
expect(svg).toContain('viewBox="0 0 880 650"')
|
||
expect(svg).toContain('x2="560"')
|
||
expect(svg).toContain('y1="544"')
|
||
expect(svg).toContain('>0<')
|
||
expect(svg.match(/class="triple-grid-line"/g)).toHaveLength(2)
|
||
expect(svg).toContain('points="570,295 580,295 590,108 594,108"')
|
||
expect(svg).toContain('class="triple-end-leader"')
|
||
expect(svg).toContain('x="600"')
|
||
})
|
||
|
||
it('draws every point observed — no projection artifact', () => {
|
||
expect(svg).not.toContain('dasharray')
|
||
expect(svg).not.toContain('projeção')
|
||
expect(svg).not.toContain('fill-opacity')
|
||
expect((svg.match(/class="triple-series/g) ?? []).length).toBe(3)
|
||
})
|
||
|
||
it('alternates the twelve annual labels, always keeping the first and the last', () => {
|
||
for (const label of ['2015', '2017', '2019', '2021', '2023', '2026']) {
|
||
expect(svg).toContain(`>${label}<`)
|
||
}
|
||
for (const label of ['2016', '2018', '2020', '2022', '2025']) {
|
||
expect(svg).not.toContain(`>${label}<`)
|
||
}
|
||
})
|
||
|
||
it('carries the observed series in the aria label', () => {
|
||
expect(svg).toContain(
|
||
'Hospital Estadual: de 235 em 2015 para 369 em 2026, aumento de 57 por cento',
|
||
)
|
||
expect(svg).toContain(
|
||
'Rede privada: de 832 em 2015 para 510 em 2026, diminuição de 39 por cento',
|
||
)
|
||
expect(svg).toContain('Todos os pontos de cada série são observados')
|
||
})
|
||
|
||
it('escapes untrusted series names', () => {
|
||
const evil = tripleLineChart({
|
||
series: [{ ...series[0], name: '<b>x</b>' }, series[1], series[2]],
|
||
colors,
|
||
valence,
|
||
format,
|
||
})
|
||
expect(evil).toContain('<b>x</b>')
|
||
})
|
||
})
|
||
|
||
describe('tripleLineChart — the projected final period (C207, feed)', () => {
|
||
const annual = (values: number[]) =>
|
||
values.map((value, index) => ({ label: `${2015 + index}`, value }))
|
||
const series = [
|
||
{
|
||
name: 'Estadual',
|
||
tone: 'good',
|
||
rows: annual([
|
||
522656, 545643, 804451, 1482193, 2733904, 3165119, 3646160, 3718176, 3669352, 3486832,
|
||
3774183, 4213052,
|
||
]),
|
||
},
|
||
{
|
||
name: 'Rede privada',
|
||
tone: 'neutral',
|
||
rows: annual([
|
||
833679, 708303, 688405, 767470, 715170, 512628, 583789, 700002, 888211, 880871, 908345,
|
||
855684,
|
||
]),
|
||
},
|
||
{
|
||
name: 'Municipal',
|
||
tone: 'neutral-dark',
|
||
rows: annual([
|
||
2904003, 2331052, 2133853, 2010696, 1968664, 1223339, 1682379, 1861214, 2014870, 2057296,
|
||
1937385, 1492020,
|
||
]),
|
||
},
|
||
]
|
||
const colors = {
|
||
good: '#e4102f',
|
||
neutral: '#78716c',
|
||
'neutral-dark': '#1c1917',
|
||
grid: '#a8a29e',
|
||
paper: '#faf9f7',
|
||
ink: '#1c1917',
|
||
}
|
||
const valence = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' }
|
||
const format = (row: { value: number }) =>
|
||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value)
|
||
const svg = tripleLineChart({ series, colors, valence, format, projected: true })
|
||
|
||
it('covers exactly the last step with the band and names the projected period on top', () => {
|
||
expect(svg).toContain('class="triple-projection-band" x="514" y="0" width="46" height="544"')
|
||
expect(svg).toContain(
|
||
'x="552" y="45" text-anchor="end" class="triple-projection-label" font-size="30">projeção 2026</text>',
|
||
)
|
||
expect(svg.match(/>projeção 2026</g)).toHaveLength(1)
|
||
})
|
||
|
||
it('dashes only the last segment of each series', () => {
|
||
expect(svg.match(/class="triple-series-projection/g)).toHaveLength(3)
|
||
expect(svg.match(/stroke-dasharray="18 12"/g)).toHaveLength(3)
|
||
expect(svg).toContain(
|
||
'points="52,493 98,491 144,465 191,399 237,277 283,235 329,188 375,181 421,186 468,204 514,176"',
|
||
)
|
||
expect(svg).toContain(
|
||
'class="triple-series-projection triple-series-good" x1="514" y1="176" x2="560" y2="133"',
|
||
)
|
||
})
|
||
|
||
it('hollows the three projected markers keeping the tone shape', () => {
|
||
expect(svg).toContain(
|
||
'class="triple-marker-projected triple-marker-projected-good" cx="560" cy="133" r="11" fill="#faf9f7" stroke="#e4102f" stroke-width="5"',
|
||
)
|
||
expect(svg).toContain(
|
||
'class="triple-marker-projected triple-marker-projected-neutral-dark" d="M545 387 L575 387 L560 413 Z" fill="#faf9f7" stroke="#1c1917"',
|
||
)
|
||
expect(svg).toContain(
|
||
'class="triple-marker-projected triple-marker-projected-neutral" d="M560 446 L574 460 L560 474 L546 460 Z" fill="#faf9f7" stroke="#78716c"',
|
||
)
|
||
})
|
||
|
||
it('shows the projected value with the variation that closes on the projected period', () => {
|
||
expect(svg).toContain('points="578,133 584,133 590,108 594,108"')
|
||
expect(svg).toContain('>4.213.052<')
|
||
expect(svg).toContain('>· +706%<')
|
||
expect(svg).toContain('>↑ amplia<')
|
||
expect(svg).toContain('>1.492.020<')
|
||
expect(svg).toContain('>· −49%<')
|
||
expect(svg).toContain('>↘ diminui<')
|
||
expect(svg).toContain('>855.684<')
|
||
expect(svg).toContain('>· +3%<')
|
||
expect(svg).toContain('>↗ cresce<')
|
||
expect(svg).not.toContain('até 2025')
|
||
expect(svg.indexOf('>Estadual<')).toBeLessThan(svg.indexOf('>Municipal<'))
|
||
expect(svg.indexOf('>Municipal<')).toBeLessThan(svg.indexOf('>Rede privada<'))
|
||
})
|
||
|
||
it('announces the projection and the observed variation in the aria label', () => {
|
||
expect(svg).toContain(
|
||
'Estadual: de 522.656 em 2015 para projeção de 4.213.052 em 2026, variação de mais 706 por cento até 2026 (projeção)',
|
||
)
|
||
expect(svg).toContain(
|
||
'Municipal: de 2.904.003 em 2015 para projeção de 1.492.020 em 2026, variação de menos 49 por cento até 2026 (projeção)',
|
||
)
|
||
expect(svg).toContain(
|
||
'Rede privada: de 833.679 em 2015 para projeção de 855.684 em 2026, variação de mais 3 por cento até 2026 (projeção)',
|
||
)
|
||
expect(svg).toContain('O último segmento e os três marcadores de 2026 são projetados')
|
||
expect(svg).not.toContain('Todos os pontos de cada série são observados')
|
||
})
|
||
})
|