From f853372c901b7a36f495c86919e351dfd83e5851 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Fri, 26 Jun 2026 14:19:50 +0200 Subject: [PATCH] test: add Vitest setup with 115 tests (backend + frontend) --- backend/tests/auth.test.ts | 269 ++++++++++++++ backend/tests/health.test.ts | 45 +++ backend/tests/projects.test.ts | 176 +++++++++ backend/vitest.config.ts | 14 + frontend/package-lock.json | 495 ++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/tests/HistoryManager.test.ts | 388 ++++++++++++++++++++ frontend/tests/LayerManager.test.ts | 346 ++++++++++++++++++ frontend/tests/SpatialIndex.test.ts | 153 ++++++++ frontend/vitest.config.ts | 21 ++ test_report.md | 196 +++++++--- 11 files changed, 2062 insertions(+), 42 deletions(-) create mode 100644 backend/tests/auth.test.ts create mode 100644 backend/tests/health.test.ts create mode 100644 backend/tests/projects.test.ts create mode 100644 backend/vitest.config.ts create mode 100644 frontend/tests/HistoryManager.test.ts create mode 100644 frontend/tests/LayerManager.test.ts create mode 100644 frontend/tests/SpatialIndex.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts new file mode 100644 index 0000000..b00f094 --- /dev/null +++ b/backend/tests/auth.test.ts @@ -0,0 +1,269 @@ +/** + * Auth API Tests – Register / Login / Logout / Me / Password Change + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { createServer } from '../src/server.js'; + +describe('Auth API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let authToken: string | null = null; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Register ──────────────────────────────────────── + + describe('POST /api/auth/register', () => { + it('should register a new user with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: 'testuser@example.com', + password: 'TestPass123!', + name: 'Test User', + role: 'planer', + }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.user).toBeDefined(); + expect(body.user.email).toBe('testuser@example.com'); + expect(body.user.name).toBe('Test User'); + expect(body.user.password_hash).toBeUndefined(); + expect(body.session).toBeDefined(); + expect(body.session.token).toBeDefined(); + authToken = body.session.token; + }); + + it('should reject duplicate registration with 409', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: 'testuser@example.com', + password: 'TestPass123!', + name: 'Test User 2', + }, + }); + expect(response.statusCode).toBe(409); + const body = JSON.parse(response.body); + expect(body.error).toContain('already registered'); + }); + + it('should reject missing fields with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: 'incomplete@example.com' }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + // ─── Login ────────────────────────────────────────── + + describe('POST /api/auth/login', () => { + it('should login with correct credentials', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'testuser@example.com', + password: 'TestPass123!', + }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.user).toBeDefined(); + expect(body.session.token).toBeDefined(); + authToken = body.session.token; + }); + + it('should reject wrong password with 401', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'testuser@example.com', + password: 'WrongPassword!', + }, + }); + expect(response.statusCode).toBe(401); + }); + + it('should reject non-existent email with 401', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'nobody@example.com', + password: 'SomePassword!', + }, + }); + expect(response.statusCode).toBe(401); + }); + + it('should reject missing fields with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'testuser@example.com' }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + // ─── Me ───────────────────────────────────────────── + + describe('GET /api/auth/me', () => { + it('should return current user profile with valid token', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.email).toBe('testuser@example.com'); + expect(body.password_hash).toBeUndefined(); + }); + + it('should reject without token with 401', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + }); + expect(response.statusCode).toBe(401); + }); + + it('should reject invalid token with 401', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + headers: { authorization: 'Bearer invalid-token-xxx' }, + }); + expect(response.statusCode).toBe(401); + }); + }); + + // ─── Password Change ─────────────────────────────── + + describe('PATCH /api/auth/password', () => { + it('should change password with correct old password', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/auth/password', + headers: { authorization: `Bearer ${authToken}` }, + payload: { + oldPassword: 'TestPass123!', + newPassword: 'NewPassword456!', + }, + }); + expect(response.statusCode).toBe(204); + }); + + it('should allow login with new password', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'testuser@example.com', + password: 'NewPassword456!', + }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.session.token).toBeDefined(); + authToken = body.session.token; + }); + + it('should reject old password after change', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'testuser@example.com', + password: 'TestPass123!', + }, + }); + expect(response.statusCode).toBe(401); + }); + + it('should reject password change with wrong old password', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/auth/password', + headers: { authorization: `Bearer ${authToken}` }, + payload: { + oldPassword: 'WrongOldPassword!', + newPassword: 'AnotherNew789!', + }, + }); + expect(response.statusCode).toBe(400); + }); + + it('should reject password change without auth', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/auth/password', + payload: { + oldPassword: 'NewPassword456!', + newPassword: 'AnotherNew789!', + }, + }); + expect(response.statusCode).toBe(401); + }); + + it('should reject missing password fields', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/auth/password', + headers: { authorization: `Bearer ${authToken}` }, + payload: { oldPassword: 'NewPassword456!' }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + // ─── Logout ───────────────────────────────────────── + + describe('POST /api/auth/logout', () => { + it('should logout and invalidate token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/logout', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(204); + + // Token should now be invalid + const meResponse = await app.inject({ + method: 'GET', + url: '/api/auth/me', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(meResponse.statusCode).toBe(401); + }); + + it('should return 204 even without token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/logout', + }); + expect(response.statusCode).toBe(204); + }); + }); +}); diff --git a/backend/tests/health.test.ts b/backend/tests/health.test.ts new file mode 100644 index 0000000..b822a6c --- /dev/null +++ b/backend/tests/health.test.ts @@ -0,0 +1,45 @@ +/** + * Health Endpoint Tests + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { createServer } from '../src/server.js'; + +describe('Health Endpoint', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + it('GET /api/health should return ok status', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/health', + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.status).toBe('ok'); + expect(body.timestamp).toBeDefined(); + }); + + it('GET /api/health should return valid ISO timestamp', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/health', + }); + const body = JSON.parse(response.body); + const date = new Date(body.timestamp); + expect(date.toString()).not.toBe('Invalid Date'); + }); +}); diff --git a/backend/tests/projects.test.ts b/backend/tests/projects.test.ts new file mode 100644 index 0000000..e39e59f --- /dev/null +++ b/backend/tests/projects.test.ts @@ -0,0 +1,176 @@ +/** + * Projects API Tests – CRUD (List / Get / Create / Update / Delete) + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { createServer } from '../src/server.js'; + +describe('Projects API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let createdProjectId: string | null = null; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Create ───────────────────────────────────────── + + describe('POST /api/projects', () => { + it('should create a project with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { + name: 'Test Project', + description: 'A test project', + }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.id).toBeDefined(); + expect(body.name).toBe('Test Project'); + expect(body.description).toBe('A test project'); + createdProjectId = body.id; + }); + + it('should reject project without name with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { description: 'No name' }, + }); + expect(response.statusCode).toBe(400); + }); + + it('should create project with default values', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Minimal Project' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.name).toBe('Minimal Project'); + expect(body.description).toBeNull(); + }); + }); + + // ─── List ─────────────────────────────────────────── + + describe('GET /api/projects', () => { + it('should list all projects', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/projects', + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + }); + }); + + // ─── Get ──────────────────────────────────────────── + + describe('GET /api/projects/:id', () => { + it('should get a single project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/projects/${createdProjectId}`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(createdProjectId); + expect(body.name).toBe('Test Project'); + }); + + it('should return 404 for non-existent project', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/projects/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Update ───────────────────────────────────────── + + describe('PATCH /api/projects/:id', () => { + it('should update project name', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/projects/${createdProjectId}`, + payload: { name: 'Updated Project Name' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('Updated Project Name'); + expect(body.id).toBe(createdProjectId); + }); + + it('should update project description', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/projects/${createdProjectId}`, + payload: { description: 'Updated description' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.description).toBe('Updated description'); + }); + + it('should return 404 for non-existent project update', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/projects/non-existent-id', + payload: { name: 'New Name' }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Delete ───────────────────────────────────────── + + describe('DELETE /api/projects/:id', () => { + it('should delete a project', async () => { + // First create a project to delete + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'To Be Deleted' }, + }); + const projectId = JSON.parse(createRes.body).id; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/projects/${projectId}`, + }); + expect(response.statusCode).toBe(204); + + // Verify it's gone + const getRes = await app.inject({ + method: 'GET', + url: `/api/projects/${projectId}`, + }); + expect(getRes.statusCode).toBe(404); + }); + + it('should return 404 for non-existent project delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/projects/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..49b1d59 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/types/**', 'src/websocket/**'], + }, + }, +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index af3803a..e1ab9a5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,11 +21,59 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^29.1.1", "typescript": "^5.5.0", "vite": "^5.4.0", "vitest": "^2.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -289,6 +337,152 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -657,6 +851,23 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1317,6 +1528,15 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -1434,12 +1654,38 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1457,6 +1703,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1510,6 +1762,18 @@ "node": ">=6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/errno": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", @@ -1616,6 +1880,18 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1648,6 +1924,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "optional": true }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", @@ -1662,6 +1944,55 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1904,6 +2235,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1959,6 +2296,18 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2025,6 +2374,15 @@ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", "optional": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", @@ -2084,6 +2442,15 @@ "node": ">= 6" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2148,6 +2515,18 @@ ], "optional": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2201,6 +2580,12 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2240,6 +2625,48 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "dev": true, + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -2258,6 +2685,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2440,6 +2876,50 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2465,6 +2945,21 @@ "async-limiter": "~1.0.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 949ee54..8f43222 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^29.1.1", "typescript": "^5.5.0", "vite": "^5.4.0", "vitest": "^2.0.0" diff --git a/frontend/tests/HistoryManager.test.ts b/frontend/tests/HistoryManager.test.ts new file mode 100644 index 0000000..1d03df4 --- /dev/null +++ b/frontend/tests/HistoryManager.test.ts @@ -0,0 +1,388 @@ +/** + * HistoryManager Tests – Undo/Redo + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { HistoryManager } from '../src/history/HistoryManager'; +import type { CADStateSnapshot } from '../src/history/HistoryManager'; +import type { CADElement, CADLayer, BlockDefinition } from '../src/types/cad.types'; +import type { ElementGroup } from '../src/tools/modification/GroupTool'; +import type { BackgroundConfig } from '../src/services/backgroundService'; + +function makeSnapshot(label: string, suffix: string = ''): Omit { + const elements: CADElement[] = [ + { id: `el-${suffix}-1`, type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 10, height: 10, properties: {} }, + { id: `el-${suffix}-2`, type: 'circle', layerId: 'layer-1', x: 50, y: 50, width: 20, height: 20, properties: {} }, + ]; + const layers: CADLayer[] = [ + { id: 'layer-1', name: 'Layer 1', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, + ]; + const blocks: BlockDefinition[] = []; + const groups: ElementGroup[] = []; + const bgConfig: BackgroundConfig | null = null; + return { elements, layers, blocks, groups, bgConfig }; +} + +describe('HistoryManager', () => { + let hm: HistoryManager; + + beforeEach(() => { + hm = new HistoryManager(); + }); + + describe('initialize', () => { + it('should set initial state without undo history', () => { + const snap = makeSnapshot('Initial'); + hm.initialize(snap); + expect(hm.getCurrentState()).not.toBeNull(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + expect(hm.canUndo()).toBe(false); + expect(hm.canRedo()).toBe(false); + }); + + it('should reset undo and redo stacks on initialize', () => { + const snap = makeSnapshot('Initial'); + hm.initialize(snap); + hm.pushSnapshot(makeSnapshot('Change 1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2'), 'Change 2'); + + // Re-initialize should clear history + hm.initialize(snap); + expect(hm.canUndo()).toBe(false); + expect(hm.canRedo()).toBe(false); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(0); + }); + }); + + describe('pushSnapshot', () => { + it('should push current state to undo stack and set new state', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + + expect(hm.getCurrentState()?.label).toBe('Change 1'); + expect(hm.canUndo()).toBe(true); + expect(hm.getUndoCount()).toBe(1); + }); + + it('should clear redo stack on new push', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); // Now redo stack has 1 entry + expect(hm.canRedo()).toBe(true); + + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + expect(hm.canRedo()).toBe(false); + expect(hm.getRedoCount()).toBe(0); + }); + + it('should store multiple snapshots', () => { + hm.initialize(makeSnapshot('Initial')); + for (let i = 1; i <= 5; i++) { + hm.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); + } + expect(hm.getUndoCount()).toBe(5); + expect(hm.getCurrentState()?.label).toBe('Change 5'); + }); + }); + + describe('undo', () => { + it('should restore previous state', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + + const prev = hm.undo(); + expect(prev).not.toBeNull(); + expect(prev?.label).toBe('Initial'); + expect(hm.getCurrentState()?.label).toBe('Initial'); + }); + + it('should return null when no undo available', () => { + hm.initialize(makeSnapshot('Initial')); + const result = hm.undo(); + expect(result).toBeNull(); + }); + + it('should move current state to redo stack', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + + expect(hm.canRedo()).toBe(true); + expect(hm.getRedoCount()).toBe(1); + }); + + it('should handle multiple undos', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); + + hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Change 2'); + hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Change 1'); + hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + expect(hm.canUndo()).toBe(false); + }); + }); + + describe('redo', () => { + it('should restore next state after undo', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + + const next = hm.redo(); + expect(next).not.toBeNull(); + expect(next?.label).toBe('Change 1'); + expect(hm.getCurrentState()?.label).toBe('Change 1'); + }); + + it('should return null when no redo available', () => { + hm.initialize(makeSnapshot('Initial')); + const result = hm.redo(); + expect(result).toBeNull(); + }); + + it('should move current state back to undo stack', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + hm.redo(); + + expect(hm.canUndo()).toBe(true); + expect(hm.getUndoCount()).toBe(1); + }); + + it('should handle multiple redos', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); + + // Undo all 3 + hm.undo(); hm.undo(); hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + + // Redo all 3 + hm.redo(); + expect(hm.getCurrentState()?.label).toBe('Change 1'); + hm.redo(); + expect(hm.getCurrentState()?.label).toBe('Change 2'); + hm.redo(); + expect(hm.getCurrentState()?.label).toBe('Change 3'); + expect(hm.canRedo()).toBe(false); + }); + }); + + describe('canUndo & canRedo', () => { + it('canUndo should be false initially', () => { + hm.initialize(makeSnapshot('Initial')); + expect(hm.canUndo()).toBe(false); + }); + + it('canUndo should be true after push', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + expect(hm.canUndo()).toBe(true); + }); + + it('canRedo should be false initially', () => { + hm.initialize(makeSnapshot('Initial')); + expect(hm.canRedo()).toBe(false); + }); + + it('canRedo should be true after undo', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + expect(hm.canRedo()).toBe(true); + }); + }); + + describe('getHistory', () => { + it('should return history entries with current marked', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + + const history = hm.getHistory(); + expect(history.length).toBe(3); // 2 undo + 1 current + const currentEntries = history.filter(e => e.isCurrent); + expect(currentEntries.length).toBe(1); + expect(currentEntries[0].label).toBe('Change 2'); + }); + + it('should include redo entries after undo', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + + const history = hm.getHistory(); + // 0 undo + 1 current (Initial) + 1 redo (Change 1) + expect(history.length).toBe(2); + const currentEntries = history.filter(e => e.isCurrent); + expect(currentEntries.length).toBe(1); + expect(currentEntries[0].label).toBe('Initial'); + }); + + it('should return empty-ish history for fresh manager', () => { + const history = hm.getHistory(); + expect(history.length).toBe(0); + }); + }); + + describe('jumpTo', () => { + it('should jump to a specific undo entry', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); + + // Jump to undo-0 (Initial state) + const result = hm.jumpTo('undo-0'); + expect(result).not.toBeNull(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + }); + + it('should jump to current', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + + const result = hm.jumpTo('current'); + expect(result).not.toBeNull(); + expect(result?.label).toBe('Change 1'); + }); + + it('should jump to a redo entry', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.undo(); + hm.undo(); + + // Jump to redo-1 (Change 2) + const result = hm.jumpTo('redo-1'); + expect(result).not.toBeNull(); + expect(hm.getCurrentState()?.label).toBe('Change 2'); + }); + + it('should return null for unknown entry id', () => { + hm.initialize(makeSnapshot('Initial')); + const result = hm.jumpTo('unknown-id'); + expect(result).toBeNull(); + }); + }); + + describe('clear', () => { + it('should clear all history', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + + hm.clear(); + expect(hm.getCurrentState()).toBeNull(); + expect(hm.canUndo()).toBe(false); + expect(hm.canRedo()).toBe(false); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(0); + }); + }); + + describe('subscribe', () => { + it('should notify listeners on state change', () => { + let callCount = 0; + const unsubscribe = hm.subscribe(() => callCount++); + + hm.initialize(makeSnapshot('Initial')); + expect(callCount).toBe(1); + + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + expect(callCount).toBe(2); + + hm.undo(); + expect(callCount).toBe(3); + + unsubscribe(); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + expect(callCount).toBe(3); // No new calls after unsubscribe + }); + + it('should notify on clear', () => { + let callCount = 0; + hm.subscribe(() => callCount++); + hm.initialize(makeSnapshot('Initial')); + hm.clear(); + expect(callCount).toBe(2); // init + clear + }); + }); + + describe('maxStackSize', () => { + it('should limit undo stack size', () => { + const smallHM = new HistoryManager({ maxStackSize: 3 }); + smallHM.initialize(makeSnapshot('Initial')); + + for (let i = 1; i <= 10; i++) { + smallHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); + } + + expect(smallHM.getUndoCount()).toBe(3); + }); + + it('should use default max size of 100', () => { + const defaultHM = new HistoryManager(); + defaultHM.initialize(makeSnapshot('Initial')); + + for (let i = 1; i <= 150; i++) { + defaultHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); + } + + expect(defaultHM.getUndoCount()).toBe(100); + }); + }); + + describe('getUndoCount & getRedoCount', () => { + it('should track counts correctly', () => { + hm.initialize(makeSnapshot('Initial')); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(0); + + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + expect(hm.getUndoCount()).toBe(1); + expect(hm.getRedoCount()).toBe(0); + + hm.undo(); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(1); + + hm.redo(); + expect(hm.getUndoCount()).toBe(1); + expect(hm.getRedoCount()).toBe(0); + }); + }); + + describe('snapshot data integrity', () => { + it('should preserve elements in snapshots', () => { + const snap = makeSnapshot('Test'); + hm.initialize(snap); + const current = hm.getCurrentState(); + expect(current?.elements.length).toBe(2); + expect(current?.elements[0].id).toBe('el--1'); + }); + + it('should preserve different element sets across snapshots', () => { + hm.initialize(makeSnapshot('Initial')); + const snap1 = makeSnapshot('Change 1', 'v1'); + hm.pushSnapshot(snap1, 'Change 1'); + + hm.undo(); + const afterUndo = hm.getCurrentState(); + expect(afterUndo?.elements[0].id).toBe('el--1'); // Initial state + + hm.redo(); + const afterRedo = hm.getCurrentState(); + expect(afterRedo?.elements[0].id).toBe('el-v1-1'); // Change 1 state + }); + }); +}); diff --git a/frontend/tests/LayerManager.test.ts b/frontend/tests/LayerManager.test.ts new file mode 100644 index 0000000..f338afb --- /dev/null +++ b/frontend/tests/LayerManager.test.ts @@ -0,0 +1,346 @@ +/** + * LayerManager Tests – Layer-Verwaltung + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { LayerManager } from '../src/canvas/LayerManager'; +import type { CADLayer } from '../src/types/cad.types'; + +function makeLayer(id: string, overrides: Partial = {}): CADLayer { + return { + id, + name: id, + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid', + transparency: 0, + sortOrder: 0, + parentId: null, + ...overrides, + }; +} + +describe('LayerManager', () => { + let lm: LayerManager; + + beforeEach(() => { + lm = new LayerManager(); + }); + + describe('addLayer & getLayer', () => { + it('should add a layer and retrieve it by id', () => { + const layer = makeLayer('layer-1', { name: 'Walls' }); + lm.addLayer(layer); + expect(lm.getLayer('layer-1')).toBeDefined(); + expect(lm.getLayer('layer-1')?.name).toBe('Walls'); + }); + + it('should set first added layer as active', () => { + lm.addLayer(makeLayer('layer-1')); + expect(lm.getActiveLayerId()).toBe('layer-1'); + }); + + it('should not set active layer if already set', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + expect(lm.getActiveLayerId()).toBe('layer-1'); + }); + + it('should return undefined for non-existent layer', () => { + expect(lm.getLayer('non-existent')).toBeUndefined(); + }); + }); + + describe('removeLayer', () => { + it('should remove a layer', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.removeLayer('layer-1'); + expect(lm.getLayer('layer-1')).toBeUndefined(); + expect(lm.getLayers().length).toBe(1); + }); + + it('should switch active layer when removing the active one', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setActiveLayer('layer-1'); + lm.removeLayer('layer-1'); + expect(lm.getActiveLayerId()).toBe('layer-2'); + }); + + it('should have empty active layer id when removing last layer', () => { + lm.addLayer(makeLayer('layer-1')); + lm.removeLayer('layer-1'); + expect(lm.getActiveLayerId()).toBe(''); + }); + }); + + describe('getLayers', () => { + it('should return layers sorted by sortOrder', () => { + lm.addLayer(makeLayer('layer-3', { sortOrder: 3 })); + lm.addLayer(makeLayer('layer-1', { sortOrder: 1 })); + lm.addLayer(makeLayer('layer-2', { sortOrder: 2 })); + + const layers = lm.getLayers(); + expect(layers[0].id).toBe('layer-1'); + expect(layers[1].id).toBe('layer-2'); + expect(layers[2].id).toBe('layer-3'); + }); + + it('should return empty array when no layers', () => { + expect(lm.getLayers()).toEqual([]); + }); + }); + + describe('getVisibleLayers', () => { + it('should return only visible and unlocked layers', () => { + lm.addLayer(makeLayer('layer-1', { visible: true, locked: false })); + lm.addLayer(makeLayer('layer-2', { visible: false, locked: false })); + lm.addLayer(makeLayer('layer-3', { visible: true, locked: true })); + lm.addLayer(makeLayer('layer-4', { visible: true, locked: false })); + + const visible = lm.getVisibleLayers(); + expect(visible.length).toBe(2); + const ids = visible.map(l => l.id); + expect(ids).toContain('layer-1'); + expect(ids).toContain('layer-4'); + }); + }); + + describe('setActiveLayer & getActiveLayer', () => { + it('should set active layer if it exists', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setActiveLayer('layer-2'); + expect(lm.getActiveLayerId()).toBe('layer-2'); + expect(lm.getActiveLayer()?.id).toBe('layer-2'); + }); + + it('should not set active layer if it does not exist', () => { + lm.addLayer(makeLayer('layer-1')); + lm.setActiveLayer('non-existent'); + expect(lm.getActiveLayerId()).toBe('layer-1'); + }); + }); + + describe('toggleVisibility', () => { + it('should toggle layer visibility', () => { + lm.addLayer(makeLayer('layer-1', { visible: true })); + lm.toggleVisibility('layer-1'); + expect(lm.getLayer('layer-1')?.visible).toBe(false); + lm.toggleVisibility('layer-1'); + expect(lm.getLayer('layer-1')?.visible).toBe(true); + }); + }); + + describe('toggleLock', () => { + it('should toggle layer lock state', () => { + lm.addLayer(makeLayer('layer-1', { locked: false })); + lm.toggleLock('layer-1'); + expect(lm.getLayer('layer-1')?.locked).toBe(true); + lm.toggleLock('layer-1'); + expect(lm.getLayer('layer-1')?.locked).toBe(false); + }); + }); + + describe('clear', () => { + it('should clear all layers and reset active', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.clear(); + expect(lm.getLayers().length).toBe(0); + expect(lm.getActiveLayerId()).toBe(''); + }); + }); + + describe('renameLayer', () => { + it('should rename a layer', () => { + lm.addLayer(makeLayer('layer-1', { name: 'Old Name' })); + lm.renameLayer('layer-1', 'New Name'); + expect(lm.getLayer('layer-1')?.name).toBe('New Name'); + }); + + it('should do nothing for non-existent layer', () => { + lm.renameLayer('non-existent', 'New Name'); + expect(lm.getLayer('non-existent')).toBeUndefined(); + }); + }); + + describe('updateLayer', () => { + it('should update multiple layer properties', () => { + lm.addLayer(makeLayer('layer-1', { color: '#ffffff', transparency: 0 })); + lm.updateLayer('layer-1', { color: '#ff0000', transparency: 50 }); + const layer = lm.getLayer('layer-1'); + expect(layer?.color).toBe('#ff0000'); + expect(layer?.transparency).toBe(50); + }); + }); + + describe('setParent', () => { + it('should set parent for a layer', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setParent('layer-2', 'layer-1'); + expect(lm.getLayer('layer-2')?.parentId).toBe('layer-1'); + }); + + it('should prevent circular references', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setParent('layer-1', 'layer-2'); + lm.setParent('layer-2', 'layer-1'); // Would create cycle + expect(lm.getLayer('layer-2')?.parentId).toBe(null); + }); + + it('should allow setting parent to null', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setParent('layer-2', 'layer-1'); + lm.setParent('layer-2', null); + expect(lm.getLayer('layer-2')?.parentId).toBe(null); + }); + }); + + describe('getChildLayers & getLayerTree', () => { + it('should get child layers of a parent', () => { + lm.addLayer(makeLayer('parent', { sortOrder: 0 })); + lm.addLayer(makeLayer('child-1', { parentId: 'parent', sortOrder: 1 })); + lm.addLayer(makeLayer('child-2', { parentId: 'parent', sortOrder: 2 })); + lm.addLayer(makeLayer('other', { sortOrder: 3 })); + + const children = lm.getChildLayers('parent'); + expect(children.length).toBe(2); + }); + + it('should get child layers for null parent (root layers)', () => { + lm.addLayer(makeLayer('root-1', { parentId: null })); + lm.addLayer(makeLayer('root-2', { parentId: null })); + lm.addLayer(makeLayer('child', { parentId: 'root-1' })); + + const roots = lm.getChildLayers(null); + expect(roots.length).toBe(2); + }); + + it('should build a tree structure', () => { + lm.addLayer(makeLayer('root', { sortOrder: 0 })); + lm.addLayer(makeLayer('child-a', { parentId: 'root', sortOrder: 1 })); + lm.addLayer(makeLayer('child-b', { parentId: 'root', sortOrder: 2 })); + lm.addLayer(makeLayer('grandchild', { parentId: 'child-a', sortOrder: 3 })); + + const tree = lm.getLayerTree(); + expect(tree.length).toBe(1); + expect(tree[0].id).toBe('root'); + expect(tree[0].children.length).toBe(2); + expect(tree[0].children[0].children.length).toBe(1); + expect(tree[0].children[0].children[0].id).toBe('grandchild'); + }); + }); + + describe('moveLayer', () => { + it('should move layer to new sort order', () => { + lm.addLayer(makeLayer('layer-1', { sortOrder: 0 })); + lm.moveLayer('layer-1', 5); + expect(lm.getLayer('layer-1')?.sortOrder).toBe(5); + }); + }); + + describe('duplicateLayer', () => { + it('should create a copy of the layer', () => { + lm.addLayer(makeLayer('layer-1', { name: 'Original', sortOrder: 1 })); + const copy = lm.duplicateLayer('layer-1'); + expect(copy).not.toBeNull(); + expect(copy?.id).not.toBe('layer-1'); + expect(copy?.name).toContain('Kopie'); + expect(copy?.sortOrder).toBe(2); + expect(lm.getLayers().length).toBe(2); + }); + + it('should return null for non-existent layer', () => { + expect(lm.duplicateLayer('non-existent')).toBeNull(); + }); + }); + + describe('filterLayers', () => { + beforeEach(() => { + lm.addLayer(makeLayer('layer-1', { name: 'Walls', visible: true, locked: false, color: '#ff0000' })); + lm.addLayer(makeLayer('layer-2', { name: 'Doors', visible: false, locked: true, color: '#00ff00' })); + lm.addLayer(makeLayer('layer-3', { name: 'Windows', visible: true, locked: false, color: '#ff0000' })); + }); + + it('should filter by visible', () => { + const result = lm.filterLayers({ visible: true }); + expect(result.length).toBe(2); + }); + + it('should filter by locked', () => { + const result = lm.filterLayers({ locked: true }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('layer-2'); + }); + + it('should filter by color', () => { + const result = lm.filterLayers({ color: '#ff0000' }); + expect(result.length).toBe(2); + }); + + it('should filter by name contains (case insensitive)', () => { + const result = lm.filterLayers({ nameContains: 'wall' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('layer-1'); + }); + + it('should filter by multiple criteria', () => { + const result = lm.filterLayers({ visible: true, color: '#ff0000' }); + expect(result.length).toBe(2); + }); + }); + + describe('getDescendantIds', () => { + it('should get all descendant IDs recursively', () => { + lm.addLayer(makeLayer('root')); + lm.addLayer(makeLayer('child-1', { parentId: 'root' })); + lm.addLayer(makeLayer('child-2', { parentId: 'root' })); + lm.addLayer(makeLayer('grandchild-1', { parentId: 'child-1' })); + lm.addLayer(makeLayer('grandchild-2', { parentId: 'child-1' })); + + const descendants = lm.getDescendantIds('root'); + expect(descendants.length).toBe(4); + expect(descendants).toContain('child-1'); + expect(descendants).toContain('child-2'); + expect(descendants).toContain('grandchild-1'); + expect(descendants).toContain('grandchild-2'); + }); + + it('should return empty array for layer with no children', () => { + lm.addLayer(makeLayer('lonely')); + expect(lm.getDescendantIds('lonely')).toEqual([]); + }); + }); + + describe('isLocked', () => { + it('should return true for locked layer', () => { + lm.addLayer(makeLayer('layer-1', { locked: true })); + expect(lm.isLocked('layer-1')).toBe(true); + }); + + it('should return false for unlocked layer', () => { + lm.addLayer(makeLayer('layer-1', { locked: false })); + expect(lm.isLocked('layer-1')).toBe(false); + }); + + it('should return false for non-existent layer', () => { + expect(lm.isLocked('non-existent')).toBe(false); + }); + }); + + describe('getLayerColor', () => { + it('should return layer color', () => { + lm.addLayer(makeLayer('layer-1', { color: '#abcdef' })); + expect(lm.getLayerColor('layer-1')).toBe('#abcdef'); + }); + + it('should return undefined for non-existent layer', () => { + expect(lm.getLayerColor('non-existent')).toBeUndefined(); + }); + }); +}); diff --git a/frontend/tests/SpatialIndex.test.ts b/frontend/tests/SpatialIndex.test.ts new file mode 100644 index 0000000..a3715ac --- /dev/null +++ b/frontend/tests/SpatialIndex.test.ts @@ -0,0 +1,153 @@ +/** + * SpatialIndex Tests – rbush-based spatial search + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import type { CADElement } from '../src/types/cad.types'; + +function makeElement(id: string, x: number, y: number, width: number, height: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x, + y, + width, + height, + properties: {}, + }; +} + +describe('SpatialIndex', () => { + let index: SpatialIndex; + + beforeEach(() => { + index = new SpatialIndex(); + }); + + describe('insert & search', () => { + it('should find an inserted element within its bounding box', () => { + const el = makeElement('el-1', 50, 50, 20, 20); + index.insert(el); + + const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-1'); + }); + + it('should not find an element outside the search viewport', () => { + const el = makeElement('el-1', 100, 100, 20, 20); + index.insert(el); + + const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 }); + expect(results.length).toBe(0); + }); + + it('should find multiple elements within viewport', () => { + index.insert(makeElement('el-1', 10, 10, 10, 10)); + index.insert(makeElement('el-2', 20, 20, 10, 10)); + index.insert(makeElement('el-3', 100, 100, 10, 10)); + + const results = index.search({ minX: 0, minY: 0, maxX: 30, maxY: 30 }); + expect(results.length).toBe(2); + const ids = results.map(e => e.id).sort(); + expect(ids).toEqual(['el-1', 'el-2']); + }); + + it('should handle elements at origin', () => { + index.insert(makeElement('el-origin', 0, 0, 10, 10)); + const results = index.search({ minX: -5, minY: -5, maxX: 5, maxY: 5 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-origin'); + }); + }); + + describe('bulkInsert', () => { + it('should bulk insert and find all elements', () => { + const elements = [ + makeElement('bulk-1', 10, 10, 5, 5), + makeElement('bulk-2', 20, 20, 5, 5), + makeElement('bulk-3', 30, 30, 5, 5), + makeElement('bulk-4', 200, 200, 5, 5), + ]; + index.bulkInsert(elements); + + const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 }); + expect(results.length).toBe(3); + }); + + it('should work with empty array', () => { + index.bulkInsert([]); + const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + expect(results.length).toBe(0); + }); + }); + + describe('remove', () => { + it('should remove an element so it is no longer found', () => { + const el = makeElement('el-rm', 50, 50, 10, 10); + index.insert(el); + + let results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(1); + + index.remove(el); + results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(0); + }); + + it('should remove only the specified element', () => { + const el1 = makeElement('el-keep', 50, 50, 10, 10); + const el2 = makeElement('el-rm', 50, 50, 10, 10); + index.insert(el1); + index.insert(el2); + + index.remove(el2); + const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-keep'); + }); + }); + + describe('clear', () => { + it('should clear all elements from the index', () => { + index.insert(makeElement('el-1', 10, 10, 5, 5)); + index.insert(makeElement('el-2', 20, 20, 5, 5)); + index.insert(makeElement('el-3', 30, 30, 5, 5)); + + index.clear(); + const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + expect(results.length).toBe(0); + }); + }); + + describe('edge cases', () => { + it('should handle overlapping bounding boxes correctly', () => { + index.insert(makeElement('el-1', 25, 25, 30, 30)); // bbox: 10,10 to 40,40 + index.insert(makeElement('el-2', 30, 30, 30, 30)); // bbox: 15,15 to 45,45 + + const results = index.search({ minX: 10, minY: 10, maxX: 40, maxY: 40 }); + expect(results.length).toBe(2); + }); + + it('should handle zero-size elements', () => { + index.insert(makeElement('el-zero', 50, 50, 0, 0)); + const results = index.search({ minX: 49, minY: 49, maxX: 51, maxY: 51 }); + expect(results.length).toBe(1); + }); + + it('should handle negative coordinates', () => { + index.insert(makeElement('el-neg', -50, -50, 20, 20)); + const results = index.search({ minX: -60, minY: -60, maxX: -40, maxY: -40 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-neg'); + }); + + it('should search with a large viewport containing all elements', () => { + index.insert(makeElement('el-1', 10, 10, 5, 5)); + index.insert(makeElement('el-2', 1000, 1000, 5, 5)); + const results = index.search({ minX: -10000, minY: -10000, maxX: 10000, maxY: 10000 }); + expect(results.length).toBe(2); + }); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..b05b6da --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + test: { + globals: true, + environment: 'jsdom', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/canvas/**', 'src/history/**'], + }, + }, +}); diff --git a/test_report.md b/test_report.md index 23a7a4a..e13ef75 100644 --- a/test_report.md +++ b/test_report.md @@ -1,58 +1,170 @@ -# Test Report: Yjs CRDT Binding with Canvas Local State +# Test Report – Phase 21: Vitest Test-Setup für web-cad-neu -**Date:** 2026-06-26 -**Task:** Wire Yjs CRDT binding with Canvas local state +**Datum:** 2026-06-26 +**Status:** ✅ Alle Tests grün -## Build Verification +## Backend Tests (31 Tests, 3 Files) +### Test-Command ``` -cd /a0/usr/workdir/web-cad-neu/frontend && npm run build +cd /a0/usr/workdir/web-cad-neu/backend && npm test ``` -**Result:** ✅ PASS — 0 TypeScript errors - +### Ergebnis ``` -> tsc && vite build - -vite v5.4.21 building for production... -transforming... -✓ 334 modules transformed. -rendering chunks... -computing gzip size... -dist/index.html 0.37 kB │ gzip: 0.27 kB -dist/assets/index-BnXa05r8.css 36.38 kB │ gzip: 6.91 kB -dist/assets/index-CjnWSOJb.js 897.00 kB │ gzip: 311.12 kB -✓ built in 3.58s +Test Files 3 passed (3) + Tests 31 passed (31) + Duration 2.92s ``` -## Smoke Test +### Test-Dateien -### Step 1: App loads -- Browser opened at `http://localhost:5173/` -- Login page → Dashboard with project list rendered correctly -- Project 'Test Collab CRDT test project' visible +#### tests/health.test.ts (2 Tests) +- ✅ GET /api/health should return ok status +- ✅ GET /api/health should return valid ISO timestamp -### Step 2: CAD Editor opens -- Clicked project card → CAD editor loaded -- Topbar shows 'web-cad | Test Collab gespeichert' -- All UI panels rendered: Ribbon, LeftSidebar, CanvasArea, RightSidebar, CommandLine, StatusBar -- No console errors +#### tests/auth.test.ts (18 Tests) +- ✅ POST /api/auth/register (3 tests) + - Register new user with 201 + - Reject duplicate registration with 409 + - Reject missing fields with 400 +- ✅ POST /api/auth/login (4 tests) + - Login with correct credentials + - Reject wrong password with 401 + - Reject non-existent email with 401 + - Reject missing fields with 400 +- ✅ GET /api/auth/me (3 tests) + - Return current user profile with valid token + - Reject without token with 401 + - Reject invalid token with 401 +- ✅ PATCH /api/auth/password (6 tests) + - Change password with correct old password + - Allow login with new password + - Reject old password after change + - Reject password change with wrong old password + - Reject password change without auth + - Reject missing password fields +- ✅ POST /api/auth/logout (2 tests) + - Logout and invalidate token + - Return 204 even without token -### Step 3: Collaboration status active -- Status bar shows: 'Online · 1 weiterer' — Yjs collaboration is connected -- Connection status: connected +#### tests/projects.test.ts (11 Tests) +- ✅ POST /api/projects (3 tests) + - Create project with 201 + - Reject without name with 400 + - Create with default values +- ✅ GET /api/projects (1 test) + - List all projects +- ✅ GET /api/projects/:id (2 tests) + - Get single project + - Return 404 for non-existent +- ✅ PATCH /api/projects/:id (3 tests) + - Update project name + - Update project description + - Return 404 for non-existent update +- ✅ DELETE /api/projects/:id (2 tests) + - Delete a project + - Return 404 for non-existent delete -## Changes Made +## Frontend Tests (84 Tests, 3 Files) -### Files Modified -1. **`frontend/src/types/ui.types.ts`** — Added `UserCursor` import and `remoteCursors?: UserCursor[]` prop to `CanvasAreaProps` -2. **`frontend/src/App.tsx`** — 4 changes: - - Push initial data to Yjs after load (`collab.loadFromState` after `setDataLoaded`) - - Remote→local sync effect (watches `collab.elements/layers/blocks`, updates local state when different) - - Push local→Yjs in handlers (`collab.setElement` in created/modified, `collab.deleteElement` in deleted) - - Cursor tracking (`collab.setCursor` in `handleCursorMoved`, `remoteCursors={collab.cursors}` prop to CanvasArea) -3. **`frontend/src/components/CanvasArea.tsx`** — Added `remoteCursors` prop, cursor screen position computation, and remote cursor overlay rendering (colored circles with user names) +### Test-Command +``` +cd /a0/usr/workdir/web-cad-neu/frontend && npm test +``` -## Multi-Tab Sync Test +### Ergebnis +``` +Test Files 3 passed (3) + Tests 84 passed (84) + Duration 2.40s +``` -Manual verification recommended: Open two browser tabs, create an element in tab A, verify it appears in tab B via Yjs sync. The status bar 'Online · 1 weiterer' confirms the WebSocket connection is active. +### Test-Dateien + +#### tests/SpatialIndex.test.ts (13 Tests) +- ✅ insert & search (4 tests) + - Find inserted element within bounding box + - Not find element outside viewport + - Find multiple elements within viewport + - Handle elements at origin +- ✅ bulkInsert (2 tests) + - Bulk insert and find all elements + - Work with empty array +- ✅ remove (2 tests) + - Remove element so it's no longer found + - Remove only specified element +- ✅ clear (1 test) + - Clear all elements from index +- ✅ edge cases (4 tests) + - Handle overlapping bounding boxes + - Handle zero-size elements + - Handle negative coordinates + - Search with large viewport + +#### tests/LayerManager.test.ts (39 Tests) +- ✅ addLayer & getLayer (4 tests) +- ✅ removeLayer (3 tests) +- ✅ getLayers (2 tests) +- ✅ getVisibleLayers (1 test) +- ✅ setActiveLayer & getActiveLayer (2 tests) +- ✅ toggleVisibility (1 test) +- ✅ toggleLock (1 test) +- ✅ clear (1 test) +- ✅ renameLayer (2 tests) +- ✅ updateLayer (1 test) +- ✅ setParent (3 tests) +- ✅ getChildLayers & getLayerTree (3 tests) +- ✅ moveLayer (1 test) +- ✅ duplicateLayer (2 tests) +- ✅ filterLayers (5 tests) +- ✅ getDescendantIds (2 tests) +- ✅ isLocked (3 tests) +- ✅ getLayerColor (2 tests) + +#### tests/HistoryManager.test.ts (32 Tests) +- ✅ initialize (2 tests) +- ✅ pushSnapshot (3 tests) +- ✅ undo (4 tests) +- ✅ redo (4 tests) +- ✅ canUndo & canRedo (4 tests) +- ✅ getHistory (3 tests) +- ✅ jumpTo (4 tests) +- ✅ clear (1 test) +- ✅ subscribe (2 tests) +- ✅ maxStackSize (2 tests) +- ✅ getUndoCount & getRedoCount (1 test) +- ✅ snapshot data integrity (2 tests) + +## Smoke-Test Beschreibung + +### Backend +- In-memory SQLite (`:memory:`) wird via `SqliteAdapter` initialisiert +- Fastify `app.inject()` simuliert HTTP-Requests ohne echte Netzwerkverbindung +- Auth-Flow vollständig getestet: Register → Login → Me → Password Change → Logout +- Project CRUD vollständig getestet: Create → List → Get → Update → Delete +- Health Endpoint gibt `{ status: 'ok', timestamp: ISO-String }` zurück + +### Frontend +- SpatialIndex: rbush-basierte räumliche Suche mit insert/search/remove/clear/bulkInsert +- LayerManager: Layer-Verwaltung mit add/remove/toggle/tree/filter/duplicate +- HistoryManager: Undo/Redo-Stack mit initialize/push/undo/redo/jumpTo/subscribe/maxStackSize +- jsdom environment für DOM-abhängige Tests aktiviert + +## Coverage + +### Backend +- Auth CRUD: Register, Login, Logout, Me, Password-Change → alle Endpoints getestet +- Project CRUD: List, Get, Create, Update, Delete → alle Endpoints getestet +- Health: GET /api/health → getestet + +### Frontend +- SpatialIndex: insert, bulkInsert, search, remove, clear → alle öffentlichen Methoden +- LayerManager: alle 18 öffentlichen Methoden getestet +- HistoryManager: initialize, pushSnapshot, undo, redo, canUndo, canRedo, getHistory, jumpTo, clear, subscribe, getUndoCount, getRedoCount → alle öffentlichen Methoden + +## Verbotene Patterns Check +- ❌ Keine echten Netzwerk-Verbindungen → ✅ Nur `app.inject()` +- ❌ Keine echten WebSocket-Verbindungen → ✅ Keine WebSocket-Tests +- ❌ Keine Datei-Schreiboperationen auf echte DB-Files → ✅ Nur `:memory:` +- ❌ Keine Mock-Libraries außer vitest built-in `vi` → ✅ Nur vitest built-ins