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:
2026-06-19 18:28:10 -03:00
parent 889929329e
commit a65bec45c3
15 changed files with 593 additions and 176 deletions

View File

@@ -258,32 +258,56 @@ async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
/**
* Cuenta píxeles por categoría de color en un buffer JPEG.
* Umbrales calibrados para los colores del heatmap con opacity 0.82 sobre #f8fafc.
* Recalibrado Sprint 5 Etapa 3 — gradiente de 4 stops (verde→amarillo→naranja→rojo).
* Colores de referencia con opacity 0.82 sobre fondo #f8fafc (blend = bg·0.18 + color·0.82):
*
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 152) → G > R·1.4, G > 120
* Ámbar (#f59e0b + bg) → RGB ≈ (246, 175, 54) → R > 180, G > 120, B < 120, R/G < 1.8
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101) → R > G·1.8, R > B·1.8, R > 180
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 151)
* Amarillo(#f59e0b + bg) → RGB ≈ (246, 175, 54)
* Naranja (#f97316 + bg) → RGB ≈ (249, 139, 63)
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101)
*
* Clasificación por vecino más cercano (distancia euclidiana en RGB) en vez de desigualdades
* ad-hoc: con 4 stops, amarillo y naranja quedan demasiado cerca para distinguirlos con
* reglas simples de umbral. MAX_DIST excluye fondo/texto/bordes que no son parte del heatmap.
*/
const HEAT_REF_COLORS = {
green: { r: 58, g: 197, b: 151 },
amber: { r: 246, g: 175, b: 54 },
orange: { r: 249, g: 139, b: 63 },
red: { r: 241, g: 101, b: 101 },
} as const
const MAX_HEAT_COLOR_DIST = 40
function colorDistance(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2)
}
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; redPx: number; total: number
greenPx: number; amberPx: number; orangePx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels // 3 para JPEG (RGB)
let greenPx = 0, amberPx = 0, redPx = 0
let greenPx = 0, amberPx = 0, orangePx = 0, redPx = 0
for (let i = 0; i < data.length; i += ch) {
const r = data[i], g = data[i + 1], b = data[i + 2]
const px = { r: data[i], g: data[i + 1], b: data[i + 2] }
if (g > r * 1.4 && g > 120 && g > b) {
greenPx++ // Verde
} else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) {
amberPx++ // Ámbar / amarillo cálido
} else if (r > g * 1.8 && r > b * 1.8 && r > 180) {
redPx++ // Rojo
let closestName: keyof typeof HEAT_REF_COLORS | null = null
let closestDist = Infinity
for (const [name, ref] of Object.entries(HEAT_REF_COLORS) as Array<[keyof typeof HEAT_REF_COLORS, typeof HEAT_REF_COLORS['green']]>) {
const dist = colorDistance(px, ref)
if (dist < closestDist) { closestDist = dist; closestName = name }
}
if (closestDist > MAX_HEAT_COLOR_DIST) continue
if (closestName === 'green') greenPx++
else if (closestName === 'amber') amberPx++
else if (closestName === 'orange') orangePx++
else if (closestName === 'red') redPx++
}
return { greenPx, amberPx, redPx, total: data.length / ch }
return { greenPx, amberPx, orangePx, redPx, total: data.length / ch }
}
// ─── Test: gradiente real con costos dispares ─────────────────────────────────
@@ -366,6 +390,13 @@ test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
// Forzar modo Min-max: este test verifica la mecánica del gradiente de color con
// costos diseñados para posicionarse proporcionalmente (task_analisis a la mitad
// del rango). El modo 'percentile' (default desde Sprint 5 Etapa 3) normaliza por
// rango/rank, no por valor — rompería la posición esperada de "mitad" del fixture.
await page.getByRole('button', { name: 'Min-max' }).click()
await page.waitForTimeout(300)
// ── 5. Esperar a que el heatmap tenga colores reales (no neutro #cbd5e1) ────
// Usamos style.fill (inline CSS) porque applyHeatmapToViewer ahora lo usa
// en vez de setAttribute (para garantizar mayor especificidad que bpmn-js CSS).
@@ -407,14 +438,18 @@ test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }
const jpegBuf = await extractJpegFromPdf(pdfBuf)
console.log(` JPEG extraído: ${jpegBuf.length} bytes`)
const { greenPx, amberPx, redPx, total } = await analyzeJpegColors(jpegBuf)
const { greenPx, amberPx, orangePx, redPx, total } = await analyzeJpegColors(jpegBuf)
console.log(` Total píxeles: ${total.toLocaleString()}`)
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
console.log(` 🟠 Naranja: ${orangePx.toLocaleString()} px`)
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
const MIN_PX = 100
expect(greenPx, `Píxeles verdes en heatmap (≥ ${MIN_PX}): ${greenPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(amberPx, `Píxeles ámbar en heatmap (≥ ${MIN_PX}): ${amberPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(redPx, `Píxeles rojos en heatmap (≥ ${MIN_PX}): ${redPx}`).toBeGreaterThanOrEqual(MIN_PX)
// orangePx no se exige con mínimo estricto: con solo 11 actividades el stop naranja
// (t=0.67) puede no tener ninguna actividad exactamente ahí — alcanza con que exista
// la transición suave entre amarillo y rojo (lo prueban los tests unitarios de colors.ts).
})

View File

@@ -71,17 +71,43 @@ async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
return jpeg
}
async function analyzeJpegColors(jpegBuf: Buffer) {
// Gradiente 4 stops — colores de referencia con opacity 0.82 sobre fondo #f8fafc
const HEAT_REF_COLORS = {
green: { r: 58, g: 197, b: 151 },
amber: { r: 246, g: 175, b: 54 },
orange: { r: 249, g: 139, b: 63 },
red: { r: 241, g: 101, b: 101 },
} as const
const MAX_HEAT_COLOR_DIST = 40
function colorDistance(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2)
}
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; orangePx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels
let greenPx = 0, amberPx = 0, redPx = 0
let greenPx = 0, amberPx = 0, orangePx = 0, redPx = 0
for (let i = 0; i < data.length; i += ch) {
const r = data[i], g = data[i + 1], b = data[i + 2]
if (g > r * 1.4 && g > 120 && g > b) greenPx++
else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) amberPx++
else if (r > g * 1.8 && r > b * 1.8 && r > 180) redPx++
const px = { r: data[i], g: data[i + 1], b: data[i + 2] }
let closestName: keyof typeof HEAT_REF_COLORS | null = null
let closestDist = Infinity
for (const [name, ref] of Object.entries(HEAT_REF_COLORS) as Array<[keyof typeof HEAT_REF_COLORS, typeof HEAT_REF_COLORS['green']]>) {
const dist = colorDistance(px, ref)
if (dist < closestDist) { closestDist = dist; closestName = name }
}
if (closestDist > MAX_HEAT_COLOR_DIST) continue
if (closestName === 'green') greenPx++
else if (closestName === 'amber') amberPx++
else if (closestName === 'orange') orangePx++
else if (closestName === 'red') redPx++
}
return { greenPx, amberPx, redPx, total: data.length / ch }
return { greenPx, amberPx, orangePx, redPx, total: data.length / ch }
}
// ─── Configuración del proceso ────────────────────────────────────────────────
@@ -196,6 +222,12 @@ test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-
})
}, { timeout: 30_000 })
// Forzar modo Min-max: los costos del fixture están diseñados para posicionarse
// proporcionalmente en el rango (task_analisis a la mitad). El modo 'percentile'
// (default desde Sprint 5 Etapa 3) normaliza por rank y rompería la posición esperada.
await page.getByRole('button', { name: 'Min-max' }).click()
await page.waitForTimeout(300)
// ── Fase 3: exportar desde tab "Actual" ──────────────────────────────────────
// Tab "Actual" está activo por defecto
@@ -283,7 +315,7 @@ test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-
actualPixels.greenPx >= 100
results.push(`║ actual.pdf │ ${actualKb.padEnd(6)} KB │ ${String(actualPages).padEnd(6)} pgs │ ${actualOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ JPEG: ${actualHasJpeg ? '✓' : '✗'} Verde:${actualPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${actualPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${actualPixels.redPx.toLocaleString().padStart(5)}px ║`)
results.push(`║ JPEG: ${actualHasJpeg ? '✓' : '✗'} Verde:${actualPixels.greenPx.toLocaleString().padStart(5)}px Ámbar:${actualPixels.amberPx.toLocaleString().padStart(5)}px Naranja:${actualPixels.orangePx.toLocaleString().padStart(5)}px Rojo:${actualPixels.redPx.toLocaleString().padStart(5)}px ║`)
// ── Validar automatizado.pdf ──────────────────────────────────────────────────
const autoPdfBuf = readFileSync(autoPdfPath)
@@ -302,7 +334,7 @@ test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-
autoPixels.greenPx >= 100
results.push(`║ automatizado.pdf│ ${autoKb.padEnd(6)} KB │ ${String(autoPages).padEnd(6)} pgs │ ${autoOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ JPEG: ${autoHasJpeg ? '✓' : '✗'} Verde:${autoPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${autoPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${autoPixels.redPx.toLocaleString().padStart(5)}px ║`)
results.push(`║ JPEG: ${autoHasJpeg ? '✓' : '✗'} Verde:${autoPixels.greenPx.toLocaleString().padStart(5)}px Ámbar:${autoPixels.amberPx.toLocaleString().padStart(5)}px Naranja:${autoPixels.orangePx.toLocaleString().padStart(5)}px Rojo:${autoPixels.redPx.toLocaleString().padStart(5)}px ║`)
// ── Validar roi.pdf ───────────────────────────────────────────────────────────
const roiPdfBuf = readFileSync(roiPdfPath)

View File

@@ -146,7 +146,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
@@ -161,7 +161,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
@@ -177,7 +177,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={onRowClick}
@@ -193,7 +193,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId="task1"
onRowClick={vi.fn()}
@@ -208,7 +208,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={[]}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}

View File

@@ -213,7 +213,7 @@ describe('buildTopActivitiesOption — estructura', () => {
it('serie de tipo bar con hasta 10 items', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const option = buildTopActivitiesOption(result.perActivity, 'percentile', 'USD')
const series = option.series as any[]
expect(series[0].type).toBe('bar')
expect(series[0].data.length).toBeLessThanOrEqual(10)
@@ -225,7 +225,7 @@ describe('buildTopActivitiesOption — estructura', () => {
const result = runSimulation(buildSimInput(xml, 200, 0))
// En modo relativo con varianza real, la 1ra actividad → t=1 → rojo
// En simple-linear todos cuestan igual → NEUTRAL_HEATMAP_COLOR
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const option = buildTopActivitiesOption(result.perActivity, 'percentile', 'USD')
const series = option.series as any[]
// Verifica que itemStyle.color existe en cada barra
series[0].data.forEach((item: any) => {
@@ -237,7 +237,7 @@ describe('buildTopActivitiesOption — estructura', () => {
it('yAxis data contiene los nombres de actividades (truncados)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const option = buildTopActivitiesOption(result.perActivity, 'percentile', 'USD')
const yAxis = option.yAxis as any
expect(yAxis.data).toHaveLength(result.perActivity.length)
// Los nombres deben existir (no vacíos)

View File

@@ -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
})
})