import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DashboardEditor, validateDashboardLayouts, validateWidget, type EditorWidget } from '../../src/DashboardEditor'; afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); function layouts(x: number, y: number, w = 6, h = 4) { return { desktop: { x, y, w, h, visible: true }, tablet: { x: Math.min(x, 2), y, w: Math.min(w, 8), h, visible: true }, mobile: { x: 0, y, w: 1, h, visible: true }, wallboard: { x, y, w, h, visible: true }, }; } function widget(id: string, title: string, x: number, y: number, locked = false): EditorWidget { return { id, type: 'stat', title, data: { sourceType: 'inventory', limit: 10 }, visualization: { decimals: 0, thresholds: [] }, behavior: { locked, hidden: false, liveIntervalSeconds: 30 }, layouts: layouts(x, y) }; } const document = { schemaVersion: 2, id: '00000000-0000-4000-8000-000000000001', slug: 'test', name: 'Test', scope: 'personal', variables: [], widgets: [widget('00000000-0000-4000-8000-000000000011', 'CPU', 0, 0), widget('00000000-0000-4000-8000-000000000012', 'Geheugen', 10, 8)], settings: { defaultTimeRange: '1h' } }; describe('DashboardEditor spatial composition', () => { it('keeps viewport layouts independent and saves x/y/w/h, thresholds and an internal link', async () => { let savedBody: Record | undefined; vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { savedBody = JSON.parse(String(init?.body)); return new Response(JSON.stringify({ dashboard: { revision: 2 } }), { status: 200, headers: { 'Content-Type': 'application/json' } }); })); const onSaved = vi.fn(); const user = userEvent.setup(); render( undefined} onSaved={onSaved} />); await user.click(screen.getByRole('button', { name: 'Widget naar rechts: CPU' })); expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1); fireEvent.change(screen.getByLabelText('Hoogte in rijen'), { target: { value: '6' } }); await user.selectOptions(screen.getByLabelText('Layout'), 'tablet'); expect(screen.getByLabelText('Kolom (x) (Tablet)')).toHaveValue(0); fireEvent.change(screen.getByLabelText('Kolom (x) (Tablet)'), { target: { value: '2' } }); await user.selectOptions(screen.getByLabelText('Layout'), 'desktop'); expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1); await user.click(screen.getByRole('button', { name: 'Drempel toevoegen' })); const threshold = screen.getByLabelText('Drempels').querySelector('.config-threshold-row')!; fireEvent.change(within(threshold).getByLabelText('Waarde'), { target: { value: '85' } }); fireEvent.change(within(threshold).getByLabelText('Kleur'), { target: { value: '#ff8800' } }); await user.type(screen.getByLabelText('Interne drilldownlink'), '/incidents'); await user.click(screen.getByRole('button', { name: 'Opslaan' })); await waitFor(() => expect(onSaved).toHaveBeenCalled()); const widgets = savedBody?.widgets as EditorWidget[]; expect((widgets[0].layouts?.desktop as Record)).toMatchObject({ x: 1, y: 0, w: 6, h: 6 }); expect((widgets[0].layouts?.tablet as Record)).toMatchObject({ x: 2, y: 0, w: 6, h: 4 }); expect(widgets[0].visualization?.thresholds).toEqual([{ value: 85, color: '#ff8800', label: '' }]); expect(widgets[0].behavior?.link).toBe('/incidents'); }, 15_000); it('prevents every spatial control from mutating a locked widget', () => { const lockedDocument = { ...document, widgets: [widget('00000000-0000-4000-8000-000000000021', 'Vergrendeld', 2, 3, true)] }; render( undefined} onSaved={() => undefined} />); expect(screen.getByRole('button', { name: 'Widget naar links: Vergrendeld' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Widget omhoog: Vergrendeld' })).toBeDisabled(); expect(screen.getByRole('slider', { name: 'Breedte aanpassen: Vergrendeld' })).toBeDisabled(); expect(screen.getByLabelText('Kolom (x) (Desktop)')).toBeDisabled(); expect(screen.getByLabelText('Hoogte in rijen')).toBeDisabled(); }); it('rejects unsafe links, malformed thresholds and colliding persisted layouts', () => { expect(validateWidget({ ...widget('x', 'Onveilig', 0, 0), visualization: { thresholds: [{ value: 80, color: 'red' }] }, behavior: { link: 'https://example.test', liveIntervalSeconds: 30 } })).toMatchObject({ 'visualization.thresholds': expect.any(String), 'behavior.link': expect.any(String) }); expect(validateDashboardLayouts([widget('a', 'A', 0, 0), widget('b', 'B', 0, 0)])).toBe(false); }); });