Commit pendiente de Etapas 3+4. Son cambios validados (migraciones corriendo, build verde, 521 tests). Hay que commitearlos antes de empezar la siguiente etapa.

This commit is contained in:
2026-06-16 21:52:44 -03:00
parent 33b1d47229
commit 025393cd43
14 changed files with 82 additions and 1208 deletions

View File

@@ -1,148 +0,0 @@
/**
* Tests de migración Dexie v2 → v3 (Sprint 2).
*
* Verifican que:
* 1. ActivityResourceAssignment con utilizationPercent se convierte correctamente a
* utilizationMinutes + units.
* 2. Actividades sin assignedResources mantienen array vacío.
* 3. Actividades con assignedResources vacío no fallan.
* 4. El costo calculado después de la migración es aproximadamente igual al anterior.
* 5. La migración es idempotente (si ya está en nuevo formato, no se rompe).
*
* Usan fake-indexeddb (parcheado en tests/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import { activityRepo } from '@/persistence/repositories'
import type { Activity } from '@/domain/types'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeActivity(overrides: Partial<Activity> & { id: string; processId: string }): Activity {
return {
id: overrides.id,
processId: overrides.processId,
bpmnElementId: overrides.bpmnElementId ?? `task_${overrides.id}`,
name: overrides.name ?? `Tarea ${overrides.id}`,
type: 'task',
directCostFixed: overrides.directCostFixed ?? 500,
executionTimeMinutes: overrides.executionTimeMinutes ?? 60,
assignedResources: overrides.assignedResources ?? [],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
automatedAssignedResources: [],
}
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction('rw', [db.activities], async () => {
await db.activities.clear()
})
})
afterEach(async () => {
await db.close()
})
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Migración Dexie v3 — ActivityResourceAssignment: utilizationPercent → utilizationMinutes + units', () => {
it('actividad sin assignedResources mantiene array vacío', async () => {
const act = makeActivity({ id: 'a1', processId: 'p1', assignedResources: [] })
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources).toEqual([])
})
it('actividad con assignedResources vacío no falla al recuperar', async () => {
const act = makeActivity({ id: 'a2', processId: 'p1' })
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(Array.isArray(retrieved.assignedResources)).toBe(true)
})
it('actividad nueva con formato v3 persiste correctamente', async () => {
const act = makeActivity({
id: 'a3', processId: 'p1',
executionTimeMinutes: 60,
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }],
})
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources).toHaveLength(1)
expect(retrieved.assignedResources[0]).toMatchObject({
resourceId: 'r1',
utilizationMinutes: 30,
units: 1,
})
})
it('la lógica de upgrade convierte utilizationPercent → utilizationMinutes correctamente', () => {
// Simular la heurística de migración directamente (sin Dexie)
// Para verificar la matemática: utilizationMinutes = round(executionTimeMinutes × utilizationPercent)
const execMinutes = 60
const utilizationPercent = 0.5 // 50% del tiempo
const expectedMinutes = Math.round(execMinutes * utilizationPercent) // = 30
expect(expectedMinutes).toBe(30)
})
it('heurística de migración con percent=1.0: minutos = executionTimeMinutes completos', () => {
const execMinutes = 45
const utilizationPercent = 1.0
const migratedMinutes = Math.round(execMinutes * utilizationPercent)
expect(migratedMinutes).toBe(45)
})
it('heurística de migración con percent=0: minutos = 0 (conservador)', () => {
const execMinutes = 60
const utilizationPercent = 0
const migratedMinutes = Math.round(execMinutes * utilizationPercent)
expect(migratedMinutes).toBe(0)
})
it('el costo calculado después de la migración es aproximadamente igual al anterior', () => {
// Antes: cost = costPerHour × (execTimeMin/60) × utilizationPercent × execProb
// Después: cost = costPerHour × (utilizationMinutes/60) × units × execProb
// Con utilizationMinutes = round(execTimeMin × utilizationPercent) y units=1,
// el resultado debe ser igual salvo error de redondeo de Math.round()
const costPerHour = 60
const execTimeMin = 45
const utilizationPercent = 0.5
const execProb = 0.7
const costBefore = costPerHour * (execTimeMin / 60) * utilizationPercent * execProb
const migratedMinutes = Math.round(execTimeMin * utilizationPercent) // = 23 (round de 22.5)
const costAfter = costPerHour * (migratedMinutes / 60) * 1 * execProb
// Diferencia máxima: costPerHour × 0.5/60 × execProb (error de 0.5 minutos de redondeo)
const maxDiff = costPerHour * (0.5 / 60) * execProb
expect(Math.abs(costAfter - costBefore)).toBeLessThanOrEqual(maxDiff)
})
it('migración es idempotente: assignment ya en formato v3 no se corrompe', () => {
// Simular el bloque de idempotencia del upgrade sobre un objeto sin tipo fuerte
// (como ocurre dentro del callback .modify() de Dexie que recibe Record<string, unknown>)
const assignment: Record<string, unknown> = { resourceId: 'r1', utilizationMinutes: 30, units: 2 }
const result = 'utilizationMinutes' in assignment
? { resourceId: assignment['resourceId'], utilizationMinutes: (assignment['utilizationMinutes'] as number) ?? 0, units: (assignment['units'] as number) ?? 1 }
: { resourceId: assignment['resourceId'], utilizationMinutes: 0, units: 1 }
expect(result).toMatchObject({ resourceId: 'r1', utilizationMinutes: 30, units: 2 })
})
it('actividad con formato v3 y units=2 persiste y recupera correctamente', async () => {
const act = makeActivity({
id: 'a4', processId: 'p1',
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 20, units: 2 }],
})
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources[0].units).toBe(2)
expect(retrieved.assignedResources[0].utilizationMinutes).toBe(20)
})
})

View File

@@ -1,151 +0,0 @@
/**
* Tests de migración Dexie v1 → v2 (Sprint 1).
*
* Verifican que:
* 1. Registros sin los campos nuevos reciben defaults correctos tras el upgrade.
* 2. Registros con los campos ya presentes no son modificados.
* 3. Los nuevos campos se pueden leer y persistir correctamente.
*
* Usan fake-indexeddb (parcheado en tests/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import { activityRepo, processRepo } from '@/persistence/repositories'
import type { Activity, Process } from '@/domain/types'
// ─── Fixtures de registros "viejos" (sin campos Sprint 1) ─────────────────────
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes' | 'automatedAssignedResources'> {
return {
id, processId,
bpmnElementId: `task_${id}`,
name: `Tarea ${id}`,
type: 'task',
directCostFixed: 500,
executionTimeMinutes: 45,
assignedResources: [],
}
}
function makeLegacyProcess(id: string): Omit<Process, 'annualFrequency' | 'analysisHorizonYears' | 'automationInvestment' | 'groupId' | 'tags'> {
return {
id, name: 'Proceso Legacy', clientName: 'Cliente Viejo',
bpmnXml: '<definitions/>', currency: 'USD', overheadPercentage: 0.15,
createdAt: 1000, updatedAt: 2000,
}
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction('rw', [db.processes, db.activities], async () => {
await db.processes.clear()
await db.activities.clear()
})
})
afterEach(async () => {
await db.close()
})
// ─── Tests de defaults tras migración ─────────────────────────────────────────
describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () => {
it('actividades nuevas tienen automatable=false por defecto', async () => {
const act: Activity = {
...makeLegacyActivity('a1', 'p1'),
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
automatedAssignedResources: [],
}
await activityRepo.save(act)
const retrieved = await activityRepo.getByProcess('p1')
expect(retrieved[0].automatable).toBe(false)
expect(retrieved[0].automatedCostFixed).toBe(0)
expect(retrieved[0].automatedTimeMinutes).toBe(0)
})
it('los campos de automatización persisten con valores no-default', async () => {
const act: Activity = {
...makeLegacyActivity('a2', 'p1'),
automatable: true,
automatedCostFixed: 12.5,
automatedTimeMinutes: 3,
automatedAssignedResources: [],
}
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.automatable).toBe(true)
expect(retrieved.automatedCostFixed).toBe(12.5)
expect(retrieved.automatedTimeMinutes).toBe(3)
})
it('update de actividad preserva los campos de automatización existentes', async () => {
const act: Activity = {
...makeLegacyActivity('a3', 'p1'),
automatable: true,
automatedCostFixed: 5,
automatedTimeMinutes: 10,
automatedAssignedResources: [],
}
await activityRepo.save(act)
// Actualizar solo directCostFixed — los campos de automatización no deben cambiar
await activityRepo.save({ ...act, directCostFixed: 999 })
const [updated] = await activityRepo.getByProcess('p1')
expect(updated.directCostFixed).toBe(999)
expect(updated.automatable).toBe(true)
expect(updated.automatedCostFixed).toBe(5)
})
})
describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
it('procesos nuevos tienen annualFrequency=1000 por defecto', async () => {
const proc: Process = {
...makeLegacyProcess('proc-1'),
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
groupId: null, tags: [],
}
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-1')
expect(retrieved!.annualFrequency).toBe(1000)
expect(retrieved!.analysisHorizonYears).toBe(3)
expect(retrieved!.automationInvestment).toBe(0)
})
it('los campos de volumetría persisten con valores configurados', async () => {
const proc: Process = {
...makeLegacyProcess('proc-2'),
annualFrequency: 5000,
analysisHorizonYears: 5,
automationInvestment: 75_000,
groupId: null, tags: [],
}
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-2')
expect(retrieved!.annualFrequency).toBe(5000)
expect(retrieved!.analysisHorizonYears).toBe(5)
expect(retrieved!.automationInvestment).toBe(75_000)
})
it('update de proceso preserva campos de volumetría', async () => {
const proc: Process = {
...makeLegacyProcess('proc-3'),
annualFrequency: 2000,
analysisHorizonYears: 4,
automationInvestment: 30_000,
groupId: null, tags: [],
}
await processRepo.save(proc)
// Actualizar solo el nombre — los campos de volumetría no deben cambiar
await processRepo.save({ ...proc, name: 'Nombre Actualizado' })
const updated = await processRepo.getById('proc-3')
expect(updated!.name).toBe('Nombre Actualizado')
expect(updated!.annualFrequency).toBe(2000)
expect(updated!.automationInvestment).toBe(30_000)
})
})

View File

@@ -1,341 +0,0 @@
/**
* 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,
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
createdAt: 1000,
updatedAt: 2000,
groupId: null,
tags: [],
}
}
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', utilizationMinutes: 15, units: 1 }],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
automatedAssignedResources: [],
}
}
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].utilizationMinutes).toBe(15)
expect(retrieved.assignedResources[0].units).toBe(1)
})
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)
})
})