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:
211
tests/lib/format.test.ts
Normal file
211
tests/lib/format.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user