feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca
Sprint 3 completo — 7 etapas: - Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4, LibraryPage, library-store, groupId/tags en Process) - Etapa 3: modal de configuración global por proceso (horizonte, frecuencia, inversión, moneda) con validación y persistencia - Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos de utilización (utilizationMinutes + units) - Etapa 5: recursos en escenario automatizado — motor de simulación, UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte - Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido, 561 tests pasando - Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
99
tests/domain/simulation-sprint3.test.ts
Normal file
99
tests/domain/simulation-sprint3.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { runSimulation } from '@/domain/simulation'
|
||||
import type { Activity, Resource, SimulationInput } from '@/domain/types'
|
||||
|
||||
// BPMN de un solo nodo — execProb = 1.0, sin gateways
|
||||
const SINGLE_TASK_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
|
||||
<process id="proc1" isExecutable="false">
|
||||
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
|
||||
<task id="taskA" name="Tarea A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
|
||||
<endEvent id="end"><incoming>f2</incoming></endEvent>
|
||||
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
|
||||
<sequenceFlow id="f2" sourceRef="taskA" targetRef="end"/>
|
||||
</process>
|
||||
</definitions>`
|
||||
|
||||
const makeResource = (id: string, costPerHour: number): Resource => ({
|
||||
id, processId: 'proc1', name: `Recurso ${id}`, type: 'role', costPerHour,
|
||||
})
|
||||
|
||||
const baseActivity = (overrides: Partial<Activity> = {}): Activity => ({
|
||||
id: 'act-taskA', processId: 'proc1', bpmnElementId: 'taskA',
|
||||
name: 'Tarea A', type: 'task',
|
||||
directCostFixed: 0, executionTimeMinutes: 0,
|
||||
assignedResources: [],
|
||||
automatable: true,
|
||||
automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const baseInput = (activity: Activity, resources: Resource[] = []): SimulationInput => ({
|
||||
processXml: SINGLE_TASK_BPMN,
|
||||
activities: new Map([['taskA', activity]]),
|
||||
gateways: new Map(),
|
||||
resources: new Map(resources.map((r) => [r.id, r])),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
||||
scenario: 'automated',
|
||||
})
|
||||
|
||||
describe('Sprint 3 — automatedAssignedResources en motor de simulación', () => {
|
||||
|
||||
it('Caso 1: solo costo fijo, sin recursos TO-BE — resourceCostBreakdown vacío', () => {
|
||||
const act = baseActivity({ automatedCostFixed: 100, automatedTimeMinutes: 60, automatedAssignedResources: [] })
|
||||
const result = runSimulation(baseInput(act))
|
||||
|
||||
const actResult = result.perActivity[0]
|
||||
expect(actResult.expectedDirectCost).toBeCloseTo(100)
|
||||
expect(actResult.resourceCostBreakdown).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('Caso 2: solo recursos TO-BE — costo = costPerHour × (min/60) × units × execProb', () => {
|
||||
const r1 = makeResource('r1', 30)
|
||||
const act = baseActivity({
|
||||
automatedCostFixed: 0, automatedTimeMinutes: 30,
|
||||
automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 2 }],
|
||||
})
|
||||
const result = runSimulation(baseInput(act, [r1]))
|
||||
|
||||
// 30 $/h × (60min/60) × 2 units × 1.0 execProb = 60
|
||||
const actResult = result.perActivity[0]
|
||||
expect(actResult.expectedDirectCost).toBeCloseTo(60)
|
||||
expect(actResult.resourceCostBreakdown).toHaveLength(1)
|
||||
expect(actResult.resourceCostBreakdown[0].resourceId).toBe('r1')
|
||||
expect(actResult.resourceCostBreakdown[0].cost).toBeCloseTo(60)
|
||||
})
|
||||
|
||||
it('Caso 3: mixto — costo fijo + recurso TO-BE se suman', () => {
|
||||
const r1 = makeResource('r1', 60)
|
||||
const act = baseActivity({
|
||||
automatedCostFixed: 50, automatedTimeMinutes: 30,
|
||||
automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }],
|
||||
})
|
||||
const result = runSimulation(baseInput(act, [r1]))
|
||||
|
||||
// fijo=50 + 60*(30/60)*1=30 → total=80
|
||||
const actResult = result.perActivity[0]
|
||||
expect(actResult.expectedDirectCost).toBeCloseTo(80)
|
||||
expect(actResult.resourceCostBreakdown[0].cost).toBeCloseTo(30)
|
||||
})
|
||||
|
||||
it('Caso 4: actividad NO automatable — usa assignedResources, ignora automatedAssignedResources', () => {
|
||||
const r1 = makeResource('r1', 60)
|
||||
const r2 = makeResource('r2', 100)
|
||||
const act = baseActivity({
|
||||
automatable: false,
|
||||
directCostFixed: 0,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatedAssignedResources: [{ resourceId: 'r2', utilizationMinutes: 60, units: 1 }],
|
||||
})
|
||||
const result = runSimulation(baseInput(act, [r1, r2]))
|
||||
|
||||
// escenario automated pero automatable=false → usa r1 (assignedResources), no r2
|
||||
const actResult = result.perActivity[0]
|
||||
expect(actResult.resourceCostBreakdown).toHaveLength(1)
|
||||
expect(actResult.resourceCostBreakdown[0].resourceId).toBe('r1')
|
||||
expect(actResult.resourceCostBreakdown[0].cost).toBeCloseTo(60)
|
||||
})
|
||||
})
|
||||
@@ -92,6 +92,7 @@ describe('runSimulation', () => {
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
})
|
||||
|
||||
it('calcula costo total sin recursos — proceso lineal', () => {
|
||||
@@ -220,6 +221,7 @@ describe('runSimulation — scenario automated', () => {
|
||||
opts: {
|
||||
directCost?: number; directTime?: number
|
||||
automatable?: boolean; automatedCost?: number; automatedTime?: number
|
||||
automatedAssignedResources?: import('@/domain/types').ActivityResourceAssignment[]
|
||||
} = {}
|
||||
): Activity => ({
|
||||
id: `act-${bpmnElementId}`,
|
||||
@@ -233,6 +235,7 @@ describe('runSimulation — scenario automated', () => {
|
||||
automatable: opts.automatable ?? false,
|
||||
automatedCostFixed: opts.automatedCost ?? 0,
|
||||
automatedTimeMinutes: opts.automatedTime ?? 0,
|
||||
automatedAssignedResources: opts.automatedAssignedResources ?? [],
|
||||
})
|
||||
|
||||
// ─── scenario=automated con costos en cero ────────────────────────────────
|
||||
@@ -409,6 +412,7 @@ describe('runSimulation — recursos con modelo utilizationMinutes + units', ()
|
||||
directCostFixed: directCost, executionTimeMinutes: minutes,
|
||||
assignedResources: resources,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
})
|
||||
|
||||
it('actividad sin recursos: costo = directCostFixed', () => {
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proceso Test', clientName: '',
|
||||
bpmnXml: '<def/>', currency: 'USD',
|
||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [],
|
||||
}
|
||||
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
|
||||
})
|
||||
@@ -237,7 +237,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [],
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -249,7 +249,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [],
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -334,6 +334,8 @@ describe('ReportPage — con datos completos', () => {
|
||||
automationInvestment: 0,
|
||||
createdAt: Date.now() - 1000 * 60 * 30,
|
||||
updatedAt: Date.now() - 1000 * 60 * 30,
|
||||
groupId: null,
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation = {
|
||||
|
||||
@@ -31,7 +31,7 @@ function buildSimInput(xml: string, directCost: number, overhead: number, curren
|
||||
directCostFixed: directCost,
|
||||
executionTimeMinutes: 30,
|
||||
assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
@@ -157,9 +157,10 @@ describe('RoiKpiBar', () => {
|
||||
expect(screen.getByText(/Inmediato/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ROI Infinity (inversión cero) → muestra "∞%"', () => {
|
||||
it('ROI Infinity (inversión cero) → muestra "—" con texto accionable', () => {
|
||||
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText('∞%')).toBeInTheDocument()
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Configurá la inversión/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ahorro negativo: "breaksEvenInHorizon = false" → subtexto fuera del horizonte', () => {
|
||||
@@ -311,6 +312,7 @@ const makeActivityDomain = (id: string, name: string, automatable: boolean, zero
|
||||
automatable,
|
||||
automatedCostFixed: zeroCosts ? 0 : 50,
|
||||
automatedTimeMinutes: zeroCosts ? 0 : 10,
|
||||
automatedAssignedResources: [],
|
||||
})
|
||||
|
||||
describe('TransparencySection', () => {
|
||||
@@ -586,6 +588,7 @@ const mockProcessBase = {
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulationBase = {
|
||||
@@ -598,6 +601,7 @@ const automtableActivity: Activity = {
|
||||
id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud',
|
||||
type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
|
||||
const nonAutomatableActivity: Activity = {
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeActivity(overrides: Partial<Activity> & { id: string; processId: st
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ 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'> {
|
||||
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes' | 'automatedAssignedResources'> {
|
||||
return {
|
||||
id, processId,
|
||||
bpmnElementId: `task_${id}`,
|
||||
@@ -28,7 +28,7 @@ function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'auto
|
||||
}
|
||||
}
|
||||
|
||||
function makeLegacyProcess(id: string): Omit<Process, 'annualFrequency' | 'analysisHorizonYears' | 'automationInvestment'> {
|
||||
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,
|
||||
@@ -59,6 +59,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
const retrieved = await activityRepo.getByProcess('p1')
|
||||
@@ -73,6 +74,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
|
||||
automatable: true,
|
||||
automatedCostFixed: 12.5,
|
||||
automatedTimeMinutes: 3,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
const [retrieved] = await activityRepo.getByProcess('p1')
|
||||
@@ -87,6 +89,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
|
||||
automatable: true,
|
||||
automatedCostFixed: 5,
|
||||
automatedTimeMinutes: 10,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
// Actualizar solo directCostFixed — los campos de automatización no deben cambiar
|
||||
@@ -105,6 +108,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
const retrieved = await processRepo.getById('proc-1')
|
||||
@@ -119,6 +123,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
|
||||
annualFrequency: 5000,
|
||||
analysisHorizonYears: 5,
|
||||
automationInvestment: 75_000,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
const retrieved = await processRepo.getById('proc-2')
|
||||
@@ -133,6 +138,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
|
||||
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
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('motor / propagación con GatewayConfig correcta', () => {
|
||||
actIds.map((id) => [id, {
|
||||
id: uuidv4(), processId, bpmnElementId: id, name: id,
|
||||
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ function makeProcess(id = 'proc-1'): Process {
|
||||
automationInvestment: 0,
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
groupId: null,
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +50,7 @@ function makeActivity(id: string, processId: string, bpmnElementId: string): Act
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ function buildInput(
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ function buildActivities(xml: string): Map<string, Activity> {
|
||||
automatable: AUTOMATABLE_IDS.has(el.bpmnElementId),
|
||||
automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0,
|
||||
automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0,
|
||||
automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildSimInput(
|
||||
automatable: automatableIds.has(el.bpmnElementId),
|
||||
automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0,
|
||||
automatedTimeMinutes: automatableIds.has(el.bpmnElementId) ? automatedTime : 0,
|
||||
automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
@@ -312,6 +313,7 @@ describe('Caso C — medium-with-gateways, 1 automatable en ceros + investment=0
|
||||
automatable: el.bpmnElementId === 'task_valid',
|
||||
automatedCostFixed: 0, // cero — trigger de transparencia
|
||||
automatedTimeMinutes: 0, // cero — trigger de transparencia
|
||||
automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
|
||||
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: 0, updatedAt: 0, ...extras,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,13 @@ const mockActivities: Activity[] = [
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
{
|
||||
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
|
||||
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
|
||||
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
|
||||
bpmnXml: '', currency, overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0, ...extras,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function buildInput(xml: string, directCost: number): SimulationInput {
|
||||
actEls.map((el) => [el.bpmnElementId, {
|
||||
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name,
|
||||
type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
|
||||
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const simulation: Simulation = {
|
||||
|
||||
@@ -51,6 +51,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const perActivityActual = [
|
||||
@@ -101,11 +102,13 @@ const mockActivities: Activity[] = [
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
{
|
||||
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
|
||||
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
// Simulación SIN recursos en ninguna actividad
|
||||
@@ -72,6 +73,7 @@ const mockActivity: Activity = {
|
||||
directCostFixed: 200, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
|
||||
const mockSimWithResources: Simulation = {
|
||||
|
||||
@@ -54,6 +54,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -59,6 +59,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -30,6 +30,7 @@ const mockActivity: Activity = {
|
||||
directCostFixed: 600, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 5, units: 3 }],
|
||||
}
|
||||
|
||||
const activityWithResources: ActivitySimResult = {
|
||||
@@ -122,3 +123,43 @@ describe('drawResourcesSection', () => {
|
||||
expect(autoTableMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Escenario automatizado usa automatedAssignedResources ────────────────────
|
||||
|
||||
describe('drawResourcesSection — escenario automatizado', () => {
|
||||
let autoTableMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
autoTableMock = vi.fn()
|
||||
})
|
||||
|
||||
it('escenario automatizado: pasa utilizationMinutes y units de automatedAssignedResources', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
drawResourcesSection(
|
||||
mockDoc, autoTableMock,
|
||||
{ perActivity: [activityWithResources] },
|
||||
[mockResource], [mockActivity], 'USD', 40,
|
||||
'automatizado'
|
||||
)
|
||||
const body = (autoTableMock.mock.calls[0][1] as any).body as any[][]
|
||||
const row = body[0]
|
||||
// Min. (índice 4) y Uds. (índice 5) deben venir de automatedAssignedResources: { utilMin: 5, units: 3 }
|
||||
expect(row[4].content).toBe('5')
|
||||
expect(row[5].content).toBe('3')
|
||||
})
|
||||
|
||||
it('escenario actual: pasa utilizationMinutes y units de assignedResources (sin regresión)', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
drawResourcesSection(
|
||||
mockDoc, autoTableMock,
|
||||
{ perActivity: [activityWithResources] },
|
||||
[mockResource], [mockActivity], 'USD', 40,
|
||||
'actual'
|
||||
)
|
||||
const body = (autoTableMock.mock.calls[0][1] as any).body as any[][]
|
||||
const row = body[0]
|
||||
// assignedResources: { utilMin: 60, units: 1 }
|
||||
expect(row[4].content).toBe('60')
|
||||
expect(row[5].content).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -52,6 +52,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockRoi: RoiResult = {
|
||||
@@ -103,10 +104,10 @@ const mockSimulation: Simulation = {
|
||||
const mockActivities: Activity[] = [
|
||||
{ id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'Tarea A', type: 'task',
|
||||
automatable: true, directCostFixed: 400, executionTimeMinutes: 30,
|
||||
automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [] },
|
||||
automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [], automatedAssignedResources: [] },
|
||||
{ id: 'a2', processId: 'p1', bpmnElementId: 't2', name: 'Tarea B', type: 'task',
|
||||
automatable: false, directCostFixed: 200, executionTimeMinutes: 20,
|
||||
automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [] },
|
||||
automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [], automatedAssignedResources: [] },
|
||||
]
|
||||
|
||||
describe('PDF ROI — Etapa 7 páginas 2-5', () => {
|
||||
|
||||
@@ -52,6 +52,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
220
tests/persistence/migration-v4.test.ts
Normal file
220
tests/persistence/migration-v4.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Tests de migración Dexie v3 → v4 (Sprint 3).
|
||||
*
|
||||
* Verifican que:
|
||||
* 1. Proceso existente conserva todos sus campos y recibe groupId=null, tags=[].
|
||||
* 2. Activity existente conserva assignedResources y recibe automatedAssignedResources=[].
|
||||
* 3. Settings singleton creado con groupLabel='Cliente' durante el upgrade.
|
||||
* 4. El upgrade es idempotente: datos ya migrados no se corrompen.
|
||||
* 5. groupId puede ser un UUID válido y es queryable como índice.
|
||||
*
|
||||
* Usan fake-indexeddb (parcheado en tests/setup.ts).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { db } from '@/persistence/db'
|
||||
import type { Process, ProcessGroup } from '@/domain/types'
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeProcess(id: string, overrides: Partial<Process> = {}): Process {
|
||||
return {
|
||||
id,
|
||||
name: `Proceso ${id}`,
|
||||
clientName: 'Cliente Test',
|
||||
bpmnXml: '<definitions/>',
|
||||
currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
groupId: null,
|
||||
tags: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 1 — Process: lógica del upgrade (puro, sin base de datos) ───────────
|
||||
|
||||
describe('Migración Dexie v4 — Process: lógica de upgrade', () => {
|
||||
it('proceso v3 (sin groupId ni tags) recibe defaults correctos preservando campos originales', () => {
|
||||
const proc: Record<string, unknown> = {
|
||||
id: 'p1',
|
||||
name: 'Proceso Legado',
|
||||
clientName: 'Cliente A',
|
||||
bpmnXml: '<definitions/>',
|
||||
currency: 'PYG',
|
||||
overheadPercentage: 0.15,
|
||||
annualFrequency: 500,
|
||||
analysisHorizonYears: 5,
|
||||
automationInvestment: 50_000,
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
// Sin groupId ni tags — formato v3
|
||||
}
|
||||
|
||||
// Simular callback del upgrade
|
||||
if (proc.groupId === undefined) proc.groupId = null
|
||||
if (!Array.isArray(proc.tags)) proc.tags = []
|
||||
|
||||
// Campos originales intactos
|
||||
expect(proc.id).toBe('p1')
|
||||
expect(proc.name).toBe('Proceso Legado')
|
||||
expect(proc.clientName).toBe('Cliente A')
|
||||
expect(proc.currency).toBe('PYG')
|
||||
expect(proc.overheadPercentage).toBe(0.15)
|
||||
expect(proc.annualFrequency).toBe(500)
|
||||
expect(proc.analysisHorizonYears).toBe(5)
|
||||
expect(proc.automationInvestment).toBe(50_000)
|
||||
expect(proc.createdAt).toBe(1000)
|
||||
expect(proc.updatedAt).toBe(2000)
|
||||
// Nuevos campos con defaults correctos
|
||||
expect(proc.groupId).toBeNull()
|
||||
expect(proc.tags).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Test 2 — Activity: lógica del upgrade (puro, sin base de datos) ─────────
|
||||
|
||||
describe('Migración Dexie v4 — Activity: lógica de upgrade', () => {
|
||||
it('activity v3 con assignedResources poblados recibe automatedAssignedResources=[]', () => {
|
||||
const act: Record<string, unknown> = {
|
||||
id: 'a1',
|
||||
processId: 'p1',
|
||||
bpmnElementId: 'task_a1',
|
||||
name: 'Tarea A',
|
||||
type: 'task',
|
||||
directCostFixed: 500,
|
||||
executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }],
|
||||
automatable: true,
|
||||
automatedCostFixed: 200,
|
||||
automatedTimeMinutes: 15,
|
||||
// Sin automatedAssignedResources — formato v3
|
||||
}
|
||||
|
||||
// Simular callback del upgrade
|
||||
if (!Array.isArray(act.automatedAssignedResources)) {
|
||||
act.automatedAssignedResources = []
|
||||
}
|
||||
|
||||
// assignedResources no fue alterado
|
||||
expect(act.assignedResources).toEqual([{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }])
|
||||
// campos de automatización intactos
|
||||
expect(act.automatable).toBe(true)
|
||||
expect(act.automatedCostFixed).toBe(200)
|
||||
expect(act.automatedTimeMinutes).toBe(15)
|
||||
// nuevo campo vacío
|
||||
expect(act.automatedAssignedResources).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Test 3 — Settings singleton (integración) ───────────────────────────────
|
||||
|
||||
describe('Migración Dexie v4 — Settings singleton', () => {
|
||||
beforeEach(async () => {
|
||||
await db.open()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close()
|
||||
})
|
||||
|
||||
it('singleton creado con groupLabel=Cliente durante el upgrade', async () => {
|
||||
const settings = await db.settings.get('singleton')
|
||||
expect(settings).toBeDefined()
|
||||
expect(settings!.id).toBe('singleton')
|
||||
expect(settings!.groupLabel).toBe('Cliente')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Test 4 — Idempotencia (puro, sin base de datos) ─────────────────────────
|
||||
|
||||
describe('Migración Dexie v4 — Idempotencia', () => {
|
||||
it('upgrade no corrompe proceso ya migrado (groupId y tags con valores)', () => {
|
||||
const proc: Record<string, unknown> = {
|
||||
id: 'p2',
|
||||
groupId: null,
|
||||
tags: ['facturación', 'BPMN'],
|
||||
}
|
||||
|
||||
// Simular upgrade (idempotente)
|
||||
if (proc.groupId === undefined) proc.groupId = null
|
||||
if (!Array.isArray(proc.tags)) proc.tags = []
|
||||
|
||||
expect(proc.groupId).toBeNull()
|
||||
expect(proc.tags).toEqual(['facturación', 'BPMN'])
|
||||
})
|
||||
|
||||
it('upgrade no corrompe activity ya migrada (automatedAssignedResources poblado)', () => {
|
||||
const act: Record<string, unknown> = {
|
||||
id: 'a2',
|
||||
automatedAssignedResources: [{ resourceId: 'r2', utilizationMinutes: 10, units: 2 }],
|
||||
}
|
||||
|
||||
if (!Array.isArray(act.automatedAssignedResources)) {
|
||||
act.automatedAssignedResources = []
|
||||
}
|
||||
|
||||
expect(act.automatedAssignedResources).toEqual([
|
||||
{ resourceId: 'r2', utilizationMinutes: 10, units: 2 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Test 5 — groupId como UUID válido (integración) ─────────────────────────
|
||||
|
||||
describe('Migración Dexie v4 — groupId como UUID válido', () => {
|
||||
beforeEach(async () => {
|
||||
await db.open()
|
||||
await db.transaction('rw', [db.processes, db.groups], async () => {
|
||||
await db.processes.clear()
|
||||
await db.groups.clear()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close()
|
||||
})
|
||||
|
||||
it('proceso con groupId persiste y es queryable por el índice groupId', async () => {
|
||||
const group: ProcessGroup = {
|
||||
id: 'group-abc-123',
|
||||
name: 'Grupo Test',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
}
|
||||
await db.groups.put(group)
|
||||
|
||||
const proc: Process = makeProcess('p3', {
|
||||
groupId: 'group-abc-123',
|
||||
tags: ['test'],
|
||||
})
|
||||
await db.processes.put(proc)
|
||||
|
||||
// El campo persiste
|
||||
const retrieved = await db.processes.get('p3')
|
||||
expect(retrieved!.groupId).toBe('group-abc-123')
|
||||
expect(retrieved!.tags).toEqual(['test'])
|
||||
|
||||
// El índice funciona para query por grupo
|
||||
const byGroup = await db.processes.where('groupId').equals('group-abc-123').toArray()
|
||||
expect(byGroup).toHaveLength(1)
|
||||
expect(byGroup[0].id).toBe('p3')
|
||||
})
|
||||
|
||||
it('proceso con groupId=null persiste y es recuperable', async () => {
|
||||
const proc: Process = makeProcess('p4', { groupId: null, tags: [] })
|
||||
await db.processes.put(proc)
|
||||
|
||||
const retrieved = await db.processes.get('p4')
|
||||
expect(retrieved).toBeDefined()
|
||||
expect(retrieved!.groupId).toBeNull()
|
||||
|
||||
// Verificar con filter (Dexie no acepta null en .equals())
|
||||
const sinGrupo = await db.processes.filter((p) => p.groupId == null).toArray()
|
||||
expect(sinGrupo.some((p) => p.id === 'p4')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -94,6 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
|
||||
createdAt: Date.now(), updatedAt: Date.now(),
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
useProcessStore.setState({ currentProcess: proc })
|
||||
|
||||
@@ -110,7 +111,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
||||
type: 'task',
|
||||
directCostFixed: 999, // campo modificado
|
||||
executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}]
|
||||
useProcessStore.setState({ activities: newActivities })
|
||||
|
||||
@@ -122,7 +123,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
||||
const activities: Activity[] = [{
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'T',
|
||||
type: 'task', directCostFixed: 100, executionTimeMinutes: 30,
|
||||
assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}]
|
||||
|
||||
useProcessStore.setState({ activities })
|
||||
|
||||
Reference in New Issue
Block a user