/** * Component Tests – RibbonBar, Topbar, StatusBar, LayerPanel, PropertiesPanel * Testet Rendering, Button-Klicks und Callback-Aufrufe. */ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import RibbonBar from '../src/components/RibbonBar'; import Topbar from '../src/components/Topbar'; import StatusBar from '../src/components/StatusBar'; import LayerPanel from '../src/components/LayerPanel'; import PropertiesPanel from '../src/components/PropertiesPanel'; import type { CADElement, CADLayer } from '../src/types/cad.types'; // ─── Fixtures ──────────────────────────────────────── function makeLayer(overrides: Partial = {}): CADLayer { return { id: 'layer-1', name: 'Layer 1', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null, ...overrides, }; } function makeElement(overrides: Partial = {}): CADElement { return { id: 'elem-1', type: 'rect', layerId: 'layer-1', x: 10, y: 20, width: 100, height: 50, properties: {}, ...overrides, }; } // ─── RibbonBar ──────────────────────────────────────── describe('RibbonBar', () => { it('should render all tab buttons', () => { render( {}} onAction={() => {}} />); // Tabs are in a tablist - use role=tab const tabs = screen.getAllByRole('tab'); expect(tabs.length).toBeGreaterThanOrEqual(3); expect(screen.getByText('Start')).toBeInTheDocument(); // 'Einfügen' appears as tab AND action - use getAllByText const einfuegen = screen.getAllByText('Einfügen'); expect(einfuegen.length).toBeGreaterThanOrEqual(1); }); it('should call onTabChange when clicking a tab', () => { const onTabChange = vi.fn(); render( {}} />); // Click the 'Format' tab (unique text) fireEvent.click(screen.getByText('Format')); expect(onTabChange).toHaveBeenCalledWith('format'); }); it('should call onAction when clicking an action button', () => { const onAction = vi.fn(); render( {}} onAction={onAction} />); // Find action buttons by class name const actionBtns = document.querySelectorAll('.ribbon-btn'); if (actionBtns.length > 0) { fireEvent.click(actionBtns[0]); expect(onAction).toHaveBeenCalled(); } }); }); // ─── Topbar ─────────────────────────────────────────── describe('Topbar', () => { it('should render project name and saved status', () => { render( {}} onRedo={() => {}} onThemeToggle={() => {}} theme="dark" />, ); expect(screen.getByText('Test Project')).toBeInTheDocument(); expect(screen.getByText('Gespeichert')).toBeInTheDocument(); }); it('should call onUndo when clicking undo button', () => { const onUndo = vi.fn(); render( {}} onThemeToggle={() => {}} theme="dark" />, ); fireEvent.click(screen.getByLabelText(/Rückgängig/i)); expect(onUndo).toHaveBeenCalled(); }); it('should call onRedo when clicking redo button', () => { const onRedo = vi.fn(); render( {}} onRedo={onRedo} onThemeToggle={() => {}} theme="dark" />, ); fireEvent.click(screen.getByLabelText(/Wiederherstellen/i)); expect(onRedo).toHaveBeenCalled(); }); it('should call onThemeToggle when clicking theme button', () => { const onThemeToggle = vi.fn(); render( {}} onRedo={() => {}} onThemeToggle={onThemeToggle} theme="dark" />, ); fireEvent.click(screen.getByLabelText(/Hell.*Dunkel|Theme/i)); expect(onThemeToggle).toHaveBeenCalled(); }); }); // ─── StatusBar ──────────────────────────────────────── describe('StatusBar', () => { const defaultProps = { snapEnabled: true, orthoEnabled: false, polarEnabled: true, gridEnabled: false, cursorX: 123.456, cursorY: 78.9, activeLayer: 'Layer 1', activeTool: 'line', onlineCount: 3, onToggleSnap: vi.fn(), onToggleOrtho: vi.fn(), onTogglePolar: vi.fn(), onToggleGrid: vi.fn(), }; it('should render cursor coordinates', () => { render(); // Default unit is mm, scaleFactor=1: 123.456 → '123', 78.9 → '79' expect(screen.getByText(/123/)).toBeInTheDocument(); expect(screen.getByText(/79/)).toBeInTheDocument(); }); it('should render active layer and tool', () => { render(); expect(screen.getByText('Layer 1')).toBeInTheDocument(); expect(screen.getByText('line')).toBeInTheDocument(); }); it('should render online count', () => { render(); // Online count is in a div with title containing 'online' const onlineEl = screen.getByTitle(/online/i); expect(onlineEl).toBeInTheDocument(); expect(onlineEl.textContent).toContain('3'); }); it('should call onToggleSnap when clicking snap toggle', () => { const onToggleSnap = vi.fn(); render(); // StatusBar uses div with title attribute, not button with aria-label fireEvent.click(screen.getByTitle(/Snap-Modus/i)); expect(onToggleSnap).toHaveBeenCalled(); }); it('should call onToggleOrtho when clicking ortho toggle', () => { const onToggleOrtho = vi.fn(); render(); fireEvent.click(screen.getByTitle(/Ortho-Modus/i)); expect(onToggleOrtho).toHaveBeenCalled(); }); }); // ─── LayerPanel ────────────────────────────────────── describe('LayerPanel', () => { const layers = [ makeLayer({ id: 'layer-1', name: 'Wände' }), makeLayer({ id: 'layer-2', name: 'Türen', visible: false }), ]; it('should render layer names', () => { render( {}} onAddLayer={() => {}} onToggleLayer={() => {}} />, ); expect(screen.getByText('Wände')).toBeInTheDocument(); expect(screen.getByText('Türen')).toBeInTheDocument(); }); it('should call onAddLayer when clicking add button', () => { const onAddLayer = vi.fn(); render( {}} onAddLayer={onAddLayer} onToggleLayer={() => {}} />, ); // Add button has class 'add-layer-btn' const addBtn = document.querySelector('.add-layer-btn'); expect(addBtn).toBeTruthy(); fireEvent.click(addBtn!); expect(onAddLayer).toHaveBeenCalled(); }); it('should call onSelectLayer when clicking a layer', () => { const onSelectLayer = vi.fn(); render( {}} onToggleLayer={() => {}} />, ); fireEvent.click(screen.getByText('Wände')); expect(onSelectLayer).toHaveBeenCalledWith('layer-1'); }); it('should call onToggleLayer when toggling visibility', () => { const onToggleLayer = vi.fn(); render( {}} onAddLayer={() => {}} onToggleLayer={onToggleLayer} />, ); // Visibility toggle has aria-label 'Verstecken' (visible) or 'Anzeigen' (hidden) const toggleBtn = screen.getByLabelText('Verstecken'); fireEvent.click(toggleBtn); expect(onToggleLayer).toHaveBeenCalled(); }); // Helper: expand a tree node by clicking its toggle button function expandLayer(label: string) { const layerNode = screen.getByText(label).closest('.tree-node'); expect(layerNode).toBeTruthy(); const toggle = layerNode?.querySelector('.tree-toggle'); expect(toggle).toBeTruthy(); fireEvent.click(toggle!); } it('should highlight selected element in tree when selectedElementIds is provided', () => { const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; const elements = [ makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), makeElement({ id: 'elem-2', type: 'circle', layerId: 'layer-1' }), ]; const { container } = render( {}} onAddLayer={() => {}} onToggleLayer={() => {}} />, ); expandLayer('Layer 1'); // The element node for elem-2 should have the 'active' class const treeNodes = container.querySelectorAll('.tree-node'); const elemNode = Array.from(treeNodes).find((n) => n.textContent?.includes('Kreis')); expect(elemNode).toBeTruthy(); expect(elemNode?.classList.contains('active')).toBe(true); }); it('should call onSelectElement when clicking an element in the tree', () => { const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; const elements = [ makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), ]; const onSelectElement = vi.fn(); render( {}} onSelectElement={onSelectElement} onAddLayer={() => {}} onToggleLayer={() => {}} />, ); expandLayer('Layer 1'); fireEvent.click(screen.getByText('Rechteck')); expect(onSelectElement).toHaveBeenCalledWith('elem-1'); }); it('should render groups as tree nodes with their elements as children', () => { const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; const elements = [ makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), makeElement({ id: 'elem-2', type: 'circle', layerId: 'layer-1' }), ]; const groups = [ { id: 'group-1', name: 'Gruppe A', elementIds: ['elem-1', 'elem-2'], parentGroupId: null }, ]; render( {}} onAddLayer={() => {}} onToggleLayer={() => {}} />, ); expandLayer('Layer 1'); expect(screen.getByText('Gruppe A')).toBeInTheDocument(); }); it('should select all group elements when clicking a group node', () => { const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; const elements = [ makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), makeElement({ id: 'elem-2', type: 'circle', layerId: 'layer-1' }), ]; const groups = [ { id: 'group-1', name: 'Gruppe A', elementIds: ['elem-1', 'elem-2'], parentGroupId: null }, ]; const onSelectElement = vi.fn(); render( {}} onSelectElement={onSelectElement} onAddLayer={() => {}} onToggleLayer={() => {}} />, ); expandLayer('Layer 1'); fireEvent.click(screen.getByText('Gruppe A')); expect(onSelectElement).toHaveBeenCalledWith('elem-1'); }); }); // ─── PropertiesPanel ───────────────────────────────── describe('PropertiesPanel', () => { const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; const element = makeElement({ id: 'elem-1', type: 'rect', x: 10, y: 20, width: 100, height: 50 }); it('should show empty inputs when no element selected', () => { render( {}} />); // When no element is selected, input fields are empty expect(screen.getByLabelText('Position X')).toHaveDisplayValue(''); expect(screen.getByLabelText('Position Y')).toHaveDisplayValue(''); }); it('should display element coordinates in mm by default', () => { render( {}} />); // Default unit is mm with scaleFactor=1, so world units are displayed as mm expect(screen.getByLabelText('Position X')).toHaveDisplayValue('10'); expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('20'); }); it('should display element dimensions in mm by default', () => { render( {}} />); expect(screen.getByLabelText('Breite')).toHaveDisplayValue('100'); expect(screen.getByLabelText('Tiefe')).toHaveDisplayValue('50'); }); it('should call onUpdateProperty with world-unit number when changing a property input', () => { const onUpdateProperty = vi.fn(); render(); fireEvent.change(screen.getByLabelText('Position X'), { target: { value: '999' } }); // In mm mode with scaleFactor=1, input '999' → 999 world units expect(onUpdateProperty).toHaveBeenCalledWith('x', 999); }); });