Files
inq-roi-simulador-web/tests/lib/colors.test.ts
Marcos Benítez bed161c92a feat: MVP Fase 0 — Process Cost Platform v0.1.0
Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:58:40 -03:00

214 lines
8.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
heatmapColorHex,
computeActivityT,
hasSignificantCostVariance,
activityColor,
NEUTRAL_HEATMAP_COLOR,
type ActivityCostData,
} from '@/lib/colors'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeActs(costs: number[]): ActivityCostData[] {
const total = costs.reduce((s, c) => s + c, 0) || 1
return costs.map((c) => ({
expectedTotalCost: Math.max(0, c),
percentOfTotal: (c / total) * 100,
}))
}
// ─── heatmapColorHex ─────────────────────────────────────────────────────────
describe('heatmapColorHex', () => {
it('t=0 → verde (#10b981)', () => {
expect(heatmapColorHex(0)).toBe('#10b981')
})
it('t=1 → rojo (#ef4444)', () => {
expect(heatmapColorHex(1)).toBe('#ef4444')
})
it('t=0.5 → amarillo (#f59e0b)', () => {
expect(heatmapColorHex(0.5)).toBe('#f59e0b')
})
it('t<0 → clampea a t=0 (verde)', () => {
expect(heatmapColorHex(-1)).toBe(heatmapColorHex(0))
})
it('t>1 → clampea a t=1 (rojo)', () => {
expect(heatmapColorHex(2)).toBe(heatmapColorHex(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)
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)
const unique = new Set(colors)
expect(unique.size).toBe(5)
})
})
// ─── hasSignificantCostVariance ──────────────────────────────────────────────
describe('hasSignificantCostVariance', () => {
it('varianza cero: todos iguales → false', () => {
expect(hasSignificantCostVariance(makeActs([100, 100, 100, 100, 100]))).toBe(false)
})
it('una sola actividad → false (no hay con qué comparar)', () => {
expect(hasSignificantCostVariance(makeActs([500]))).toBe(false)
})
it('lista vacía → false', () => {
expect(hasSignificantCostVariance([])).toBe(false)
})
it('varianza < 5%: costos casi iguales → false', () => {
// rango = (103-100)/103 ≈ 2.9% < 5%
expect(hasSignificantCostVariance(makeActs([100, 101, 102, 103]))).toBe(false)
})
it('varianza > 5%: costos claramente distintos → true', () => {
// rango = (200-100)/200 = 50% > 5%
expect(hasSignificantCostVariance(makeActs([100, 200]))).toBe(true)
})
it('varianza ≈ THRESHOLD (5%): está justo en el límite', () => {
// rango = (105-100)/105 ≈ 4.76% < 5% → false
expect(hasSignificantCostVariance(makeActs([100, 105]))).toBe(false)
// rango = (111-100)/111 ≈ 9.9% > 5% → true
expect(hasSignificantCostVariance(makeActs([100, 111]))).toBe(true)
})
it('no crashea con costos negativos (defensivo)', () => {
expect(() => hasSignificantCostVariance(makeActs([-10, -5, 0]))).not.toThrow()
})
it('todos en cero → false (max=0)', () => {
expect(hasSignificantCostVariance(makeActs([0, 0, 0]))).toBe(false)
})
})
// ─── computeActivityT ────────────────────────────────────────────────────────
describe('computeActivityT — modo relativo', () => {
it('actividad con mayor % → t = 1.0', () => {
const acts = makeActs([300, 100, 50])
const t = computeActivityT(acts[0], acts, 'relative')
expect(t).toBeCloseTo(1.0)
})
it('actividad con menor costo → t < t de la más cara', () => {
const acts = makeActs([300, 100, 50])
const tBarata = computeActivityT(acts[2], acts, 'relative')
const tCara = computeActivityT(acts[0], acts, 'relative')
expect(tBarata).toBeLessThan(tCara)
})
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')
expect(t).toBeGreaterThanOrEqual(0)
expect(t).toBeLessThanOrEqual(1)
}
})
it('lista vacía → t = 0 sin crash', () => {
const act: ActivityCostData = { expectedTotalCost: 100, percentOfTotal: 100 }
expect(computeActivityT(act, [], 'relative')).toBe(0)
})
})
describe('computeActivityT — modo absoluto', () => {
it('actividad más cara → t = 1.0', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[2], acts, 'absolute')
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')
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')
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')
expect(t).toBe(0.5)
})
})
// ─── activityColor — función unificada ───────────────────────────────────────
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)
}
})
it('CON varianza real modo relativo: la más cara NO es neutra', () => {
const acts = makeActs([500, 100, 10])
const color = activityColor(acts[0], acts, 'relative')
expect(color).not.toBe(NEUTRAL_HEATMAP_COLOR)
})
it('CON varianza real modo relativo: la más cara es roja (#ef4444)', () => {
const acts = makeActs([500, 100, 10])
const colorCara = activityColor(acts[0], acts, 'relative')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo relativo: 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')
})
it('CON varianza real modo absoluto: la más cara es roja', () => {
const acts = makeActs([1000, 100, 10])
const colorCara = activityColor(acts[0], acts, 'absolute')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo absoluto: la más barata es verde', () => {
const acts = makeActs([1000, 100, 10])
const colorBarata = activityColor(acts[2], acts, 'absolute')
expect(colorBarata).toBe('#10b981')
})
it('edge: costos negativos no crashean', () => {
const acts = makeActs([0, 0, 0])
expect(() => activityColor(acts[0], acts, 'relative')).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'))
expect(colors.every((c) => c === NEUTRAL_HEATMAP_COLOR)).toBe(true)
})
it('NEUTRAL_HEATMAP_COLOR es un hex válido y claramente distinto del gradiente', () => {
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('#ef4444') // no es rojo
})
})