Files
web-cad/frontend/tests/Components.test.tsx
T

384 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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> = {}): 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> = {}): 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(<RibbonBar activeTab="start" onTabChange={() => {}} 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(<RibbonBar activeTab="start" onTabChange={onTabChange} onAction={() => {}} />);
// 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(<RibbonBar activeTab="start" onTabChange={() => {}} 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(
<Topbar projectName="Test Project" savedStatus="Gespeichert" onUndo={() => {}} 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(
<Topbar projectName="P" savedStatus="" onUndo={onUndo} onRedo={() => {}} 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(
<Topbar projectName="P" savedStatus="" onUndo={() => {}} 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(
<Topbar projectName="P" savedStatus="" onUndo={() => {}} 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(<StatusBar {...defaultProps} />);
// 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(<StatusBar {...defaultProps} />);
expect(screen.getByText('Layer 1')).toBeInTheDocument();
expect(screen.getByText('line')).toBeInTheDocument();
});
it('should render online count', () => {
render(<StatusBar {...defaultProps} />);
// 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 {...defaultProps} onToggleSnap={onToggleSnap} />);
// 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(<StatusBar {...defaultProps} onToggleOrtho={onToggleOrtho} />);
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(
<LayerPanel
layers={layers}
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
activeLayerId="layer-1"
onSelectLayer={onSelectLayer}
onAddLayer={() => {}}
onToggleLayer={() => {}}
/>,
);
fireEvent.click(screen.getByText('Wände'));
expect(onSelectLayer).toHaveBeenCalledWith('layer-1');
});
it('should call onToggleLayer when toggling visibility', () => {
const onToggleLayer = vi.fn();
render(
<LayerPanel
layers={layers}
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
elements={elements}
selectedElementIds={['elem-2']}
activeLayerId="layer-1"
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
elements={elements}
activeLayerId="layer-1"
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
elements={elements}
groups={groups}
activeLayerId="layer-1"
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
elements={elements}
groups={groups}
activeLayerId="layer-1"
onSelectLayer={() => {}}
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(<PropertiesPanel selectedElement={null} layers={layers} onUpdateProperty={() => {}} />);
// 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(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
// 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(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
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(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={onUpdateProperty} />);
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);
});
});