diff --git a/OBSERVATIONS.md b/OBSERVATIONS.md index fd3a67b..b9e71e7 100644 --- a/OBSERVATIONS.md +++ b/OBSERVATIONS.md @@ -29,7 +29,11 @@ ## Hallazgos resueltos durante el sprint -*(vacío)* +### [RESOLVED] Etapa 2 — Zoom BPMN fix + controles + skeleton + +**Estado:** OK formal del director — 2026-06-19. +**Fixes:** fit-viewport `'auto'` agregado (una línea), zoom-in/zoom-out en esquina inferior derecha (Lucide icons, 1.2x factor, límites 0.1x–4x), skeleton wired correctamente en WorkspacePage (guard `isLoading` simple sin `!currentProcess`). +**Carry-over a Etapa 3:** controles de zoom solapan el logo bpmn.io (esquina inferior derecha). Fix: mover a esquina inferior izquierda (`left: 12` en lugar de `right: 12`). Una línea CSS. --- diff --git a/sprints/sprint-5/ETAPA_3_PROMPT.md b/sprints/sprint-5/ETAPA_3_PROMPT.md new file mode 100644 index 0000000..18af124 --- /dev/null +++ b/sprints/sprint-5/ETAPA_3_PROMPT.md @@ -0,0 +1,250 @@ +# Sprint 5 — Etapa 3: Heatmap gradiente continuo + fix posición zoom + +## Contexto + +InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase. +Etapas 1 y 2 completadas con OK formal. Esta etapa tiene un fix carry-over de Etapa 2 y la mejora principal del heatmap. + +--- + +## Fix 0 — Posición controles de zoom (carry-over Etapa 2) + +En `BpmnCanvas.tsx`, los controles de zoom-in/zoom-out quedaron en la esquina inferior derecha (`bottom: 12, right: 12`), solapando el logo `bpmn.io` que bpmn-js renderiza en esa esquina por licencia. + +**Fix:** cambiar la posición del grupo de controles de zoom a la esquina inferior izquierda. + +```tsx +// Antes: +style={{ position: 'absolute', bottom: 12, right: 12, ... }} + +// Después: +style={{ position: 'absolute', bottom: 12, left: 12, ... }} +``` + +El botón de encuadre (fit-viewport, esquina superior derecha) no se mueve — solo el grupo zoom-in/zoom-out. + +--- + +## Mejora principal — Heatmap gradiente con percentil + +### Contexto del sistema actual + +El sistema de heatmap ya existe y está centralizado en `src/lib/colors.ts`. Tiene: + +- Dos modos: `'relative'` (normalización por máximo de porcentaje) y `'absolute'` (min-max sobre costo) +- Gradiente de 3 stops: verde `#10b981` → ámbar `#f59e0b` → rojo `#ef4444` +- Función central `activityColor()` usada por todos los consumidores +- Compuerta de varianza: si varianza < 5%, todos reciben color neutro `#cbd5e1` +- El toggle de modo ya existe en `ReportPage.tsx` como estado local (no persistente) + +El PDF captura el canvas coloreado con `html2canvas`, pero tiene una **leyenda hardcodeada** con los mismos 3 stops en `src/lib/export/pdf-sections.ts` (función `drawHeatmapLegend()`). Hay que mantenerla sincronizada. + +### Cambio 1 — Cuarto stop en el gradiente (naranja) + +Modificar `colors.ts` para usar 4 stops en lugar de 3: + +``` +0.00 → #10b981 (verde) +0.33 → #f59e0b (amarillo) +0.67 → #f97316 (naranja) ← nuevo +1.00 → #ef4444 (rojo vivo) +``` + +La función de interpolación actual usa `lerp` sobre 3 stops. Extenderla para que soporte N stops genéricos: + +```ts +function interpolateColor(t: number, stops: [number, string][]): string { + // t está clampeado a [0, 1] antes de entrar + const clamped = Math.max(0, Math.min(1, t)) + + for (let i = 0; i < stops.length - 1; i++) { + const [t0, c0] = stops[i] + const [t1, c1] = stops[i + 1] + if (clamped <= t1) { + const localT = (clamped - t0) / (t1 - t0) + return lerpColor(c0, c1, localT) // interpolación RGB lineal + } + } + return stops[stops.length - 1][1] +} + +const HEATMAP_STOPS: [number, string][] = [ + [0.00, '#10b981'], + [0.33, '#f59e0b'], + [0.67, '#f97316'], + [1.00, '#ef4444'], +] +``` + +Mantener `NEUTRAL_HEATMAP_COLOR = '#cbd5e1'` para el caso de varianza nula. + +### Cambio 2 — Modo `'percentile'` reemplaza `'relative'` + +**Renombrar** `'relative'` → `'percentile'` en el tipo `HeatmapMode`. Actualizar todos los usos (buscar con grep). + +**Implementar normalización por percentil real** en `computeActivityT()`: + +```ts +function percentileNorm( + current: ActivityWithCost, + all: ActivityWithCost[] +): number { + const n = all.length + if (n <= 1) return 0.5 + + const currentCost = current.expectedTotalCost + // Rango: cantidad de actividades con costo estrictamente menor + const rank = all.filter(a => a.expectedTotalCost < currentCost).length + + // Normalizar a [0, 1]: 0 = la más barata, 1 = la más cara + // Ties reciben el mismo rango (densidad uniforme) + return rank / (n - 1) +} +``` + +**Edge cases obligatorios:** +- `n === 1`: retornar `0.5` (actividad única → color intermedio) +- Todas iguales (`max === min` o todos los ranks son 0): la compuerta de varianza ya maneja esto con `#cbd5e1` — verificar que siga funcionando con percentil +- `expectedTotalCost < 0`: clampear `t` a 0 (costo negativo = "más barato posible") + +**Modo `'minmax'`** (renombrado de `'absolute'`): mantener la lógica existente, solo renombrar el string. + +### Cambio 3 — Persistencia del modo en localStorage + +El toggle hoy es estado local en `ReportPage.tsx` — se pierde al navegar o recargar. + +Crear un hook `useHeatmapMode()` en `src/lib/colors.ts` o en `src/lib/hooks/useHeatmapMode.ts`: + +```ts +const STORAGE_KEY = 'inq-roi:heatmap-mode' +const DEFAULT_MODE: HeatmapMode = 'percentile' + +export function useHeatmapMode(): [HeatmapMode, (mode: HeatmapMode) => void] { + const [mode, setModeState] = useState(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + // Validar que el valor guardado sea un modo válido + return (stored === 'percentile' || stored === 'minmax') ? stored : DEFAULT_MODE + } catch { + return DEFAULT_MODE + } + }) + + const setMode = (newMode: HeatmapMode) => { + try { + localStorage.setItem(STORAGE_KEY, newMode) + } catch { + // localStorage no disponible — continuar sin persistir + } + setModeState(newMode) + } + + return [mode, setMode] +} +``` + +Reemplazar el `useState` local en `ReportPage.tsx` por `useHeatmapMode()`. Si el heatmap también se controla desde el workspace (canvas BPMN), usar el mismo hook allí. + +**Nota:** el valor guardado en localStorage debe validarse antes de usarse (el hook ya lo hace con el check de `stored === 'percentile' || stored === 'minmax'`). Nunca confiar en strings crudos de localStorage sin validación. + +### Cambio 4 — Actualizar leyenda del PDF + +En `src/lib/export/pdf-sections.ts`, la función `drawHeatmapLegend()` tiene los colores hardcodeados en RGB con 3 stops. Actualizar a 4 stops para mantener sincronía visual con la web: + +```ts +// Reemplazar los stops hardcodeados por los mismos 4: +const heatStops = [ + { t: 0.00, r: 16, g: 185, b: 129 }, // #10b981 verde + { t: 0.33, r: 245, g: 158, b: 11 }, // #f59e0b amarillo + { t: 0.67, r: 249, g: 115, b: 22 }, // #f97316 naranja + { t: 1.00, r: 239, g: 68, b: 68 }, // #ef4444 rojo +] +``` + +La lógica de 40 segmentos puede mantenerse — solo actualizan los puntos de referencia. + +El PDF siempre usa el modo activo al momento de exportar (lo que está en localStorage). No hace falta cambiar la lógica de exportación — el canvas ya está coloreado antes de que `html2canvas` lo capture. + +### Cambio 5 — Toggle en UI + +El toggle ya existe en `ReportPage.tsx`. Solo actualizar: +- Strings del label: `'relative'` → `'percentile'`, `'absolute'` → `'minmax'` +- Texto visible: "Relativo" → "Percentil", "Absoluto" → "Min-max" +- Conectar al hook `useHeatmapMode()` en lugar del estado local + +Si el toggle también existe o debe existir en el workspace (para ver el heatmap en el canvas), agregar el mismo toggle allí usando el mismo hook. + +--- + +## Tests obligatorios + +### Tests unitarios en Vitest — `colors.ts` + +Agregar o actualizar tests para: + +```ts +// Normalización percentil +test('percentile: actividad más barata → t = 0', ...) +test('percentile: actividad más cara → t = 1', ...) +test('percentile: actividades con mismo costo → mismo t', ...) +test('percentile: n=1 → t = 0.5', ...) + +// Interpolación de color +test('intensityToColor(0) → #10b981', ...) +test('intensityToColor(1) → #ef4444', ...) +test('intensityToColor(0.5) → color naranja intermedio (entre #f59e0b y #f97316)', ...) +test('intensityToColor(-0.1) → mismo que intensityToColor(0) [clamp]', ...) +test('intensityToColor(1.5) → mismo que intensityToColor(1) [clamp]', ...) + +// Compuerta de varianza +test('todas las actividades con mismo costo → color neutro #cbd5e1', ...) +``` + +### Tests E2E — recalibrar specs de píxeles + +Buscar en `tests/e2e/` los specs que verifican colores del canvas BPMN (probablemente `etapa-*.spec.ts` o `heatmap*.spec.ts`). Actualizarlos para los nuevos valores de color del gradiente de 4 stops. + +Si un spec verifica que "la actividad más costosa es roja" — sigue siendo válido. Si verifica un color RGB exacto con los 3 stops anteriores — actualizar al nuevo gradiente. + +--- + +## Qué NO entra en esta etapa + +- Cambios en el motor de simulación o cálculos de ROI +- Cambios en el esquema de Supabase +- Nuevas columnas en tablas o paneles +- Exportación a Excel +- Cualquier feature fuera del heatmap y el fix de posición zoom + +--- + +## Orden de ejecución + +1. Fix 0 — mover controles zoom a `left: 12` en `BpmnCanvas.tsx` +2. Agregar cuarto stop naranja al gradiente en `colors.ts` +3. Implementar `percentileNorm()` y renombrar modos en `colors.ts` +4. Crear `useHeatmapMode()` hook con persistencia localStorage +5. Actualizar `ReportPage.tsx` para usar `useHeatmapMode()` (y workspace si aplica) +6. Actualizar `drawHeatmapLegend()` en `pdf-sections.ts` con 4 stops +7. Escribir/actualizar tests unitarios en Vitest para las funciones de color +8. Buscar y recalibrar specs E2E que verifiquen colores del heatmap +9. `npm run build` — 0 errores TypeScript +10. `npm run lint` — 0 warnings nuevos +11. `npm run test` — 521+ Vitest verdes +12. `npx playwright test` — sin nuevos rojos +13. Commit: `feat(sprint-5/etapa-3): heatmap gradiente 4 stops + percentil + localStorage + fix zoom position` + +--- + +## Criterios de validación + +- [ ] Controles de zoom en esquina inferior izquierda — sin solapamiento con logo bpmn.io +- [ ] El heatmap del canvas BPMN muestra transiciones suaves (verde → amarillo → naranja → rojo) +- [ ] Con outlier extremo en modo percentil: las actividades restantes tienen colores variados (no todas verde) +- [ ] Con outlier extremo en modo min-max: las actividades restantes se comprimen hacia el verde +- [ ] Toggle "Percentil / Min-max" visible en el reporte — cambia el heatmap en tiempo real +- [ ] El modo persiste entre recargas (verificar con localStorage en DevTools) +- [ ] PDF: la leyenda del heatmap muestra el gradiente de 4 stops +- [ ] Tests unitarios de `colors.ts` todos verdes +- [ ] E2E specs de heatmap recalibrados y verdes +- [ ] **Validación visual del director** antes del OK formal diff --git a/src/features/report/HeatmapLegend.tsx b/src/features/report/HeatmapLegend.tsx index dfe0a7e..f3883b1 100644 --- a/src/features/report/HeatmapLegend.tsx +++ b/src/features/report/HeatmapLegend.tsx @@ -1,5 +1,5 @@ -import { formatCurrency, formatPercent } from '@/lib/format' -import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors' +import { formatCurrency } from '@/lib/format' +import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors' import type { HeatmapMode } from '@/lib/colors' import type { ActivitySimResult } from '@/domain/types' @@ -12,8 +12,10 @@ interface HeatmapLegendProps { export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) { const bounds = heatmapLegendBounds(perActivity, mode) + // En modo percentile los extremos son rango (0 = más barata, 100 = más cara), no costo + // literal — el percentil no es lineal respecto al costo real. function formatBound(value: number): string { - if (bounds.unit === 'percent') return formatPercent(value, value % 1 === 0 ? 0 : 1) + if (bounds.unit === 'rank') return `P${Math.round(value)}` return formatCurrency(value, currency) } @@ -21,7 +23,7 @@ export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProp
Bajo costo
Alto costo
diff --git a/src/features/report/HeatmapModeToggle.tsx b/src/features/report/HeatmapModeToggle.tsx index 3acdb50..ac931d5 100644 --- a/src/features/report/HeatmapModeToggle.tsx +++ b/src/features/report/HeatmapModeToggle.tsx @@ -8,20 +8,20 @@ interface HeatmapModeToggleProps { } const MODE_LABELS: Record = { - relative: { - label: 'Costo relativo (%)', - tip: 'Colorea cada actividad en función de su % del costo total. Útil para ver proporciones.', + percentile: { + label: 'Percentil', + tip: 'Colorea cada actividad según su posición relativa entre todas las actividades del proceso. No se distorsiona por outliers.', }, - absolute: { - label: 'Costo absoluto', - tip: 'Colorea según el costo esperado en la moneda del proceso. Útil para comparar magnitudes reales.', + minmax: { + label: 'Min-max', + tip: '(costo − mínimo) / (máximo − mínimo). Refleja valores absolutos, pero se comprime si hay outliers extremos.', }, } export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) { return (
- {(['relative', 'absolute'] as HeatmapMode[]).map((m) => { + {(['percentile', 'minmax'] as HeatmapMode[]).map((m) => { const { label, tip } = MODE_LABELS[m] return ( diff --git a/src/features/report/ReportPage.tsx b/src/features/report/ReportPage.tsx index 8422653..9b6d8b5 100644 --- a/src/features/report/ReportPage.tsx +++ b/src/features/report/ReportPage.tsx @@ -26,7 +26,7 @@ import { TopSavingsChart } from './TopSavingsChart' import { TransparencySection } from './TransparencySection' import { AutomationScenarioNote } from './AutomationScenarioNote' import { MethodologyFooter } from './MethodologyFooter' -import type { HeatmapMode } from '@/lib/colors' +import { useHeatmapMode } from '@/lib/hooks/useHeatmapMode' import { useSimulationStore } from '@/store/simulation-store' import { AppHeader } from '@/components/AppHeader' @@ -36,7 +36,7 @@ export function ReportPage() { const { process, simulation, activities, resources, isLoading, error } = useReportData(processId) const { toast } = useToast() - const [heatmapMode, setHeatmapMode] = useState('relative') + const [heatmapMode, setHeatmapMode] = useHeatmapMode() const { lastPersistedExecutedAt, loadLatestForProcess } = useSimulationStore() // Cargar executedAt persistido si el usuario llega directo al reporte (sin pasar por workspace) diff --git a/src/features/workspace/BpmnCanvas.tsx b/src/features/workspace/BpmnCanvas.tsx index 146474d..6ccb3a7 100644 --- a/src/features/workspace/BpmnCanvas.tsx +++ b/src/features/workspace/BpmnCanvas.tsx @@ -204,7 +204,7 @@ export function BpmnCanvas({ {/* Zoom in/out — esquina inferior derecha, no interfiere con el botón de encuadre */} -
+
diff --git a/src/lib/colors.ts b/src/lib/colors.ts index 5acf9d9..294d7b8 100644 --- a/src/lib/colors.ts +++ b/src/lib/colors.ts @@ -1,73 +1,66 @@ -// Paleta heatmap: verde → amarillo → rojo -// INVARIANTE: estos valores DEBEN coincidir con tailwind.config.js heatmap.* -const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981 -const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b -const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444 - +// Paleta heatmap: verde → amarillo → naranja → rojo (4 stops, Sprint 5 Etapa 3) +// INVARIANTE: estos valores DEBEN coincidir con tailwind.config.js heatmap.* y con +// drawHeatmapLegend() en src/lib/export/pdf-sections.ts export const HEATMAP_LOW_HEX = '#10b981' export const HEATMAP_MID_HEX = '#f59e0b' +export const HEATMAP_HIGH_MID_HEX = '#f97316' export const HEATMAP_HIGH_HEX = '#ef4444' +const HEATMAP_STOPS: Array<[number, string]> = [ + [0.00, HEATMAP_LOW_HEX], + [0.33, HEATMAP_MID_HEX], + [0.67, HEATMAP_HIGH_MID_HEX], + [1.00, HEATMAP_HIGH_HEX], +] + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const v = hex.replace('#', '') + return { + r: parseInt(v.slice(0, 2), 16), + g: parseInt(v.slice(2, 4), 16), + b: parseInt(v.slice(4, 6), 16), + } +} + function lerp(a: number, b: number, t: number): number { return Math.round(a + (b - a) * t) } -// t en [0, 1] -export function heatmapColor(t: number): string { - const clamped = Math.max(0, Math.min(1, t)) - let r: number, g: number, b: number - - if (clamped <= 0.5) { - const localT = clamped * 2 - r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT) - g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT) - b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT) - } else { - const localT = (clamped - 0.5) * 2 - r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT) - g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT) - b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT) - } - - return `rgb(${r}, ${g}, ${b})` +function lerpColor(c0: string, c1: string, t: number): { r: number; g: number; b: number } { + const rgb0 = hexToRgb(c0) + const rgb1 = hexToRgb(c1) + return { r: lerp(rgb0.r, rgb1.r, t), g: lerp(rgb0.g, rgb1.g, t), b: lerp(rgb0.b, rgb1.b, t) } } -export function heatmapColorHex(t: number): string { +// Interpolación genérica sobre N stops [t, color-hex] ordenados ascendentemente por t. +// t se clampea a [0, 1] antes de ubicar el segmento correspondiente. +function interpolateStops(t: number, stops: Array<[number, string]>): { r: number; g: number; b: number } { const clamped = Math.max(0, Math.min(1, t)) - let r: number, g: number, b: number - - if (clamped <= 0.5) { - const localT = clamped * 2 - r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT) - g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT) - b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT) - } else { - const localT = (clamped - 0.5) * 2 - r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT) - g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT) - b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT) + for (let i = 0; i < stops.length - 1; i++) { + const [t0, c0] = stops[i] + const [t1, c1] = stops[i + 1] + if (clamped <= t1) { + const localT = t1 === t0 ? 0 : (clamped - t0) / (t1 - t0) + return lerpColor(c0, c1, localT) + } } + return hexToRgb(stops[stops.length - 1][1]) +} +// t en [0, 1] → color hex del gradiente heatmap (verde → amarillo → naranja → rojo) +export function intensityToColor(t: number): string { + const { r, g, b } = interpolateStops(t, HEATMAP_STOPS) return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` } -// Retorna opacidad para colores con alpha (útil para overlays sobre bpmn-js) +export function heatmapColor(t: number): string { + const { r, g, b } = interpolateStops(t, HEATMAP_STOPS) + return `rgb(${r}, ${g}, ${b})` +} + +// Retorna color con alpha (útil para overlays sobre bpmn-js) export function heatmapColorWithAlpha(t: number, alpha = 0.75): string { - const clamped = Math.max(0, Math.min(1, t)) - let r: number, g: number, b: number - - if (clamped <= 0.5) { - const localT = clamped * 2 - r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT) - g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT) - b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT) - } else { - const localT = (clamped - 0.5) * 2 - r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT) - g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT) - b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT) - } - + const { r, g, b } = interpolateStops(t, HEATMAP_STOPS) return `rgba(${r}, ${g}, ${b}, ${alpha})` } @@ -78,7 +71,10 @@ export function getContrastText(t: number): 'white' | 'black' { // ─── Helpers para el heatmap del reporte ────────────────────────────────────── -export type HeatmapMode = 'relative' | 'absolute' +// percentile: posición relativa por rango entre todas las actividades del proceso (no sufre +// distorsión por outliers). +// minmax: (costo - mínimo) / (máximo - mínimo) — refleja valores absolutos, se comprime con outliers. +export type HeatmapMode = 'percentile' | 'minmax' export interface ActivityCostData { expectedTotalCost: number @@ -101,9 +97,20 @@ export function hasSignificantCostVariance(activities: ActivityCostData[]): bool return (max - min) / max > VARIANCE_THRESHOLD } +// Normalización por percentil: posición de `current` en el ranking de costos de `all`. +// rank = cantidad de actividades estrictamente más baratas. Ties reciben el mismo rango +// (densidad uniforme). No sufre distorsión por outliers, a diferencia de minmax. +function percentileNorm(current: ActivityCostData, all: ActivityCostData[]): number { + const n = all.length + if (n <= 1) return 0.5 + if (current.expectedTotalCost < 0) return 0 // costo negativo = "más barato posible" + + const currentCost = current.expectedTotalCost + const rank = all.filter((a) => a.expectedTotalCost < currentCost).length + return rank / (n - 1) +} + // Calcula t ∈ [0,1] para una actividad dada la lista completa y el modo. -// Modo relativo: normaliza sobre la actividad más costosa (t=1 = la que más pesa). -// Modo absoluto: normaliza sobre el rango min-max del proceso. // Pre-condición: llamar solo cuando hasSignificantCostVariance = true. export function computeActivityT( activity: ActivityCostData, @@ -112,9 +119,8 @@ export function computeActivityT( ): number { if (allActivities.length === 0) return 0 - if (mode === 'relative') { - const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal), 0.001) - return Math.max(0, Math.min(1, activity.percentOfTotal / maxPercent)) + if (mode === 'percentile') { + return percentileNorm(activity, allActivities) } const costs = allActivities.map((a) => a.expectedTotalCost) @@ -132,19 +138,21 @@ export function activityColor( mode: HeatmapMode ): string { if (!hasSignificantCostVariance(allActivities)) return NEUTRAL_HEATMAP_COLOR - return heatmapColorHex(computeActivityT(activity, allActivities, mode)) + return intensityToColor(computeActivityT(activity, allActivities, mode)) } -// Valores de los extremos para la leyenda del heatmap +// Valores de los extremos para la leyenda del heatmap. +// percentile: los extremos del gradiente representan el rango (0 = más barata, 100 = más cara), +// no un costo o porcentaje literal — el rank no es lineal respecto al costo. +// minmax: los extremos son el costo mínimo y máximo reales del proceso. export function heatmapLegendBounds( allActivities: ActivityCostData[], mode: HeatmapMode -): { min: number; mid: number; max: number; unit: 'currency' | 'percent' } { - if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'percent' } +): { min: number; mid: number; max: number; unit: 'currency' | 'rank' } { + if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'rank' } - if (mode === 'relative') { - const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal)) - return { min: 0, mid: maxPercent / 2, max: maxPercent, unit: 'percent' } + if (mode === 'percentile') { + return { min: 0, mid: 50, max: 100, unit: 'rank' } } const costs = allActivities.map((a) => a.expectedTotalCost) diff --git a/src/lib/export/pdf-sections.ts b/src/lib/export/pdf-sections.ts index ba97fc3..205886e 100644 --- a/src/lib/export/pdf-sections.ts +++ b/src/lib/export/pdf-sections.ts @@ -28,6 +28,7 @@ export const PDF = { slate50: [248, 250, 252] as [number, number, number], heatLow: [16, 185, 129] as [number, number, number], heatMid: [245, 158, 11] as [number, number, number], + heatHighMid: [249, 115, 22] as [number, number, number], heatHigh: [239, 68, 68] as [number, number, number], } @@ -155,22 +156,31 @@ export function drawHeatmapLegend(doc: DocLike, startY: number): number { doc.setTextColor(...PDF.heatLow) doc.text('Bajo costo', x, labelY) - // Barra de gradiente: dibujada como N segmentos de colores + // Barra de gradiente: dibujada como N segmentos de colores, 4 stops + // (mismo gradiente que src/lib/colors.ts — verde → amarillo → naranja → rojo) + const heatStops: Array<[number, [number, number, number]]> = [ + [0.00, PDF.heatLow], + [0.33, PDF.heatMid], + [0.67, PDF.heatHighMid], + [1.00, PDF.heatHigh], + ] const steps = 40 const segW = barW / steps for (let i = 0; i < steps; i++) { const t = i / (steps - 1) - let r: number, g: number, b: number - if (t <= 0.5) { - const lt = t * 2 - r = Math.round(PDF.heatLow[0] + (PDF.heatMid[0] - PDF.heatLow[0]) * lt) - g = Math.round(PDF.heatLow[1] + (PDF.heatMid[1] - PDF.heatLow[1]) * lt) - b = Math.round(PDF.heatLow[2] + (PDF.heatMid[2] - PDF.heatLow[2]) * lt) - } else { - const lt = (t - 0.5) * 2 - r = Math.round(PDF.heatMid[0] + (PDF.heatHigh[0] - PDF.heatMid[0]) * lt) - g = Math.round(PDF.heatMid[1] + (PDF.heatHigh[1] - PDF.heatMid[1]) * lt) - b = Math.round(PDF.heatMid[2] + (PDF.heatHigh[2] - PDF.heatMid[2]) * lt) + let r = heatStops[heatStops.length - 1][1][0] + let g = heatStops[heatStops.length - 1][1][1] + let b = heatStops[heatStops.length - 1][1][2] + for (let s = 0; s < heatStops.length - 1; s++) { + const [t0, c0] = heatStops[s] + const [t1, c1] = heatStops[s + 1] + if (t <= t1) { + const lt = t1 === t0 ? 0 : (t - t0) / (t1 - t0) + r = Math.round(c0[0] + (c1[0] - c0[0]) * lt) + g = Math.round(c0[1] + (c1[1] - c0[1]) * lt) + b = Math.round(c0[2] + (c1[2] - c0[2]) * lt) + break + } } doc.setFillColor(r, g, b) doc.rect(x + i * segW, startY, segW + 0.1, barH, 'F') diff --git a/src/lib/hooks/useHeatmapMode.ts b/src/lib/hooks/useHeatmapMode.ts new file mode 100644 index 0000000..96ae7ae --- /dev/null +++ b/src/lib/hooks/useHeatmapMode.ts @@ -0,0 +1,33 @@ +import { useState } from 'react' +import type { HeatmapMode } from '@/lib/colors' + +const STORAGE_KEY = 'inq-roi:heatmap-mode' +const DEFAULT_MODE: HeatmapMode = 'percentile' + +function isValidMode(value: string | null): value is HeatmapMode { + return value === 'percentile' || value === 'minmax' +} + +// Persiste la preferencia de modo de heatmap en localStorage. Nunca confía en el string +// crudo leído de localStorage sin validarlo contra los modos válidos. +export function useHeatmapMode(): [HeatmapMode, (mode: HeatmapMode) => void] { + const [mode, setModeState] = useState(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return isValidMode(stored) ? stored : DEFAULT_MODE + } catch { + return DEFAULT_MODE + } + }) + + function setMode(newMode: HeatmapMode) { + try { + localStorage.setItem(STORAGE_KEY, newMode) + } catch { + // localStorage no disponible (modo privado, cuota excedida) — continuar sin persistir + } + setModeState(newMode) + } + + return [mode, setMode] +} diff --git a/tailwind.config.js b/tailwind.config.js index aa571e5..d5e0987 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -59,11 +59,12 @@ export default { magenta: '#ED3E8F', }, // ── Heatmap (saturado, calibrado para análisis de píxeles E2E) ── - // DEBE coincidir con HEATMAP_LOW/MID/HIGH en src/lib/colors.ts. + // DEBE coincidir con HEATMAP_STOPS en src/lib/colors.ts (4 stops, Sprint 5 Etapa 3). // Cambiar solo en conjunto con recalibración de tests E2E. heatmap: { low: '#10B981', mid: '#F59E0B', + highMid: '#F97316', high: '#EF4444', }, // ── Semánticos ─────────────────────────────────────────────────── diff --git a/tests/e2e/export-pdf.spec.ts b/tests/e2e/export-pdf.spec.ts index d96f139..28d87cc 100644 --- a/tests/e2e/export-pdf.spec.ts +++ b/tests/e2e/export-pdf.spec.ts @@ -258,32 +258,56 @@ async function extractJpegFromPdf(pdfBuf: Buffer): Promise { /** * 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). }) diff --git a/tests/e2e/validate-dod-etapa4.spec.ts b/tests/e2e/validate-dod-etapa4.spec.ts index 7843426..32911e4 100644 --- a/tests/e2e/validate-dod-etapa4.spec.ts +++ b/tests/e2e/validate-dod-etapa4.spec.ts @@ -71,17 +71,43 @@ async function extractJpegFromPdf(pdfBuf: Buffer): Promise { 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) diff --git a/tests/features/report/components.test.tsx b/tests/features/report/components.test.tsx index 8eebe0b..47d3527 100644 --- a/tests/features/report/components.test.tsx +++ b/tests/features/report/components.test.tsx @@ -146,7 +146,7 @@ describe('ActivitiesTable', () => { render( { render( { render( { render( { render( { 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) diff --git a/tests/lib/colors.test.ts b/tests/lib/colors.test.ts index abd3ff5..f647168 100644 --- a/tests/lib/colors.test.ts +++ b/tests/lib/colors.test.ts @@ -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 }) })