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:
@@ -226,7 +226,8 @@ describe('MethodologyFooter', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proceso Test', clientName: '',
|
||||
bpmnXml: '<def/>', currency: 'USD',
|
||||
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
|
||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
|
||||
})
|
||||
@@ -235,7 +236,8 @@ describe('MethodologyFooter', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.25, createdAt: 0, updatedAt: 0,
|
||||
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -246,7 +248,8 @@ describe('MethodologyFooter', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
|
||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -326,6 +329,9 @@ describe('ReportPage — con datos completos', () => {
|
||||
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
|
||||
currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
createdAt: Date.now() - 1000 * 60 * 30,
|
||||
updatedAt: Date.now() - 1000 * 60 * 30,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function buildSimInput(xml: string, directCost: number, overhead: number, curren
|
||||
directCostFixed: directCost,
|
||||
executionTimeMinutes: 30,
|
||||
assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
792
tests/features/report/roi-components.test.tsx
Normal file
792
tests/features/report/roi-components.test.tsx
Normal file
@@ -0,0 +1,792 @@
|
||||
/**
|
||||
* Tests de los componentes de ROI del reporte (Sprint 1 — Etapa 3).
|
||||
*
|
||||
* Cubre: RoiKpiBar, ComparisonTable, TransparencySection, TopSavingsChart,
|
||||
* CumulativeSavingsChart, y el comportamiento de tabs de ReportPage.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import React from 'react'
|
||||
|
||||
// ─── Mocks de dependencias del browser ───────────────────────────────────────
|
||||
|
||||
vi.mock('bpmn-js/lib/Viewer', () => ({
|
||||
default: class MockViewer {
|
||||
importXML = vi.fn().mockResolvedValue({ warnings: [] })
|
||||
get = vi.fn().mockImplementation((svc: string) => {
|
||||
if (svc === 'canvas') return { zoom: vi.fn(), addMarker: vi.fn(), removeMarker: vi.fn() }
|
||||
if (svc === 'elementRegistry') return { forEach: vi.fn(), getGraphics: vi.fn().mockReturnValue(null), get: vi.fn().mockReturnValue({ width: 100 }) }
|
||||
if (svc === 'overlays') return { add: vi.fn(), remove: vi.fn() }
|
||||
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
|
||||
return {}
|
||||
})
|
||||
destroy = vi.fn()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('echarts-for-react', () => ({
|
||||
default: ({ style }: { option: unknown; style?: React.CSSProperties }) => (
|
||||
<div data-testid="echarts-chart" style={style} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/report/useReportData', () => ({
|
||||
useReportData: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', async (importActual) => {
|
||||
const actual = await importActual<typeof import('@tanstack/react-router')>()
|
||||
return {
|
||||
...actual,
|
||||
useParams: vi.fn().mockReturnValue({ processId: 'test-proc-id' }),
|
||||
useNavigate: vi.fn().mockReturnValue(vi.fn()),
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { ActivitySimResult, Activity, SimulationResult } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
const makeActivity = (id: string, name: string, cost: number): ActivitySimResult => ({
|
||||
activityId: id,
|
||||
bpmnElementId: `bpmn_${id}`,
|
||||
activityName: name,
|
||||
expectedDirectCost: cost * 0.83,
|
||||
expectedIndirectCost: cost * 0.17,
|
||||
expectedTotalCost: cost,
|
||||
percentOfTotal: 33,
|
||||
executionProbability: 1.0,
|
||||
expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60,
|
||||
resourceCostBreakdown: [],
|
||||
})
|
||||
|
||||
const perActivityActual: ActivitySimResult[] = [
|
||||
makeActivity('a1', 'Recibir solicitud', 600),
|
||||
makeActivity('a2', 'Calcular score', 300),
|
||||
makeActivity('a3', 'Analizar riesgo', 100),
|
||||
]
|
||||
|
||||
const perActivityAutomated: ActivitySimResult[] = [
|
||||
makeActivity('a1', 'Recibir solicitud', 60), // 90% ahorro
|
||||
makeActivity('a2', 'Calcular score', 30), // 90% ahorro
|
||||
makeActivity('a3', 'Analizar riesgo', 100), // sin cambio (no automatable)
|
||||
]
|
||||
|
||||
const mockResult: SimulationResult = {
|
||||
totalCost: 1000,
|
||||
totalDirectCost: 833,
|
||||
totalIndirectCost: 167,
|
||||
totalTimeMinutes: 180,
|
||||
perActivity: perActivityActual,
|
||||
perResource: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const mockResultAutomated: SimulationResult = {
|
||||
totalCost: 190,
|
||||
totalDirectCost: 158,
|
||||
totalIndirectCost: 32,
|
||||
totalTimeMinutes: 30,
|
||||
perActivity: perActivityAutomated,
|
||||
perResource: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const mockRoi: RoiResult = {
|
||||
savingsPerExecution: 810,
|
||||
savingsPerExecutionPercent: 81,
|
||||
annualSavings: 9_720_000,
|
||||
paybackMonths: 0.617,
|
||||
paybackYears: 0.051,
|
||||
cumulativeSavingsHorizon: 29_110_000,
|
||||
breaksEvenInHorizon: true,
|
||||
roiAnnualPercent: 19440,
|
||||
}
|
||||
|
||||
const roiNoSavings: RoiResult = {
|
||||
savingsPerExecution: -50,
|
||||
savingsPerExecutionPercent: -10,
|
||||
annualSavings: -60_000,
|
||||
paybackMonths: Infinity,
|
||||
paybackYears: Infinity,
|
||||
cumulativeSavingsHorizon: -230_000,
|
||||
breaksEvenInHorizon: false,
|
||||
roiAnnualPercent: -60,
|
||||
}
|
||||
|
||||
const roiZeroInvestment: RoiResult = {
|
||||
savingsPerExecution: 200,
|
||||
savingsPerExecutionPercent: 50,
|
||||
annualSavings: 240_000,
|
||||
paybackMonths: 0,
|
||||
paybackYears: 0,
|
||||
cumulativeSavingsHorizon: 720_000,
|
||||
breaksEvenInHorizon: true,
|
||||
roiAnnualPercent: Infinity,
|
||||
}
|
||||
|
||||
// ─── RoiKpiBar ────────────────────────────────────────────────────────────────
|
||||
|
||||
import { RoiKpiBar } from '@/features/report/RoiKpiBar'
|
||||
|
||||
describe('RoiKpiBar', () => {
|
||||
it('renderiza exactamente 5 ROI KPI cards', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('muestra el ahorro por ejecución formateado con moneda', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
// $810.00 debe aparecer
|
||||
expect(text).toMatch(/810/)
|
||||
})
|
||||
|
||||
it('payback Infinity → muestra "No se recupera"', () => {
|
||||
render(<RoiKpiBar roi={roiNoSavings} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/No se recupera/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('payback 0 (inversión cero) → muestra "Inmediato"', () => {
|
||||
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/Inmediato/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ROI Infinity (inversión cero) → muestra "∞%"', () => {
|
||||
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText('∞%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ahorro negativo: "breaksEvenInHorizon = false" → subtexto fuera del horizonte', () => {
|
||||
render(<RoiKpiBar roi={roiNoSavings} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/Fuera del horizonte/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('breaksEvenInHorizon = true → subtexto dentro del horizonte', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/Dentro del horizonte/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('horizonYears se refleja en el label del KPI de acumulado', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={5} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/5 años/)
|
||||
})
|
||||
|
||||
it('ningún KPI muestra NaN', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).not.toContain('NaN')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ComparisonTable ──────────────────────────────────────────────────────────
|
||||
|
||||
import { ComparisonTable } from '@/features/report/ComparisonTable'
|
||||
|
||||
const automatableIds = new Set(['bpmn_a1', 'bpmn_a2']) // a1 y a2 son automatizables
|
||||
const noAutomatableIds = new Set<string>()
|
||||
|
||||
describe('ComparisonTable', () => {
|
||||
it('renderiza N+1 rows (N actividades + 1 header)', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
const rows = screen.getAllByRole('row')
|
||||
expect(rows).toHaveLength(perActivityActual.length + 1)
|
||||
})
|
||||
|
||||
it('actividades automatable muestran ícono Bot', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// a1 y a2 son automatable → 2 íconos Bot
|
||||
const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]')
|
||||
expect(botIcons).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('actividades NO automatable NO muestran ícono Bot', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={noAutomatableIds} // ninguna automatable
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]')
|
||||
expect(botIcons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ahorro de a1: 600 - 60 = 540 aparece en la tabla', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/540/)
|
||||
})
|
||||
|
||||
it('tabla vacía: muestra mensaje sin actividades', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={[]}
|
||||
perActivityAutomated={[]}
|
||||
automatableIds={noAutomatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Sin actividades para comparar/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ahorro negativo: se muestra sin prefijo +', () => {
|
||||
const negativeActual = [makeActivity('x1', 'Tarea cara auto', 100)]
|
||||
const negativeAuto = [makeActivity('x1', 'Tarea cara auto', 200)] // costo auto > actual
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={negativeActual}
|
||||
perActivityAutomated={negativeAuto}
|
||||
automatableIds={new Set(['bpmn_x1'])}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// -100 de ahorro, sin prefijo '+'
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).not.toMatch(/\+.*-/)
|
||||
})
|
||||
|
||||
it('sort toggle: clic en encabezado "Ahorro" invierte el orden', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// Buscar el th que contiene "Ahorro" (hay múltiples elementos con ese texto)
|
||||
const headers = screen.getAllByRole('columnheader')
|
||||
const ahorroHeader = headers.find((h) => /Ahorro/i.test(h.textContent ?? ''))
|
||||
expect(ahorroHeader).toBeDefined()
|
||||
|
||||
// Antes del click: orden desc (a1=540 primero)
|
||||
const rowsBefore = screen.getAllByRole('row').slice(1)
|
||||
const firstNameBefore = rowsBefore[0].textContent
|
||||
|
||||
fireEvent.click(ahorroHeader!)
|
||||
// Después del click: orden asc (a3=0 primero)
|
||||
const rowsAfter = screen.getAllByRole('row').slice(1)
|
||||
const firstNameAfter = rowsAfter[0].textContent
|
||||
|
||||
expect(firstNameBefore).not.toBe(firstNameAfter)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TransparencySection ──────────────────────────────────────────────────────
|
||||
|
||||
import { TransparencySection } from '@/features/report/TransparencySection'
|
||||
|
||||
const makeActivityDomain = (id: string, name: string, automatable: boolean, zeroCosts: boolean): Activity => ({
|
||||
id, processId: 'p1', bpmnElementId: `bpmn_${id}`, name, type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable,
|
||||
automatedCostFixed: zeroCosts ? 0 : 50,
|
||||
automatedTimeMinutes: zeroCosts ? 0 : 10,
|
||||
})
|
||||
|
||||
describe('TransparencySection', () => {
|
||||
it('retorna null cuando no hay issues — no renderiza nada', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Tarea', true, false)]} // automatable con costos configurados
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('Caso 1: actividad automatable con costos en cero → sección visible', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // automatable, costo=0
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Facturación')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 1: mensaje explica que el ahorro puede estar inflado', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]}
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/inflar artificialmente el ahorro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 1: plural correcto cuando hay múltiples actividades en cero', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[
|
||||
makeActivityDomain('a1', 'Tarea 1', true, true),
|
||||
makeActivityDomain('a2', 'Tarea 2', true, true),
|
||||
]}
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/2 actividades automatizables sin costo configurado/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 2: investment=0 → muestra aviso de inversión no configurada', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Tarea', true, false)]} // costos OK
|
||||
investment={0}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/payback aparece como inmediato/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 3: ambos issues coexisten — se muestran ambos mensajes en la misma sección', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // costo cero
|
||||
investment={0} // inversión cero
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Facturación')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('actividades no automatable no generan issue aunque tengan costos cero', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Manual', false, true)]} // no automatable
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
// ─── Caso 4: ROI inusualmente alto ────────────────────────────────────────
|
||||
|
||||
const makeRoi = (roiAnnualPercent: number): RoiResult => ({
|
||||
savingsPerExecution: 100,
|
||||
savingsPerExecutionPercent: 50,
|
||||
annualSavings: 1_200_000,
|
||||
paybackMonths: 0.5,
|
||||
paybackYears: 0.04,
|
||||
cumulativeSavingsHorizon: 3_550_000,
|
||||
breaksEvenInHorizon: true,
|
||||
roiAnnualPercent,
|
||||
})
|
||||
|
||||
it('Caso 4: ROI al 999% NO dispara el aviso de ROI inusual', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={50_000}
|
||||
roi={makeRoi(999)}
|
||||
/>
|
||||
)
|
||||
// No hay otros issues → el componente no renderiza (999% no supera el umbral)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('Caso 4: ROI al 1001% SÍ dispara el aviso de ROI inusual', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={50_000}
|
||||
roi={makeRoi(1001)}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument()
|
||||
// El texto incluye el valor formateado (>1000%)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/1\.001/)
|
||||
})
|
||||
|
||||
it('Caso 4: ROI Infinity (investment=0) NO dispara este aviso — lo cubre el caso 2', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={0} // caso 2 activo
|
||||
roi={makeRoi(Infinity)}
|
||||
/>
|
||||
)
|
||||
// El caso 2 (investment=0) sí aparece, pero NO el aviso de "ROI inusualmente alto"
|
||||
expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument()
|
||||
// El caso 2 sí está presente
|
||||
expect(container.firstChild).not.toBeNull()
|
||||
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 4: ROI negativo NO dispara el aviso de ROI inusual', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={50_000}
|
||||
roi={makeRoi(-50)}
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 4 coexiste con Caso 1: ROI alto Y actividad con costo=0 → ambos mensajes', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // costo=0
|
||||
investment={50_000} // no es cero
|
||||
roi={makeRoi(5000)} // > 1000 y finite
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Facturación')).toBeInTheDocument() // Caso 1
|
||||
expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument() // Caso 4
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TopSavingsChart ──────────────────────────────────────────────────────────
|
||||
|
||||
import { TopSavingsChart } from '@/features/report/TopSavingsChart'
|
||||
|
||||
describe('TopSavingsChart', () => {
|
||||
it('muestra empty state cuando no hay ahorros positivos', () => {
|
||||
// perActivityAutomated con costos mayores → ahorro negativo
|
||||
const actualCosts = [makeActivity('a1', 'Tarea', 100)]
|
||||
const autoCosts = [makeActivity('a1', 'Tarea', 200)]
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={actualCosts}
|
||||
perActivityAutomated={autoCosts}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Sin actividades con ahorro positivo/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('con ahorros positivos: renderiza el chart ECharts', () => {
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excluye actividades con ahorro cero o negativo del chart', () => {
|
||||
// a3 no tiene ahorro (costos iguales) → no debe aparecer en el chart
|
||||
// Solo a1 y a2 tienen ahorro positivo
|
||||
// Si renderiza chart → hay ≥1 actividad con ahorro > 0
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// Chart renderizado → hay actividades con ahorro positivo
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('con más de 5 actividades: sigue renderizando chart (top-5 filtrado internamente)', () => {
|
||||
const manyActual = Array.from({ length: 8 }, (_, i) =>
|
||||
makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 100)
|
||||
)
|
||||
const manyAuto = Array.from({ length: 8 }, (_, i) =>
|
||||
makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 10)
|
||||
)
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={manyActual}
|
||||
perActivityAutomated={manyAuto}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// No debe tirar error ni mostrar más de 5 en el chart (ECharts mockeado — no podemos inspeccionar option)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CumulativeSavingsChart ───────────────────────────────────────────────────
|
||||
|
||||
import { CumulativeSavingsChart } from '@/features/report/CumulativeSavingsChart'
|
||||
|
||||
describe('CumulativeSavingsChart', () => {
|
||||
it('renderiza el chart sin crash (payback dentro del horizonte)', () => {
|
||||
render(
|
||||
<CumulativeSavingsChart
|
||||
roi={mockRoi}
|
||||
horizonYears={3}
|
||||
currency="USD"
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renderiza el chart sin crash cuando payback = Infinity (sin ahorro)', () => {
|
||||
render(
|
||||
<CumulativeSavingsChart
|
||||
roi={roiNoSavings}
|
||||
horizonYears={3}
|
||||
currency="USD"
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renderiza el chart sin crash cuando investment = 0', () => {
|
||||
render(
|
||||
<CumulativeSavingsChart
|
||||
roi={roiZeroInvestment}
|
||||
horizonYears={3}
|
||||
currency="USD"
|
||||
investment={0}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ReportPage — comportamiento de tabs ──────────────────────────────────────
|
||||
|
||||
import { useReportData } from '@/features/report/useReportData'
|
||||
import { ReportPage } from '@/features/report/ReportPage'
|
||||
|
||||
const mockProcessBase = {
|
||||
id: 'test-proc-id', name: 'Proceso Test', clientName: 'Cliente',
|
||||
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
|
||||
}
|
||||
|
||||
const mockSimulationBase = {
|
||||
id: 'sim-1', processId: 'test-proc-id',
|
||||
executedAt: Date.now() - 600_000,
|
||||
result: mockResult,
|
||||
}
|
||||
|
||||
const automtableActivity: Activity = {
|
||||
id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud',
|
||||
type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5,
|
||||
}
|
||||
|
||||
const nonAutomatableActivity: Activity = {
|
||||
...automtableActivity, id: 'act-2', bpmnElementId: 'bpmn_a2', name: 'Procesar',
|
||||
automatable: false,
|
||||
}
|
||||
|
||||
describe('ReportPage — comportamiento de tabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sin resultAutomated (snapshot viejo): tabs "Automatizado" y "ROI" están disabled', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase }, // sin resultAutomated
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
|
||||
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
|
||||
expect(tabAuto).toBeDisabled()
|
||||
expect(tabRoi).toBeDisabled()
|
||||
})
|
||||
|
||||
it('con resultAutomated pero SIN actividades automatizables: tabs disabled', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [nonAutomatableActivity], // no hay automatable
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
|
||||
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
|
||||
expect(tabAuto).toBeDisabled()
|
||||
expect(tabRoi).toBeDisabled()
|
||||
})
|
||||
|
||||
it('con resultAutomated Y actividades automatizables: tabs habilitados', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity], // hay 1 automatable
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
|
||||
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
|
||||
expect(tabAuto).not.toBeDisabled()
|
||||
expect(tabRoi).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('tab "Actual" activo por defecto: muestra los 4 KPI cards del costo actual', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
// Tab "Actual" activo por defecto → 4 kpi-cards (no 5 roi-kpi-cards)
|
||||
expect(screen.getAllByTestId('kpi-card')).toHaveLength(4)
|
||||
expect(screen.queryByTestId('roi-kpi-card')).toBeNull()
|
||||
})
|
||||
|
||||
it('click en tab "Comparación + ROI": muestra 5 ROI KPI cards', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /comparación/i }))
|
||||
await waitFor(() => expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5))
|
||||
})
|
||||
|
||||
it('click en tab "Comparación + ROI": la tabla de comparación tiene N+1 rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /comparación/i }))
|
||||
// mockResult tiene 3 actividades → 3 data rows + 1 header
|
||||
await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(perActivityActual.length + 1))
|
||||
})
|
||||
|
||||
it('click en tab "Comparación + ROI" con investment=0: TransparencySection visible', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: { ...mockProcessBase, automationInvestment: 0 },
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /comparación/i }))
|
||||
await waitFor(() => expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('tab "Automatizado": muestra los 4 KPI cards del escenario automatizado', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: /automatizado/i }))
|
||||
// El tab automatizado también muestra 4 kpi-cards (del escenario automatizado)
|
||||
const kpiCards = screen.getAllByTestId('kpi-card')
|
||||
// Puede haber 4 del tab actual (ocultos) + 4 del tab automatizado = 8
|
||||
// O solo 4 si Radix desmonta el tab inactivo
|
||||
expect(kpiCards.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('tab "Automatizado": la nota de AutomationScenarioNote está presente', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /automatizado/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/actividades no marcadas como automatizables/i)).toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
})
|
||||
157
tests/features/workspace/ActivityPanel.test.tsx
Normal file
157
tests/features/workspace/ActivityPanel.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
131
tests/features/workspace/BpmnCanvas.test.tsx
Normal file
131
tests/features/workspace/BpmnCanvas.test.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
132
tests/features/workspace/GlobalSettingsPanel.test.tsx
Normal file
132
tests/features/workspace/GlobalSettingsPanel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user