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:
331
tests/integration/indexeddb-persistence.test.ts
Normal file
331
tests/integration/indexeddb-persistence.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Tests permanentes de persistencia en IndexedDB vía Dexie.
|
||||
* Usan fake-indexeddb (parcheado en tests/setup.ts) para correr en Node.
|
||||
*
|
||||
* Cada test limpia las tablas en beforeEach para garantizar aislamiento.
|
||||
* Verifican que los repositorios escriben y recuperan datos correctamente.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { db } from '@/persistence/db'
|
||||
import {
|
||||
processRepo,
|
||||
activityRepo,
|
||||
gatewayRepo,
|
||||
resourceRepo,
|
||||
simulationRepo,
|
||||
} from '@/persistence/repositories'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation, SimulationResult } from '@/domain/types'
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeProcess(id = 'proc-1'): Process {
|
||||
return {
|
||||
id,
|
||||
name: 'Proceso de prueba',
|
||||
clientName: 'Cliente Test',
|
||||
bpmnXml: '<definitions/>',
|
||||
currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
function makeActivity(id: string, processId: string, bpmnElementId: string): Activity {
|
||||
return {
|
||||
id,
|
||||
processId,
|
||||
bpmnElementId,
|
||||
name: `Tarea ${bpmnElementId}`,
|
||||
type: 'task',
|
||||
directCostFixed: 100,
|
||||
executionTimeMinutes: 30,
|
||||
assignedResources: [{ resourceId: 'res-1', utilizationPercent: 0.5 }],
|
||||
}
|
||||
}
|
||||
|
||||
function makeGateway(id: string, processId: string, bpmnElementId: string): GatewayConfig {
|
||||
return {
|
||||
id,
|
||||
processId,
|
||||
bpmnElementId,
|
||||
gatewayType: 'exclusive',
|
||||
branches: [
|
||||
{ flowId: 'f1', targetElementId: 'taskA', probability: 0.7 },
|
||||
{ flowId: 'f2', targetElementId: 'taskB', probability: 0.3 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function makeResource(id: string, processId: string): Resource {
|
||||
return {
|
||||
id,
|
||||
processId,
|
||||
name: 'Analista',
|
||||
type: 'role',
|
||||
costPerHour: 60,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSimulation(id: string, processId: string): Simulation {
|
||||
const result: SimulationResult = {
|
||||
totalCost: 1200,
|
||||
totalDirectCost: 1000,
|
||||
totalIndirectCost: 200,
|
||||
totalTimeMinutes: 90,
|
||||
perActivity: [],
|
||||
perResource: [],
|
||||
warnings: [],
|
||||
}
|
||||
return { id, processId, executedAt: 3000, result }
|
||||
}
|
||||
|
||||
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.open()
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db.processes, db.activities, db.gateways, db.resources, db.simulations],
|
||||
async () => {
|
||||
await Promise.all([
|
||||
db.processes.clear(),
|
||||
db.activities.clear(),
|
||||
db.gateways.clear(),
|
||||
db.resources.clear(),
|
||||
db.simulations.clear(),
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close()
|
||||
})
|
||||
|
||||
// ─── Process ─────────────────────────────────────────────────────────────────
|
||||
describe('IndexedDB / processRepo', () => {
|
||||
it('guarda un proceso y lo recupera por id', async () => {
|
||||
const proc = makeProcess()
|
||||
await processRepo.save(proc)
|
||||
const retrieved = await processRepo.getById('proc-1')
|
||||
expect(retrieved).toBeDefined()
|
||||
expect(retrieved!.name).toBe('Proceso de prueba')
|
||||
expect(retrieved!.currency).toBe('USD')
|
||||
expect(retrieved!.overheadPercentage).toBe(0.2)
|
||||
})
|
||||
|
||||
it('getById devuelve undefined para id inexistente', async () => {
|
||||
const result = await processRepo.getById('no-existe')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getAll devuelve todos los procesos guardados', async () => {
|
||||
await processRepo.save(makeProcess('p1'))
|
||||
await processRepo.save(makeProcess('p2'))
|
||||
await processRepo.save(makeProcess('p3'))
|
||||
const all = await processRepo.getAll()
|
||||
expect(all).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('save sobrescribe (put) un proceso existente', async () => {
|
||||
const proc = makeProcess()
|
||||
await processRepo.save(proc)
|
||||
await processRepo.save({ ...proc, name: 'Nombre actualizado', updatedAt: 9999 })
|
||||
const updated = await processRepo.getById('proc-1')
|
||||
expect(updated!.name).toBe('Nombre actualizado')
|
||||
expect(updated!.updatedAt).toBe(9999)
|
||||
})
|
||||
|
||||
it('delete elimina el proceso y sus entidades relacionadas en cascada', async () => {
|
||||
const proc = makeProcess('proc-del')
|
||||
await processRepo.save(proc)
|
||||
await activityRepo.saveMany([makeActivity('a1', 'proc-del', 'task1')])
|
||||
await gatewayRepo.saveMany([makeGateway('g1', 'proc-del', 'gw1')])
|
||||
await resourceRepo.save(makeResource('r1', 'proc-del'))
|
||||
|
||||
await processRepo.delete('proc-del')
|
||||
|
||||
expect(await processRepo.getById('proc-del')).toBeUndefined()
|
||||
expect(await activityRepo.getByProcess('proc-del')).toHaveLength(0)
|
||||
expect(await gatewayRepo.getByProcess('proc-del')).toHaveLength(0)
|
||||
expect(await resourceRepo.getByProcess('proc-del')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Activity ─────────────────────────────────────────────────────────────────
|
||||
describe('IndexedDB / activityRepo', () => {
|
||||
const processId = 'proc-act'
|
||||
|
||||
it('saveMany y getByProcess devuelven las mismas actividades', async () => {
|
||||
const acts = [
|
||||
makeActivity('a1', processId, 'task1'),
|
||||
makeActivity('a2', processId, 'task2'),
|
||||
makeActivity('a3', processId, 'task3'),
|
||||
]
|
||||
await activityRepo.saveMany(acts)
|
||||
const retrieved = await activityRepo.getByProcess(processId)
|
||||
expect(retrieved).toHaveLength(3)
|
||||
const ids = retrieved.map((a) => a.id).sort()
|
||||
expect(ids).toEqual(['a1', 'a2', 'a3'])
|
||||
})
|
||||
|
||||
it('persiste assignedResources (array anidado) correctamente', async () => {
|
||||
const act = makeActivity('a1', processId, 'task1')
|
||||
await activityRepo.save(act)
|
||||
const [retrieved] = await activityRepo.getByProcess(processId)
|
||||
expect(retrieved.assignedResources).toHaveLength(1)
|
||||
expect(retrieved.assignedResources[0].resourceId).toBe('res-1')
|
||||
expect(retrieved.assignedResources[0].utilizationPercent).toBe(0.5)
|
||||
})
|
||||
|
||||
it('no devuelve actividades de otro proceso', async () => {
|
||||
await activityRepo.save(makeActivity('a-other', 'otro-proc', 'task1'))
|
||||
const result = await activityRepo.getByProcess(processId)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── GatewayConfig ────────────────────────────────────────────────────────────
|
||||
describe('IndexedDB / gatewayRepo', () => {
|
||||
const processId = 'proc-gw'
|
||||
|
||||
it('saveMany y getByProcess devuelven la config con branches correctas', async () => {
|
||||
const gw = makeGateway('g1', processId, 'gw_decision')
|
||||
await gatewayRepo.saveMany([gw])
|
||||
const [retrieved] = await gatewayRepo.getByProcess(processId)
|
||||
expect(retrieved.bpmnElementId).toBe('gw_decision')
|
||||
expect(retrieved.gatewayType).toBe('exclusive')
|
||||
expect(retrieved.branches).toHaveLength(2)
|
||||
expect(retrieved.branches[0].probability).toBe(0.7)
|
||||
expect(retrieved.branches[1].probability).toBe(0.3)
|
||||
})
|
||||
|
||||
it('save individual actualiza la config existente', async () => {
|
||||
const gw = makeGateway('g1', processId, 'gw1')
|
||||
await gatewayRepo.save(gw)
|
||||
const updated: GatewayConfig = {
|
||||
...gw,
|
||||
branches: [
|
||||
{ flowId: 'f1', targetElementId: 'taskA', probability: 0.6 },
|
||||
{ flowId: 'f2', targetElementId: 'taskB', probability: 0.4 },
|
||||
],
|
||||
}
|
||||
await gatewayRepo.save(updated)
|
||||
const [retrieved] = await gatewayRepo.getByProcess(processId)
|
||||
expect(retrieved.branches[0].probability).toBe(0.6)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Resource ─────────────────────────────────────────────────────────────────
|
||||
describe('IndexedDB / resourceRepo', () => {
|
||||
const processId = 'proc-res'
|
||||
|
||||
it('save y getByProcess recupera el recurso correctamente', async () => {
|
||||
await resourceRepo.save(makeResource('r1', processId))
|
||||
const [retrieved] = await resourceRepo.getByProcess(processId)
|
||||
expect(retrieved.name).toBe('Analista')
|
||||
expect(retrieved.costPerHour).toBe(60)
|
||||
expect(retrieved.type).toBe('role')
|
||||
})
|
||||
|
||||
it('delete elimina un recurso por id', async () => {
|
||||
await resourceRepo.save(makeResource('r1', processId))
|
||||
await resourceRepo.save(makeResource('r2', processId))
|
||||
await resourceRepo.delete('r1')
|
||||
const remaining = await resourceRepo.getByProcess(processId)
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0].id).toBe('r2')
|
||||
})
|
||||
|
||||
it('múltiples recursos para el mismo proceso', async () => {
|
||||
const types = ['role', 'system', 'equipment'] as const
|
||||
for (const [i, type] of types.entries()) {
|
||||
await resourceRepo.save({ ...makeResource(`r${i}`, processId), type })
|
||||
}
|
||||
const all = await resourceRepo.getByProcess(processId)
|
||||
expect(all).toHaveLength(3)
|
||||
const typeSet = new Set(all.map((r) => r.type))
|
||||
expect(typeSet.has('role')).toBe(true)
|
||||
expect(typeSet.has('system')).toBe(true)
|
||||
expect(typeSet.has('equipment')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Simulation ───────────────────────────────────────────────────────────────
|
||||
describe('IndexedDB / simulationRepo', () => {
|
||||
const processId = 'proc-sim'
|
||||
|
||||
it('save y getLatestByProcess recuperan el snapshot de resultado', async () => {
|
||||
const sim = makeSimulation('sim-1', processId)
|
||||
await simulationRepo.save(sim)
|
||||
const retrieved = await simulationRepo.getLatestByProcess(processId)
|
||||
expect(retrieved).toBeDefined()
|
||||
expect(retrieved!.result.totalCost).toBe(1200)
|
||||
expect(retrieved!.result.totalDirectCost).toBe(1000)
|
||||
expect(retrieved!.result.totalIndirectCost).toBe(200)
|
||||
})
|
||||
|
||||
it('getLatestByProcess devuelve undefined si no hay simulaciones', async () => {
|
||||
const result = await simulationRepo.getLatestByProcess('proc-sin-sim')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('múltiples simulaciones: getByProcess las recupera todas', async () => {
|
||||
await simulationRepo.save({ ...makeSimulation('s1', processId), executedAt: 1000 })
|
||||
await simulationRepo.save({ ...makeSimulation('s2', processId), executedAt: 2000 })
|
||||
await simulationRepo.save({ ...makeSimulation('s3', processId), executedAt: 3000 })
|
||||
const all = await simulationRepo.getByProcess(processId)
|
||||
expect(all).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('persiste warnings en el resultado (campo libre)', async () => {
|
||||
const sim: Simulation = {
|
||||
...makeSimulation('s-warn', processId),
|
||||
result: {
|
||||
...makeSimulation('s-warn', processId).result,
|
||||
warnings: ['Loop detectado en taskX'],
|
||||
},
|
||||
}
|
||||
await simulationRepo.save(sim)
|
||||
const retrieved = await simulationRepo.getLatestByProcess(processId)
|
||||
expect(retrieved!.result.warnings).toHaveLength(1)
|
||||
expect(retrieved!.result.warnings[0]).toContain('Loop detectado')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Round-trip completo (simula el flujo de ImportPage) ─────────────────────
|
||||
describe('IndexedDB / round-trip proceso completo', () => {
|
||||
it('guarda proceso + actividades + gateways + recursos, luego los recupera íntegros', async () => {
|
||||
const proc = makeProcess('rt-proc')
|
||||
const acts = [
|
||||
makeActivity('rt-a1', 'rt-proc', 'taskA'),
|
||||
makeActivity('rt-a2', 'rt-proc', 'taskB'),
|
||||
]
|
||||
const gws = [makeGateway('rt-g1', 'rt-proc', 'gw1')]
|
||||
const resources = [makeResource('rt-r1', 'rt-proc')]
|
||||
|
||||
await Promise.all([
|
||||
processRepo.save(proc),
|
||||
activityRepo.saveMany(acts),
|
||||
gatewayRepo.saveMany(gws),
|
||||
resourceRepo.save(resources[0]),
|
||||
])
|
||||
|
||||
// Simular recarga de página
|
||||
const [retrievedProc, retrievedActs, retrievedGws, retrievedRes] = await Promise.all([
|
||||
processRepo.getById('rt-proc'),
|
||||
activityRepo.getByProcess('rt-proc'),
|
||||
gatewayRepo.getByProcess('rt-proc'),
|
||||
resourceRepo.getByProcess('rt-proc'),
|
||||
])
|
||||
|
||||
expect(retrievedProc!.id).toBe('rt-proc')
|
||||
expect(retrievedActs).toHaveLength(2)
|
||||
expect(retrievedGws).toHaveLength(1)
|
||||
expect(retrievedGws[0].branches).toHaveLength(2)
|
||||
expect(retrievedRes).toHaveLength(1)
|
||||
expect(retrievedRes[0].costPerHour).toBe(60)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user