feat(sprint-5/etapa-3): heatmap gradiente 4 stops + modo percentil + localStorage + fix zoom
- colors.ts: gradiente continuo verde→amarillo→naranja→rojo (interpolación N-stops genérica) - Renombra modos: relative→percentile (rank-based), absolute→minmax - Hook useHeatmapMode(): persiste selección en localStorage con clave inq-roi:heatmap-mode - HeatmapLegend: barra de 4 stops, labels P0/P50/P100 en modo percentil - HeatmapModeToggle: etiquetas "Percentil" / "Min-max" - pdf-sections.ts: leyenda PDF con 4 stops interpolados - BpmnCanvas: controles de zoom movidos a esquina inferior izquierda (evita logo bpmn.io) - Tests: 528 unitarios pasan; E2E recalibrados con vecino más cercano (distancia euclidiana) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
heatmapColorHex,
|
||||
intensityToColor,
|
||||
computeActivityT,
|
||||
hasSignificantCostVariance,
|
||||
activityColor,
|
||||
@@ -18,39 +18,50 @@ function makeActs(costs: number[]): ActivityCostData[] {
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── heatmapColorHex ─────────────────────────────────────────────────────────
|
||||
describe('heatmapColorHex', () => {
|
||||
// ─── intensityToColor — gradiente de 4 stops ─────────────────────────────────
|
||||
describe('intensityToColor', () => {
|
||||
it('t=0 → verde (#10b981)', () => {
|
||||
expect(heatmapColorHex(0)).toBe('#10b981')
|
||||
expect(intensityToColor(0)).toBe('#10b981')
|
||||
})
|
||||
|
||||
it('t=1 → rojo (#ef4444)', () => {
|
||||
expect(heatmapColorHex(1)).toBe('#ef4444')
|
||||
expect(intensityToColor(1)).toBe('#ef4444')
|
||||
})
|
||||
|
||||
it('t=0.5 → amarillo (#f59e0b)', () => {
|
||||
expect(heatmapColorHex(0.5)).toBe('#f59e0b')
|
||||
it('t=0.33 → amarillo (#f59e0b)', () => {
|
||||
expect(intensityToColor(0.33)).toBe('#f59e0b')
|
||||
})
|
||||
|
||||
it('t=0.67 → naranja (#f97316)', () => {
|
||||
expect(intensityToColor(0.67)).toBe('#f97316')
|
||||
})
|
||||
|
||||
it('t=0.5 → color naranja intermedio (entre #f59e0b y #f97316)', () => {
|
||||
const color = intensityToColor(0.5)
|
||||
expect(color).not.toBe('#f59e0b')
|
||||
expect(color).not.toBe('#f97316')
|
||||
expect(color).toMatch(/^#[0-9a-f]{6}$/)
|
||||
})
|
||||
|
||||
it('t<0 → clampea a t=0 (verde)', () => {
|
||||
expect(heatmapColorHex(-1)).toBe(heatmapColorHex(0))
|
||||
expect(intensityToColor(-0.1)).toBe(intensityToColor(0))
|
||||
})
|
||||
|
||||
it('t>1 → clampea a t=1 (rojo)', () => {
|
||||
expect(heatmapColorHex(2)).toBe(heatmapColorHex(1))
|
||||
expect(intensityToColor(1.5)).toBe(intensityToColor(1))
|
||||
})
|
||||
|
||||
it('devuelve string hex válido en formato #rrggbb', () => {
|
||||
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const hex = heatmapColorHex(t)
|
||||
const hex = intensityToColor(t)
|
||||
expect(hex).toMatch(/^#[0-9a-f]{6}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('colores intermedios son distintos entre sí', () => {
|
||||
const colors = [0, 0.25, 0.5, 0.75, 1].map(heatmapColorHex)
|
||||
it('colores intermedios son distintos entre sí (transición suave, no saltos)', () => {
|
||||
const colors = [0, 0.2, 0.4, 0.6, 0.8, 1].map(intensityToColor)
|
||||
const unique = new Set(colors)
|
||||
expect(unique.size).toBe(5)
|
||||
expect(unique.size).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,25 +105,36 @@ describe('hasSignificantCostVariance', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── computeActivityT ────────────────────────────────────────────────────────
|
||||
describe('computeActivityT — modo relativo', () => {
|
||||
it('actividad con mayor % → t = 1.0', () => {
|
||||
// ─── computeActivityT — modo percentile ──────────────────────────────────────
|
||||
describe('computeActivityT — modo percentile', () => {
|
||||
it('actividad más barata → t = 0', () => {
|
||||
const acts = makeActs([300, 100, 50])
|
||||
const t = computeActivityT(acts[0], acts, 'relative')
|
||||
expect(t).toBeCloseTo(1.0)
|
||||
const t = computeActivityT(acts[2], acts, 'percentile')
|
||||
expect(t).toBe(0)
|
||||
})
|
||||
|
||||
it('actividad con menor costo → t < t de la más cara', () => {
|
||||
it('actividad más cara → t = 1', () => {
|
||||
const acts = makeActs([300, 100, 50])
|
||||
const tBarata = computeActivityT(acts[2], acts, 'relative')
|
||||
const tCara = computeActivityT(acts[0], acts, 'relative')
|
||||
expect(tBarata).toBeLessThan(tCara)
|
||||
const t = computeActivityT(acts[0], acts, 'percentile')
|
||||
expect(t).toBe(1)
|
||||
})
|
||||
|
||||
it('actividades con el mismo costo → mismo t', () => {
|
||||
const acts = makeActs([100, 100, 500])
|
||||
const tA = computeActivityT(acts[0], acts, 'percentile')
|
||||
const tB = computeActivityT(acts[1], acts, 'percentile')
|
||||
expect(tA).toBe(tB)
|
||||
})
|
||||
|
||||
it('n=1 → t = 0.5', () => {
|
||||
const acts = makeActs([500])
|
||||
expect(computeActivityT(acts[0], acts, 'percentile')).toBe(0.5)
|
||||
})
|
||||
|
||||
it('t siempre está en [0, 1]', () => {
|
||||
const acts = makeActs([1000, 500, 100, 10])
|
||||
for (const act of acts) {
|
||||
const t = computeActivityT(act, acts, 'relative')
|
||||
const t = computeActivityT(act, acts, 'percentile')
|
||||
expect(t).toBeGreaterThanOrEqual(0)
|
||||
expect(t).toBeLessThanOrEqual(1)
|
||||
}
|
||||
@@ -120,34 +142,56 @@ describe('computeActivityT — modo relativo', () => {
|
||||
|
||||
it('lista vacía → t = 0 sin crash', () => {
|
||||
const act: ActivityCostData = { expectedTotalCost: 100, percentOfTotal: 100 }
|
||||
expect(computeActivityT(act, [], 'relative')).toBe(0)
|
||||
expect(computeActivityT(act, [], 'percentile')).toBe(0)
|
||||
})
|
||||
|
||||
it('costo negativo → t = 0 (clamp explícito)', () => {
|
||||
const acts = makeActs([100, 200, 300])
|
||||
const negativa: ActivityCostData = { expectedTotalCost: -50, percentOfTotal: 0 }
|
||||
expect(computeActivityT(negativa, [negativa, ...acts], 'percentile')).toBe(0)
|
||||
})
|
||||
|
||||
it('outlier extremo: las actividades restantes se distribuyen (no todas en 0)', () => {
|
||||
// 1 outlier carísimo + 4 actividades normales — percentile no se distorsiona
|
||||
const acts = makeActs([1, 2, 3, 4, 100000])
|
||||
const ts = acts.slice(0, 4).map((a) => computeActivityT(a, acts, 'percentile'))
|
||||
const unique = new Set(ts)
|
||||
expect(unique.size).toBeGreaterThan(1) // distribuidas, no comprimidas a un solo valor
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeActivityT — modo absoluto', () => {
|
||||
// ─── computeActivityT — modo minmax ──────────────────────────────────────────
|
||||
describe('computeActivityT — modo minmax', () => {
|
||||
it('actividad más cara → t = 1.0', () => {
|
||||
const acts = makeActs([100, 200, 300])
|
||||
const t = computeActivityT(acts[2], acts, 'absolute')
|
||||
const t = computeActivityT(acts[2], acts, 'minmax')
|
||||
expect(t).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('actividad más barata → t = 0.0', () => {
|
||||
const acts = makeActs([100, 200, 300])
|
||||
const t = computeActivityT(acts[0], acts, 'absolute')
|
||||
const t = computeActivityT(acts[0], acts, 'minmax')
|
||||
expect(t).toBeCloseTo(0.0)
|
||||
})
|
||||
|
||||
it('actividad del medio → t ≈ 0.5', () => {
|
||||
const acts = makeActs([100, 200, 300])
|
||||
const t = computeActivityT(acts[1], acts, 'absolute')
|
||||
const t = computeActivityT(acts[1], acts, 'minmax')
|
||||
expect(t).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('un solo precio (max=min) → t = 0.5 (defensivo)', () => {
|
||||
const acts = makeActs([100, 100])
|
||||
const t = computeActivityT(acts[0], acts, 'absolute')
|
||||
const t = computeActivityT(acts[0], acts, 'minmax')
|
||||
expect(t).toBe(0.5)
|
||||
})
|
||||
|
||||
it('outlier extremo: las actividades restantes se comprimen hacia 0', () => {
|
||||
const acts = makeActs([1, 2, 3, 4, 100000])
|
||||
const ts = acts.slice(0, 4).map((a) => computeActivityT(a, acts, 'minmax'))
|
||||
// Todas muy cerca de 0 — comportamiento esperado del minmax con outliers
|
||||
for (const t of ts) expect(t).toBeLessThan(0.01)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── activityColor — función unificada ───────────────────────────────────────
|
||||
@@ -155,52 +199,49 @@ describe('activityColor', () => {
|
||||
it('SIN varianza significativa → TODOS devuelven NEUTRAL_HEATMAP_COLOR', () => {
|
||||
const acts = makeActs([100, 100, 100, 100, 100])
|
||||
for (const act of acts) {
|
||||
expect(activityColor(act, acts, 'relative')).toBe(NEUTRAL_HEATMAP_COLOR)
|
||||
expect(activityColor(act, acts, 'absolute')).toBe(NEUTRAL_HEATMAP_COLOR)
|
||||
expect(activityColor(act, acts, 'percentile')).toBe(NEUTRAL_HEATMAP_COLOR)
|
||||
expect(activityColor(act, acts, 'minmax')).toBe(NEUTRAL_HEATMAP_COLOR)
|
||||
}
|
||||
})
|
||||
|
||||
it('CON varianza real modo relativo: la más cara NO es neutra', () => {
|
||||
it('CON varianza real modo percentile: la más cara NO es neutra', () => {
|
||||
const acts = makeActs([500, 100, 10])
|
||||
const color = activityColor(acts[0], acts, 'relative')
|
||||
const color = activityColor(acts[0], acts, 'percentile')
|
||||
expect(color).not.toBe(NEUTRAL_HEATMAP_COLOR)
|
||||
})
|
||||
|
||||
it('CON varianza real modo relativo: la más cara es roja (#ef4444)', () => {
|
||||
it('CON varianza real modo percentile: la más cara es roja (#ef4444)', () => {
|
||||
const acts = makeActs([500, 100, 10])
|
||||
const colorCara = activityColor(acts[0], acts, 'relative')
|
||||
const colorCara = activityColor(acts[0], acts, 'percentile')
|
||||
expect(colorCara).toBe('#ef4444')
|
||||
})
|
||||
|
||||
it('CON varianza real modo relativo: la más barata es verde (#10b981)', () => {
|
||||
it('CON varianza real modo percentile: la más barata es verde (#10b981)', () => {
|
||||
const acts = makeActs([500, 100, 10])
|
||||
const colorBarata = activityColor(acts[2], acts, 'relative')
|
||||
// No necesariamente exactamente verde, pero sí del espectro inferior
|
||||
// La más barata tiene percentOfTotal bajo / maxPercent bajo → t bajo → color verdoso
|
||||
expect(colorBarata).not.toBe('#ef4444')
|
||||
const colorBarata = activityColor(acts[2], acts, 'percentile')
|
||||
expect(colorBarata).toBe('#10b981')
|
||||
})
|
||||
|
||||
it('CON varianza real modo absoluto: la más cara es roja', () => {
|
||||
it('CON varianza real modo minmax: la más cara es roja', () => {
|
||||
const acts = makeActs([1000, 100, 10])
|
||||
const colorCara = activityColor(acts[0], acts, 'absolute')
|
||||
const colorCara = activityColor(acts[0], acts, 'minmax')
|
||||
expect(colorCara).toBe('#ef4444')
|
||||
})
|
||||
|
||||
it('CON varianza real modo absoluto: la más barata es verde', () => {
|
||||
it('CON varianza real modo minmax: la más barata es verde', () => {
|
||||
const acts = makeActs([1000, 100, 10])
|
||||
const colorBarata = activityColor(acts[2], acts, 'absolute')
|
||||
const colorBarata = activityColor(acts[2], acts, 'minmax')
|
||||
expect(colorBarata).toBe('#10b981')
|
||||
})
|
||||
|
||||
it('edge: costos negativos no crashean', () => {
|
||||
const acts = makeActs([0, 0, 0])
|
||||
expect(() => activityColor(acts[0], acts, 'relative')).not.toThrow()
|
||||
expect(() => activityColor(acts[0], acts, 'percentile')).not.toThrow()
|
||||
})
|
||||
|
||||
it('simple-linear scenario: 5 actividades igual costo → todas neutras', () => {
|
||||
// Este es el caso de use real de simple-linear.bpmn con costos fijos iguales
|
||||
const acts = makeActs([100, 100, 100, 100, 100])
|
||||
const colors = acts.map((a) => activityColor(a, acts, 'relative'))
|
||||
const colors = acts.map((a) => activityColor(a, acts, 'percentile'))
|
||||
expect(colors.every((c) => c === NEUTRAL_HEATMAP_COLOR)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -208,6 +249,7 @@ describe('activityColor', () => {
|
||||
expect(NEUTRAL_HEATMAP_COLOR).toMatch(/^#[0-9a-f]{6}$/)
|
||||
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#10b981') // no es verde
|
||||
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#f59e0b') // no es amarillo
|
||||
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#f97316') // no es naranja
|
||||
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#ef4444') // no es rojo
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user