test: add Vitest setup with 115 tests (backend + frontend)

This commit is contained in:
2026-06-26 14:19:50 +02:00
parent 19884b7b5d
commit f853372c90
11 changed files with 2062 additions and 42 deletions
+269
View File
@@ -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);
});
});
});
+45
View File
@@ -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');
});
});
+176
View File
@@ -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);
});
});
});
+14
View File
@@ -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/**'],
},
},
});
+495
View File
@@ -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",
+1
View File
@@ -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"
+388
View File
@@ -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<CADStateSnapshot, 'timestamp' | 'label'> {
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
});
});
});
+346
View File
@@ -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> = {}): 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();
});
});
});
+153
View File
@@ -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);
});
});
});
+21
View File
@@ -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/**'],
},
},
});
+154 -42
View File
@@ -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