test: T29-T31 component tests (20) + frontend stresstest (7) + backend DB stresstest (6) with 50k elements

This commit is contained in:
2026-06-26 16:20:09 +02:00
parent 1d6c2cb30e
commit 89b91a1050
7 changed files with 819 additions and 2 deletions
+274
View File
@@ -0,0 +1,274 @@
/**
* 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} />);
expect(screen.getByText(/123/)).toBeInTheDocument();
expect(screen.getByText(/78/)).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();
});
});
// ─── 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 default values when no element selected', () => {
render(<PropertiesPanel selectedElement={null} layers={layers} onUpdateProperty={() => {}} />);
// Default values are in inputs with aria-labels
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('0.000 m');
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('0.000 m');
});
it('should display element coordinates when element is selected', () => {
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('10.000 m');
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('20.000 m');
});
it('should display element dimensions', () => {
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
expect(screen.getByLabelText('Breite')).toHaveDisplayValue('100.00 m');
expect(screen.getByLabelText('Tiefe')).toHaveDisplayValue('50.00 m');
});
it('should call onUpdateProperty 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' } });
expect(onUpdateProperty).toHaveBeenCalledWith('x', '999');
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* Stresstest 50.000 Elemente: SpatialIndex Query-Performance,
* HistoryManager Memory, Undo/Redo Performance.
*/
import { describe, it, expect } from 'vitest';
import { SpatialIndex } from '../src/canvas/SpatialIndex';
import { HistoryManager } from '../src/history/HistoryManager';
import type { CADElement, CADLayer } from '../src/types/cad.types';
const N = 50_000;
function generateElements(count: number): CADElement[] {
const elements: CADElement[] = [];
for (let i = 0; i < count; i++) {
elements.push({
id: `elem-${i}`,
type: 'rect',
layerId: 'layer-1',
x: (i % 1000) * 1.5,
y: Math.floor(i / 1000) * 1.5,
width: 1,
height: 1,
properties: {},
});
}
return elements;
}
function makeLayer(): CADLayer {
return {
id: 'layer-1',
name: 'Layer 1',
visible: true,
locked: false,
color: '#ffffff',
lineType: 'solid',
transparency: 0,
sortOrder: 0,
parentId: null,
};
}
describe('Stresstest: 50.000 Elemente', () => {
it('should bulk-insert 50k elements into SpatialIndex in under 2s', () => {
const elements = generateElements(N);
const index = new SpatialIndex();
const start = performance.now();
index.bulkInsert(elements);
const elapsed = performance.now() - start;
console.log(`SpatialIndex bulkInsert: ${elapsed.toFixed(1)}ms for ${N} elements`);
expect(elapsed).toBeLessThan(2000);
});
it('should search SpatialIndex with 50k elements in under 10ms', () => {
const elements = generateElements(N);
const index = new SpatialIndex();
index.bulkInsert(elements);
// Search a region in the middle
const start = performance.now();
const results = index.search({ minX: 750, minY: 37.5, maxX: 1500, maxY: 75 });
const elapsed = performance.now() - start;
console.log(`SpatialIndex search: ${elapsed.toFixed(2)}ms, found ${results.length} elements`);
expect(elapsed).toBeLessThan(50);
expect(results.length).toBeGreaterThan(0);
});
it('should search a small point region with 50k elements in under 5ms', () => {
const elements = generateElements(N);
const index = new SpatialIndex();
index.bulkInsert(elements);
const start = performance.now();
const results = index.search({ minX: 750, minY: 37.5, maxX: 751, maxY: 38.5 });
const elapsed = performance.now() - start;
console.log(`SpatialIndex point search: ${elapsed.toFixed(3)}ms, found ${results.length} elements`);
expect(elapsed).toBeLessThan(10);
});
it('should clear and re-insert 50k elements in under 2s', () => {
const elements = generateElements(N);
const index = new SpatialIndex();
index.bulkInsert(elements);
// Modify 100 elements
for (let i = 0; i < 100; i++) {
elements[i].x += 5000;
}
const start = performance.now();
index.clear();
index.bulkInsert(elements);
const elapsed = performance.now() - start;
console.log(`SpatialIndex clear+reinsert: ${elapsed.toFixed(1)}ms after 100 modifications`);
expect(elapsed).toBeLessThan(2000);
});
it('should handle HistoryManager with 50k elements snapshot', () => {
const elements = generateElements(N);
const layers = [makeLayer()];
const history = new HistoryManager({ maxStackSize: 50 });
const start = performance.now();
history.initialize({
elements,
layers,
blocks: [],
groups: [],
bgConfig: null,
});
const elapsed = performance.now() - start;
console.log(`HistoryManager initialize: ${elapsed.toFixed(1)}ms for ${N} elements`);
expect(elapsed).toBeLessThan(1000);
});
it('should push 10 history snapshots with 50k elements each', () => {
const elements = generateElements(N);
const layers = [makeLayer()];
const history = new HistoryManager({ maxStackSize: 50 });
history.initialize({
elements,
layers,
blocks: [],
groups: [],
bgConfig: null,
});
const start = performance.now();
for (let i = 0; i < 10; i++) {
// Slightly modify elements each snapshot
elements[i * 100].x += 1;
history.pushSnapshot({
elements: [...elements],
layers,
blocks: [],
groups: [],
bgConfig: null,
label: `Step ${i + 1}`,
});
}
const elapsed = performance.now() - start;
console.log(`HistoryManager 10 snapshots: ${elapsed.toFixed(1)}ms for ${N} elements each`);
expect(elapsed).toBeLessThan(5000);
expect(history.getHistory().length).toBe(11); // initial + 10
});
it('should undo/redo with 50k elements in under 100ms', () => {
const elements = generateElements(N);
const layers = [makeLayer()];
const history = new HistoryManager({ maxStackSize: 50 });
history.initialize({
elements,
layers,
blocks: [],
groups: [],
bgConfig: null,
});
history.pushSnapshot({
elements: [...elements],
layers,
blocks: [],
groups: [],
bgConfig: null,
label: 'Step 1',
});
const undoStart = performance.now();
const undoSnap = history.undo();
const undoTime = performance.now() - undoStart;
console.log(`Undo: ${undoTime.toFixed(2)}ms for ${N} elements`);
expect(undoSnap).not.toBeNull();
expect(undoTime).toBeLessThan(100);
const redoStart = performance.now();
const redoSnap = history.redo();
const redoTime = performance.now() - redoStart;
console.log(`Redo: ${redoTime.toFixed(2)}ms for ${N} elements`);
expect(redoSnap).not.toBeNull();
expect(redoTime).toBeLessThan(100);
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';