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>
This commit is contained in:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

211
tests/lib/format.test.ts Normal file
View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatPercent, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
// ─── formatCurrency ───────────────────────────────────────────────────────────
describe('formatCurrency', () => {
// USD
it('USD: formatea 0 correctamente', () => {
const result = formatCurrency(0, 'USD')
expect(result).toMatch(/0/)
expect(result).toMatch(/\$|USD/)
})
it('USD: formatea 1 con 2 decimales', () => {
const result = formatCurrency(1, 'USD')
expect(result).toMatch(/1[.,]00/)
})
it('USD: formatea 1000 con separador de miles', () => {
const result = formatCurrency(1000, 'USD')
// Debe tener separador (coma o punto) antes de los tres ceros
expect(result).toMatch(/1[.,]000/)
})
it('USD: formatea 1234567 con separadores', () => {
const result = formatCurrency(1234567, 'USD')
expect(result).toMatch(/1/)
expect(result).toMatch(/234/)
expect(result).toMatch(/567/)
})
it('USD: formatea decimales correctamente', () => {
const result = formatCurrency(1234.56, 'USD')
expect(result).toMatch(/1[.,]234/)
expect(result).toMatch(/56/)
})
// PYG — el guaraní no tiene decimales centavos en la práctica
it('PYG: devuelve una cadena con el valor', () => {
const result = formatCurrency(1000000, 'PYG')
expect(result).toBeTruthy()
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('PYG: 0 formatea sin crash', () => {
expect(() => formatCurrency(0, 'PYG')).not.toThrow()
})
it('PYG: número grande formatea sin crash', () => {
expect(() => formatCurrency(1_234_567_890, 'PYG')).not.toThrow()
const result = formatCurrency(1_234_567_890, 'PYG')
expect(result).toMatch(/1/)
})
// BRL
it('BRL: formatea con símbolo correcto', () => {
const result = formatCurrency(1000, 'BRL')
expect(result).toBeTruthy()
// El símbolo R$ puede aparecer o la abreviatura BRL
expect(result).toMatch(/1/)
})
// EUR
it('EUR: formatea 1234.56 sin crash', () => {
expect(() => formatCurrency(1234.56, 'EUR')).not.toThrow()
const result = formatCurrency(1234.56, 'EUR')
expect(result).toMatch(/1/)
})
// Negativos — el reporte no debe tener valores negativos, pero la función no debe crashear
it('No crashea con valores negativos', () => {
expect(() => formatCurrency(-100, 'USD')).not.toThrow()
const result = formatCurrency(-100, 'USD')
expect(result).toMatch(/-|100/)
})
// Múltiples monedas producen outputs distintos
it('USD y PYG producen outputs distintos para el mismo número', () => {
const usd = formatCurrency(1000, 'USD')
const pyg = formatCurrency(1000, 'PYG')
expect(usd).not.toBe(pyg)
})
})
// ─── formatMinutes ────────────────────────────────────────────────────────────
describe('formatMinutes', () => {
it('5 min → "5 min"', () => {
expect(formatMinutes(5)).toBe('5 min')
})
it('30 min → "30 min"', () => {
expect(formatMinutes(30)).toBe('30 min')
})
it('59 min → "59 min" (aún no llega a 1h)', () => {
expect(formatMinutes(59)).toBe('59 min')
})
it('60 min → "1h" (exactamente 1 hora)', () => {
expect(formatMinutes(60)).toBe('1h')
})
it('90 min → "1h 30min"', () => {
expect(formatMinutes(90)).toBe('1h 30min')
})
it('125 min → "2h 5min"', () => {
expect(formatMinutes(125)).toBe('2h 5min')
})
it('120 min → "2h" (horas exactas, sin fracción)', () => {
expect(formatMinutes(120)).toBe('2h')
})
it('1440 min → "24h" (un día completo)', () => {
expect(formatMinutes(1440)).toBe('24h')
})
it('0 min → "0 min"', () => {
expect(formatMinutes(0)).toBe('0 min')
})
it('No crashea con valores decimales', () => {
expect(() => formatMinutes(90.7)).not.toThrow()
})
})
// ─── formatPercent ────────────────────────────────────────────────────────────
// Nota: el locale es-PY usa coma como separador decimal → "23,4%" no "23.4%".
// Los tests verifican estructura (contiene el valor y el símbolo) sin asumir separador.
describe('formatPercent', () => {
it('23.4 → contiene "23" y "4" y "%" con 1 decimal', () => {
const result = formatPercent(23.4)
expect(result).toContain('23')
expect(result).toContain('4')
expect(result).toContain('%')
})
it('100 → contiene "100" y "%" con 1 decimal', () => {
const result = formatPercent(100)
expect(result).toContain('100')
expect(result).toContain('%')
})
it('0 → contiene "0" y "%" sin crash', () => {
const result = formatPercent(0)
expect(result).toContain('0')
expect(result).toContain('%')
})
it('el locale es-PY usa coma decimal: 23.4 → "23,4%"', () => {
// Documenta el comportamiento real del locale configurado
expect(formatPercent(23.4)).toBe('23,4%')
})
it('100% con 0 decimales → "100%"', () => {
expect(formatPercent(100, 0)).toBe('100%')
})
it('20 con 0 decimales → "20%"', () => {
expect(formatPercent(20, 0)).toBe('20%')
})
})
// ─── formatSimulationTimestamp ────────────────────────────────────────────────
describe('formatSimulationTimestamp', () => {
// Timestamp fijo para reproducibilidad: 13 de mayo de 2026, 09:45 UTC-4 (PY hora de verano)
// Usamos una fecha conocida y verificamos la estructura del output sin depender de TZ del CI.
const KNOWN_TS = new Date('2026-05-13T13:45:00Z').getTime() // UTC
it('devuelve objeto con date y time strings no vacíos', () => {
const { date, time } = formatSimulationTimestamp(KNOWN_TS)
expect(date.length).toBeGreaterThan(0)
expect(time.length).toBeGreaterThan(0)
})
it('date contiene el año 2026', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toContain('2026')
})
it('date contiene el número 13 (día)', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toContain('13')
})
it('date contiene "mayo" (nombre del mes en es-PY)', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toMatch(/mayo/i)
})
it('time tiene formato HH:mm (dos pares de dígitos separados por ":")', () => {
const { time } = formatSimulationTimestamp(KNOWN_TS)
expect(time).toMatch(/\d{1,2}:\d{2}/)
})
it('no crashea con timestamp 0 (epoch)', () => {
expect(() => formatSimulationTimestamp(0)).not.toThrow()
const { date, time } = formatSimulationTimestamp(0)
expect(date.length).toBeGreaterThan(0)
expect(time.length).toBeGreaterThan(0)
})
it('timestamps distintos producen salidas distintas', () => {
const ts1 = new Date('2026-01-01T10:00:00Z').getTime()
const ts2 = new Date('2026-06-15T18:30:00Z').getTime()
const r1 = formatSimulationTimestamp(ts1)
const r2 = formatSimulationTimestamp(ts2)
expect(r1.date).not.toBe(r2.date)
})
})