From 025393cd43a5b2d6612af54a6b4cf0ee60574517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Tue, 16 Jun 2026 21:52:44 -0300 Subject: [PATCH] Commit pendiente de Etapas 3+4. Son cambios validados (migraciones corriendo, build verde, 521 tests). Hay que commitearlos antes de empezar la siguiente etapa. --- package-lock.json | 7 - package.json | 1 - src/features/report/useReportData.ts | 4 +- src/persistence/db.ts | 125 ------- src/persistence/repositories.ts | 180 --------- src/persistence/supabase/process-repo.ts | 8 +- src/persistence/supabase/simulation-repo.ts | 49 +++ src/store/library-store.ts | 21 +- src/store/simulation-store.ts | 18 +- .../migrations/009_create_simulations.sql | 17 + tests/integration/db-migration-v3.test.ts | 148 -------- tests/integration/dexie-migration.test.ts | 151 -------- .../integration/indexeddb-persistence.test.ts | 341 ------------------ tests/persistence/migration-v4.test.ts | 220 ----------- 14 files changed, 82 insertions(+), 1208 deletions(-) delete mode 100644 src/persistence/db.ts delete mode 100644 src/persistence/repositories.ts create mode 100644 src/persistence/supabase/simulation-repo.ts create mode 100644 supabase/migrations/009_create_simulations.sql delete mode 100644 tests/integration/db-migration-v3.test.ts delete mode 100644 tests/integration/dexie-migration.test.ts delete mode 100644 tests/integration/indexeddb-persistence.test.ts delete mode 100644 tests/persistence/migration-v4.test.ts diff --git a/package-lock.json b/package-lock.json index ac88566..a6fb9ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3": "^7.9.0", - "dexie": "^4.4.2", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "html2canvas": "^1.4.1", @@ -5220,12 +5219,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "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": { "version": "15.14.0", "resolved": "https://registry.npmjs.org/diagram-js/-/diagram-js-15.14.0.tgz", diff --git a/package.json b/package.json index 6787528..569e23a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3": "^7.9.0", - "dexie": "^4.4.2", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "html2canvas": "^1.4.1", diff --git a/src/features/report/useReportData.ts b/src/features/report/useReportData.ts index 89d8c58..e55deaa 100644 --- a/src/features/report/useReportData.ts +++ b/src/features/report/useReportData.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo' import { supabaseResourceRepo } from '@/persistence/supabase/resource-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' interface ReportData { @@ -31,7 +31,7 @@ export function useReportData(processId: string): ReportData { try { const [proc, sim, acts, res] = await Promise.all([ supabaseProcessRepo.getById(processId), - simulationRepo.getLatestByProcess(processId), + supabaseSimulationRepo.getLatestByProcess(processId), supabaseActivityRepo.getByProcess(processId), supabaseResourceRepo.getAll(), ]) diff --git a/src/persistence/db.ts b/src/persistence/db.ts deleted file mode 100644 index 771a3df..0000000 --- a/src/persistence/db.ts +++ /dev/null @@ -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 - activities!: Table - gateways!: Table - resources!: Table - simulations!: Table - groups!: Table // πŸ†• Sprint 3 - settings!: Table // πŸ†• 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) => { - 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() diff --git a/src/persistence/repositories.ts b/src/persistence/repositories.ts deleted file mode 100644 index e70ee4a..0000000 --- a/src/persistence/repositories.ts +++ /dev/null @@ -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 { - await db.processes.put(process) - }, - - async getById(id: string): Promise { - return db.processes.get(id) - }, - - async getAll(): Promise { - return db.processes.orderBy('updatedAt').reverse().toArray() - }, - - async delete(id: string): Promise { - 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 { - await db.activities.put(activity) - }, - - async saveMany(activities: Activity[]): Promise { - await db.activities.bulkPut(activities) - }, - - async getByProcess(processId: string): Promise { - return db.activities.where('processId').equals(processId).toArray() - }, - - async getByBpmnElementId(processId: string, bpmnElementId: string): Promise { - return db.activities - .where('[processId+bpmnElementId]') - .equals([processId, bpmnElementId]) - .first() - }, - - async deleteByProcess(processId: string): Promise { - 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 { - await db.gateways.put(gateway) - }, - - async saveMany(gateways: GatewayConfig[]): Promise { - await db.gateways.bulkPut(gateways) - }, - - async getByProcess(processId: string): Promise { - return db.gateways.where('processId').equals(processId).toArray() - }, - - async deleteByProcess(processId: string): Promise { - 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 { - await db.resources.put(resource) - }, - - async getByProcess(processId: string): Promise { - return db.resources.where('processId').equals(processId).toArray() - }, - - async delete(id: string): Promise { - await db.resources.delete(id) - }, - - async deleteByProcess(processId: string): Promise { - 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 { - await db.simulations.put(simulation) - }, - - async getLatestByProcess(processId: string): Promise { - // 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 { - 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 { - await db.groups.put(group) - }, - - async getAll(): Promise { - return db.groups.orderBy('name').toArray() - }, - - async getById(id: string): Promise { - return db.groups.get(id) - }, - - async delete(id: string): Promise { - // 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 { - const s = await db.settings.get('singleton') - return s ?? { id: 'singleton', groupLabel: 'Cliente' } - }, - - async update(updates: Partial>): Promise { - await db.settings.update('singleton', updates) - }, -} diff --git a/src/persistence/supabase/process-repo.ts b/src/persistence/supabase/process-repo.ts index bb8aed9..0534aee 100644 --- a/src/persistence/supabase/process-repo.ts +++ b/src/persistence/supabase/process-repo.ts @@ -1,5 +1,4 @@ import { supabase } from '@/lib/supabase' -import { db } from '@/persistence/db' import type { Process } from '@/domain/types' function toRow(process: Process, userId: string) { @@ -66,13 +65,8 @@ export const supabaseProcessRepo = { }, async delete(id: string): Promise { - // 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) 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() - }) }, } diff --git a/src/persistence/supabase/simulation-repo.ts b/src/persistence/supabase/simulation-repo.ts new file mode 100644 index 0000000..c54ebcb --- /dev/null +++ b/src/persistence/supabase/simulation-repo.ts @@ -0,0 +1,49 @@ +import { supabase } from '@/lib/supabase' +import type { Simulation, SimulationResult } from '@/domain/types' + +function fromRow(row: Record): 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 { + 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 { + 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) + }, + + async getByProcess(processId: string): Promise { + 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) + }, +} diff --git a/src/store/library-store.ts b/src/store/library-store.ts index 4a6b66a..d750867 100644 --- a/src/store/library-store.ts +++ b/src/store/library-store.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid' import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import { supabaseGroupRepo } from '@/persistence/supabase/group-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' interface LibraryState { @@ -39,19 +39,10 @@ export const useLibraryStore = create((set, get) => ({ load: async () => { set({ isLoading: true }) try { - const TIMEOUT_MS = 8_000 - const [groups, processes, settings] = await Promise.race([ - Promise.all([ - supabaseGroupRepo.getAll(), - supabaseProcessRepo.getAll(), - supabaseSettingsRepo.get(), - ]), - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout: la biblioteca tardΓ³ mΓ‘s de 8s en cargar')), - TIMEOUT_MS, - ) - ), + const [groups, processes, settings] = await Promise.all([ + supabaseGroupRepo.getAll(), + supabaseProcessRepo.getAll(), + supabaseSettingsRepo.get(), ]) set({ groups, processes, settings, isLoading: false }) } catch (err) { @@ -99,7 +90,7 @@ export const useLibraryStore = create((set, get) => ({ }, getLatestSimulation: (processId) => { - return simulationRepo.getLatestByProcess(processId) + return supabaseSimulationRepo.getLatestByProcess(processId) }, updateSettings: async (updates) => { diff --git a/src/store/simulation-store.ts b/src/store/simulation-store.ts index c8b4345..086b0d7 100644 --- a/src/store/simulation-store.ts +++ b/src/store/simulation-store.ts @@ -1,28 +1,25 @@ import { create } from 'zustand' import { v4 as uuidv4 } from 'uuid' 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 // para suscribirse a cambios de datos y invalidar resultados. No existe dependencia inversa. import { useProcessStore } from '@/store/process-store' interface SimulationState { - resultActual: SimulationResult | null // resultado del escenario actual - resultAutomated: SimulationResult | null // resultado del escenario automatizado - lastSimulatedAt: Date | null // timestamp de la ΓΊltima simulaciΓ³n exitosa - lastPersistedExecutedAt: number | null // executedAt de la ΓΊltima sim guardada en IndexedDB (sobrevive recargas) + resultActual: SimulationResult | null + resultAutomated: SimulationResult | null + lastSimulatedAt: Date | null + lastPersistedExecutedAt: number | null isRunning: boolean error: 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 setRunning: (running: boolean) => void setError: (error: string | null) => void selectActivity: (bpmnElementId: string | null) => void - // Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso. invalidateResults: () => void reset: () => void loadLatestForProcess: (processId: string) => Promise @@ -39,7 +36,7 @@ export const useSimulationStore = create((set) => ({ persistResults: async (processId, actual, automated) => { const executedAt = Date.now() - await simulationRepo.save({ + await supabaseSimulationRepo.save({ id: uuidv4(), processId, executedAt, @@ -74,14 +71,13 @@ export const useSimulationStore = create((set) => ({ }), loadLatestForProcess: async (processId) => { - const sim = await simulationRepo.getLatestByProcess(processId) + const sim = await supabaseSimulationRepo.getLatestByProcess(processId) set({ lastPersistedExecutedAt: sim?.executedAt ?? null }) }, })) // SuscripciΓ³n reactiva: cualquier cambio en activities o currentProcess del process-store // 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) => { if ( state.activities !== prevState.activities || diff --git a/supabase/migrations/009_create_simulations.sql b/supabase/migrations/009_create_simulations.sql new file mode 100644 index 0000000..63b5e1a --- /dev/null +++ b/supabase/migrations/009_create_simulations.sql @@ -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); diff --git a/tests/integration/db-migration-v3.test.ts b/tests/integration/db-migration-v3.test.ts deleted file mode 100644 index 79f878b..0000000 --- a/tests/integration/db-migration-v3.test.ts +++ /dev/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 & { 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) - const assignment: Record = { 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) - }) -}) diff --git a/tests/integration/dexie-migration.test.ts b/tests/integration/dexie-migration.test.ts deleted file mode 100644 index 7fe4bab..0000000 --- a/tests/integration/dexie-migration.test.ts +++ /dev/null @@ -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 { - return { - id, processId, - bpmnElementId: `task_${id}`, - name: `Tarea ${id}`, - type: 'task', - directCostFixed: 500, - executionTimeMinutes: 45, - assignedResources: [], - } -} - -function makeLegacyProcess(id: string): Omit { - return { - id, name: 'Proceso Legacy', clientName: 'Cliente Viejo', - bpmnXml: '', 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) - }) -}) diff --git a/tests/integration/indexeddb-persistence.test.ts b/tests/integration/indexeddb-persistence.test.ts deleted file mode 100644 index 10825eb..0000000 --- a/tests/integration/indexeddb-persistence.test.ts +++ /dev/null @@ -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: '', - 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) - }) -}) diff --git a/tests/persistence/migration-v4.test.ts b/tests/persistence/migration-v4.test.ts deleted file mode 100644 index 219619a..0000000 --- a/tests/persistence/migration-v4.test.ts +++ /dev/null @@ -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 { - return { - id, - name: `Proceso ${id}`, - clientName: 'Cliente Test', - bpmnXml: '', - 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 = { - id: 'p1', - name: 'Proceso Legado', - clientName: 'Cliente A', - bpmnXml: '', - 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 = { - 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 = { - 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 = { - 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) - }) -})