import { describe, it, expect } from 'vitest' import { intensityToColor, 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, })) } // ─── intensityToColor — gradiente de 4 stops ───────────────────────────────── describe('intensityToColor', () => { it('t=0 → verde (#10b981)', () => { expect(intensityToColor(0)).toBe('#10b981') }) it('t=1 → rojo (#ef4444)', () => { expect(intensityToColor(1)).toBe('#ef4444') }) 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(intensityToColor(-0.1)).toBe(intensityToColor(0)) }) it('t>1 → clampea a t=1 (rojo)', () => { 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 = intensityToColor(t) expect(hex).toMatch(/^#[0-9a-f]{6}$/) } }) 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(6) }) }) // ─── 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 — modo percentile ────────────────────────────────────── describe('computeActivityT — modo percentile', () => { it('actividad más barata → t = 0', () => { const acts = makeActs([300, 100, 50]) const t = computeActivityT(acts[2], acts, 'percentile') expect(t).toBe(0) }) it('actividad más cara → t = 1', () => { const acts = makeActs([300, 100, 50]) 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, 'percentile') 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, [], '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 }) }) // ─── 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, '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, '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, '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, '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 ─────────────────────────────────────── 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, 'percentile')).toBe(NEUTRAL_HEATMAP_COLOR) expect(activityColor(act, acts, 'minmax')).toBe(NEUTRAL_HEATMAP_COLOR) } }) 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, 'percentile') expect(color).not.toBe(NEUTRAL_HEATMAP_COLOR) }) 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, 'percentile') expect(colorCara).toBe('#ef4444') }) 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, 'percentile') expect(colorBarata).toBe('#10b981') }) it('CON varianza real modo minmax: la más cara es roja', () => { const acts = makeActs([1000, 100, 10]) const colorCara = activityColor(acts[0], acts, 'minmax') expect(colorCara).toBe('#ef4444') }) it('CON varianza real modo minmax: la más barata es verde', () => { const acts = makeActs([1000, 100, 10]) 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, 'percentile')).not.toThrow() }) it('simple-linear scenario: 5 actividades igual costo → todas neutras', () => { const acts = makeActs([100, 100, 100, 100, 100]) const colors = acts.map((a) => activityColor(a, acts, 'percentile')) 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('#f97316') // no es naranja expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#ef4444') // no es rojo }) })