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:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -27,7 +27,6 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"dexie": "^4.4.2",
|
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
@@ -5220,12 +5219,6 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dexie": {
|
|
||||||
"version": "4.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
|
|
||||||
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/diagram-js": {
|
"node_modules/diagram-js": {
|
||||||
"version": "15.14.0",
|
"version": "15.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/diagram-js/-/diagram-js-15.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/diagram-js/-/diagram-js-15.14.0.tgz",
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"dexie": "^4.4.2",
|
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
||||||
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
|
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
|
||||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||||
import { simulationRepo } from '@/persistence/repositories'
|
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
|
||||||
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
|
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
|
||||||
|
|
||||||
interface ReportData {
|
interface ReportData {
|
||||||
@@ -31,7 +31,7 @@ export function useReportData(processId: string): ReportData {
|
|||||||
try {
|
try {
|
||||||
const [proc, sim, acts, res] = await Promise.all([
|
const [proc, sim, acts, res] = await Promise.all([
|
||||||
supabaseProcessRepo.getById(processId),
|
supabaseProcessRepo.getById(processId),
|
||||||
simulationRepo.getLatestByProcess(processId),
|
supabaseSimulationRepo.getLatestByProcess(processId),
|
||||||
supabaseActivityRepo.getByProcess(processId),
|
supabaseActivityRepo.getByProcess(processId),
|
||||||
supabaseResourceRepo.getAll(),
|
supabaseResourceRepo.getAll(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import Dexie, { type Table } from 'dexie'
|
|
||||||
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
|
|
||||||
|
|
||||||
export class ProcessCostDb extends Dexie {
|
|
||||||
processes!: Table<Process>
|
|
||||||
activities!: Table<Activity>
|
|
||||||
gateways!: Table<GatewayConfig>
|
|
||||||
resources!: Table<Resource>
|
|
||||||
simulations!: Table<Simulation>
|
|
||||||
groups!: Table<ProcessGroup> // 🆕 Sprint 3
|
|
||||||
settings!: Table<AppSettings> // 🆕 Sprint 3
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super('ProcessCostPlatform')
|
|
||||||
|
|
||||||
this.version(1).stores({
|
|
||||||
processes: 'id, name, updatedAt',
|
|
||||||
activities: 'id, processId, bpmnElementId',
|
|
||||||
gateways: 'id, processId, bpmnElementId',
|
|
||||||
resources: 'id, processId, name',
|
|
||||||
simulations: 'id, processId, executedAt',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sprint 1: agrega campos de automatización a Activity y volumetría a Process.
|
|
||||||
// El upgrade popula datos existentes con defaults para garantizar invariante de tipo.
|
|
||||||
this.version(2)
|
|
||||||
.stores({
|
|
||||||
processes: 'id, name, updatedAt',
|
|
||||||
activities: 'id, processId, bpmnElementId',
|
|
||||||
gateways: 'id, processId, bpmnElementId',
|
|
||||||
resources: 'id, processId, name',
|
|
||||||
simulations: 'id, processId, executedAt',
|
|
||||||
})
|
|
||||||
.upgrade(async (tx) => {
|
|
||||||
await tx.table('activities').toCollection().modify((act) => {
|
|
||||||
if (act.automatable === undefined) act.automatable = false
|
|
||||||
if (act.automatedCostFixed === undefined) act.automatedCostFixed = 0
|
|
||||||
if (act.automatedTimeMinutes === undefined) act.automatedTimeMinutes = 0
|
|
||||||
})
|
|
||||||
await tx.table('processes').toCollection().modify((proc) => {
|
|
||||||
if (proc.annualFrequency === undefined) proc.annualFrequency = 1000
|
|
||||||
if (proc.analysisHorizonYears === undefined) proc.analysisHorizonYears = 3
|
|
||||||
if (proc.automationInvestment === undefined) proc.automationInvestment = 0
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sprint 2: migra ActivityResourceAssignment.utilizationPercent → utilizationMinutes + units.
|
|
||||||
// Los stores no cambian — assignments están embebidos en Activity, no son tabla propia.
|
|
||||||
this.version(3)
|
|
||||||
.stores({
|
|
||||||
processes: 'id, name, updatedAt',
|
|
||||||
activities: 'id, processId, bpmnElementId',
|
|
||||||
gateways: 'id, processId, bpmnElementId',
|
|
||||||
resources: 'id, processId, name',
|
|
||||||
simulations: 'id, processId, executedAt',
|
|
||||||
})
|
|
||||||
.upgrade(async (tx) => {
|
|
||||||
await tx.table('activities').toCollection().modify((act) => {
|
|
||||||
if (!Array.isArray(act.assignedResources)) {
|
|
||||||
act.assignedResources = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
act.assignedResources = act.assignedResources.map((assignment: Record<string, unknown>) => {
|
|
||||||
if ('utilizationPercent' in assignment) {
|
|
||||||
// Heurística: minutos = round(executionTimeMinutes × utilizationPercent)
|
|
||||||
// Si executionTimeMinutes no está disponible o es 0, usar 0 (conservador)
|
|
||||||
const execMinutes = typeof act.executionTimeMinutes === 'number' ? act.executionTimeMinutes : 0
|
|
||||||
const utilizationMinutes = Math.round(execMinutes * (assignment.utilizationPercent as number))
|
|
||||||
return { resourceId: assignment.resourceId, utilizationMinutes, units: 1 }
|
|
||||||
}
|
|
||||||
if ('utilizationMinutes' in assignment) {
|
|
||||||
// Ya está en nuevo formato (idempotencia)
|
|
||||||
return {
|
|
||||||
resourceId: assignment.resourceId,
|
|
||||||
utilizationMinutes: assignment.utilizationMinutes ?? 0,
|
|
||||||
units: assignment.units ?? 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback defensivo
|
|
||||||
return { resourceId: assignment.resourceId, utilizationMinutes: 0, units: 1 }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sprint 3: agrega tabla de grupos (Process Library), tabla de settings,
|
|
||||||
// campos groupId/tags en Process, y automatedAssignedResources en Activity.
|
|
||||||
this.version(4)
|
|
||||||
.stores({
|
|
||||||
processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo
|
|
||||||
activities: 'id, processId, bpmnElementId',
|
|
||||||
gateways: 'id, processId, bpmnElementId',
|
|
||||||
resources: 'id, processId, name',
|
|
||||||
simulations: 'id, processId, executedAt',
|
|
||||||
groups: 'id, name, updatedAt', // nueva tabla
|
|
||||||
settings: 'id', // nueva tabla (singleton)
|
|
||||||
})
|
|
||||||
.upgrade(async (tx) => {
|
|
||||||
// Process: agregar groupId (null) y tags ([])
|
|
||||||
await tx.table('processes').toCollection().modify((proc) => {
|
|
||||||
if (proc.groupId === undefined) proc.groupId = null
|
|
||||||
if (!Array.isArray(proc.tags)) proc.tags = []
|
|
||||||
})
|
|
||||||
// Activity: agregar automatedAssignedResources ([])
|
|
||||||
await tx.table('activities').toCollection().modify((act) => {
|
|
||||||
if (!Array.isArray(act.automatedAssignedResources)) {
|
|
||||||
act.automatedAssignedResources = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// Settings: crear singleton con defaults (put es idempotente por clave primaria)
|
|
||||||
await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Belt-and-suspenders: garantizar singleton en fresh installs y entornos de test.
|
|
||||||
// El upgrade también lo crea; este handler cubre el caso en que la tabla sea nueva
|
|
||||||
// pero el upgrade no haya persistido el put (conocido con fake-indexeddb).
|
|
||||||
this.on('ready', async () => {
|
|
||||||
const existing = await this.settings.get('singleton')
|
|
||||||
if (!existing) {
|
|
||||||
await this.settings.put({ id: 'singleton', groupLabel: 'Cliente' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const db = new ProcessCostDb()
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* @internal
|
|
||||||
*
|
|
||||||
* Repositorios de acceso a IndexedDB (Dexie).
|
|
||||||
*
|
|
||||||
* REGLA ARQUITECTÓNICA: este módulo solo debe importarse desde `src/store/**`.
|
|
||||||
* Las features y hooks deben mutar datos a través de los stores (useProcessStore,
|
|
||||||
* useSimulationStore), que garantizan la invalidación del resultado de simulación
|
|
||||||
* cuando los datos cambian.
|
|
||||||
*
|
|
||||||
* Excepciones permitidas (ver eslint.config.js):
|
|
||||||
* - src/features/import/** → operación de importación inicial (no hay simulación previa)
|
|
||||||
* - src/features/report/useReportData.ts → solo lectura, sin mutaciones
|
|
||||||
* - tests/** → acceso directo controlado en contexto de testing
|
|
||||||
*
|
|
||||||
* Cualquier import desde fuera de estos paths falla `npm run lint`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { db } from './db'
|
|
||||||
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
|
|
||||||
|
|
||||||
// ─── Process ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
|
||||||
export const processRepo = {
|
|
||||||
async save(process: Process): Promise<void> {
|
|
||||||
await db.processes.put(process)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getById(id: string): Promise<Process | undefined> {
|
|
||||||
return db.processes.get(id)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAll(): Promise<Process[]> {
|
|
||||||
return db.processes.orderBy('updatedAt').reverse().toArray()
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await db.transaction('rw', [db.processes, db.activities, db.gateways, db.resources, db.simulations], async () => {
|
|
||||||
await db.processes.delete(id)
|
|
||||||
await db.activities.where('processId').equals(id).delete()
|
|
||||||
await db.gateways.where('processId').equals(id).delete()
|
|
||||||
await db.resources.where('processId').equals(id).delete()
|
|
||||||
await db.simulations.where('processId').equals(id).delete()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Activity ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
|
||||||
export const activityRepo = {
|
|
||||||
async save(activity: Activity): Promise<void> {
|
|
||||||
await db.activities.put(activity)
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveMany(activities: Activity[]): Promise<void> {
|
|
||||||
await db.activities.bulkPut(activities)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getByProcess(processId: string): Promise<Activity[]> {
|
|
||||||
return db.activities.where('processId').equals(processId).toArray()
|
|
||||||
},
|
|
||||||
|
|
||||||
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined> {
|
|
||||||
return db.activities
|
|
||||||
.where('[processId+bpmnElementId]')
|
|
||||||
.equals([processId, bpmnElementId])
|
|
||||||
.first()
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteByProcess(processId: string): Promise<void> {
|
|
||||||
await db.activities.where('processId').equals(processId).delete()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GatewayConfig ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
|
||||||
export const gatewayRepo = {
|
|
||||||
async save(gateway: GatewayConfig): Promise<void> {
|
|
||||||
await db.gateways.put(gateway)
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveMany(gateways: GatewayConfig[]): Promise<void> {
|
|
||||||
await db.gateways.bulkPut(gateways)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getByProcess(processId: string): Promise<GatewayConfig[]> {
|
|
||||||
return db.gateways.where('processId').equals(processId).toArray()
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteByProcess(processId: string): Promise<void> {
|
|
||||||
await db.gateways.where('processId').equals(processId).delete()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Resource ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
|
||||||
export const resourceRepo = {
|
|
||||||
async save(resource: Resource): Promise<void> {
|
|
||||||
await db.resources.put(resource)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getByProcess(processId: string): Promise<Resource[]> {
|
|
||||||
return db.resources.where('processId').equals(processId).toArray()
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await db.resources.delete(id)
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteByProcess(processId: string): Promise<void> {
|
|
||||||
await db.resources.where('processId').equals(processId).delete()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Simulation ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @internal Solo llamar desde src/store/simulation-store.ts */
|
|
||||||
export const simulationRepo = {
|
|
||||||
async save(simulation: Simulation): Promise<void> {
|
|
||||||
await db.simulations.put(simulation)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
|
|
||||||
// Ordena por executedAt descendente para retornar la simulación más reciente.
|
|
||||||
// No se usa .last() porque el primary key es UUID (no ordenado cronológicamente).
|
|
||||||
const all = await db.simulations
|
|
||||||
.where('processId')
|
|
||||||
.equals(processId)
|
|
||||||
.toArray()
|
|
||||||
if (!all.length) return undefined
|
|
||||||
return all.reduce((latest, sim) => sim.executedAt > latest.executedAt ? sim : latest)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getByProcess(processId: string): Promise<Simulation[]> {
|
|
||||||
return db.simulations.where('processId').equals(processId).toArray()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ProcessGroup ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @internal Solo llamar desde src/store/library-store.ts */
|
|
||||||
export const groupRepo = {
|
|
||||||
async save(group: ProcessGroup): Promise<void> {
|
|
||||||
await db.groups.put(group)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAll(): Promise<ProcessGroup[]> {
|
|
||||||
return db.groups.orderBy('name').toArray()
|
|
||||||
},
|
|
||||||
|
|
||||||
async getById(id: string): Promise<ProcessGroup | undefined> {
|
|
||||||
return db.groups.get(id)
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
// Procesos del grupo pasan a "Sin clasificar" (groupId = null)
|
|
||||||
await db.transaction('rw', [db.groups, db.processes], async () => {
|
|
||||||
await db.processes.where('groupId').equals(id).modify({ groupId: null })
|
|
||||||
await db.groups.delete(id)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AppSettings ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @internal Solo llamar desde src/store/library-store.ts */
|
|
||||||
export const settingsRepo = {
|
|
||||||
async get(): Promise<AppSettings> {
|
|
||||||
const s = await db.settings.get('singleton')
|
|
||||||
return s ?? { id: 'singleton', groupLabel: 'Cliente' }
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(updates: Partial<Omit<AppSettings, 'id'>>): Promise<void> {
|
|
||||||
await db.settings.update('singleton', updates)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { db } from '@/persistence/db'
|
|
||||||
import type { Process } from '@/domain/types'
|
import type { Process } from '@/domain/types'
|
||||||
|
|
||||||
function toRow(process: Process, userId: string) {
|
function toRow(process: Process, userId: string) {
|
||||||
@@ -66,13 +65,8 @@ export const supabaseProcessRepo = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
// CASCADE en Supabase elimina: activities, gateways, activity_resource_assignments
|
// CASCADE en Supabase elimina: activities, gateways, activity_resource_assignments, simulations
|
||||||
const { error } = await supabase.from('processes').delete().eq('id', id)
|
const { error } = await supabase.from('processes').delete().eq('id', id)
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
// TODO Sprint 4 Etapa 4: eliminar cuando simulaciones migren a Supabase
|
|
||||||
await db.transaction('rw', [db.simulations], async () => {
|
|
||||||
await db.simulations.where('processId').equals(id).delete()
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/persistence/supabase/simulation-repo.ts
Normal file
49
src/persistence/supabase/simulation-repo.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { Simulation, SimulationResult } from '@/domain/types'
|
||||||
|
|
||||||
|
function fromRow(row: Record<string, unknown>): Simulation {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
processId: row.process_id as string,
|
||||||
|
executedAt: Number(row.executed_at),
|
||||||
|
result: row.result as SimulationResult,
|
||||||
|
resultAutomated: row.result_automated != null
|
||||||
|
? (row.result_automated as SimulationResult)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabaseSimulationRepo = {
|
||||||
|
async save(simulation: Simulation): Promise<void> {
|
||||||
|
const { error } = await supabase.from('simulations').insert({
|
||||||
|
id: simulation.id,
|
||||||
|
process_id: simulation.processId,
|
||||||
|
executed_at: simulation.executedAt,
|
||||||
|
result: simulation.result,
|
||||||
|
result_automated: simulation.resultAutomated ?? null,
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('simulations')
|
||||||
|
.select('*')
|
||||||
|
.eq('process_id', processId)
|
||||||
|
.order('executed_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
if (error) return undefined
|
||||||
|
return fromRow(data as Record<string, unknown>)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByProcess(processId: string): Promise<Simulation[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('simulations')
|
||||||
|
.select('*')
|
||||||
|
.eq('process_id', processId)
|
||||||
|
.order('executed_at', { ascending: false })
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map(fromRow)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||||
import { supabaseGroupRepo } from '@/persistence/supabase/group-repo'
|
import { supabaseGroupRepo } from '@/persistence/supabase/group-repo'
|
||||||
import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo'
|
import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo'
|
||||||
import { simulationRepo } from '@/persistence/repositories'
|
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
|
||||||
import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types'
|
import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types'
|
||||||
|
|
||||||
interface LibraryState {
|
interface LibraryState {
|
||||||
@@ -39,19 +39,10 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
|||||||
load: async () => {
|
load: async () => {
|
||||||
set({ isLoading: true })
|
set({ isLoading: true })
|
||||||
try {
|
try {
|
||||||
const TIMEOUT_MS = 8_000
|
const [groups, processes, settings] = await Promise.all([
|
||||||
const [groups, processes, settings] = await Promise.race([
|
supabaseGroupRepo.getAll(),
|
||||||
Promise.all([
|
supabaseProcessRepo.getAll(),
|
||||||
supabaseGroupRepo.getAll(),
|
supabaseSettingsRepo.get(),
|
||||||
supabaseProcessRepo.getAll(),
|
|
||||||
supabaseSettingsRepo.get(),
|
|
||||||
]),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error('Timeout: la biblioteca tardó más de 8s en cargar')),
|
|
||||||
TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
set({ groups, processes, settings, isLoading: false })
|
set({ groups, processes, settings, isLoading: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -99,7 +90,7 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
getLatestSimulation: (processId) => {
|
getLatestSimulation: (processId) => {
|
||||||
return simulationRepo.getLatestByProcess(processId)
|
return supabaseSimulationRepo.getLatestByProcess(processId)
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSettings: async (updates) => {
|
updateSettings: async (updates) => {
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import type { SimulationResult } from '@/domain/types'
|
import type { SimulationResult } from '@/domain/types'
|
||||||
import { simulationRepo } from '@/persistence/repositories'
|
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
|
||||||
|
|
||||||
// Excepción arquitectónica documentada: simulation-store importa process-store en una dirección
|
// Excepción arquitectónica documentada: simulation-store importa process-store en una dirección
|
||||||
// para suscribirse a cambios de datos y invalidar resultados. No existe dependencia inversa.
|
// para suscribirse a cambios de datos y invalidar resultados. No existe dependencia inversa.
|
||||||
import { useProcessStore } from '@/store/process-store'
|
import { useProcessStore } from '@/store/process-store'
|
||||||
|
|
||||||
interface SimulationState {
|
interface SimulationState {
|
||||||
resultActual: SimulationResult | null // resultado del escenario actual
|
resultActual: SimulationResult | null
|
||||||
resultAutomated: SimulationResult | null // resultado del escenario automatizado
|
resultAutomated: SimulationResult | null
|
||||||
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa
|
lastSimulatedAt: Date | null
|
||||||
lastPersistedExecutedAt: number | null // executedAt de la última sim guardada en IndexedDB (sobrevive recargas)
|
lastPersistedExecutedAt: number | null
|
||||||
isRunning: boolean
|
isRunning: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
selectedActivityId: string | null
|
selectedActivityId: string | null
|
||||||
|
|
||||||
// Persiste ambos resultados en IndexedDB y actualiza el estado en memoria.
|
|
||||||
// REGLA: los dos resultados siempre se guardan y actualizan en conjunto.
|
|
||||||
persistResults: (processId: string, actual: SimulationResult, automated: SimulationResult) => Promise<void>
|
persistResults: (processId: string, actual: SimulationResult, automated: SimulationResult) => Promise<void>
|
||||||
setRunning: (running: boolean) => void
|
setRunning: (running: boolean) => void
|
||||||
setError: (error: string | null) => void
|
setError: (error: string | null) => void
|
||||||
selectActivity: (bpmnElementId: string | null) => void
|
selectActivity: (bpmnElementId: string | null) => void
|
||||||
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
|
|
||||||
invalidateResults: () => void
|
invalidateResults: () => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
loadLatestForProcess: (processId: string) => Promise<void>
|
loadLatestForProcess: (processId: string) => Promise<void>
|
||||||
@@ -39,7 +36,7 @@ export const useSimulationStore = create<SimulationState>((set) => ({
|
|||||||
|
|
||||||
persistResults: async (processId, actual, automated) => {
|
persistResults: async (processId, actual, automated) => {
|
||||||
const executedAt = Date.now()
|
const executedAt = Date.now()
|
||||||
await simulationRepo.save({
|
await supabaseSimulationRepo.save({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
processId,
|
processId,
|
||||||
executedAt,
|
executedAt,
|
||||||
@@ -74,14 +71,13 @@ export const useSimulationStore = create<SimulationState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
loadLatestForProcess: async (processId) => {
|
loadLatestForProcess: async (processId) => {
|
||||||
const sim = await simulationRepo.getLatestByProcess(processId)
|
const sim = await supabaseSimulationRepo.getLatestByProcess(processId)
|
||||||
set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
|
set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
|
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
|
||||||
// invalida los resultados para garantizar que nunca haya resultados "viejos" visibles.
|
// invalida los resultados para garantizar que nunca haya resultados "viejos" visibles.
|
||||||
// La comparación por referencia de Zustand asegura que solo fires en cambios reales.
|
|
||||||
useProcessStore.subscribe((state, prevState) => {
|
useProcessStore.subscribe((state, prevState) => {
|
||||||
if (
|
if (
|
||||||
state.activities !== prevState.activities ||
|
state.activities !== prevState.activities ||
|
||||||
|
|||||||
17
supabase/migrations/009_create_simulations.sql
Normal file
17
supabase/migrations/009_create_simulations.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS public.simulations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
|
||||||
|
executed_at bigint NOT NULL,
|
||||||
|
result jsonb NOT NULL,
|
||||||
|
result_automated jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS simulations_process_executed_idx
|
||||||
|
ON public.simulations(process_id, executed_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE public.simulations ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "all_simulations" ON public.simulations
|
||||||
|
FOR ALL USING (auth.uid() IS NOT NULL)
|
||||||
|
WITH CHECK (auth.uid() IS NOT NULL);
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user