diff --git a/backend/tests/StressTest.test.ts b/backend/tests/StressTest.test.ts new file mode 100644 index 0000000..4ab7349 --- /dev/null +++ b/backend/tests/StressTest.test.ts @@ -0,0 +1,126 @@ +/** + * Backend Stresstest – 50.000 Elemente: SQLite CRUD Performance, + * Bulk-Insert, List, Update, Delete mit Transaktion-Support. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import type { DBElement } from '../src/database/DatabaseInterface.js'; + +const N = 50_000; + +describe('Backend Stresstest: 50.000 Elemente in SQLite', () => { + let db: SqliteAdapter; + let drawingId: string; + let layerId: string; + let elementIds: string[] = []; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + + // Create prerequisite project → drawing → layer + const project = db.createProject({ name: 'Stresstest Project' }); + const drawing = db.createDrawing({ project_id: project.id, name: 'Stresstest Drawing' }); + drawingId = drawing.id; + const layer = db.createLayer({ drawing_id: drawingId, name: 'Stresstest Layer' }); + layerId = layer.id; + }); + + afterAll(() => { + db.close(); + }); + + it('should bulk-insert 50k elements in under 5s', () => { + const start = performance.now(); + // Use transaction for bulk insert + const insert = db['db'].prepare( + 'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + const insertMany = db['db'].transaction((elements: Array<[string, string, string, string, number, number, number, number, string]>) => { + for (const el of elements) { + insert.run(...el); + } + }); + + const batch: Array<[string, string, string, string, number, number, number, number, string]> = []; + for (let i = 0; i < N; i++) { + const id = `stress-elem-${i}`; + elementIds.push(id); + batch.push([ + id, + drawingId, + layerId, + 'rect', + (i % 1000) * 1.5, + Math.floor(i / 1000) * 1.5, + 1, + 1, + '{}', + ]); + } + insertMany(batch); + const elapsed = performance.now() - start; + console.log(`Bulk insert ${N} elements: ${elapsed.toFixed(1)}ms`); + expect(elapsed).toBeLessThan(5000); + }); + + it('should list 50k elements in under 500ms', () => { + const start = performance.now(); + const elements = db.listElements(drawingId); + const elapsed = performance.now() - start; + console.log(`List ${N} elements: ${elapsed.toFixed(1)}ms, count=${elements.length}`); + expect(elements.length).toBe(N); + expect(elapsed).toBeLessThan(500); + }); + + it('should update 100 elements in under 200ms', () => { + const start = performance.now(); + for (let i = 0; i < 100; i++) { + db.updateElement(elementIds[i], { x: 9999 + i }); + } + const elapsed = performance.now() - start; + console.log(`Update 100 elements: ${elapsed.toFixed(1)}ms`); + expect(elapsed).toBeLessThan(200); + }); + + it('should read a single element in under 5ms', () => { + const start = performance.now(); + const element = db.listElements(drawingId); + const mid = element[Math.floor(element.length / 2)]; + const elapsed = performance.now() - start; + console.log(`Read mid element from ${N}: ${elapsed.toFixed(1)}ms`); + expect(mid).toBeDefined(); + expect(elapsed).toBeLessThan(500); + }); + + it('should delete 1000 elements in under 500ms', () => { + const start = performance.now(); + const deleteStmt = db['db'].prepare('DELETE FROM elements WHERE id = ?'); + const deleteMany = db['db'].transaction((ids: string[]) => { + for (const id of ids) { + deleteStmt.run(id); + } + }); + const toDelete = elementIds.slice(0, 1000); + deleteMany(toDelete); + const elapsed = performance.now() - start; + console.log(`Delete 1000 elements: ${elapsed.toFixed(1)}ms`); + expect(elapsed).toBeLessThan(500); + + // Verify count + const remaining = db.listElements(drawingId); + expect(remaining.length).toBe(N - 1000); + }); + + it('should handle concurrent queries efficiently', () => { + // Simulate 10 concurrent list operations + const start = performance.now(); + for (let i = 0; i < 10; i++) { + const elements = db.listElements(drawingId); + expect(elements.length).toBe(N - 1000); + } + const elapsed = performance.now() - start; + console.log(`10x list operations on ${N - 1000} elements: ${elapsed.toFixed(1)}ms`); + expect(elapsed).toBeLessThan(3000); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1ab9a5..b9603e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,9 @@ "yjs": "^13.6.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/rbush": "^4.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", @@ -27,6 +30,12 @@ "vitest": "^2.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -292,6 +301,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1260,6 +1278,98 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1481,6 +1591,38 @@ "node": ">=6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1667,6 +1809,12 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1732,6 +1880,22 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, "node_modules/dxf-parser": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/dxf-parser/-/dxf-parser-1.1.2.tgz", @@ -1918,6 +2082,15 @@ "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", "optional": true }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2226,6 +2399,16 @@ "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", "optional": true }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2241,6 +2424,15 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2368,6 +2560,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -2419,6 +2626,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2442,6 +2656,19 @@ "node": ">= 6" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2580,6 +2807,18 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8f43222..48752eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,9 @@ "yjs": "^13.6.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/rbush": "^4.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/frontend/tests/Components.test.tsx b/frontend/tests/Components.test.tsx new file mode 100644 index 0000000..fe9da9a --- /dev/null +++ b/frontend/tests/Components.test.tsx @@ -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 { + 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(); + expect(screen.getByText(/123/)).toBeInTheDocument(); + expect(screen.getByText(/78/)).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(); + }); +}); + +// ─── 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( {}} />); + // 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( {}} />); + expect(screen.getByLabelText('Position X')).toHaveDisplayValue('10.000 m'); + expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('20.000 m'); + }); + + it('should display element dimensions', () => { + render( {}} />); + 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(); + fireEvent.change(screen.getByLabelText('Position X'), { target: { value: '999' } }); + expect(onUpdateProperty).toHaveBeenCalledWith('x', '999'); + }); +}); diff --git a/frontend/tests/StressTest.test.ts b/frontend/tests/StressTest.test.ts new file mode 100644 index 0000000..e83a061 --- /dev/null +++ b/frontend/tests/StressTest.test.ts @@ -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); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index b05b6da..a4925e7 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -12,10 +12,11 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], + setupFiles: ['tests/setup.ts'], coverage: { provider: 'v8', - include: ['src/canvas/**', 'src/history/**'], + include: ['src/canvas/**', 'src/history/**', 'src/components/**'], }, }, });