Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.

This commit is contained in:
2026-05-15 22:14:53 -03:00
parent b6f31f4371
commit 8b71c6ee13
54 changed files with 6497 additions and 261 deletions

View File

@@ -0,0 +1,157 @@
/**
* Tests de la sección "Automatización" del ActivityPanel.
*
* Los mocks del store usan vi.hoisted() para crear referencias estables:
* si el mock devuelve una nueva referencia de activities en cada render,
* el useEffect de ActivityPanel entra en loop infinito.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
// ─── Mocks con referencias estables vía vi.hoisted ───────────────────────────
const processStoreMock = vi.hoisted(() => {
const activity = {
id: 'act-1', processId: 'p1', bpmnElementId: 'task1', name: 'Facturación',
type: 'task' as const,
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}
return {
activities: [activity], // referencia estable — no cambia entre renders
resources: [],
updateActivity: vi.fn(),
subscribe: vi.fn(), // requerido por simulation-store.ts al importarse
}
})
const simulationStoreMock = vi.hoisted(() => ({
resultActual: null as null | object,
resultAutomated: null as null | object,
invalidateResults: vi.fn(),
}))
vi.mock('@/store/process-store', () => ({
useProcessStore: () => processStoreMock,
// Zustand también expone .subscribe() como método estático del hook
useProcessStore_subscribe: vi.fn(),
}))
vi.mock('@/store/simulation-store', () => ({
useSimulationStore: () => simulationStoreMock,
}))
import { ActivityPanel } from '@/features/workspace/ActivityPanel'
// ─── Helper ───────────────────────────────────────────────────────────────────
function renderPanel(selectedElementId = 'task1') {
return render(
<TooltipProvider>
<ActivityPanel selectedElementId={selectedElementId} />
</TooltipProvider>
)
}
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
// Resetear el mock de updateActivity y restaurar la actividad base
processStoreMock.updateActivity.mockReset()
processStoreMock.updateActivity.mockResolvedValue(undefined)
processStoreMock.activities[0].automatable = false
processStoreMock.activities[0].automatedCostFixed = 0
processStoreMock.activities[0].automatedTimeMinutes = 0
simulationStoreMock.resultActual = null
simulationStoreMock.resultAutomated = null
simulationStoreMock.invalidateResults.mockReset()
})
describe('ActivityPanel — sección Automatización', () => {
it('sección Automatización está presente y toggle en OFF por defecto', () => {
renderPanel()
expect(screen.getByText('¿Es automatizable?')).toBeInTheDocument()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
expect(toggle).toHaveAttribute('aria-checked', 'false')
})
it('Toggle ON: aria-checked cambia a true', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
it('Toggle ON con ambos campos en 0: helper ámbar está en el DOM y visible (opacity-100)', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
// El helper siempre está en el DOM — verificamos que el texto existe y el contenedor es visible
const helperText = screen.getByText(/Transparencia metodológica/i)
expect(helperText).toBeInTheDocument()
// El contenedor padre tiene opacity-100 cuando showHelper=true
const helperContainer = helperText.closest('[aria-hidden]')
expect(helperContainer).toHaveAttribute('aria-hidden', 'false')
})
it('Toggle ON con costo > 0: helper ámbar está en el DOM pero oculto (aria-hidden=true)', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
const costoInput = screen.getByLabelText(/Costo automatizado/i)
await act(async () => {
fireEvent.change(costoInput, { target: { value: '50' } })
})
// El texto sigue en el DOM (transición CSS, no unmount)
const helperText = screen.getByText(/Transparencia metodológica/i)
expect(helperText).toBeInTheDocument()
// Pero el contenedor está marcado aria-hidden=true (oculto visualmente)
const helperContainer = helperText.closest('[aria-hidden]')
expect(helperContainer).toHaveAttribute('aria-hidden', 'true')
})
it('Editar costo automatizado y guardar llama updateActivity con el valor correcto', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
const costoInput = screen.getByLabelText(/Costo automatizado/i)
await act(async () => {
fireEvent.change(costoInput, { target: { value: '25' } })
})
const guardarBtn = screen.getByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
expect(processStoreMock.updateActivity).toHaveBeenCalledOnce()
const arg = processStoreMock.updateActivity.mock.calls[0][0]
expect(arg.automatedCostFixed).toBe(25)
expect(arg.automatable).toBe(true)
})
it('Guardar llama updateActivity (la invalidación reactiva es responsabilidad del store)', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
const tiempoInput = screen.getByLabelText(/Tiempo automatizado/i)
await act(async () => {
fireEvent.change(tiempoInput, { target: { value: '5' } })
})
const guardarBtn = screen.getByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
expect(processStoreMock.updateActivity).toHaveBeenCalledOnce()
const arg = processStoreMock.updateActivity.mock.calls[0][0]
expect(arg.automatedTimeMinutes).toBe(5)
})
})

View File

@@ -0,0 +1,131 @@
/**
* Tests del overlay de automatización en BpmnCanvas.
*
* bpmn-js se mockea completamente. El elementRegistry.get() devuelve
* elementos con width=100 para verificar el posicionamiento left=width-12=88.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, act } from '@testing-library/react'
// ─── Mock de bpmn-js ──────────────────────────────────────────────────────────
const mockOverlaysAdd = vi.fn()
const mockOverlaysRemove = vi.fn()
const mockCanvasZoom = vi.fn()
const mockRegForEach = vi.fn()
// elementRegistry.get() devuelve un elemento con width=100 (nodo estándar de bpmn-js)
const mockRegGet = vi.fn().mockReturnValue({ width: 100, height: 80 })
vi.mock('bpmn-js/lib/Viewer', () => ({
default: class MockViewer {
importXML = vi.fn().mockResolvedValue({ warnings: [] })
get = vi.fn().mockImplementation((svc: string) => {
if (svc === 'overlays') return { add: mockOverlaysAdd, remove: mockOverlaysRemove }
if (svc === 'canvas') return { zoom: mockCanvasZoom, addMarker: vi.fn(), removeMarker: vi.fn() }
if (svc === 'elementRegistry') return { forEach: mockRegForEach, get: mockRegGet }
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
return {}
})
destroy = vi.fn()
},
}))
import { BpmnCanvas } from '@/features/workspace/BpmnCanvas'
// ─── Fixture ──────────────────────────────────────────────────────────────────
const SIMPLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="p"><startEvent id="s"/></process>
</definitions>`
// Con width=100 de mockRegGet, left = 100 - 12 = 88
const EXPECTED_LEFT = 88
const EXPECTED_TOP = -8
beforeEach(() => {
vi.clearAllMocks()
mockRegGet.mockReturnValue({ width: 100, height: 80 })
})
async function flush() {
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('BpmnCanvas — overlay de automatización', () => {
it('sin actividades automatizables: overlays.add NO se llama, remove sí', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={[]} />)
})
await flush()
expect(mockOverlaysAdd).not.toHaveBeenCalled()
expect(mockOverlaysRemove).toHaveBeenCalledWith({ type: 'automation-indicator' })
})
it('posición: usa elementRegistry para calcular left=width-12, top=-8 (esquina superior derecha)', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a']} />)
})
await flush()
expect(mockOverlaysAdd).toHaveBeenCalledOnce()
const position = mockOverlaysAdd.mock.calls[0][2].position
// top: -8 — badge 8px por encima del borde superior del nodo
expect(position.top).toBe(EXPECTED_TOP)
// left: width-12 = 88 — badge en la región derecha, 8px fuera del borde derecho (20-12=8px sticking out)
expect(position.left).toBe(EXPECTED_LEFT)
// No usa right (semántica confusa en diagram-js: right:-8 → left=8+width, enteramente fuera)
expect(position.right).toBeUndefined()
})
it('con 2 actividades automatizables: overlays.add llamado 2 veces con posición correcta', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a', 'task_b']} />)
})
await flush()
expect(mockOverlaysAdd).toHaveBeenCalledTimes(2)
for (const call of mockOverlaysAdd.mock.calls) {
expect(call[2].position).toEqual({ top: EXPECTED_TOP, left: EXPECTED_LEFT })
}
})
it('HTML del overlay: title correcto, SVG ámbar (#f59e0b), fondo #fffbeb', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_1']} />)
})
await flush()
const html: string = mockOverlaysAdd.mock.calls[0][2].html
expect(html).toContain('Actividad marcada como automatizable')
expect(html).toContain('<svg')
expect(html).toContain('#f59e0b') // color ámbar del ícono Bot
expect(html).toContain('#fffbeb') // fondo amber-50 del círculo
})
it('al quitar una actividad: remove() vuelve a llamarse y add() refleja la lista nueva', async () => {
const { rerender } = render(
<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a', 'task_b']} />
)
await flush()
expect(mockOverlaysAdd).toHaveBeenCalledTimes(2)
const removeCalls1 = mockOverlaysRemove.mock.calls.length
await act(async () => {
rerender(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a']} />)
})
await flush()
expect(mockOverlaysRemove.mock.calls.length).toBeGreaterThan(removeCalls1)
expect(mockOverlaysAdd).toHaveBeenCalledTimes(3) // 2 + 1
})
})

View File

@@ -0,0 +1,132 @@
/**
* Tests de la sección "Análisis de Impacto" del GlobalSettingsPanel.
*
* Los mocks usan vi.hoisted() para que currentProcess sea una referencia
* estable entre renders (evita el loop infinito del useEffect de sync).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
// ─── Mocks con referencia estable ─────────────────────────────────────────────
const processStoreMock = vi.hoisted(() => ({
currentProcess: {
id: 'p1', name: 'Proceso test', clientName: 'Cliente A',
bpmnXml: '<def/>', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
},
updateProcess: vi.fn(),
subscribe: vi.fn(),
}))
vi.mock('@/store/process-store', () => ({
useProcessStore: () => processStoreMock,
}))
vi.mock('@/store/simulation-store', () => ({
useSimulationStore: () => ({
resultActual: null,
resultAutomated: null,
invalidateResults: vi.fn(),
}),
}))
import { GlobalSettingsPanel } from '@/features/workspace/GlobalSettingsPanel'
// ─── Helper ───────────────────────────────────────────────────────────────────
function renderPanel() {
return render(
<TooltipProvider>
<GlobalSettingsPanel />
</TooltipProvider>
)
}
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
processStoreMock.updateProcess.mockReset()
processStoreMock.updateProcess.mockResolvedValue(undefined)
// Restaurar defaults del proceso
processStoreMock.currentProcess.annualFrequency = 1000
processStoreMock.currentProcess.analysisHorizonYears = 3
processStoreMock.currentProcess.automationInvestment = 0
})
describe('GlobalSettingsPanel — sección Análisis de Impacto', () => {
it('renderiza los 3 campos de impacto con sus labels correctos', () => {
renderPanel()
expect(screen.getByLabelText(/Frecuencia anual/i)).toBeInTheDocument()
expect(screen.getByText(/Horizonte de análisis/i)).toBeInTheDocument()
expect(screen.getByLabelText(/Inversión en automatización/i)).toBeInTheDocument()
})
it('frecuencia anual muestra el default 1000 del proceso', () => {
renderPanel()
const freqInput = screen.getByLabelText(/Frecuencia anual/i) as HTMLInputElement
expect(freqInput.value).toBe('1000')
})
it('editar frecuencia y guardar incluye el nuevo valor en updateProcess', async () => {
renderPanel()
const freqInput = screen.getByLabelText(/Frecuencia anual/i)
await act(async () => {
fireEvent.change(freqInput, { target: { value: '5000' } })
})
// Después de cambiar, el botón debería mostrar "Guardar configuración"
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
expect(processStoreMock.updateProcess).toHaveBeenCalledOnce()
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
expect(calledWith.annualFrequency).toBe(5000)
})
it('horizonte de análisis se muestra en texto (default 3 años)', () => {
renderPanel()
expect(screen.getByText('3 años')).toBeInTheDocument()
})
it('guardar incluye horizonte actual en updateProcess', async () => {
renderPanel()
const freqInput = screen.getByLabelText(/Frecuencia anual/i)
await act(async () => {
fireEvent.change(freqInput, { target: { value: '1200' } })
})
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
expect(calledWith.analysisHorizonYears).toBe(3)
expect(calledWith.analysisHorizonYears).toBeGreaterThanOrEqual(1)
expect(calledWith.analysisHorizonYears).toBeLessThanOrEqual(10)
})
it('editar inversión en automatización y guardar la incluye correctamente', async () => {
renderPanel()
const investInput = screen.getByLabelText(/Inversión en automatización/i)
await act(async () => {
fireEvent.change(investInput, { target: { value: '75000' } })
})
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
expect(calledWith.automationInvestment).toBe(75000)
})
it('sección "ANÁLISIS DE IMPACTO" aparece con su label de sección', () => {
renderPanel()
expect(screen.getByText(/Análisis de Impacto/i)).toBeInTheDocument()
})
})