Files
inq-roi-simulador-web/tests/lib/export/csv-export-roi.test.ts
Marcos Benítez ee356e5de6
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Etapa 7 ya estaba completamente ejecutada en esta sesión. Todo está en su lugar:
Fix A1 — isProfileLoaded en AuthContext 

useState(false) inicial
setIsProfileLoaded(true) si onAuthStateChange devuelve null (no hay usuario que sincronizar)
setIsProfileLoaded(false) en SIGNED_IN (resetea para el nuevo sync)
setIsProfileLoaded(true) en el finally del sync effect (después de syncProfileFromDB, éxito o error)
Fix A2 — Guard en LibraryPage 

if (!user || !isProfileLoaded) devuelve skeleton animado antes de cualquier contenido rol-dependiente
Fix A3 — Campo "cliente" read-only para client_editor 

GlobalSettingsPanel.tsx: <p> estático en lugar de <Input> cuando isClientRole
WorkspacePage.tsx: topbar muestra el valor sin input ni lápiz para client_editor
Fix B — Migración 016 + aplicada a DB 

Archivo supabase/migrations/016_add_simulations_update_policy.sql creado
Policy simulations_update confirmada activa en DB (pg_policies devuelve las 4 policies de simulations)
Fix adicional — 403 en POST /processes  (ejecutado en el turno inmediato anterior)

updateEditable() en process-repo.ts usa .update() en lugar de .upsert() para todos los paths de edición
Tests: 586 verdes, build limpio.

Lo que falta según el checklist del prompt es la validación visual del director (msebem@gmail.com en la app real). Eso requiere tu OK después de verificar en el navegador.

domain/types.ts — orgId: string | null agregado a Process (campo de DB ya existente, ahora mapeado en el dominio).

process-repo.ts — fromRow incluye orgId: (row.org_id as string | null) ?? null. Ambas queries (getById y getAll) usan select('*'), por lo que el campo viene gratis.

WorkspacePage.tsx:

Import supabase para el fetch de org name
Estado orgName + useEffect que hace SELECT name FROM organizations WHERE id = orgId cuando cambia currentProcess?.orgId
Variable isClientRole (solo client_editor — client_viewer ya fue redirigido)
backLabel: "Procesos" para client_editor, nombre de org (o "Volver" mientras carga) si hay orgId, "Biblioteca" si no hay
handleBack(): navega a /?org=<orgId> para admin con proceso de cliente, a / para el resto
El Home button + Tooltip fueron reemplazados por <Button variant="ghost" size="sm"> con ArrowLeft + backLabel
LibraryPage.tsx:

Import useSearch
Lee orgParam = useSearch({ from: '/' }) as { org?: string }
useEffect que, cuando orgParam está presente y la carga terminó, busca el proceso con p.orgId === orgParam, obtiene su groupId, y hace scrollIntoView al elemento group-{groupId}
Los GroupCards en el grid están ahora envueltos en <div id="group-{group.id}"> para ser alcanzables por el scroll
Tests — workspace-back-button.test.ts con 6 casos: admin+orgName, admin+orgName loading, admin sin org, member sin org, client_editor, client_viewer. Fixtures actualizados en 13 archivos con orgId: null. Mock de useSearch agregado en library-ui.test.tsx.
2026-07-07 14:12:10 -03:00

219 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests del export CSV para el tab "Comparación + ROI" (Sprint 1 — Etapa 4).
* Verifica las 16 columnas, la metadata extendida, sufijos de archivo y
* compatibilidad con el comportamiento anterior (tabs actual y automatizado).
*/
import { describe, it, expect } from 'vitest'
import { generateCsvContent } from '@/lib/export/csv-export'
import type { Process, Simulation, Activity, SimulationResult } from '@/domain/types'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeProcess(extras: Partial<Process> = {}): Process {
return {
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, ...extras,
}
}
const perActivityActual: SimulationResult['perActivity'] = [
{
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 240,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 40, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
]
const perActivityAutomated: SimulationResult['perActivity'] = [
{
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
expectedDirectCost: 20, expectedIndirectCost: 4, expectedTotalCost: 24,
percentOfTotal: 40, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 5, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 60, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
]
const mockSimulation: Simulation = {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
result: {
totalCost: 360, totalDirectCost: 300, totalIndirectCost: 60, totalTimeMinutes: 84,
perActivity: perActivityActual, perResource: [], warnings: [],
},
resultAutomated: {
totalCost: 144, totalDirectCost: 120, totalIndirectCost: 24, totalTimeMinutes: 8,
perActivity: perActivityAutomated, perResource: [], warnings: [],
},
}
const mockActivities: Activity[] = [
{
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
automatedAssignedResources: [],
},
{
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
},
]
// ─── CSV ROI: estructura de columnas ──────────────────────────────────────────
describe('CSV tab=roi — 16 columnas', () => {
it('tiene exactamente 16 columnas en el header', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
// Papa.unparse envuelve en comillas — contar separadores + 1
const cols = headerLine.split('","').length
expect(cols).toBe(16)
})
it('tiene los headers esperados (muestra)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('ID BPMN')
expect(csv).toContain('Automatizable')
expect(csv).toContain('Costo actual total')
expect(csv).toContain('Costo automatizado total')
expect(csv).toContain('Ahorro')
expect(csv).toContain('Tiempo automatizado')
expect(csv).toContain('Recursos asignados')
})
})
describe('CSV tab=roi — metadata extendida', () => {
it('contiene campos de ROI en la metadata', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('# Ahorro por ejecución')
expect(csv).toContain('# Ahorro anual')
expect(csv).toContain('# Payback (meses)')
expect(csv).toContain('# ROI anualizado')
expect(csv).toContain('# Frecuencia anual')
expect(csv).toContain('# Horizonte de análisis')
expect(csv).toContain('# Inversión en automatización')
})
it('costo total actual y automatizado están en la metadata', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('# Costo total actual')
expect(csv).toContain('# Costo total automatizado')
// Valores concretos del fixture: 360 y 144
expect(csv).toContain('360.00')
expect(csv).toContain('144.00')
})
it('metadata incluye el horizonte y frecuencia configurados', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('# Frecuencia anual: 1200')
expect(csv).toContain('# Horizonte de análisis: 3 años')
})
})
describe('CSV tab=roi — datos de actividades', () => {
it('actividad automatable tiene "true" en la columna Automatizable', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
// task_recv es automatable=true
expect(csv).toContain('"true"')
})
it('actividad NO automatable tiene "false" en la columna Automatizable', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('"false"')
})
it('ahorro de task_recv: 240 - 24 = 216 aparece en los datos', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('216.00')
})
it('actividad no automatable tiene ahorro 0 (costos iguales)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
// task_valid: 120 - 120 = 0
expect(csv).toContain('0.00')
})
it('N+1 filas (N actividades + 1 header)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const dataLines = csvNoBom.split('\r\n').filter((l) => l.trim() && !l.startsWith('#'))
expect(dataLines).toHaveLength(perActivityActual.length + 1)
})
})
describe('CSV tab=roi — separadores y BOM', () => {
it('BOM UTF-8 presente', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('USD: separador coma', () => {
const csv = generateCsvContent({ process: makeProcess({ currency: 'USD' }), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
expect(headerLine).toContain('","')
})
it('PYG: separador punto y coma', () => {
const csv = generateCsvContent({ process: makeProcess({ currency: 'PYG' }), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
expect(headerLine).toContain('";"')
})
})
// ─── CSV tab=automatizado ─────────────────────────────────────────────────────
describe('CSV tab=automatizado', () => {
it('usa los datos del escenario automatizado (totalCost=144)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'automatizado' })
expect(csv).toContain('# Escenario: Automatizado')
expect(csv).toContain('144.00')
})
it('tiene 10 columnas (mismo formato que actual)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'automatizado' })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
const cols = headerLine.split('","').length
expect(cols).toBe(10)
})
})
// ─── CSV tab=actual — regresión ───────────────────────────────────────────────
describe('CSV tab=actual (regresión)', () => {
it('tab=actual funciona igual que antes (escenario actual, 10 cols)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'actual' })
expect(csv).toContain('# Escenario: Actual')
expect(csv).toContain('360.00')
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
expect(headerLine.split('","').length).toBe(10)
})
it('tab=undefined → igual que tab=actual', () => {
const csvDefault = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [] })
const csvActual = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'actual' })
expect(csvDefault).toBe(csvActual)
})
})