mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
- C237 (CENA 02F): --paired desenha duas colunas zero-based por categoria (inicial cinza, recente vermelha, valência positiva compartilhada), com legenda de períodos, valor sobre cada barra e rótulo sob o par; até 4 categorias; design DEGRADED com sign-off humano de 2026-09-29. - C238/C239 (CENAS 02G/02H): --motion gera MP4 H.264 local (6s @ 30fps, frames determinísticos por seek + ffmpeg do repo) para o par emparelhado, a coluna de 2 períodos e o número-âncora; C238 com sign-off humano de 2026-09-30 e C239 aguardando gate. - Superfície: CLI, SKILL.md, hi-fi e specs novos (chartMotion/ chartMotionRender; SCRIPTS_SPEC_PINNED sincronizado). Design tier: DEGRADED (inline, tier 4 — frontier sem quota).
138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
COLUMN_MOTION,
|
|
columnMotion,
|
|
MOTION_CSS,
|
|
MOTION_DURATION_S,
|
|
MOTION_EASING,
|
|
MOTION_FPS,
|
|
MOTION_FRAMES,
|
|
MOTION_STAGES,
|
|
motionEnd,
|
|
motionFrameTimes,
|
|
motionKindFor,
|
|
motionStages,
|
|
PAIR_MOTION,
|
|
pairMotion,
|
|
validateMotion,
|
|
} from '../../scripts/lib/chartMotion.mjs'
|
|
|
|
// C238/C239: the motion clock is data — the renderer serializes the schedule
|
|
// into inline vars and the capture seeks the animations to the grid, so the
|
|
// timing is pinned here instead of living inside the stylesheet.
|
|
|
|
const pairedSpec = (pairCount = 4) => ({
|
|
chartType: 'delta',
|
|
pairedColumns: true,
|
|
startLabel: '2006',
|
|
endLabel: '2026',
|
|
rows: Array.from({ length: pairCount }, (_value, index) => ({
|
|
label: `Tipo ${index}`,
|
|
initial: 1,
|
|
final: 2,
|
|
})),
|
|
})
|
|
|
|
const columnSpec = (count = 2) => ({
|
|
chartType: 'column',
|
|
rows: Array.from({ length: count }, (_value, index) => ({
|
|
label: String(2005 + index),
|
|
value: 100 + index,
|
|
})),
|
|
})
|
|
|
|
const anchorSpec = () => ({
|
|
chartType: 'anchor',
|
|
rows: [{ label: 'leitos privados contratados para o atendimento pelo SUS', value: 1130 }],
|
|
})
|
|
|
|
describe('chartMotion — the deterministic clock', () => {
|
|
it('runs 6s at 30fps on an exact 180-frame grid', () => {
|
|
expect(MOTION_FPS).toBe(30)
|
|
expect(MOTION_DURATION_S).toBe(6)
|
|
expect(MOTION_FRAMES).toBe(180)
|
|
const times = motionFrameTimes()
|
|
expect(times).toHaveLength(MOTION_FRAMES)
|
|
expect(times[0]).toBe(0)
|
|
expect(times[times.length - 1]).toBeCloseTo(((MOTION_FRAMES - 1) * 1000) / MOTION_FPS, 6)
|
|
})
|
|
|
|
it('staggers the pairs left to right and lands the values after the bars', () => {
|
|
const first = pairMotion(0)
|
|
const second = pairMotion(1)
|
|
expect(first.bar.delay).toBe(PAIR_MOTION.firstAt)
|
|
expect(second.bar.delay - first.bar.delay).toBeCloseTo(PAIR_MOTION.stagger, 6)
|
|
expect(first.label.delay).toBeCloseTo(first.bar.delay + PAIR_MOTION.labelOffset, 6)
|
|
expect(first.value.delay).toBeCloseTo(first.bar.delay + PAIR_MOTION.valueOffset, 6)
|
|
expect(first.value.delay).toBeGreaterThan(first.bar.delay + first.bar.duration)
|
|
})
|
|
|
|
it('grows the two-period columns left to right, values after the bars', () => {
|
|
const first = columnMotion(0)
|
|
const second = columnMotion(1)
|
|
expect(first.bar.delay).toBe(COLUMN_MOTION.firstAt)
|
|
expect(second.bar.delay - first.bar.delay).toBeCloseTo(COLUMN_MOTION.stagger, 6)
|
|
expect(second.label.delay).toBeCloseTo(second.bar.delay + COLUMN_MOTION.labelOffset, 6)
|
|
expect(second.value.delay).toBeGreaterThan(second.bar.delay + second.bar.duration)
|
|
})
|
|
|
|
it('maps each motion shape to its variant and refuses the undesigned ones', () => {
|
|
expect(motionKindFor(pairedSpec())).toBe('paired')
|
|
expect(motionKindFor(columnSpec())).toBe('column')
|
|
expect(motionKindFor(anchorSpec())).toBe('anchor')
|
|
expect(motionKindFor({ chartType: 'bar', rows: [] })).toBeNull()
|
|
expect(motionKindFor({ chartType: 'line', rows: [] })).toBeNull()
|
|
})
|
|
|
|
it('settles every stage inside the duration, leaving a static tail to cut into', () => {
|
|
const { end, kind } = validateMotion(pairedSpec())
|
|
expect(kind).toBe('paired')
|
|
expect(end).toBeLessThanOrEqual(MOTION_DURATION_S)
|
|
expect(MOTION_DURATION_S - end).toBeGreaterThanOrEqual(2)
|
|
expect(validateMotion(columnSpec())).toMatchObject({ kind: 'column' })
|
|
expect(validateMotion(anchorSpec())).toMatchObject({ kind: 'anchor' })
|
|
})
|
|
|
|
it('lists the stages of each shape for the clock guard', () => {
|
|
// Shell (rule, kicker, headline, subtitle, footer) + the marks.
|
|
expect(motionStages(columnSpec())).toHaveLength(5 + 2 * 3)
|
|
expect(motionStages(pairedSpec())).toHaveLength(5 + 1 + 4 * 3) // + legend
|
|
expect(motionStages(anchorSpec())).toHaveLength(5 + 2) // + number + copy
|
|
expect(motionEnd(motionStages(anchorSpec()))).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('fails closed when the shape is undesigned, the column is not a pair or the clock overflows', () => {
|
|
expect(() => validateMotion({ chartType: 'bar', rows: [{ label: 'x', value: 1 }] })).toThrow(
|
|
/indisponível/,
|
|
)
|
|
expect(() => validateMotion(columnSpec(3))).toThrow(/2 períodos/)
|
|
expect(() => validateMotion(pairedSpec(20))).toThrow(/reduza os pontos/)
|
|
})
|
|
|
|
it('gives the anchor the hero number and the copy, never a counting rule', () => {
|
|
expect(MOTION_STAGES.number.kind).toBe('rise')
|
|
expect(MOTION_STAGES.copy.kind).toBe('fade')
|
|
expect(MOTION_STAGES.copy.delay).toBeGreaterThan(MOTION_STAGES.number.delay)
|
|
})
|
|
|
|
it('carries only keyframes and easing; timings travel as inline vars', () => {
|
|
for (const kind of ['sweep', 'rise', 'fade', 'grow']) {
|
|
expect(MOTION_CSS).toContain(`@keyframes motion-${kind}`)
|
|
expect(MOTION_CSS).toContain(`.motion .motion-${kind}`)
|
|
}
|
|
expect(MOTION_CSS).toContain(MOTION_EASING)
|
|
expect(MOTION_CSS).toContain('var(--md, 0s)')
|
|
expect(MOTION_CSS).toContain('var(--mud, 0.5s)')
|
|
expect(MOTION_CSS).toContain('animation-fill-mode: backwards')
|
|
})
|
|
|
|
it('uses the same motion vocabulary in the shell and the pairs', () => {
|
|
const kinds = new Set([
|
|
...Object.values(MOTION_STAGES).map((stage) => stage.kind),
|
|
...Object.values(pairMotion(3)).map((stage) => stage.kind),
|
|
...Object.values(columnMotion(1)).map((stage) => stage.kind),
|
|
])
|
|
for (const kind of kinds) expect(['sweep', 'rise', 'fade', 'grow']).toContain(kind)
|
|
})
|
|
})
|