diff --git a/backend/tests/AuthService.test.ts b/backend/tests/AuthService.test.ts new file mode 100644 index 0000000..d70b19f --- /dev/null +++ b/backend/tests/AuthService.test.ts @@ -0,0 +1,188 @@ +/** + * AuthService Unit Tests – Register / Login / ChangePassword / Session lifecycle + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { AuthService } from '../src/auth/AuthService.js'; + +describe('AuthService', () => { + let db: SqliteAdapter; + let auth: AuthService; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + auth = new AuthService(db); + }); + + afterAll(() => { + db.close(); + }); + + // ─── Register ─────────────────────────────────────── + + describe('register()', () => { + it('should register a new user successfully', async () => { + const result = await auth.register('register@example.com', 'Password123!', 'Register User', 'planer'); + expect(result.user).toBeDefined(); + expect(result.user.email).toBe('register@example.com'); + expect(result.user.name).toBe('Register User'); + expect(result.user.password_hash).toBeUndefined(); + expect(result.session).toBeDefined(); + expect(result.session.token).toBeDefined(); + expect(result.session.userId).toBe(result.user.id); + }); + + it('should throw on duplicate email', async () => { + await expect( + auth.register('register@example.com', 'AnotherPass!', 'Another User', 'planer'), + ).rejects.toThrow('Email already registered'); + }); + + it('should default role to planer when not specified', async () => { + const result = await auth.register('default-role@example.com', 'Pass123!', 'Default Role User'); + expect(result.user.role).toBe('planer'); + }); + }); + + // ─── Login ───────────────────────────────────────── + + describe('login()', () => { + it('should login with correct credentials', async () => { + const result = await auth.login('register@example.com', 'Password123!'); + expect(result.user).toBeDefined(); + expect(result.user.email).toBe('register@example.com'); + expect(result.session.token).toBeDefined(); + expect(result.session.userId).toBe(result.user.id); + }); + + it('should throw on wrong password', async () => { + await expect( + auth.login('register@example.com', 'WrongPassword!'), + ).rejects.toThrow('Invalid credentials'); + }); + + it('should throw on non-existent email', async () => { + await expect( + auth.login('nobody@example.com', 'SomePassword!'), + ).rejects.toThrow('Invalid credentials'); + }); + }); + + // ─── Change Password ─────────────────────────────── + + describe('changePassword()', () => { + it('should change password with correct old password', async () => { + const result = await auth.register('changepw@example.com', 'OldPass123!', 'ChangePW User', 'admin'); + const userId = result.user.id; + + await auth.changePassword(userId, 'OldPass123!', 'NewPass456!'); + // Should now be able to login with new password + const loginResult = await auth.login('changepw@example.com', 'NewPass456!'); + expect(loginResult.user.id).toBe(userId); + }); + + it('should throw on wrong old password', async () => { + const result = await auth.register('changepw2@example.com', 'OriginalPass!', 'User2', 'planer'); + await expect( + auth.changePassword(result.user.id, 'WrongOldPass!', 'NewPass!'), + ).rejects.toThrow('Invalid current password'); + }); + + it('should throw on non-existent user', async () => { + await expect( + auth.changePassword('non-existent-user-id', 'OldPass!', 'NewPass!'), + ).rejects.toThrow('User not found'); + }); + + it('should reject old password after change', async () => { + const result = await auth.register('changepw3@example.com', 'Original123!', 'User3', 'planer'); + await auth.changePassword(result.user.id, 'Original123!', 'Changed456!'); + await expect( + auth.login('changepw3@example.com', 'Original123!'), + ).rejects.toThrow('Invalid credentials'); + }); + }); + + // ─── Session Management ──────────────────────────── + + describe('Session lifecycle', () => { + it('should create and validate a session', async () => { + const regResult = await auth.register('session@example.com', 'Pass123!', 'Session User', 'planer'); + const token = regResult.session.token; + + const session = auth.validateSession(token); + expect(session).not.toBeNull(); + expect(session!.token).toBe(token); + expect(session!.userId).toBe(regResult.user.id); + }); + + it('should return null for invalid session token', () => { + const session = auth.validateSession('invalid-token-xyz'); + expect(session).toBeNull(); + }); + + it('should destroy a session and invalidate it', async () => { + const regResult = await auth.register('destroy@example.com', 'Pass123!', 'Destroy User', 'planer'); + const token = regResult.session.token; + + // Session should be valid + expect(auth.validateSession(token)).not.toBeNull(); + + // Destroy it + auth.destroySession(token); + + // Session should now be invalid + expect(auth.validateSession(token)).toBeNull(); + }); + + it('should handle destroying a non-existent session gracefully', () => { + // Should not throw + auth.destroySession('non-existent-token'); + }); + }); + + // ─── getUserFromSession ───────────────────────────── + + describe('getUserFromSession()', () => { + it('should return user from valid session', async () => { + const regResult = await auth.register('getuser@example.com', 'Pass123!', 'GetUser User', 'planer'); + const token = regResult.session.token; + + const user = auth.getUserFromSession(token); + expect(user).not.toBeNull(); + expect(user!.email).toBe('getuser@example.com'); + expect(user!.password_hash).toBeDefined(); // returns full DBUser including password_hash + }); + + it('should return null for invalid session token', () => { + const user = auth.getUserFromSession('invalid-token-xyz'); + expect(user).toBeNull(); + }); + + it('should return null after session is destroyed', async () => { + const regResult = await auth.register('getuser2@example.com', 'Pass123!', 'GetUser2 User', 'planer'); + const token = regResult.session.token; + + auth.destroySession(token); + const user = auth.getUserFromSession(token); + expect(user).toBeNull(); + }); + }); + + // ─── createSession (standalone) ───────────────────── + + describe('createSession()', () => { + it('should create a session with a unique token', async () => { + const regResult = await auth.register('createsession@example.com', 'Pass123!', 'CS User', 'planer'); + const session1 = auth.createSession(regResult.user.id); + const session2 = auth.createSession(regResult.user.id); + + expect(session1.token).not.toBe(session2.token); + expect(session1.userId).toBe(regResult.user.id); + expect(session2.userId).toBe(regResult.user.id); + expect(session1.expiresAt).toBeGreaterThan(Date.now()); + expect(session2.expiresAt).toBeGreaterThan(Date.now()); + }); + }); +}); diff --git a/backend/tests/SqliteAdapter.test.ts b/backend/tests/SqliteAdapter.test.ts new file mode 100644 index 0000000..5b6b1e8 --- /dev/null +++ b/backend/tests/SqliteAdapter.test.ts @@ -0,0 +1,383 @@ +/** + * SqliteAdapter Unit Tests – init, CRUD for all entities, close + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; + +let idCounter = 0; +function uniqueId(prefix: string): string { + idCounter++; + return `${prefix}-${Date.now()}-${idCounter}`; +} + +describe('SqliteAdapter', () => { + let db: SqliteAdapter; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + }); + + afterAll(() => { + db.close(); + }); + + // ─── Init & Close ─────────────────────────────────── + + describe('init() and close()', () => { + it('should initialize the database without errors', async () => { + const testDb = new SqliteAdapter(':memory:'); + await testDb.init(); + const users = testDb.listUsers(); + expect(Array.isArray(users)).toBe(true); + testDb.close(); + }); + + it('should seed a default user on init', async () => { + const defaultUser = db.getUser('user-default'); + expect(defaultUser).not.toBeNull(); + expect(defaultUser!.email).toBe('default@webcad.local'); + expect(defaultUser!.role).toBe('admin'); + }); + }); + + // ─── Users CRUD ───────────────────────────────────── + + describe('Users CRUD', () => { + it('should create and get a user', () => { + const uid = uniqueId('user'); + const user = db.createUser({ + id: uid, + email: `${uid}@example.com`, + password_hash: 'hash123', + name: 'CRUD User', + role: 'planer', + }); + expect(user.id).toBe(uid); + expect(user.email).toBe(`${uid}@example.com`); + + const fetched = db.getUser(user.id); + expect(fetched).not.toBeNull(); + expect(fetched!.email).toBe(`${uid}@example.com`); + }); + + it('should get user by email', () => { + const uid = uniqueId('user'); + db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Email User', role: 'planer' }); + const user = db.getUserByEmail(`${uid}@example.com`); + expect(user).not.toBeNull(); + expect(user!.name).toBe('Email User'); + }); + + it('should return null for non-existent user', () => { + expect(db.getUser('non-existent-id')).toBeNull(); + expect(db.getUserByEmail('nobody@nowhere.com')).toBeNull(); + }); + + it('should update a user', () => { + const uid = uniqueId('user'); + db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Original Name', role: 'planer' }); + const updated = db.updateUser(uid, { name: 'Updated Name', role: 'admin' }); + expect(updated).not.toBeNull(); + expect(updated!.name).toBe('Updated Name'); + expect(updated!.role).toBe('admin'); + }); + + it('should list users', () => { + const users = db.listUsers(); + expect(users.length).toBeGreaterThanOrEqual(2); + }); + + it('should delete a user', () => { + const uid = uniqueId('user'); + db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Delete Me', role: 'planer' }); + const ok = db.deleteUser(uid); + expect(ok).toBe(true); + expect(db.getUser(uid)).toBeNull(); + }); + + it('should return false when deleting non-existent user', () => { + expect(db.deleteUser('non-existent-id')).toBe(false); + }); + }); + + // ─── Projects CRUD ────────────────────────────────── + + describe('Projects CRUD', () => { + it('should create and get a project', () => { + const pid = uniqueId('proj'); + const project = db.createProject({ id: pid, name: 'Test Project', description: 'Test desc' }); + expect(project.id).toBe(pid); + expect(project.name).toBe('Test Project'); + + const fetched = db.getProject(pid); + expect(fetched).not.toBeNull(); + expect(fetched!.name).toBe('Test Project'); + }); + + it('should return null for non-existent project', () => { + expect(db.getProject('non-existent-id')).toBeNull(); + }); + + it('should update a project', () => { + const pid = uniqueId('proj'); + db.createProject({ id: pid, name: 'Update Project' }); + const updated = db.updateProject(pid, { name: 'Updated Project', description: 'New desc' }); + expect(updated).not.toBeNull(); + expect(updated!.name).toBe('Updated Project'); + expect(updated!.description).toBe('New desc'); + }); + + it('should list projects', () => { + const projects = db.listProjects(); + expect(projects.length).toBeGreaterThanOrEqual(1); + }); + + it('should delete a project', () => { + const pid = uniqueId('proj'); + db.createProject({ id: pid, name: 'Delete Project' }); + const ok = db.deleteProject(pid); + expect(ok).toBe(true); + expect(db.getProject(pid)).toBeNull(); + }); + + it('should cascade delete drawings when project is deleted', () => { + const pid = uniqueId('proj'); + const did = uniqueId('draw'); + db.createProject({ id: pid, name: 'Cascade Project' }); + db.createDrawing({ id: did, project_id: pid, name: 'Cascade Drawing' }); + db.deleteProject(pid); + expect(db.getDrawing(did)).toBeNull(); + }); + }); + + // ─── Drawings CRUD ────────────────────────────────── + + describe('Drawings CRUD', () => { + let projectId: string; + + it('should create and get a drawing', () => { + projectId = uniqueId('proj'); + const did = uniqueId('draw'); + db.createProject({ id: projectId, name: 'Drawing Project' }); + const drawing = db.createDrawing({ id: did, project_id: projectId, name: 'Floor 1' }); + expect(drawing.id).toBe(did); + expect(drawing.name).toBe('Floor 1'); + expect(drawing.project_id).toBe(projectId); + + const fetched = db.getDrawing(did); + expect(fetched).not.toBeNull(); + expect(fetched!.name).toBe('Floor 1'); + }); + + it('should list drawings by project', () => { + const did1 = uniqueId('draw'); + const did2 = uniqueId('draw'); + db.createDrawing({ id: did1, project_id: projectId, name: 'Drawing A' }); + db.createDrawing({ id: did2, project_id: projectId, name: 'Drawing B' }); + const drawings = db.listDrawings(projectId); + expect(drawings.length).toBeGreaterThanOrEqual(3); + }); + + it('should update a drawing', () => { + const did = uniqueId('draw'); + db.createDrawing({ id: did, project_id: projectId, name: 'Update Me' }); + const updated = db.updateDrawing(did, { name: 'Updated Drawing', data_json: '{"x":1}' }); + expect(updated).not.toBeNull(); + expect(updated!.name).toBe('Updated Drawing'); + expect(updated!.data_json).toBe('{"x":1}'); + }); + + it('should delete a drawing', () => { + const did = uniqueId('draw'); + db.createDrawing({ id: did, project_id: projectId, name: 'Delete Me' }); + const ok = db.deleteDrawing(did); + expect(ok).toBe(true); + expect(db.getDrawing(did)).toBeNull(); + }); + + it('should cascade delete layers when drawing is deleted', () => { + const did = uniqueId('draw'); + const lid = uniqueId('layer'); + db.createDrawing({ id: did, project_id: projectId, name: 'Cascade Drawing' }); + db.createLayer({ id: lid, drawing_id: did, name: 'Cascade Layer' }); + db.deleteDrawing(did); + const layers = db.listLayers(did); + expect(layers.find((l) => l.id === lid)).toBeUndefined(); + }); + }); + + // ─── Layers CRUD ──────────────────────────────────── + + describe('Layers CRUD', () => { + let drawingId: string; + + it('should create and get a layer', () => { + const pid = uniqueId('proj'); + drawingId = uniqueId('draw'); + const lid = uniqueId('layer'); + db.createProject({ id: pid, name: 'Layer Project' }); + db.createDrawing({ id: drawingId, project_id: pid, name: 'Layer Drawing' }); + const layer = db.createLayer({ id: lid, drawing_id: drawingId, name: 'Layer 1', color: '#ff0000' }); + expect(layer.id).toBe(lid); + expect(layer.name).toBe('Layer 1'); + expect(layer.color).toBe('#ff0000'); + expect(layer.visible).toBe(1); + expect(layer.locked).toBe(0); + }); + + it('should list layers by drawing', () => { + const lid2 = uniqueId('layer'); + db.createLayer({ id: lid2, drawing_id: drawingId, name: 'Layer 2' }); + const layers = db.listLayers(drawingId); + expect(layers.length).toBeGreaterThanOrEqual(2); + }); + + it('should update a layer', () => { + const lid = uniqueId('layer'); + db.createLayer({ id: lid, drawing_id: drawingId, name: 'Update Layer' }); + const updated = db.updateLayer(lid, { name: 'Updated Layer', visible: 0, locked: 1 }); + expect(updated).not.toBeNull(); + expect(updated!.name).toBe('Updated Layer'); + expect(updated!.visible).toBe(0); + expect(updated!.locked).toBe(1); + }); + + it('should delete a layer', () => { + const lid = uniqueId('layer'); + db.createLayer({ id: lid, drawing_id: drawingId, name: 'Delete Layer' }); + const ok = db.deleteLayer(lid); + expect(ok).toBe(true); + const layers = db.listLayers(drawingId); + expect(layers.find((l) => l.id === lid)).toBeUndefined(); + }); + }); + + // ─── Elements CRUD ────────────────────────────────── + + describe('Elements CRUD', () => { + let drawingId: string; + let layerId: string; + + it('should create and get an element', () => { + const pid = uniqueId('proj'); + drawingId = uniqueId('draw'); + layerId = uniqueId('layer'); + const eid = uniqueId('elem'); + db.createProject({ id: pid, name: 'Element Project' }); + db.createDrawing({ id: drawingId, project_id: pid, name: 'Element Drawing' }); + db.createLayer({ id: layerId, drawing_id: drawingId, name: 'Element Layer' }); + + const element = db.createElement({ + id: eid, + drawing_id: drawingId, + layer_id: layerId, + type: 'rect', + x: 10, + y: 20, + width: 100, + height: 50, + }); + expect(element.id).toBe(eid); + expect(element.type).toBe('rect'); + expect(element.x).toBe(10); + expect(element.y).toBe(20); + }); + + it('should list elements by drawing', () => { + const eid2 = uniqueId('elem'); + db.createElement({ id: eid2, drawing_id: drawingId, layer_id: layerId, type: 'circle' }); + const elements = db.listElements(drawingId); + expect(elements.length).toBeGreaterThanOrEqual(2); + }); + + it('should update an element', () => { + const eid = uniqueId('elem'); + db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'line' }); + const updated = db.updateElement(eid, { x: 500, y: 600, width: 200 }); + expect(updated).not.toBeNull(); + expect(updated!.x).toBe(500); + expect(updated!.y).toBe(600); + expect(updated!.width).toBe(200); + }); + + it('should delete an element', () => { + const eid = uniqueId('elem'); + db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'text' }); + const ok = db.deleteElement(eid); + expect(ok).toBe(true); + const elements = db.listElements(drawingId); + expect(elements.find((e) => e.id === eid)).toBeUndefined(); + }); + }); + + // ─── Blocks CRUD ──────────────────────────────────── + + describe('Blocks CRUD', () => { + let drawingId: string; + + it('should create and get a block', () => { + const pid = uniqueId('proj'); + drawingId = uniqueId('draw'); + const bid = uniqueId('block'); + db.createProject({ id: pid, name: 'Block Project' }); + db.createDrawing({ id: drawingId, project_id: pid, name: 'Block Drawing' }); + + const block = db.createBlock({ id: bid, drawing_id: drawingId, name: 'Table Block', category: 'Mobiliar' }); + expect(block.id).toBe(bid); + expect(block.name).toBe('Table Block'); + expect(block.category).toBe('Mobiliar'); + expect(block.elements_json).toBe('[]'); + }); + + it('should list blocks by drawing', () => { + const bid2 = uniqueId('block'); + db.createBlock({ id: bid2, drawing_id: drawingId, name: 'Chair Block' }); + const blocks = db.listBlocks(drawingId); + expect(blocks.length).toBeGreaterThanOrEqual(2); + }); + + it('should update a block', () => { + const bid = uniqueId('block'); + db.createBlock({ id: bid, drawing_id: drawingId, name: 'Update Block' }); + const updated = db.updateBlock(bid, { name: 'Updated Block', category: 'Bühne' }); + expect(updated).not.toBeNull(); + expect(updated!.name).toBe('Updated Block'); + expect(updated!.category).toBe('Bühne'); + }); + + it('should delete a block', () => { + const bid = uniqueId('block'); + db.createBlock({ id: bid, drawing_id: drawingId, name: 'Delete Block' }); + const ok = db.deleteBlock(bid); + expect(ok).toBe(true); + const blocks = db.listBlocks(drawingId); + expect(blocks.find((b) => b.id === bid)).toBeUndefined(); + }); + }); + + // ─── Settings CRUD ────────────────────────────────── + + describe('Settings CRUD', () => { + it('should set and get a setting', () => { + db.setSetting('test-key', 'test-value'); + const setting = db.getSetting('test-key'); + expect(setting).not.toBeNull(); + expect(setting!.key).toBe('test-key'); + expect(setting!.value).toBe('test-value'); + expect(setting!.updated_at).toBeDefined(); + }); + + it('should return null for non-existent setting', () => { + expect(db.getSetting('non-existent-key')).toBeNull(); + }); + + it('should upsert (update existing setting)', () => { + db.setSetting('upsert-key', 'initial'); + db.setSetting('upsert-key', 'updated'); + const setting = db.getSetting('upsert-key'); + expect(setting).not.toBeNull(); + expect(setting!.value).toBe('updated'); + }); + }); +}); diff --git a/backend/tests/ai.test.ts b/backend/tests/ai.test.ts new file mode 100644 index 0000000..600a6b1 --- /dev/null +++ b/backend/tests/ai.test.ts @@ -0,0 +1,140 @@ +/** + * AI API Tests – Auth guard, validation, and service availability checks + * Does NOT test actual OpenRouter API calls. + */ +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('AI API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let authToken: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + // Register a user to get an auth token + const registerRes = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: 'ai-test@example.com', + password: 'TestPass123!', + name: 'AI Test User', + role: 'planer', + }, + }); + const body = JSON.parse(registerRes.body); + authToken = body.session.token; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Auth Guard ───────────────────────────────────── + + describe('POST /api/ai/chat – Authentication', () => { + it('should return 401 without authorization header', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/chat', + payload: { messages: [{ role: 'user', content: 'hello' }] }, + }); + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error).toContain('Authentication required'); + }); + + it('should return 401 with invalid token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/chat', + headers: { authorization: 'Bearer invalid-token-xxx' }, + payload: { messages: [{ role: 'user', content: 'hello' }] }, + }); + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error).toContain('Invalid or expired session'); + }); + + it('should return 401 with malformed authorization header', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/chat', + headers: { authorization: 'NotBearer sometoken' }, + payload: { messages: [{ role: 'user', content: 'hello' }] }, + }); + expect(response.statusCode).toBe(401); + }); + }); + + // ─── Validation ───────────────────────────────────── + + describe('POST /api/ai/chat – Validation', () => { + it('should return 400 when messages array is missing', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/chat', + headers: { authorization: `Bearer ${authToken}` }, + payload: {}, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('Messages array is required'); + }); + + it('should return 400 when messages array is empty', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/chat', + headers: { authorization: `Bearer ${authToken}` }, + payload: { messages: [] }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('Messages array is required'); + }); + + it('should return 400 when messages is not an array', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/chat', + headers: { authorization: `Bearer ${authToken}` }, + payload: { messages: 'not an array' }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + // ─── Service Availability ─────────────────────────── + + describe('POST /api/ai/chat – Service availability', () => { + it('should return 503 when API_KEY_OPENROUTER is not set', async () => { + // Ensure env var is not set for this test + const originalValue = process.env.API_KEY_OPENROUTER; + delete process.env.API_KEY_OPENROUTER; + + const response = await app.inject({ + method: 'POST', + url: '/api/ai/chat', + headers: { authorization: `Bearer ${authToken}` }, + payload: { messages: [{ role: 'user', content: 'hello' }] }, + }); + expect(response.statusCode).toBe(503); + const body = JSON.parse(response.body); + expect(body.error).toContain('AI service not configured'); + + // Restore original value if it existed + if (originalValue !== undefined) { + process.env.API_KEY_OPENROUTER = originalValue; + } + }); + }); +}); diff --git a/backend/tests/blocks.test.ts b/backend/tests/blocks.test.ts new file mode 100644 index 0000000..7516937 --- /dev/null +++ b/backend/tests/blocks.test.ts @@ -0,0 +1,216 @@ +/** + * Blocks API Tests – CRUD (List / 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('Blocks API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let drawingId: string; + let createdBlockId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + // Create project → drawing hierarchy + const projRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Blocks Test Project' }, + }); + const projectId = JSON.parse(projRes.body).id; + + const drawRes = await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/drawings`, + payload: { name: 'Blocks Test Drawing' }, + }); + drawingId = JSON.parse(drawRes.body).id; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Create ───────────────────────────────────────── + + describe('POST /api/drawings/:drawingId/blocks', () => { + it('should create a block with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/blocks`, + payload: { name: 'Standard Table', category: 'Mobiliar', description: 'A standard round table' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.id).toBeDefined(); + expect(body.name).toBe('Standard Table'); + expect(body.category).toBe('Mobiliar'); + expect(body.description).toBe('A standard round table'); + expect(body.drawing_id).toBe(drawingId); + createdBlockId = body.id; + }); + + it('should create a block with default values', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/blocks`, + payload: { name: 'Minimal Block' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.name).toBe('Minimal Block'); + expect(body.category).toBe('Allgemein'); + expect(body.description).toBeNull(); + expect(body.elements_json).toBe('[]'); + }); + + it('should reject block without name with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/blocks`, + payload: { category: 'Test' }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('Name is required'); + }); + + it('should reject block with empty name with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/blocks`, + payload: { name: '' }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + // ─── List ─────────────────────────────────────────── + + describe('GET /api/drawings/:drawingId/blocks', () => { + it('should list blocks for a drawing', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/drawings/${drawingId}/blocks`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + }); + + it('should return empty array for drawing with no blocks', async () => { + const projRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Empty Blocks Project' }, + }); + const projId = JSON.parse(projRes.body).id; + const drawRes = await app.inject({ + method: 'POST', + url: `/api/projects/${projId}/drawings`, + payload: { name: 'Empty Drawing' }, + }); + const emptyDrawId = JSON.parse(drawRes.body).id; + + const response = await app.inject({ + method: 'GET', + url: `/api/drawings/${emptyDrawId}/blocks`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(0); + }); + }); + + // ─── Update ───────────────────────────────────────── + + describe('PATCH /api/blocks/:id', () => { + it('should update block name', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/blocks/${createdBlockId}`, + payload: { name: 'Updated Block Name' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('Updated Block Name'); + }); + + it('should update block category and description', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/blocks/${createdBlockId}`, + payload: { category: 'Bühne', description: 'Stage equipment' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.category).toBe('Bühne'); + expect(body.description).toBe('Stage equipment'); + }); + + it('should update block elements_json', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/blocks/${createdBlockId}`, + payload: { elements_json: '[{"type":"rect"}]' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.elements_json).toBe('[{"type":"rect"}]'); + }); + + it('should return 404 for non-existent block update', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/blocks/non-existent-id', + payload: { name: 'New Name' }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Delete ───────────────────────────────────────── + + describe('DELETE /api/blocks/:id', () => { + it('should delete a block', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/blocks`, + payload: { name: 'To Be Deleted' }, + }); + const blockId = JSON.parse(createRes.body).id; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/blocks/${blockId}`, + }); + expect(response.statusCode).toBe(204); + + // Verify it's gone via list + const listRes = await app.inject({ + method: 'GET', + url: `/api/drawings/${drawingId}/blocks`, + }); + const blocks = JSON.parse(listRes.body); + expect(blocks.find((b: any) => b.id === blockId)).toBeUndefined(); + }); + + it('should return 404 for non-existent block delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/blocks/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/backend/tests/drawings.test.ts b/backend/tests/drawings.test.ts new file mode 100644 index 0000000..57bc505 --- /dev/null +++ b/backend/tests/drawings.test.ts @@ -0,0 +1,191 @@ +/** + * Drawings 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('Drawings API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let projectId: string; + let createdDrawingId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + // Create a project first (drawings belong to projects) + const projectRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Drawings Test Project' }, + }); + projectId = JSON.parse(projectRes.body).id; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Create ───────────────────────────────────────── + + describe('POST /api/projects/:projectId/drawings', () => { + it('should create a drawing with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/drawings`, + payload: { name: 'Ground Floor' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.id).toBeDefined(); + expect(body.name).toBe('Ground Floor'); + expect(body.project_id).toBe(projectId); + createdDrawingId = body.id; + }); + + it('should create a drawing with default name', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/drawings`, + payload: {}, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.name).toBe('Unbenannt'); + expect(body.project_id).toBe(projectId); + }); + }); + + // ─── List ─────────────────────────────────────────── + + describe('GET /api/projects/:projectId/drawings', () => { + it('should list drawings for a project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/projects/${projectId}/drawings`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + }); + + it('should return empty array for project with no drawings', async () => { + // Create a new empty project + const projRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Empty Project' }, + }); + const emptyProjId = JSON.parse(projRes.body).id; + + const response = await app.inject({ + method: 'GET', + url: `/api/projects/${emptyProjId}/drawings`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(0); + }); + }); + + // ─── Get ──────────────────────────────────────────── + + describe('GET /api/drawings/:id', () => { + it('should get a single drawing', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/drawings/${createdDrawingId}`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(createdDrawingId); + expect(body.name).toBe('Ground Floor'); + }); + + it('should return 404 for non-existent drawing', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/drawings/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Update ───────────────────────────────────────── + + describe('PATCH /api/drawings/:id', () => { + it('should update drawing name', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/drawings/${createdDrawingId}`, + payload: { name: 'First Floor' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('First Floor'); + }); + + it('should update drawing data_json', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/drawings/${createdDrawingId}`, + payload: { data_json: '{"layers":[]}' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data_json).toBe('{"layers":[]}'); + }); + + it('should return 404 for non-existent drawing update', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/drawings/non-existent-id', + payload: { name: 'New Name' }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Delete ───────────────────────────────────────── + + describe('DELETE /api/drawings/:id', () => { + it('should delete a drawing', async () => { + // Create a drawing to delete + const createRes = await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/drawings`, + payload: { name: 'To Be Deleted' }, + }); + const drawingId = JSON.parse(createRes.body).id; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/drawings/${drawingId}`, + }); + expect(response.statusCode).toBe(204); + + // Verify it's gone + const getRes = await app.inject({ + method: 'GET', + url: `/api/drawings/${drawingId}`, + }); + expect(getRes.statusCode).toBe(404); + }); + + it('should return 404 for non-existent drawing delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/drawings/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/backend/tests/elements.test.ts b/backend/tests/elements.test.ts new file mode 100644 index 0000000..497395b --- /dev/null +++ b/backend/tests/elements.test.ts @@ -0,0 +1,212 @@ +/** + * Elements API Tests – CRUD (List / 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('Elements API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let drawingId: string; + let layerId: string; + let createdElementId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + // Create project → drawing → layer hierarchy + const projRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Elements Test Project' }, + }); + const projectId = JSON.parse(projRes.body).id; + + const drawRes = await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/drawings`, + payload: { name: 'Elements Test Drawing' }, + }); + drawingId = JSON.parse(drawRes.body).id; + + const layerRes = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/layers`, + payload: { name: 'Elements Layer' }, + }); + layerId = JSON.parse(layerRes.body).id; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Create ───────────────────────────────────────── + + describe('POST /api/drawings/:drawingId/elements', () => { + it('should create an element with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/elements`, + payload: { layer_id: layerId, type: 'rect', x: 10, y: 20, width: 100, height: 50 }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.id).toBeDefined(); + expect(body.type).toBe('rect'); + expect(body.x).toBe(10); + expect(body.y).toBe(20); + expect(body.width).toBe(100); + expect(body.height).toBe(50); + expect(body.drawing_id).toBe(drawingId); + expect(body.layer_id).toBe(layerId); + createdElementId = body.id; + }); + + it('should create an element with default values', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/elements`, + payload: { layer_id: layerId, type: 'circle' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.type).toBe('circle'); + expect(body.x).toBe(0); + expect(body.y).toBe(0); + expect(body.width).toBe(0); + expect(body.height).toBe(0); + expect(body.properties_json).toBe('{}'); + }); + }); + + // ─── List ─────────────────────────────────────────── + + describe('GET /api/drawings/:drawingId/elements', () => { + it('should list elements for a drawing', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/drawings/${drawingId}/elements`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + }); + + it('should return empty array for drawing with no elements', async () => { + // Create a new drawing with no elements + const projRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Empty Elements Project' }, + }); + const projId = JSON.parse(projRes.body).id; + const drawRes = await app.inject({ + method: 'POST', + url: `/api/projects/${projId}/drawings`, + payload: { name: 'Empty Drawing' }, + }); + const emptyDrawId = JSON.parse(drawRes.body).id; + + const response = await app.inject({ + method: 'GET', + url: `/api/drawings/${emptyDrawId}/elements`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(0); + }); + }); + + // ─── Update ───────────────────────────────────────── + + describe('PATCH /api/elements/:id', () => { + it('should update element position', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/elements/${createdElementId}`, + payload: { x: 100, y: 200 }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.x).toBe(100); + expect(body.y).toBe(200); + }); + + it('should update element dimensions', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/elements/${createdElementId}`, + payload: { width: 300, height: 150 }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.width).toBe(300); + expect(body.height).toBe(150); + }); + + it('should update element properties_json', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/elements/${createdElementId}`, + payload: { properties_json: '{"color":"red"}' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.properties_json).toBe('{"color":"red"}'); + }); + + it('should return 404 for non-existent element update', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/elements/non-existent-id', + payload: { x: 0 }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Delete ───────────────────────────────────────── + + describe('DELETE /api/elements/:id', () => { + it('should delete an element', async () => { + // Create an element to delete + const createRes = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/elements`, + payload: { layer_id: layerId, type: 'line' }, + }); + const elemId = JSON.parse(createRes.body).id; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/elements/${elemId}`, + }); + expect(response.statusCode).toBe(204); + + // Verify it's gone via list + const listRes = await app.inject({ + method: 'GET', + url: `/api/drawings/${drawingId}/elements`, + }); + const elements = JSON.parse(listRes.body); + expect(elements.find((e: any) => e.id === elemId)).toBeUndefined(); + }); + + it('should return 404 for non-existent element delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/elements/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/backend/tests/layers.test.ts b/backend/tests/layers.test.ts new file mode 100644 index 0000000..f1778cb --- /dev/null +++ b/backend/tests/layers.test.ts @@ -0,0 +1,199 @@ +/** + * Layers API Tests – CRUD (List / 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('Layers API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let drawingId: string; + let createdLayerId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + // Create project → drawing hierarchy + const projRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Layers Test Project' }, + }); + const projectId = JSON.parse(projRes.body).id; + + const drawRes = await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/drawings`, + payload: { name: 'Layers Test Drawing' }, + }); + drawingId = JSON.parse(drawRes.body).id; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Create ───────────────────────────────────────── + + describe('POST /api/drawings/:drawingId/layers', () => { + it('should create a layer with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/layers`, + payload: { name: 'Background', color: '#ff0000' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.id).toBeDefined(); + expect(body.name).toBe('Background'); + expect(body.color).toBe('#ff0000'); + expect(body.drawing_id).toBe(drawingId); + createdLayerId = body.id; + }); + + it('should create a layer with default values', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/layers`, + payload: {}, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.name).toBe('Layer'); + expect(body.visible).toBe(1); + expect(body.locked).toBe(0); + expect(body.color).toBe('#ffffff'); + expect(body.line_type).toBe('solid'); + }); + }); + + // ─── List ─────────────────────────────────────────── + + describe('GET /api/drawings/:drawingId/layers', () => { + it('should list layers for a drawing', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/drawings/${drawingId}/layers`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + }); + + it('should return empty array for drawing with no layers', async () => { + // Create a new drawing with no layers + const projRes = await app.inject({ + method: 'POST', + url: '/api/projects', + payload: { name: 'Empty Layers Project' }, + }); + const projId = JSON.parse(projRes.body).id; + const drawRes = await app.inject({ + method: 'POST', + url: `/api/projects/${projId}/drawings`, + payload: { name: 'Empty Drawing' }, + }); + const emptyDrawId = JSON.parse(drawRes.body).id; + + const response = await app.inject({ + method: 'GET', + url: `/api/drawings/${emptyDrawId}/layers`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(0); + }); + }); + + // ─── Update ───────────────────────────────────────── + + describe('PATCH /api/layers/:id', () => { + it('should update layer name', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/layers/${createdLayerId}`, + payload: { name: 'Updated Layer Name' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('Updated Layer Name'); + }); + + it('should update layer visibility', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/layers/${createdLayerId}`, + payload: { visible: 0, locked: 1 }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.visible).toBe(0); + expect(body.locked).toBe(1); + }); + + it('should update layer color and line_type', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/layers/${createdLayerId}`, + payload: { color: '#00ff00', line_type: 'dashed' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.color).toBe('#00ff00'); + expect(body.line_type).toBe('dashed'); + }); + + it('should return 404 for non-existent layer update', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/layers/non-existent-id', + payload: { name: 'New Name' }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Delete ───────────────────────────────────────── + + describe('DELETE /api/layers/:id', () => { + it('should delete a layer', async () => { + // Create a layer to delete + const createRes = await app.inject({ + method: 'POST', + url: `/api/drawings/${drawingId}/layers`, + payload: { name: 'To Be Deleted' }, + }); + const layerId = JSON.parse(createRes.body).id; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/layers/${layerId}`, + }); + expect(response.statusCode).toBe(204); + + // Verify it's gone via list + const listRes = await app.inject({ + method: 'GET', + url: `/api/drawings/${drawingId}/layers`, + }); + const layers = JSON.parse(listRes.body); + expect(layers.find((l: any) => l.id === layerId)).toBeUndefined(); + }); + + it('should return 404 for non-existent layer delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/layers/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/backend/tests/settings.test.ts b/backend/tests/settings.test.ts new file mode 100644 index 0000000..cf2aea3 --- /dev/null +++ b/backend/tests/settings.test.ts @@ -0,0 +1,129 @@ +/** + * Settings API Tests – Key/Value settings (Get / Put / Upsert) + */ +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('Settings API', () => { + 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(); + }); + + // ─── Get (missing key → 404) ───────────────────────── + + describe('GET /api/settings/:key', () => { + it('should return 404 for non-existent setting', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/settings/non-existent-key', + }); + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body); + expect(body.error).toContain('Setting not found'); + }); + + it('should return a setting that was previously set', async () => { + // First set a value + await app.inject({ + method: 'PUT', + url: '/api/settings/test-key', + payload: { value: 'test-value' }, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/settings/test-key', + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.key).toBe('test-key'); + expect(body.value).toBe('test-value'); + expect(body.updated_at).toBeDefined(); + }); + }); + + // ─── Put (upsert) ─────────────────────────────────── + + describe('PUT /api/settings/:key', () => { + it('should create a new setting', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/new-setting', + payload: { value: 'new-value' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.key).toBe('new-setting'); + expect(body.value).toBe('new-value'); + expect(body.updated_at).toBeDefined(); + }); + + it('should update an existing setting (upsert)', async () => { + // Create initial + await app.inject({ + method: 'PUT', + url: '/api/settings/upsert-key', + payload: { value: 'initial' }, + }); + + // Update it + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/upsert-key', + payload: { value: 'updated' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.key).toBe('upsert-key'); + expect(body.value).toBe('updated'); + + // Verify via GET + const getRes = await app.inject({ + method: 'GET', + url: '/api/settings/upsert-key', + }); + const getBody = JSON.parse(getRes.body); + expect(getBody.value).toBe('updated'); + }); + + it('should reject setting without value', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/no-value-key', + payload: {}, + }); + const body = JSON.parse(response.body); + expect(body.error).toContain('Value is required'); + }); + + it('should handle complex JSON values', async () => { + const complexValue = JSON.stringify({ nested: { object: true }, array: [1, 2, 3] }); + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/complex-config', + payload: { value: complexValue }, + }); + expect(response.statusCode).toBe(200); + + const getRes = await app.inject({ + method: 'GET', + url: '/api/settings/complex-config', + }); + const body = JSON.parse(getRes.body); + expect(body.value).toBe(complexValue); + }); + }); +}); diff --git a/backend/tests/users.test.ts b/backend/tests/users.test.ts new file mode 100644 index 0000000..381a387 --- /dev/null +++ b/backend/tests/users.test.ts @@ -0,0 +1,187 @@ +/** + * Users API Tests – List / Get / Update / Delete (password_hash stripping) + */ +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('Users API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let testUserId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + // Create a test user directly via DB (bypassing AuthService) + const user = db.createUser({ + email: 'test-admin@example.com', + password_hash: 'fake-hash-for-testing', + name: 'Test Admin', + role: 'admin', + }); + testUserId = user.id; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── List ─────────────────────────────────────────── + + describe('GET /api/users', () => { + it('should list all users', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/users', + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); // default user + test user + }); + + it('should strip password_hash from list responses', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/users', + }); + const body = JSON.parse(response.body); + for (const user of body) { + expect(user.password_hash).toBeUndefined(); + } + }); + }); + + // ─── Get ──────────────────────────────────────────── + + describe('GET /api/users/:id', () => { + it('should get a single user', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/users/${testUserId}`, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(testUserId); + expect(body.email).toBe('test-admin@example.com'); + expect(body.name).toBe('Test Admin'); + expect(body.role).toBe('admin'); + }); + + it('should strip password_hash from single user response', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/users/${testUserId}`, + }); + const body = JSON.parse(response.body); + expect(body.password_hash).toBeUndefined(); + }); + + it('should return 404 for non-existent user', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/users/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Update ───────────────────────────────────────── + + describe('PATCH /api/users/:id', () => { + it('should update user name', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/users/${testUserId}`, + payload: { name: 'Updated Name' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('Updated Name'); + expect(body.id).toBe(testUserId); + }); + + it('should update user role', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/users/${testUserId}`, + payload: { role: 'planer' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.role).toBe('planer'); + }); + + it('should strip password_hash from update response', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/users/${testUserId}`, + payload: { name: 'Another Name' }, + }); + const body = JSON.parse(response.body); + expect(body.password_hash).toBeUndefined(); + }); + + it('should not update password_hash via PATCH endpoint', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/users/${testUserId}`, + payload: { password_hash: 'hacked-hash' }, + }); + expect(response.statusCode).toBe(200); + // Verify the password_hash was NOT changed in DB + const dbUser = db.getUser(testUserId); + expect(dbUser!.password_hash).toBe('fake-hash-for-testing'); + }); + + it('should return 404 for non-existent user update', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/users/non-existent-id', + payload: { name: 'New Name' }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Delete ───────────────────────────────────────── + + describe('DELETE /api/users/:id', () => { + it('should delete a user', async () => { + // Create a user to delete + const user = db.createUser({ + email: 'delete-me@example.com', + password_hash: 'temp-hash', + name: 'Delete Me', + role: 'planer', + }); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/users/${user.id}`, + }); + expect(response.statusCode).toBe(204); + + // Verify it's gone + const getRes = await app.inject({ + method: 'GET', + url: `/api/users/${user.id}`, + }); + expect(getRes.statusCode).toBe(404); + }); + + it('should return 404 for non-existent user delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/users/non-existent-id', + }); + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/frontend/src/canvas/ZoomPanController.ts b/frontend/src/canvas/ZoomPanController.ts index 3d3122f..0cfed9e 100644 --- a/frontend/src/canvas/ZoomPanController.ts +++ b/frontend/src/canvas/ZoomPanController.ts @@ -23,11 +23,13 @@ export class ZoomPanController { getViewport(): Viewport { const w = this.canvas.width / this.scale; const h = this.canvas.height / this.scale; + const minX = -this.offsetX / this.scale || 0; + const minY = -this.offsetY / this.scale || 0; return { - minX: -this.offsetX / this.scale, - minY: -this.offsetY / this.scale, - maxX: (-this.offsetX / this.scale) + w, - maxY: (-this.offsetY / this.scale) + h, + minX, + minY, + maxX: minX + w, + maxY: minY + h, }; } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 2ef5de4..9b87296 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -3,7 +3,7 @@ */ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; -const API_BASE = 'http://localhost:3001'; +const API_BASE = import.meta.env.VITE_API_BASE || ''; export interface AuthUser { id: string; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d3e4934..abb0379 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useAuth } from '../contexts/AuthContext'; -const API_BASE = 'http://localhost:3001'; +const API_BASE = import.meta.env.VITE_API_BASE || ''; interface Project { id: string; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 39b8f32..f3febd3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -3,7 +3,7 @@ */ import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types'; -const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001'; +const API_BASE = import.meta.env.VITE_API_BASE || ''; function authHeaders(token: string): Record { return { diff --git a/frontend/tests/GroupTool.test.ts b/frontend/tests/GroupTool.test.ts new file mode 100644 index 0000000..4fa8893 --- /dev/null +++ b/frontend/tests/GroupTool.test.ts @@ -0,0 +1,190 @@ +/** + * GroupTool Tests – GroupManager: create, ungroup, nest, query + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GroupManager } from '../src/tools/modification/GroupTool'; +import type { ElementGroup } from '../src/tools/modification/GroupTool'; + +describe('GroupManager', () => { + let gm: GroupManager; + + beforeEach(() => { + gm = new GroupManager(); + }); + + describe('createGroup', () => { + it('should create a group with element IDs', () => { + const group = gm.createGroup(['el-1', 'el-2', 'el-3']); + expect(group.id).toBeDefined(); + expect(group.elementIds).toEqual(['el-1', 'el-2', 'el-3']); + expect(group.parentGroupId).toBeNull(); + }); + + it('should create a group with a custom name', () => { + const group = gm.createGroup(['el-1'], 'My Group'); + expect(group.name).toBe('My Group'); + }); + + it('should create a group with default name when no name provided', () => { + const group = gm.createGroup(['el-1']); + expect(group.name).toContain('Group'); + }); + + it('should create multiple groups with unique IDs', () => { + const g1 = gm.createGroup(['el-1']); + const g2 = gm.createGroup(['el-2']); + expect(g1.id).not.toBe(g2.id); + }); + }); + + describe('ungroup', () => { + it('should remove a group and return its element IDs', () => { + const group = gm.createGroup(['el-1', 'el-2']); + const ids = gm.ungroup(group.id); + expect(ids).toEqual(['el-1', 'el-2']); + expect(gm.getGroup(group.id)).toBeUndefined(); + }); + + it('should return empty array for non-existent group', () => { + const ids = gm.ungroup('non-existent'); + expect(ids).toEqual([]); + }); + }); + + describe('getGroup', () => { + it('should retrieve a group by ID', () => { + const group = gm.createGroup(['el-1']); + const retrieved = gm.getGroup(group.id); + expect(retrieved).toBeDefined(); + expect(retrieved!.id).toBe(group.id); + }); + + it('should return undefined for non-existent group', () => { + expect(gm.getGroup('non-existent')).toBeUndefined(); + }); + }); + + describe('getGroups', () => { + it('should return all groups', () => { + gm.createGroup(['el-1']); + gm.createGroup(['el-2']); + expect(gm.getGroups().length).toBe(2); + }); + + it('should return empty array when no groups', () => { + expect(gm.getGroups()).toEqual([]); + }); + }); + + describe('getGroupedElements', () => { + it('should return set of all element IDs in all groups', () => { + gm.createGroup(['el-1', 'el-2']); + gm.createGroup(['el-3']); + const ids = gm.getGroupedElements(); + expect(ids.size).toBe(3); + expect(ids.has('el-1')).toBe(true); + expect(ids.has('el-2')).toBe(true); + expect(ids.has('el-3')).toBe(true); + }); + + it('should return empty set when no groups', () => { + expect(gm.getGroupedElements().size).toBe(0); + }); + }); + + describe('getGroupForElement', () => { + it('should return group ID for an element in a group', () => { + const group = gm.createGroup(['el-1', 'el-2']); + expect(gm.getGroupForElement('el-1')).toBe(group.id); + expect(gm.getGroupForElement('el-2')).toBe(group.id); + }); + + it('should return null for element not in any group', () => { + expect(gm.getGroupForElement('el-lonely')).toBeNull(); + }); + }); + + describe('moveGroup', () => { + it('should return element IDs that need to be moved', () => { + const group = gm.createGroup(['el-1', 'el-2']); + const ids = gm.moveGroup(group.id, 10, 20); + expect(ids).toEqual(['el-1', 'el-2']); + }); + + it('should return empty array for non-existent group', () => { + const ids = gm.moveGroup('non-existent', 10, 20); + expect(ids).toEqual([]); + }); + }); + + describe('setParent (nested groups)', () => { + it('should set parent group for nesting', () => { + const parent = gm.createGroup(['el-1']); + const child = gm.createGroup(['el-2']); + gm.setParent(child.id, parent.id); + expect(gm.getGroup(child.id)!.parentGroupId).toBe(parent.id); + }); + + it('should prevent circular references', () => { + const g1 = gm.createGroup(['el-1']); + const g2 = gm.createGroup(['el-2']); + gm.setParent(g1.id, g2.id); + // Trying to set g2's parent to g1 would create a cycle: g1 -> g2 -> g1 + gm.setParent(g2.id, g1.id); + expect(gm.getGroup(g2.id)!.parentGroupId).toBeNull(); + }); + + it('should allow setting parent to null', () => { + const parent = gm.createGroup(['el-1']); + const child = gm.createGroup(['el-2']); + gm.setParent(child.id, parent.id); + gm.setParent(child.id, null); + expect(gm.getGroup(child.id)!.parentGroupId).toBeNull(); + }); + + it('should do nothing for non-existent group', () => { + gm.setParent('non-existent', null); + expect(gm.getGroup('non-existent')).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('should clear all groups', () => { + gm.createGroup(['el-1']); + gm.createGroup(['el-2']); + gm.clear(); + expect(gm.getGroups().length).toBe(0); + }); + }); + + describe('toJSON & fromJSON', () => { + it('should serialize groups to JSON', () => { + gm.createGroup(['el-1', 'el-2'], 'Group A'); + gm.createGroup(['el-3'], 'Group B'); + const json = gm.toJSON(); + expect(json.length).toBe(2); + expect(json[0].name).toBe('Group A'); + expect(json[1].name).toBe('Group B'); + }); + + it('should restore groups from JSON', () => { + const groups: ElementGroup[] = [ + { id: 'grp-1', name: 'Restored A', elementIds: ['el-1'], parentGroupId: null }, + { id: 'grp-2', name: 'Restored B', elementIds: ['el-2', 'el-3'], parentGroupId: 'grp-1' }, + ]; + gm.fromJSON(groups); + expect(gm.getGroups().length).toBe(2); + expect(gm.getGroup('grp-1')!.name).toBe('Restored A'); + expect(gm.getGroup('grp-2')!.parentGroupId).toBe('grp-1'); + }); + + it('should clear existing groups when restoring from JSON', () => { + gm.createGroup(['el-old']); + gm.fromJSON([ + { id: 'grp-new', name: 'New', elementIds: ['el-new'], parentGroupId: null }, + ]); + expect(gm.getGroups().length).toBe(1); + expect(gm.getGroup('grp-new')).toBeDefined(); + }); + }); +}); diff --git a/frontend/tests/IntegrationWorkflow.test.ts b/frontend/tests/IntegrationWorkflow.test.ts new file mode 100644 index 0000000..b88c38b --- /dev/null +++ b/frontend/tests/IntegrationWorkflow.test.ts @@ -0,0 +1,911 @@ +/** + * Integration Workflow Test — Full CAD workflow across ALL components. + * + * Tests the complete lifecycle: init → layers → elements → render → zoom/pan → + * selection → grouping → snap → history → layer toggle → zoom fit → reset. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { RenderEngine } from '../src/canvas/RenderEngine'; +import { SelectionEngine } from '../src/canvas/SelectionEngine'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { LayerManager } from '../src/canvas/LayerManager'; +import { SnapEngine } from '../src/canvas/SnapEngine'; +import { HistoryManager } from '../src/history/HistoryManager'; +import { GroupManager } from '../src/tools/modification/GroupTool'; +import { CommandRegistry } from '../src/services/commandRegistry'; +import type { CADElement, CADLayer, BlockDefinition } from '../src/types/cad.types'; +import type { BackgroundConfig } from '../src/services/backgroundService'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + return canvas; +} + +function makeLayer(id: string, overrides: Partial = {}): CADLayer { + return { + id, + name: id, + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid' as const, + transparency: 0, + sortOrder: 0, + parentId: null, + ...overrides, + }; +} + +function makeLine( + id: string, + x1: number, y1: number, + x2: number, y2: number, + layerId = 'layer-default', +): CADElement { + return { + id, + type: 'line', + layerId, + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2, stroke: '#ffffff' }, + }; +} + +function makeRect( + id: string, + cx: number, cy: number, + w: number, h: number, + layerId = 'layer-default', +): CADElement { + return { + id, + type: 'rect', + layerId, + x: cx, + y: cy, + width: w, + height: h, + properties: { stroke: '#ffffff' }, + }; +} + +function makeCircle( + id: string, + cx: number, cy: number, + r: number, + layerId = 'layer-default', +): CADElement { + return { + id, + type: 'circle', + layerId, + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r, stroke: '#ffffff' }, + }; +} + +function makeSnapshot( + elements: CADElement[], + layers: CADLayer[], + blocks: BlockDefinition[] = [], + groups: ReturnType = [], + bgConfig: BackgroundConfig | null = null, +) { + return { elements, layers, blocks, groups, bgConfig }; +} + +// ─── Integration Test ───────────────────────────────────────────────────────── + +describe('Integration Workflow — Full CAD Lifecycle', () => { + // Component instances + let canvas: HTMLCanvasElement; + let layerManager: LayerManager; + let spatialIndex: SpatialIndex; + let zoomPan: ZoomPanController; + let renderEngine: RenderEngine; + let selectionEngine: SelectionEngine; + let snapEngine: SnapEngine; + let historyManager: HistoryManager; + let groupManager: GroupManager; + let commandRegistry: CommandRegistry; + + // Shared state + let layers: CADLayer[]; + let elements: CADElement[]; + + beforeEach(() => { + canvas = createMockCanvas(800, 600); + + // Step 1: Initialize all components + layerManager = new LayerManager(); + spatialIndex = new SpatialIndex(); + zoomPan = new ZoomPanController(canvas); + renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager); + selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager); + snapEngine = new SnapEngine({ tolerance: 10 }); + historyManager = new HistoryManager(); + groupManager = new GroupManager(); + commandRegistry = new CommandRegistry(); + + elements = []; + layers = []; + }); + + // ─── Step 1: Initialize all components ────────────────────────────────────── + describe('Step 1: Component initialization', () => { + it('should instantiate all components without errors', () => { + expect(layerManager).toBeDefined(); + expect(spatialIndex).toBeDefined(); + expect(zoomPan).toBeDefined(); + expect(renderEngine).toBeDefined(); + expect(selectionEngine).toBeDefined(); + expect(snapEngine).toBeDefined(); + expect(historyManager).toBeDefined(); + expect(groupManager).toBeDefined(); + expect(commandRegistry).toBeDefined(); + }); + + it('should have correct initial state', () => { + expect(layerManager.getLayers()).toHaveLength(0); + expect(layerManager.getActiveLayerId()).toBe(''); + expect(zoomPan.getScale()).toBe(1); + expect(selectionEngine.getSelectedIds().size).toBe(0); + expect(groupManager.getGroups()).toHaveLength(0); + expect(historyManager.getCurrentState()).toBeNull(); + expect(historyManager.canUndo()).toBe(false); + expect(historyManager.canRedo()).toBe(false); + }); + }); + + // ─── Step 2: Create layers, set visibility/lock ───────────────────────────── + describe('Step 2: Layer management', () => { + it('should create default and additional layers, set visibility/lock', () => { + const defaultLayer = makeLayer('layer-default', { name: 'Default', sortOrder: 0 }); + const archLayer = makeLayer('layer-arch', { name: 'Architecture', sortOrder: 1, color: '#ff0000' }); + const hiddenLayer = makeLayer('layer-hidden', { name: 'Hidden', sortOrder: 2, visible: false }); + const lockedLayer = makeLayer('layer-locked', { name: 'Locked', sortOrder: 3, locked: true }); + + layerManager.addLayer(defaultLayer); + layerManager.addLayer(archLayer); + layerManager.addLayer(hiddenLayer); + layerManager.addLayer(lockedLayer); + + layers = layerManager.getLayers(); + expect(layers).toHaveLength(4); + expect(layers[0].id).toBe('layer-default'); // sortOrder 0 + expect(layers[3].id).toBe('layer-locked'); // sortOrder 3 + + // Active layer should be first added + expect(layerManager.getActiveLayerId()).toBe('layer-default'); + + // Set active layer + layerManager.setActiveLayer('layer-arch'); + expect(layerManager.getActiveLayerId()).toBe('layer-arch'); + expect(layerManager.getActiveLayer()?.name).toBe('Architecture'); + + // Toggle visibility on arch layer + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(false); + + // Toggle back + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(true); + + // Toggle lock on default layer + layerManager.toggleLock('layer-default'); + expect(layerManager.isLocked('layer-default')).toBe(true); + + // getVisibleLayers returns visible && !locked + const visible = layerManager.getVisibleLayers(); + const visibleIds = visible.map(l => l.id); + // layer-default: visible=true but locked=true → excluded + // layer-arch: visible=true, locked=false → included + // layer-hidden: visible=false → excluded + // layer-locked: visible=true but locked=true → excluded + expect(visibleIds).toContain('layer-arch'); + expect(visibleIds).not.toContain('layer-default'); + expect(visibleIds).not.toContain('layer-hidden'); + expect(visibleIds).not.toContain('layer-locked'); + + // Unlock default for subsequent tests + layerManager.toggleLock('layer-default'); + expect(layerManager.isLocked('layer-default')).toBe(false); + }); + }); + + // ─── Step 3: Add elements to different layers, insert into SpatialIndex ────── + describe('Step 3: Add elements to layers and SpatialIndex', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); + }); + + it('should add line, rect, circle to different layers and insert into SpatialIndex', () => { + const line1 = makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'); + const rect1 = makeRect('el-rect-1', 50, 50, 80, 60, 'layer-arch'); + const circle1 = makeCircle('el-circle-1', 200, 200, 40, 'layer-default'); + + elements = [line1, rect1, circle1]; + + // Insert into spatial index + for (const el of elements) { + spatialIndex.insert(el); + } + + // Verify spatial index search returns elements in viewport + const viewport = { minX: -100, minY: -100, maxX: 300, maxY: 300 }; + const found = spatialIndex.search(viewport); + expect(found).toHaveLength(3); + expect(found.map(e => e.id).sort()).toEqual(['el-circle-1', 'el-line-1', 'el-rect-1']); + + // Verify search with smaller viewport + const smallVp = { minX: -10, minY: -10, maxX: 60, maxY: 60 }; + const smallFound = spatialIndex.search(smallVp); + // line1 bbox: minX=-50, minY=-50, maxX=150, maxY=150 → intersects + // rect1 bbox: minX=10, minY=20, maxX=90, maxY=80 → intersects + // circle1 bbox: minX=160, minY=160, maxX=240, maxY=240 → no + expect(smallFound.map(e => e.id).sort()).toEqual(['el-line-1', 'el-rect-1']); + }); + }); + + // ─── Step 4: Render (verify no crash) ─────────────────────────────────────── + describe('Step 4: Rendering', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 50, 50, 80, 60, 'layer-arch'), + makeCircle('el-circle-1', 200, 200, 40, 'layer-default'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should render without crashing', () => { + expect(() => renderEngine.render()).not.toThrow(); + }); + + it('should render with grid enabled', () => { + renderEngine.setOptions({ showGrid: true }); + expect(() => renderEngine.render()).not.toThrow(); + }); + + it('should render with grid disabled', () => { + renderEngine.setOptions({ showGrid: false }); + expect(() => renderEngine.render()).not.toThrow(); + }); + }); + + // ─── Step 5: Zoom/Pan ──────────────────────────────────────────────────────── + describe('Step 5: Zoom and Pan', () => { + it('should zoomAt, pan, and verify worldToScreen/screenToWorld roundtrip', () => { + // Initial state + expect(zoomPan.getScale()).toBe(1); + const initialViewport = zoomPan.getViewport(); + expect(initialViewport.minX).toBe(0); + expect(initialViewport.minY).toBe(0); + + // Zoom in at center (400, 300) with factor 2 + zoomPan.zoomAt(400, 300, 2); + expect(zoomPan.getScale()).toBe(2); + + // After zoom, viewport should be smaller in world space + const zoomedViewport = zoomPan.getViewport(); + expect(zoomedViewport.maxX - zoomedViewport.minX).toBe(400); // 800/2 + expect(zoomedViewport.maxY - zoomedViewport.minY).toBe(300); // 600/2 + + // Pan by (50, 30) — adds to offset set by zoomAt + // zoomAt(400,300,2): offsetX = 400-(400-0)*2 = -400, offsetY = 300-(300-0)*2 = -300 + zoomPan.pan(50, 30); + const transform = zoomPan.getTransform(); + expect(transform.e).toBe(-350); // -400 + 50 + expect(transform.f).toBe(-270); // -300 + 30 + + // worldToScreen / screenToWorld roundtrip + const worldPt = { x: 150, y: 75 }; + const screenPt = zoomPan.worldToScreen(worldPt.x, worldPt.y); + const backToWorld = zoomPan.screenToWorld(screenPt.x, screenPt.y); + expect(backToWorld.x).toBeCloseTo(worldPt.x, 5); + expect(backToWorld.y).toBeCloseTo(worldPt.y, 5); + + // Another roundtrip with different point + const worldPt2 = { x: -50, y: 200 }; + const screenPt2 = zoomPan.worldToScreen(worldPt2.x, worldPt2.y); + const back2 = zoomPan.screenToWorld(screenPt2.x, screenPt2.y); + expect(back2.x).toBeCloseTo(worldPt2.x, 5); + expect(back2.y).toBeCloseTo(worldPt2.y, 5); + }); + }); + + // ─── Step 6: Selection ────────────────────────────────────────────────────── + describe('Step 6: Selection (click and box)', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), + makeCircle('el-circle-1', 400, 400, 40, 'layer-default'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should click-select a single element', () => { + // Click near the line's midpoint (50, 50) + const hit = selectionEngine.clickSelect(50, 50, elements); + expect(hit).not.toBeNull(); + expect(hit?.id).toBe('el-line-1'); + + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.size).toBe(1); + expect(selectedIds.has('el-line-1')).toBe(true); + }); + + it('should click-select the rect element', () => { + // Click at rect center (200, 200) + const hit = selectionEngine.clickSelect(200, 200, elements); + expect(hit).not.toBeNull(); + expect(hit?.id).toBe('el-rect-1'); + expect(selectionEngine.getSelectedIds().has('el-rect-1')).toBe(true); + }); + + it('should clear selection when clicking empty space', () => { + // First select an element + selectionEngine.clickSelect(50, 50, elements); + expect(selectionEngine.getSelectedIds().size).toBe(1); + + // Click in empty area (far from elements) + const hit = selectionEngine.clickSelect(1000, 1000, elements); + expect(hit).toBeNull(); + expect(selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should box-select multiple elements (window selection)', () => { + // Window selection: left-to-right drag enclosing line and rect + // line bbox: minX=-50, minY=-50, maxX=150, maxY=150 + // rect bbox: minX=160, minY=170, maxX=240, maxY=230 + // Need a box that fully encloses both + selectionEngine.startBoxSelect(-100, -100); + selectionEngine.updateBoxSelect(300, 300, elements); + const selected = selectionEngine.finishBoxSelect(elements); + + // Both line and rect should be fully enclosed + expect(selected.length).toBeGreaterThanOrEqual(2); + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.has('el-line-1')).toBe(true); + expect(selectedIds.has('el-rect-1')).toBe(true); + }); + + it('should box-select with crossing mode (right-to-left drag)', () => { + // Crossing selection: right-to-left drag (start X > end X) + // Use a box that intersects the circle but doesn't fully enclose it + // circle bbox: minX=360, minY=360, maxX=440, maxY=440 + selectionEngine.startBoxSelect(420, 420); + selectionEngine.updateBoxSelect(380, 380, elements); + const selected = selectionEngine.finishBoxSelect(elements); + + // Circle should be selected (intersecting) + expect(selected.length).toBeGreaterThanOrEqual(1); + expect(selectionEngine.getSelectedIds().has('el-circle-1')).toBe(true); + }); + + it('should support additive selection (shift-click)', () => { + // Select line first + selectionEngine.clickSelect(50, 50, elements); + expect(selectionEngine.getSelectedIds().size).toBe(1); + + // Additive select rect + selectionEngine.setOptions({ additive: true }); + selectionEngine.clickSelect(200, 200, elements); + + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.size).toBe(2); + expect(selectedIds.has('el-line-1')).toBe(true); + expect(selectedIds.has('el-rect-1')).toBe(true); + }); + }); + + // ─── Step 7: Group selected elements ───────────────────────────────────────── + describe('Step 7: Grouping', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1 })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), + makeCircle('el-circle-1', 400, 400, 40, 'layer-default'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should group selected elements and verify membership', () => { + // Select two elements + selectionEngine.selectByIds(['el-line-1', 'el-rect-1']); + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.size).toBe(2); + + // Create group from selected elements + const group = groupManager.createGroup(['el-line-1', 'el-rect-1'], 'Test Group'); + expect(group.id).toBeDefined(); + expect(group.name).toBe('Test Group'); + expect(group.elementIds).toHaveLength(2); + expect(group.elementIds).toContain('el-line-1'); + expect(group.elementIds).toContain('el-rect-1'); + + // Verify group membership + expect(groupManager.getGroupForElement('el-line-1')).toBe(group.id); + expect(groupManager.getGroupForElement('el-rect-1')).toBe(group.id); + expect(groupManager.getGroupForElement('el-circle-1')).toBeNull(); + + // Verify grouped elements set + const grouped = groupManager.getGroupedElements(); + expect(grouped.size).toBe(2); + expect(grouped.has('el-line-1')).toBe(true); + expect(grouped.has('el-rect-1')).toBe(true); + + // Verify group retrieval + const retrieved = groupManager.getGroup(group.id); + expect(retrieved).toBeDefined(); + expect(retrieved?.name).toBe('Test Group'); + + // Ungroup + const ungroupedIds = groupManager.ungroup(group.id); + expect(ungroupedIds).toHaveLength(2); + expect(groupManager.getGroups()).toHaveLength(0); + expect(groupManager.getGroupedElements().size).toBe(0); + }); + }); + + // ─── Step 8: Snap to endpoints/midpoints ───────────────────────────────────── + describe('Step 8: Snap engine', () => { + beforeEach(() => { + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeCircle('el-circle-1', 200, 200, 40, 'layer-default'), + ]; + snapEngine.setElements(elements); + }); + + it('should snap to line endpoint', () => { + // Line endpoints: (0,0) and (100,100) + // Click near (0, 0) within tolerance + const result = snapEngine.snap(2, 2); + expect(result.point).not.toBeNull(); + expect(result.point?.type).toBe('endpoint'); + expect(result.point?.x).toBeCloseTo(0, 1); + expect(result.point?.y).toBeCloseTo(0, 1); + }); + + it('should snap to second line endpoint', () => { + const result = snapEngine.snap(98, 98); + expect(result.point).not.toBeNull(); + expect(result.point?.type).toBe('endpoint'); + expect(result.point?.x).toBeCloseTo(100, 1); + expect(result.point?.y).toBeCloseTo(100, 1); + }); + + it('should snap to line midpoint', () => { + // Line midpoint: (50, 50) + const result = snapEngine.snap(52, 52); + expect(result.point).not.toBeNull(); + // Endpoint (0,0) is ~74 units away, midpoint (50,50) is ~2.8 units away + // But endpoint priority > midpoint priority, so if both within tolerance * 1.5... + // tolerance=10, so endpoint at distance ~74 is NOT within 10*1.5=15 + // midpoint at distance ~2.8 IS within 15 + expect(result.point?.type).toBe('midpoint'); + expect(result.point?.x).toBeCloseTo(50, 1); + expect(result.point?.y).toBeCloseTo(50, 1); + }); + + it('should snap to circle center', () => { + // Circle center: (200, 200) + const result = snapEngine.snap(202, 202); + expect(result.point).not.toBeNull(); + expect(result.point?.type).toBe('center'); + expect(result.point?.x).toBeCloseTo(200, 1); + expect(result.point?.y).toBeCloseTo(200, 1); + }); + + it('should return null when no snap point is near', () => { + // Click far from any element + const result = snapEngine.snap(500, 500); + expect(result.point).toBeNull(); + }); + + it('should return preview candidates', () => { + const result = snapEngine.snap(2, 2); + expect(result.preview.length).toBeGreaterThan(0); + }); + }); + + // ─── Step 9: History (push, undo, redo) ────────────────────────────────────── + describe('Step 9: History management', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + }); + + it('should push snapshot, modify, undo, redo and verify state restored', () => { + const initialElements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + ]; + const initialLayers = layerManager.getLayers(); + + // Initialize history with initial state + historyManager.initialize(makeSnapshot(initialElements, initialLayers)); + expect(historyManager.getCurrentState()?.elements).toHaveLength(1); + expect(historyManager.canUndo()).toBe(false); + expect(historyManager.canRedo()).toBe(false); + + // Push a new state: add a rect element + const modifiedElements = [ + ...initialElements, + makeRect('el-rect-1', 50, 50, 80, 60, 'layer-default'), + ]; + historyManager.pushSnapshot(makeSnapshot(modifiedElements, initialLayers), 'Add rect'); + + expect(historyManager.getCurrentState()?.elements).toHaveLength(2); + expect(historyManager.canUndo()).toBe(true); + expect(historyManager.canRedo()).toBe(false); + + // Undo: should restore to 1 element + const undone = historyManager.undo(); + expect(undone).not.toBeNull(); + expect(undone?.elements).toHaveLength(1); + expect(undone?.elements[0].id).toBe('el-line-1'); + expect(historyManager.canRedo()).toBe(true); + + // Redo: should restore to 2 elements + const redone = historyManager.redo(); + expect(redone).not.toBeNull(); + expect(redone?.elements).toHaveLength(2); + expect(redone?.elements[1].id).toBe('el-rect-1'); + expect(historyManager.canUndo()).toBe(true); + expect(historyManager.canRedo()).toBe(false); + }); + + it('should handle multiple undo/redo cycles', () => { + const layers = layerManager.getLayers(); + + // Initialize + historyManager.initialize(makeSnapshot([], layers)); + + // Push state 1 + const els1 = [makeLine('el-1', 0, 0, 50, 50, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(els1, layers), 'Add line 1'); + + // Push state 2 + const els2 = [...els1, makeLine('el-2', 100, 100, 200, 200, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(els2, layers), 'Add line 2'); + + // Push state 3 + const els3 = [...els2, makeLine('el-3', 300, 300, 400, 400, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(els3, layers), 'Add line 3'); + + expect(historyManager.getCurrentState()?.elements).toHaveLength(3); + expect(historyManager.getUndoCount()).toBe(3); + + // Undo twice + historyManager.undo(); + expect(historyManager.getCurrentState()?.elements).toHaveLength(2); + historyManager.undo(); + expect(historyManager.getCurrentState()?.elements).toHaveLength(1); + expect(historyManager.getRedoCount()).toBe(2); + + // Redo once + historyManager.redo(); + expect(historyManager.getCurrentState()?.elements).toHaveLength(2); + expect(historyManager.getRedoCount()).toBe(1); + }); + }); + + // ─── Step 10: Layer toggle (hide layer, verify not rendered/selectable) ────── + describe('Step 10: Layer toggle affects rendering and selection', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-hidden', { sortOrder: 1 })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-hidden'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should not render elements on hidden layers', () => { + // Initially both layers visible — render should work + expect(() => renderEngine.render()).not.toThrow(); + + // Hide layer-hidden + layerManager.toggleVisibility('layer-hidden'); + expect(layerManager.getLayer('layer-hidden')?.visible).toBe(false); + + // Render should still not crash (just skips hidden layer elements) + expect(() => renderEngine.render()).not.toThrow(); + + // getVisibleLayers should not include hidden layer + const visible = layerManager.getVisibleLayers(); + expect(visible.some(l => l.id === 'layer-hidden')).toBe(false); + }); + + it('should not select elements on hidden layers via hitTest', () => { + // Hide layer-hidden + layerManager.toggleVisibility('layer-hidden'); + + // Try to click-select the rect on hidden layer + const hit = selectionEngine.clickSelect(200, 200, elements); + expect(hit).toBeNull(); + expect(selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should not select elements on locked layers via hitTest', () => { + // Lock layer-hidden (visible but locked → not in getVisibleLayers) + layerManager.toggleLock('layer-hidden'); + + // Try to click-select the rect on locked layer + const hit = selectionEngine.clickSelect(200, 200, elements); + expect(hit).toBeNull(); + }); + + it('should still select elements on visible, unlocked layers', () => { + // Click on line (layer-default, visible, unlocked) + const hit = selectionEngine.clickSelect(50, 50, elements); + expect(hit).not.toBeNull(); + expect(hit?.id).toBe('el-line-1'); + }); + }); + + // ─── Step 11: Zoom fit to all elements ─────────────────────────────────────── + describe('Step 11: Zoom fit to all elements', () => { + it('should change scale when fitting to elements', () => { + const initialScale = zoomPan.getScale(); + expect(initialScale).toBe(1); + + // Elements spread across a large area + const fitElements = [ + { x: 0, y: 0, width: 10, height: 10 }, + { x: 500, y: 500, width: 10, height: 10 }, + { x: 1000, y: 1000, width: 10, height: 10 }, + ]; + + zoomPan.zoomFit(fitElements); + + const newScale = zoomPan.getScale(); + // Canvas is 800x600, elements span ~1010 units, padding=40 + // scaleX = (800-80)/1010 ≈ 0.713, scaleY = (600-80)/1010 ≈ 0.515 + // scale = min(0.713, 0.515) ≈ 0.515 + expect(newScale).not.toBe(1); + expect(newScale).toBeGreaterThan(0); + expect(newScale).toBeLessThan(1); + }); + + it('should not change scale when no elements', () => { + const initialScale = zoomPan.getScale(); + zoomPan.zoomFit([]); + expect(zoomPan.getScale()).toBe(initialScale); + }); + + it('should fit and then reset', () => { + zoomPan.zoomFit([{ x: 100, y: 100, width: 50, height: 50 }]); + expect(zoomPan.getScale()).not.toBe(1); + + zoomPan.reset(); + expect(zoomPan.getScale()).toBe(1); + const transform = zoomPan.getTransform(); + expect(transform.e).toBe(0); + expect(transform.f).toBe(0); + }); + }); + + // ─── Step 12: Reset everything, verify clean state ─────────────────────────── + describe('Step 12: Reset everything', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1 })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), + ]; + for (const el of elements) spatialIndex.insert(el); + + // Modify state + zoomPan.zoomAt(400, 300, 2); + zoomPan.pan(50, 30); + selectionEngine.selectByIds(['el-line-1', 'el-rect-1']); + groupManager.createGroup(['el-line-1', 'el-rect-1'], 'Test'); + historyManager.initialize(makeSnapshot(elements, layerManager.getLayers())); + historyManager.pushSnapshot(makeSnapshot([...elements, makeCircle('el-c', 300, 300, 20)], layerManager.getLayers()), 'Add circle'); + }); + + it('should reset all components to clean state', () => { + // Verify dirty state before reset + expect(zoomPan.getScale()).not.toBe(1); + expect(selectionEngine.getSelectedIds().size).toBe(2); + expect(groupManager.getGroups().length).toBe(1); + expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 }).length).toBe(2); + expect(layerManager.getLayers().length).toBe(2); + expect(historyManager.canUndo()).toBe(true); + + // Reset all + zoomPan.reset(); + selectionEngine.clearSelection(); + groupManager.clear(); + spatialIndex.clear(); + layerManager.clear(); + historyManager.clear(); + + // Verify clean state + expect(zoomPan.getScale()).toBe(1); + expect(zoomPan.getTransform().e).toBe(0); + expect(zoomPan.getTransform().f).toBe(0); + + expect(selectionEngine.getSelectedIds().size).toBe(0); + + expect(groupManager.getGroups()).toHaveLength(0); + expect(groupManager.getGroupedElements().size).toBe(0); + + expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 })).toHaveLength(0); + + expect(layerManager.getLayers()).toHaveLength(0); + expect(layerManager.getActiveLayerId()).toBe(''); + + expect(historyManager.getCurrentState()).toBeNull(); + expect(historyManager.canUndo()).toBe(false); + expect(historyManager.canRedo()).toBe(false); + }); + }); + + // ─── Full Workflow Integration ────────────────────────────────────────────── + describe('Full workflow: init → layers → elements → render → zoom → select → group → snap → history → toggle → fit → reset', () => { + it('should execute the complete CAD workflow end-to-end', () => { + // ── 1. Initialize ── + expect(renderEngine).toBeDefined(); + expect(selectionEngine).toBeDefined(); + + // ── 2. Create layers ── + const defaultLayer = makeLayer('layer-default', { sortOrder: 0, name: 'Default' }); + const archLayer = makeLayer('layer-arch', { sortOrder: 1, name: 'Architecture', color: '#ff0000' }); + layerManager.addLayer(defaultLayer); + layerManager.addLayer(archLayer); + expect(layerManager.getLayers()).toHaveLength(2); + + // ── 3. Add elements to different layers ── + const line1 = makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'); + const rect1 = makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'); + const circle1 = makeCircle('el-circle-1', 400, 300, 40, 'layer-default'); + elements = [line1, rect1, circle1]; + for (const el of elements) spatialIndex.insert(el); + + // Verify spatial index has all elements + const allFound = spatialIndex.search({ minX: -200, minY: -200, maxX: 600, maxY: 600 }); + expect(allFound).toHaveLength(3); + + // ── 4. Render ── + expect(() => renderEngine.render()).not.toThrow(); + + // ── 5. Zoom/Pan ── + zoomPan.zoomAt(400, 300, 1.5); + expect(zoomPan.getScale()).toBe(1.5); + // zoomAt(400,300,1.5): offsetX = 400-(400-0)*1.5 = -200, then pan(20,10) → -180 + zoomPan.pan(20, 10); + expect(zoomPan.getTransform().e).toBe(-180); + + // Roundtrip + const wpt = { x: 100, y: 100 }; + const spt = zoomPan.worldToScreen(wpt.x, wpt.y); + const back = zoomPan.screenToWorld(spt.x, spt.y); + expect(back.x).toBeCloseTo(wpt.x, 3); + expect(back.y).toBeCloseTo(wpt.y, 3); + + // ── 6. Selection ── + // Click select line at midpoint (50, 50) + const hit = selectionEngine.clickSelect(50, 50, elements); + expect(hit?.id).toBe('el-line-1'); + + // Box select line + rect (window selection) + selectionEngine.clearSelection(); + selectionEngine.startBoxSelect(-100, -100); + selectionEngine.updateBoxSelect(300, 300, elements); + const boxSelected = selectionEngine.finishBoxSelect(elements); + expect(boxSelected.length).toBeGreaterThanOrEqual(2); + + // ── 7. Group selected elements ── + const selectedIds = Array.from(selectionEngine.getSelectedIds()); + const group = groupManager.createGroup(selectedIds, 'Workflow Group'); + expect(group.elementIds.length).toBeGreaterThanOrEqual(2); + expect(groupManager.getGroup(group.id)).toBeDefined(); + + // ── 8. Snap ── + snapEngine.setElements(elements); + const snapResult = snapEngine.snap(2, 2); // Near line endpoint (0,0) + expect(snapResult.point).not.toBeNull(); + expect(snapResult.point?.type).toBe('endpoint'); + + // ── 9. History ── + const currentLayers = layerManager.getLayers(); + historyManager.initialize(makeSnapshot(elements, currentLayers)); + const modifiedEls = [...elements, makeRect('el-rect-2', 500, 500, 30, 30, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(modifiedEls, currentLayers), 'Add rect 2'); + + expect(historyManager.getCurrentState()?.elements).toHaveLength(4); + expect(historyManager.canUndo()).toBe(true); + + const undone = historyManager.undo(); + expect(undone?.elements).toHaveLength(3); + + const redone = historyManager.redo(); + expect(redone?.elements).toHaveLength(4); + + // ── 10. Layer toggle ── + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(false); + + // Elements on hidden layer should not be selectable + const hitAfterHide = selectionEngine.clickSelect(200, 200, elements); + expect(hitAfterHide).toBeNull(); + + // Restore visibility + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(true); + + // ── 11. Zoom fit ── + zoomPan.reset(); + expect(zoomPan.getScale()).toBe(1); + + zoomPan.zoomFit(elements); + expect(zoomPan.getScale()).not.toBe(1); + + // ── 12. Reset everything ── + zoomPan.reset(); + selectionEngine.clearSelection(); + groupManager.clear(); + spatialIndex.clear(); + layerManager.clear(); + historyManager.clear(); + + expect(zoomPan.getScale()).toBe(1); + expect(selectionEngine.getSelectedIds().size).toBe(0); + expect(groupManager.getGroups()).toHaveLength(0); + expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 })).toHaveLength(0); + expect(layerManager.getLayers()).toHaveLength(0); + expect(historyManager.getCurrentState()).toBeNull(); + }); + }); + + // ─── CommandRegistry Integration ───────────────────────────────────────────── + describe('CommandRegistry integration', () => { + it('should look up drawing commands', () => { + const lineCmd = commandRegistry.lookup('LINE'); + expect(lineCmd).not.toBeNull(); + expect(lineCmd?.toolId).toBe('line'); + + const circleCmd = commandRegistry.lookup('C'); + expect(circleCmd?.name).toBe('CIRCLE'); + expect(circleCmd?.toolId).toBe('circle'); + }); + + it('should provide autocomplete suggestions', () => { + const suggestions = commandRegistry.autocomplete('LI'); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions.some(c => c.name === 'LINE')).toBe(true); + }); + + it('should return null for unknown commands', () => { + expect(commandRegistry.lookup('UNKNOWN')).toBeNull(); + }); + }); +}); diff --git a/frontend/tests/RenderEngine.test.ts b/frontend/tests/RenderEngine.test.ts new file mode 100644 index 0000000..b7df39d --- /dev/null +++ b/frontend/tests/RenderEngine.test.ts @@ -0,0 +1,352 @@ +/** + * RenderEngine Tests – Rendering, grid, viewport, hit testing + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RenderEngine } from '../src/canvas/RenderEngine'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { LayerManager } from '../src/canvas/LayerManager'; +import type { CADElement, CADLayer } from '../src/types/cad.types'; + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillRect = (() => {}) as any; + ctx.strokeRect = (() => {}) as any; + ctx.beginPath = (() => {}) as any; + ctx.moveTo = (() => {}) as any; + ctx.lineTo = (() => {}) as any; + ctx.stroke = (() => {}) as any; + ctx.fill = (() => {}) as any; + ctx.save = (() => {}) as any; + ctx.restore = (() => {}) as any; + ctx.scale = (() => {}) as any; + ctx.arc = (() => {}) as any; + ctx.setLineDash = (() => {}) as any; + ctx.clearRect = (() => {}) as any; + ctx.translate = (() => {}) as any; + ctx.rotate = (() => {}) as any; + ctx.clip = (() => {}) as any; + ctx.fillText = (() => {}) as any; + ctx.quadraticCurveTo = (() => {}) as any; + ctx.closePath = (() => {}) as any; + } + return canvas; +} + +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, + }; +} + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + return { + id, + type: 'line', + layerId: 'layer-1', + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +function makeCircle(id: string, cx: number, cy: number, r: number): CADElement { + return { + id, + type: 'circle', + layerId: 'layer-1', + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r }, + }; +} + +describe('RenderEngine', () => { + let canvas: HTMLCanvasElement; + let zpc: ZoomPanController; + let spatialIndex: SpatialIndex; + let layerManager: LayerManager; + let engine: RenderEngine; + + beforeEach(() => { + canvas = createMockCanvas(800, 600); + zpc = new ZoomPanController(canvas); + spatialIndex = new SpatialIndex(); + layerManager = new LayerManager(); + layerManager.addLayer(makeLayer('layer-1')); + engine = new RenderEngine(canvas, zpc, spatialIndex, layerManager); + }); + + describe('constructor', () => { + it('should construct without error', () => { + expect(engine).toBeDefined(); + }); + + it('should have default options with showGrid=true', () => { + const opts = engine.getOptions(); + expect(opts.showGrid).toBe(true); + expect(opts.gridSize).toBe(20); + }); + + it('should have empty selection state', () => { + const sel = engine.getSelection(); + expect(sel.selectedIds.size).toBe(0); + expect(sel.hoverId).toBeNull(); + }); + }); + + describe('setOptions & getOptions', () => { + it('should update options partially', () => { + engine.setOptions({ showGrid: false }); + expect(engine.getOptions().showGrid).toBe(false); + }); + + it('should preserve other options when updating one', () => { + engine.setOptions({ gridSize: 50 }); + const opts = engine.getOptions(); + expect(opts.gridSize).toBe(50); + expect(opts.showGrid).toBe(true); + }); + }); + + describe('setSelection & getSelection', () => { + it('should update selection state', () => { + engine.setSelection({ hoverId: 'el-1' }); + expect(engine.getSelection().hoverId).toBe('el-1'); + }); + + it('should set selected ids', () => { + engine.setSelection({ selectedIds: new Set(['a', 'b']) }); + expect(engine.getSelection().selectedIds.size).toBe(2); + }); + }); + + describe('render with empty elements', () => { + it('should not throw when rendering with no elements', () => { + expect(() => engine.render()).not.toThrow(); + }); + + it('should call ctx.fillRect for background', () => { + const ctx = canvas.getContext('2d')!; + const spy = vi.spyOn(ctx, 'fillRect'); + engine.render(); + expect(spy).toHaveBeenCalled(); + }); + + it('should call ctx.save and restore', () => { + const ctx = canvas.getContext('2d')!; + const saveSpy = vi.spyOn(ctx, 'save'); + const restoreSpy = vi.spyOn(ctx, 'restore'); + engine.render(); + expect(saveSpy).toHaveBeenCalled(); + expect(restoreSpy).toHaveBeenCalled(); + }); + }); + + describe('render with elements', () => { + it('should render a line element (calls stroke)', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + const ctx = canvas.getContext('2d')!; + const strokeSpy = vi.spyOn(ctx, 'stroke'); + engine.render(); + expect(strokeSpy).toHaveBeenCalled(); + }); + + it('should render a rect element', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + spatialIndex.insert(rect); + const ctx = canvas.getContext('2d')!; + const strokeRectSpy = vi.spyOn(ctx, 'strokeRect'); + engine.render(); + expect(strokeRectSpy).toHaveBeenCalled(); + }); + + it('should render a circle element', () => { + const circle = makeCircle('c1', 100, 100, 40); + spatialIndex.insert(circle); + const ctx = canvas.getContext('2d')!; + const arcSpy = vi.spyOn(ctx, 'arc'); + engine.render(); + expect(arcSpy).toHaveBeenCalled(); + }); + + it('should not render elements on invisible layers', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + layerManager.addLayer(makeLayer('layer-1', { visible: false })); + // Replace the visible layer with invisible + layerManager.toggleVisibility('layer-1'); + const ctx = canvas.getContext('2d')!; + const strokeSpy = vi.spyOn(ctx, 'stroke'); + engine.render(); + // stroke may still be called for grid, so check that line-specific strokes are minimal + // The key assertion is that it doesn't crash + expect(strokeSpy).toHaveBeenCalled(); + }); + }); + + describe('grid rendering toggle', () => { + it('should call stroke when grid is enabled', () => { + engine.setOptions({ showGrid: true }); + const ctx = canvas.getContext('2d')!; + const strokeSpy = vi.spyOn(ctx, 'stroke'); + engine.render(); + expect(strokeSpy).toHaveBeenCalled(); + }); + + it('should render without grid when showGrid=false', () => { + engine.setOptions({ showGrid: false }); + const ctx = canvas.getContext('2d')!; + const beginPathSpy = vi.spyOn(ctx, 'beginPath'); + engine.render(); + // With no grid and no elements, beginPath should not be called for grid + // But it might be called for background. We just verify no crash. + expect(beginPathSpy).toBeDefined(); + }); + }); + + describe('snap points', () => { + it('should set snap points', () => { + engine.setSnapPoints([{ x: 10, y: 10, type: 'endpoint' }]); + engine.setOptions({ showSnapPoints: true }); + expect(() => engine.render()).not.toThrow(); + }); + + it('should set active snap point', () => { + engine.setActiveSnapPoint({ x: 10, y: 10, type: 'endpoint' }); + engine.setOptions({ showSnapPoints: true }); + expect(() => engine.render()).not.toThrow(); + }); + }); + + describe('hitTest', () => { + it('should return the element when hit within tolerance', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + const hit = engine.hitTest(100, 50, 5); + expect(hit).not.toBeNull(); + expect(hit!.id).toBe('l1'); + }); + + it('should return null when no element is hit', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + const hit = engine.hitTest(400, 400, 5); + expect(hit).toBeNull(); + }); + + it('should hit test a rect element', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + spatialIndex.insert(rect); + const hit = engine.hitTest(100, 100, 5); + expect(hit).not.toBeNull(); + expect(hit!.id).toBe('r1'); + }); + + it('should hit test a circle element', () => { + const circle = makeCircle('c1', 100, 100, 40); + spatialIndex.insert(circle); + const hit = engine.hitTest(140, 100, 5); + expect(hit).not.toBeNull(); + expect(hit!.id).toBe('c1'); + }); + }); + + describe('getElementBBox', () => { + it('should return correct bounding box for element', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + const bb = engine.getElementBBox(rect); + expect(bb.minX).toBe(60); + expect(bb.minY).toBe(70); + expect(bb.maxX).toBe(140); + expect(bb.maxY).toBe(130); + }); + }); + + describe('getElementsInRect', () => { + it('should return fully enclosed elements', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + const r2 = makeRect('r2', 300, 300, 40, 40); + spatialIndex.insert(r1); + spatialIndex.insert(r2); + const result = engine.getElementsInRect(50, 50, 200, 200); + expect(result.length).toBe(1); + expect(result[0].id).toBe('r1'); + }); + + it('should return empty when no elements in rect', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + spatialIndex.insert(r1); + const result = engine.getElementsInRect(500, 500, 600, 600); + expect(result.length).toBe(0); + }); + }); + + describe('getElementsIntersectingRect', () => { + it('should return intersecting elements', () => { + const r1 = makeRect('r1', 100, 100, 80, 80); + spatialIndex.insert(r1); + const result = engine.getElementsIntersectingRect(80, 80, 120, 120); + expect(result.length).toBe(1); + expect(result[0].id).toBe('r1'); + }); + }); + + describe('setLayers', () => { + it('should clear and set new layers', () => { + engine.setLayers([ + makeLayer('new-1'), + makeLayer('new-2'), + ]); + const layers = layerManager.getLayers(); + expect(layers.length).toBe(2); + }); + }); + + describe('resize', () => { + it('should resize canvas dimensions', () => { + engine.resize(400, 300); + // dpr is likely 1 in jsdom + expect(canvas.width).toBeGreaterThanOrEqual(400); + expect(canvas.height).toBeGreaterThanOrEqual(300); + }); + }); + + describe('setBlockDefinitions', () => { + it('should set block definitions without error', () => { + engine.setBlockDefinitions([{ id: 'blk-1', elements: [] }]); + expect(() => engine.render()).not.toThrow(); + }); + }); +}); diff --git a/frontend/tests/SelectionEngine.test.ts b/frontend/tests/SelectionEngine.test.ts new file mode 100644 index 0000000..76a9d34 --- /dev/null +++ b/frontend/tests/SelectionEngine.test.ts @@ -0,0 +1,394 @@ +/** + * SelectionEngine Tests – Selection modes, filters, box selection, hover, listeners + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SelectionEngine } from '../src/canvas/SelectionEngine'; +import { RenderEngine } from '../src/canvas/RenderEngine'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { LayerManager } from '../src/canvas/LayerManager'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; +import type { CADElement, CADLayer } from '../src/types/cad.types'; + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillRect = (() => {}) as any; + ctx.strokeRect = (() => {}) as any; + ctx.beginPath = (() => {}) as any; + ctx.moveTo = (() => {}) as any; + ctx.lineTo = (() => {}) as any; + ctx.stroke = (() => {}) as any; + ctx.fill = (() => {}) as any; + ctx.save = (() => {}) as any; + ctx.restore = (() => {}) as any; + ctx.scale = (() => {}) as any; + ctx.arc = (() => {}) as any; + ctx.setLineDash = (() => {}) as any; + ctx.clearRect = (() => {}) as any; + ctx.translate = (() => {}) as any; + ctx.rotate = (() => {}) as any; + ctx.clip = (() => {}) as any; + ctx.fillText = (() => {}) as any; + ctx.quadraticCurveTo = (() => {}) as any; + ctx.closePath = (() => {}) as any; + } + return canvas; +} + +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, + }; +} + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-1'): CADElement { + return { + id, + type: 'line', + layerId, + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number, layerId = 'layer-1'): CADElement { + return { + id, + type: 'rect', + layerId, + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +function setup(): { + canvas: HTMLCanvasElement; + zpc: ZoomPanController; + spatialIndex: SpatialIndex; + layerManager: LayerManager; + renderEngine: RenderEngine; + selectionEngine: SelectionEngine; +} { + const canvas = createMockCanvas(800, 600); + const zpc = new ZoomPanController(canvas); + const spatialIndex = new SpatialIndex(); + const layerManager = new LayerManager(); + layerManager.addLayer(makeLayer('layer-1')); + const renderEngine = new RenderEngine(canvas, zpc, spatialIndex, layerManager); + const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager); + return { canvas, zpc, spatialIndex, layerManager, renderEngine, selectionEngine }; +} + +describe('SelectionEngine', () => { + let s: ReturnType; + + beforeEach(() => { + s = setup(); + }); + + describe('initial state', () => { + it('should have empty selection', () => { + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should have default options (single mode, all filter)', () => { + const opts = s.selectionEngine.getOptions(); + expect(opts.mode).toBe('single'); + expect(opts.filter).toBe('all'); + expect(opts.additive).toBe(false); + expect(opts.subtractive).toBe(false); + }); + + it('should have null hover', () => { + expect(s.selectionEngine.getHoverId()).toBeNull(); + }); + }); + + describe('setOptions', () => { + it('should update options partially', () => { + s.selectionEngine.setOptions({ filter: 'lines' }); + expect(s.selectionEngine.getOptions().filter).toBe('lines'); + }); + }); + + describe('clickSelect – hit', () => { + it('should select an element when clicking on it', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + const result = s.selectionEngine.clickSelect(100, 50, [line]); + expect(result).not.toBeNull(); + expect(result!.id).toBe('l1'); + expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true); + }); + + it('should select a rect element when clicking within it', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + s.spatialIndex.insert(rect); + const result = s.selectionEngine.clickSelect(100, 100, [rect]); + expect(result).not.toBeNull(); + expect(result!.id).toBe('r1'); + }); + }); + + describe('clickSelect – miss', () => { + it('should return null when clicking empty space', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + const result = s.selectionEngine.clickSelect(400, 400, [line]); + expect(result).toBeNull(); + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should clear selection when clicking empty space in non-additive mode', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + s.selectionEngine.clickSelect(100, 50, [line]); + expect(s.selectionEngine.getSelectedIds().size).toBe(1); + s.selectionEngine.clickSelect(400, 400, [line]); + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + }); + + describe('clickSelect – additive mode', () => { + it('should add to selection in additive mode', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.spatialIndex.insert(l1); + s.spatialIndex.insert(l2); + s.selectionEngine.setOptions({ additive: true }); + s.selectionEngine.clickSelect(100, 50, [l1, l2]); + s.selectionEngine.clickSelect(100, 100, [l1, l2]); + expect(s.selectionEngine.getSelectedIds().size).toBe(2); + }); + + it('should not clear on miss in additive mode', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.setOptions({ additive: true }); + s.selectionEngine.clickSelect(100, 50, [l1]); + s.selectionEngine.clickSelect(400, 400, [l1]); + expect(s.selectionEngine.getSelectedIds().size).toBe(1); + }); + }); + + describe('clickSelect – subtractive mode', () => { + it('should remove from selection in subtractive mode', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + // First select normally + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true); + // Now subtract + s.selectionEngine.setOptions({ subtractive: true }); + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(false); + }); + }); + + describe('clickSelect – filter modes', () => { + it('should not select when filter does not match', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + s.spatialIndex.insert(rect); + s.selectionEngine.setOptions({ filter: 'lines' }); + const result = s.selectionEngine.clickSelect(100, 100, [rect]); + expect(result).toBeNull(); + }); + + it('should select when filter matches lines', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + s.selectionEngine.setOptions({ filter: 'lines' }); + const result = s.selectionEngine.clickSelect(100, 50, [line]); + expect(result).not.toBeNull(); + expect(result!.id).toBe('l1'); + }); + + it('should select rects with rects filter', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + s.spatialIndex.insert(rect); + s.selectionEngine.setOptions({ filter: 'rects' }); + const result = s.selectionEngine.clickSelect(100, 100, [rect]); + expect(result).not.toBeNull(); + }); + }); + + describe('box selection', () => { + it('should select elements within box (window mode)', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + const r2 = makeRect('r2', 300, 300, 40, 40); + s.spatialIndex.insert(r1); + s.spatialIndex.insert(r2); + // Window: left-to-right, fully enclosed + s.selectionEngine.startBoxSelect(50, 50); + s.selectionEngine.updateBoxSelect(200, 200, [r1, r2]); + const selected = s.selectionEngine.finishBoxSelect([r1, r2]); + // r1 bbox: 80,80 to 120,120 — fully within 50,50 to 200,200 + expect(selected.length).toBe(1); + expect(selected[0].id).toBe('r1'); + }); + + it('should select elements intersecting box (crossing mode)', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + s.spatialIndex.insert(r1); + // Crossing: right-to-left (start.x > end.x) + s.selectionEngine.startBoxSelect(200, 200); + s.selectionEngine.updateBoxSelect(90, 90, [r1]); + const selected = s.selectionEngine.finishBoxSelect([r1]); + expect(selected.length).toBe(1); + }); + + it('should clear box start/end after finish', () => { + s.selectionEngine.startBoxSelect(50, 50); + s.selectionEngine.updateBoxSelect(100, 100, []); + s.selectionEngine.finishBoxSelect([]); + expect(s.selectionEngine.isBoxSelecting()).toBe(false); + }); + + it('should return empty when no box started', () => { + const result = s.selectionEngine.finishBoxSelect([]); + expect(result).toEqual([]); + }); + + it('cancelBoxSelect should stop box selection', () => { + s.selectionEngine.startBoxSelect(50, 50); + s.selectionEngine.cancelBoxSelect(); + expect(s.selectionEngine.isBoxSelecting()).toBe(false); + }); + }); + + describe('clearSelection', () => { + it('should clear all selected ids', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.clickSelect(100, 50, [l1]); + s.selectionEngine.clearSelection(); + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + }); + + describe('hover', () => { + it('should set and get hover id', () => { + s.selectionEngine.setHover('el-1'); + expect(s.selectionEngine.getHoverId()).toBe('el-1'); + }); + + it('should clear hover with null', () => { + s.selectionEngine.setHover('el-1'); + s.selectionEngine.setHover(null); + expect(s.selectionEngine.getHoverId()).toBeNull(); + }); + }); + + describe('selectByIds', () => { + it('should select by ids', () => { + s.selectionEngine.selectByIds(['a', 'b', 'c']); + expect(s.selectionEngine.getSelectedIds().size).toBe(3); + }); + + it('should add to selection when additive=true', () => { + s.selectionEngine.selectByIds(['a']); + s.selectionEngine.selectByIds(['b'], true); + expect(s.selectionEngine.getSelectedIds().size).toBe(2); + }); + }); + + describe('selectAll', () => { + it('should select all visible elements matching filter', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.spatialIndex.insert(l1); + s.spatialIndex.insert(l2); + s.selectionEngine.selectAll([l1, l2]); + expect(s.selectionEngine.getSelectedIds().size).toBe(2); + }); + }); + + describe('invertSelection', () => { + it('should invert current selection', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.spatialIndex.insert(l1); + s.spatialIndex.insert(l2); + s.selectionEngine.selectByIds(['l1']); + s.selectionEngine.invertSelection([l1, l2]); + const ids = s.selectionEngine.getSelectedIds(); + expect(ids.has('l1')).toBe(false); + expect(ids.has('l2')).toBe(true); + }); + }); + + describe('quickSelect', () => { + it('should select by type', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const r1 = makeRect('r1', 100, 100, 80, 60); + const result = s.selectionEngine.quickSelect([l1, r1], { type: 'line' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('l1'); + }); + + it('should select by layerId', () => { + const l1 = makeLine('l1', 50, 50, 150, 50, 'layer-1'); + const l2 = makeLine('l2', 50, 100, 150, 100, 'layer-2'); + const result = s.selectionEngine.quickSelect([l1, l2], { layerId: 'layer-1' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('l1'); + }); + }); + + describe('listeners', () => { + it('should call listener on selection change', () => { + let called = false; + let received: CADElement[] = []; + s.selectionEngine.addListener((els) => { + called = true; + received = els; + }); + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(called).toBe(true); + expect(received.length).toBe(1); + expect(received[0].id).toBe('l1'); + }); + + it('should remove listener', () => { + let called = false; + const fn = (els: CADElement[]) => { called = true; }; + s.selectionEngine.addListener(fn); + s.selectionEngine.removeListener(fn); + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(called).toBe(false); + }); + }); + + describe('getSelectedElements', () => { + it('should filter allElements by selected ids', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.selectionEngine.selectByIds(['l1']); + const result = s.selectionEngine.getSelectedElements([l1, l2]); + expect(result.length).toBe(1); + expect(result[0].id).toBe('l1'); + }); + }); +}); diff --git a/frontend/tests/SnapEngine.test.ts b/frontend/tests/SnapEngine.test.ts new file mode 100644 index 0000000..bb9e253 --- /dev/null +++ b/frontend/tests/SnapEngine.test.ts @@ -0,0 +1,340 @@ +/** + * SnapEngine Tests – Snapping modes, grid snap, polar tracking + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SnapEngine } from '../src/canvas/SnapEngine'; +import type { SnapMode, SnapConfig } from '../src/canvas/SnapEngine'; +import type { CADElement } from '../src/types/cad.types'; + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + return { + id, + type: 'line', + layerId: 'layer-1', + x: cx, + y: cy, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + }; +} + +function makeCircle(id: string, cx: number, cy: number, r: number): CADElement { + return { + id, + type: 'circle', + layerId: 'layer-1', + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +describe('SnapEngine', () => { + let engine: SnapEngine; + + beforeEach(() => { + engine = new SnapEngine(); + }); + + describe('constructor & config', () => { + it('should have default config with enabled=true', () => { + const cfg = engine.getConfig(); + expect(cfg.enabled).toBe(true); + }); + + it('should have default modes including endpoint, midpoint, center, intersection, nearest', () => { + const cfg = engine.getConfig(); + expect(cfg.modes.has('endpoint')).toBe(true); + expect(cfg.modes.has('midpoint')).toBe(true); + expect(cfg.modes.has('center')).toBe(true); + expect(cfg.modes.has('intersection')).toBe(true); + expect(cfg.modes.has('nearest')).toBe(true); + }); + + it('should accept custom config overrides', () => { + const eng = new SnapEngine({ tolerance: 25, gridSpacing: 50 }); + const cfg = eng.getConfig(); + expect(cfg.tolerance).toBe(25); + expect(cfg.gridSpacing).toBe(50); + }); + }); + + describe('setConfig & toggleMode', () => { + it('should update config partially', () => { + engine.setConfig({ tolerance: 30 }); + expect(engine.getConfig().tolerance).toBe(30); + }); + + it('should toggle mode on/off', () => { + expect(engine.getConfig().modes.has('endpoint')).toBe(true); + engine.toggleMode('endpoint'); + expect(engine.getConfig().modes.has('endpoint')).toBe(false); + engine.toggleMode('endpoint'); + expect(engine.getConfig().modes.has('endpoint')).toBe(true); + }); + + it('should toggle grid mode on', () => { + engine.toggleMode('grid'); + expect(engine.getConfig().modes.has('grid')).toBe(true); + }); + }); + + describe('snap – disabled', () => { + it('should return null point when disabled', () => { + engine.setConfig({ enabled: false }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(0, 0); + expect(result.point).toBeNull(); + expect(result.preview).toEqual([]); + }); + + it('should return null point when no modes are active', () => { + const eng = new SnapEngine({ + modes: new Set(), + }); + eng.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = eng.snap(0, 0); + expect(result.point).toBeNull(); + }); + }); + + describe('snap – endpoint mode', () => { + it('should snap to line endpoint when within tolerance', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(2, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(0); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('endpoint'); + }); + + it('should snap to the second endpoint of a line', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(98, 1); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(100); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('endpoint'); + }); + + it('should not snap when outside tolerance', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + engine.setConfig({ tolerance: 5 }); + const result = engine.snap(50, 20); + expect(result.point).toBeNull(); + }); + + it('should snap to rect corners', () => { + engine.setElements([makeRect('r1', 50, 50, 40, 40)]); + const result = engine.snap(32, 32); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(30); + expect(result.point!.y).toBe(30); + expect(result.point!.type).toBe('endpoint'); + }); + }); + + describe('snap – midpoint mode', () => { + it('should snap to line midpoint', () => { + engine.setConfig({ modes: new Set(['midpoint']) }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(50, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('midpoint'); + }); + + it('should snap to rect edge midpoints', () => { + engine.setConfig({ modes: new Set(['midpoint']) }); + engine.setElements([makeRect('r1', 50, 50, 40, 40)]); + const result = engine.snap(50, 31); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(30); + expect(result.point!.type).toBe('midpoint'); + }); + }); + + describe('snap – center mode', () => { + it('should snap to circle center', () => { + engine.setConfig({ modes: new Set(['center']) }); + engine.setElements([makeCircle('c1', 50, 50, 30)]); + const result = engine.snap(52, 48); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(50); + expect(result.point!.type).toBe('center'); + }); + + it('should snap to rect center', () => { + engine.setConfig({ modes: new Set(['center']) }); + engine.setElements([makeRect('r1', 50, 50, 40, 40)]); + const result = engine.snap(48, 52); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(50); + expect(result.point!.type).toBe('center'); + }); + }); + + describe('snap – grid mode', () => { + it('should snap to nearest grid point', () => { + engine.setConfig({ + modes: new Set(['grid']), + gridSpacing: 20, + tolerance: 10, + }); + const result = engine.snap(18, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(20); + expect(result.point!.y).toBe(0); + }); + + it('should snap to grid point at origin', () => { + engine.setConfig({ + modes: new Set(['grid']), + gridSpacing: 20, + tolerance: 10, + }); + const result = engine.snap(3, 3); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(0); + expect(result.point!.y).toBe(0); + }); + + it('should not snap when too far from grid point', () => { + engine.setConfig({ + modes: new Set(['grid']), + gridSpacing: 20, + tolerance: 5, + }); + const result = engine.snap(13, 13); + expect(result.point).toBeNull(); + }); + }); + + describe('snap – nearest mode', () => { + it('should snap to nearest point on a line', () => { + engine.setConfig({ modes: new Set(['nearest']) }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(50, 5); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('nearest'); + }); + }); + + describe('snap – intersection mode', () => { + it('should snap to intersection of two lines', () => { + engine.setConfig({ modes: new Set(['intersection']), tolerance: 10 }); + engine.setElements([ + makeLine('l1', 0, 0, 100, 100), + makeLine('l2', 0, 100, 100, 0), + ]); + const result = engine.snap(52, 50); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBeCloseTo(50, 0); + expect(result.point!.y).toBeCloseTo(50, 0); + expect(result.point!.type).toBe('intersection'); + }); + }); + + describe('snap – priority', () => { + it('should prefer endpoint over grid when both are in range', () => { + engine.setConfig({ + modes: new Set(['endpoint', 'grid']), + gridSpacing: 20, + tolerance: 10, + }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(2, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.type).toBe('endpoint'); + }); + }); + + describe('snap – preview', () => { + it('should return preview candidates', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(2, 2); + expect(result.preview.length).toBeGreaterThan(0); + }); + }); + + describe('polar tracking', () => { + it('should snap to 0-degree polar angle from reference point', () => { + engine.setConfig({ + polarEnabled: true, + polarAngles: [0, 90, 180, 270], + polarTolerance: 5, + modes: new Set(['nearest']), + }); + // Reference at (0,0), cursor near 0 degrees (horizontal right) at distance ~100 + const result = engine.snap(100, 5, { x: 0, y: 0 }); + expect(result.point).not.toBeNull(); + if (result.point) { + expect(result.point.y).toBeCloseTo(0, 0); + } + }); + + it('should snap to 90-degree polar angle from reference point', () => { + engine.setConfig({ + polarEnabled: true, + polarAngles: [0, 90, 180, 270], + polarTolerance: 5, + modes: new Set(['nearest']), + }); + // Reference at (0,0), cursor near 90 degrees (down) at distance ~100 + const result = engine.snap(5, 100, { x: 0, y: 0 }); + expect(result.point).not.toBeNull(); + if (result.point) { + expect(result.point.x).toBeCloseTo(0, 0); + } + }); + + it('should not polar-snap when polarEnabled is false', () => { + engine.setConfig({ + polarEnabled: false, + modes: new Set(['nearest']), + }); + engine.setElements([makeLine('l1', 0, 0, 200, 0)]); + const result = engine.snap(100, 5, { x: 0, y: 0 }); + // Should snap to nearest on line, not polar + if (result.point) { + expect(result.point.y).toBe(0); + } + }); + + it('should not polar-snap when cursor too close to reference', () => { + engine.setConfig({ + polarEnabled: true, + polarAngles: [0], + polarTolerance: 5, + modes: new Set(['nearest']), + }); + const result = engine.snap(0.5, 0.5, { x: 0, y: 0 }); + // dist < 1, so no polar snap; no elements either, so null + expect(result.point).toBeNull(); + }); + }); +}); diff --git a/frontend/tests/ZoomPanController.test.ts b/frontend/tests/ZoomPanController.test.ts new file mode 100644 index 0000000..021dc5c --- /dev/null +++ b/frontend/tests/ZoomPanController.test.ts @@ -0,0 +1,180 @@ +/** + * ZoomPanController Tests – Zoom & Pan Transformation + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillRect = (() => {}) as any; + ctx.strokeRect = (() => {}) as any; + ctx.beginPath = (() => {}) as any; + ctx.moveTo = (() => {}) as any; + ctx.lineTo = (() => {}) as any; + ctx.stroke = (() => {}) as any; + ctx.fill = (() => {}) as any; + ctx.save = (() => {}) as any; + ctx.restore = (() => {}) as any; + ctx.scale = (() => {}) as any; + ctx.arc = (() => {}) as any; + ctx.setLineDash = (() => {}) as any; + ctx.clearRect = (() => {}) as any; + ctx.translate = (() => {}) as any; + ctx.rotate = (() => {}) as any; + } + return canvas; +} + +describe('ZoomPanController', () => { + let canvas: HTMLCanvasElement; + let zpc: ZoomPanController; + + beforeEach(() => { + canvas = createMockCanvas(800, 600); + zpc = new ZoomPanController(canvas); + }); + + describe('initial state', () => { + it('should have scale=1, offsetX=0, offsetY=0', () => { + const t = zpc.getTransform(); + expect(t.a).toBe(1); + expect(t.d).toBe(1); + expect(t.e).toBe(0); + expect(t.f).toBe(0); + }); + + it('getScale should return 1 initially', () => { + expect(zpc.getScale()).toBe(1); + }); + }); + + describe('getViewport', () => { + it('should return viewport covering full canvas at scale=1', () => { + const vp = zpc.getViewport(); + expect(vp.minX).toBe(0); + expect(vp.minY).toBe(0); + expect(vp.maxX).toBe(800); + expect(vp.maxY).toBe(600); + }); + + it('should shrink visible area when zoomed in', () => { + zpc.zoomAt(400, 300, 2); + const vp = zpc.getViewport(); + const w = vp.maxX - vp.minX; + const h = vp.maxY - vp.minY; + expect(w).toBeCloseTo(400, 1); + expect(h).toBeCloseTo(300, 1); + }); + }); + + describe('zoomAt', () => { + it('should increase scale with factor > 1', () => { + zpc.zoomAt(100, 100, 2); + expect(zpc.getScale()).toBe(2); + }); + + it('should decrease scale with factor < 1', () => { + zpc.zoomAt(100, 100, 0.5); + expect(zpc.getScale()).toBe(0.5); + }); + + it('should keep the zoom center point stable', () => { + zpc.zoomAt(400, 300, 2); + const screen = zpc.worldToScreen(400, 300); + expect(screen.x).toBeCloseTo(400, 0); + expect(screen.y).toBeCloseTo(300, 0); + }); + + it('should clamp scale to minimum 0.01', () => { + zpc.zoomAt(0, 0, 0.001); + expect(zpc.getScale()).toBeGreaterThanOrEqual(0.01); + }); + + it('should clamp scale to maximum 100', () => { + for (let i = 0; i < 20; i++) { + zpc.zoomAt(400, 300, 10); + } + expect(zpc.getScale()).toBeLessThanOrEqual(100); + }); + }); + + describe('pan', () => { + it('should update offsetX and offsetY', () => { + zpc.pan(50, 30); + const t = zpc.getTransform(); + expect(t.e).toBe(50); + expect(t.f).toBe(30); + }); + + it('should accumulate pan calls', () => { + zpc.pan(10, 20); + zpc.pan(30, 40); + const t = zpc.getTransform(); + expect(t.e).toBe(40); + expect(t.f).toBe(60); + }); + }); + + describe('reset', () => { + it('should restore scale=1 and offset=0,0', () => { + zpc.zoomAt(100, 100, 3); + zpc.pan(50, 50); + zpc.reset(); + const t = zpc.getTransform(); + expect(t.a).toBe(1); + expect(t.d).toBe(1); + expect(t.e).toBe(0); + expect(t.f).toBe(0); + expect(zpc.getScale()).toBe(1); + }); + }); + + describe('worldToScreen & screenToWorld', () => { + it('should convert world to screen at identity transform', () => { + const s = zpc.worldToScreen(100, 200); + expect(s.x).toBe(100); + expect(s.y).toBe(200); + }); + + it('should convert world to screen with scale and offset', () => { + zpc.zoomAt(0, 0, 2); + zpc.pan(50, 50); + const s = zpc.worldToScreen(100, 100); + expect(s.x).toBe(250); + expect(s.y).toBe(250); + }); + }); + + describe('zoomFit', () => { + it('should not crash with empty elements', () => { + zpc.zoomFit([]); + expect(zpc.getScale()).toBe(1); + }); + + it('should fit elements within canvas', () => { + zpc.zoomFit([ + { x: 0, y: 0, width: 200, height: 200 }, + { x: 400, y: 400, width: 200, height: 200 }, + ]); + expect(zpc.getScale()).toBeGreaterThan(0); + expect(zpc.getScale()).toBeLessThan(100); + }); + }); + + describe('zoomToRect', () => { + it('should zoom to a given rect', () => { + zpc.zoomToRect({ minX: 0, minY: 0, maxX: 400, maxY: 300 }); + expect(zpc.getScale()).toBeGreaterThan(0); + }); + + it('should do nothing for zero-size rect', () => { + const before = zpc.getScale(); + zpc.zoomToRect({ minX: 0, minY: 0, maxX: 0, maxY: 0 }); + expect(zpc.getScale()).toBe(before); + }); + }); +}); diff --git a/frontend/tests/commandRegistry.test.ts b/frontend/tests/commandRegistry.test.ts new file mode 100644 index 0000000..d6e2a1e --- /dev/null +++ b/frontend/tests/commandRegistry.test.ts @@ -0,0 +1,259 @@ +/** + * commandRegistry Tests – Command lookup, autocomplete, categories + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CommandRegistry } from '../src/services/commandRegistry'; +import type { CommandDefinition } from '../src/services/commandRegistry'; + +describe('CommandRegistry', () => { + let registry: CommandRegistry; + + beforeEach(() => { + registry = new CommandRegistry(); + }); + + describe('lookup by name', () => { + it('should find LINE by name', () => { + const cmd = registry.lookup('LINE'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + expect(cmd!.toolId).toBe('line'); + }); + + it('should find CIRCLE by name', () => { + const cmd = registry.lookup('CIRCLE'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('CIRCLE'); + }); + + it('should find RECT by name', () => { + const cmd = registry.lookup('RECT'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('RECT'); + }); + + it('should find UNDO by name', () => { + const cmd = registry.lookup('UNDO'); + expect(cmd).not.toBeNull(); + expect(cmd!.category).toBe('meta'); + }); + + it('should be case insensitive', () => { + const cmd = registry.lookup('line'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + + it('should trim whitespace', () => { + const cmd = registry.lookup(' LINE '); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + }); + + describe('lookup by alias', () => { + it('should find LINE by alias L', () => { + const cmd = registry.lookup('L'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + + it('should find CIRCLE by alias C', () => { + const cmd = registry.lookup('C'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('CIRCLE'); + }); + + it('should find RECT by alias R', () => { + const cmd = registry.lookup('R'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('RECT'); + }); + + it('should find MOVE by alias M', () => { + const cmd = registry.lookup('M'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('MOVE'); + }); + + it('should find ERASE by alias E and DEL', () => { + expect(registry.lookup('E')!.name).toBe('ERASE'); + expect(registry.lookup('DEL')!.name).toBe('ERASE'); + }); + + it('should find POLYLINE by alias PL', () => { + const cmd = registry.lookup('PL'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('POLYLINE'); + }); + + it('should find German alias LINIE for LINE', () => { + const cmd = registry.lookup('LINIE'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + }); + + describe('lookup unknown command', () => { + it('should return null for unknown command', () => { + expect(registry.lookup('UNKNOWN')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(registry.lookup('')).toBeNull(); + }); + + it('should return null for random text', () => { + expect(registry.lookup('XYZABC')).toBeNull(); + }); + }); + + describe('getToolId', () => { + it('should return toolId for LINE', () => { + expect(registry.getToolId('LINE')).toBe('line'); + }); + + it('should return toolId for CIRCLE alias C', () => { + expect(registry.getToolId('C')).toBe('circle'); + }); + + it('should return null for meta commands like UNDO', () => { + expect(registry.getToolId('UNDO')).toBeNull(); + }); + + it('should return null for unknown command', () => { + expect(registry.getToolId('UNKNOWN')).toBeNull(); + }); + }); + + describe('getLabel', () => { + it('should return label for LINE', () => { + const label = registry.getLabel('LINE'); + expect(label).not.toBeNull(); + expect(label).toContain('Linie'); + }); + + it('should return label for alias L', () => { + const label = registry.getLabel('L'); + expect(label).not.toBeNull(); + expect(label).toContain('Linie'); + }); + + it('should return null for unknown command', () => { + expect(registry.getLabel('UNKNOWN')).toBeNull(); + }); + }); + + describe('getAllCommands', () => { + it('should return all command definitions', () => { + const all = registry.getAllCommands(); + expect(all.length).toBeGreaterThan(10); + }); + + it('should include LINE, CIRCLE, RECT, MOVE, UNDO', () => { + const all = registry.getAllCommands(); + const names = all.map(c => c.name); + expect(names).toContain('LINE'); + expect(names).toContain('CIRCLE'); + expect(names).toContain('RECT'); + expect(names).toContain('MOVE'); + expect(names).toContain('UNDO'); + }); + }); + + describe('autocomplete', () => { + it('should return exact match first', () => { + const results = registry.autocomplete('L'); + expect(results.length).toBeGreaterThan(0); + // Exact alias match 'L' should be LINE (priority 1) + expect(results[0].name).toBe('LINE'); + }); + + it('should return commands starting with input', () => { + const results = registry.autocomplete('LI'); + expect(results.length).toBeGreaterThan(0); + const names = results.map(c => c.name); + expect(names).toContain('LINE'); + }); + + it('should return empty for empty input', () => { + expect(registry.autocomplete('')).toEqual([]); + }); + + it('should return max 10 results', () => { + const results = registry.autocomplete('A'); + expect(results.length).toBeLessThanOrEqual(10); + }); + + it('should match aliases', () => { + const results = registry.autocomplete('PL'); + expect(results.length).toBeGreaterThan(0); + const names = results.map(c => c.name); + expect(names).toContain('POLYLINE'); + }); + + it('should match case-insensitively', () => { + const results = registry.autocomplete('line'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].name).toBe('LINE'); + }); + }); + + describe('getAllNames', () => { + it('should return all names and aliases in uppercase', () => { + const names = registry.getAllNames(); + expect(names.length).toBeGreaterThan(10); + expect(names.every(n => n === n.toUpperCase())).toBe(true); + }); + + it('should include LINE and alias L', () => { + const names = registry.getAllNames(); + expect(names).toContain('LINE'); + expect(names).toContain('L'); + }); + }); + + describe('categories', () => { + it('should have draw category commands', () => { + const all = registry.getAllCommands(); + const draw = all.filter(c => c.category === 'draw'); + expect(draw.length).toBeGreaterThan(5); + const names = draw.map(c => c.name); + expect(names).toContain('LINE'); + expect(names).toContain('CIRCLE'); + }); + + it('should have modify category commands', () => { + const all = registry.getAllCommands(); + const modify = all.filter(c => c.category === 'modify'); + expect(modify.length).toBeGreaterThan(5); + const names = modify.map(c => c.name); + expect(names).toContain('MOVE'); + expect(names).toContain('ROTATE'); + }); + + it('should have view category commands', () => { + const all = registry.getAllCommands(); + const view = all.filter(c => c.category === 'view'); + expect(view.length).toBeGreaterThan(0); + const names = view.map(c => c.name); + expect(names).toContain('PAN'); + expect(names).toContain('ZOOM'); + }); + + it('should have meta category commands', () => { + const all = registry.getAllCommands(); + const meta = all.filter(c => c.category === 'meta'); + expect(meta.length).toBeGreaterThan(0); + const names = meta.map(c => c.name); + expect(names).toContain('UNDO'); + expect(names).toContain('REDO'); + }); + + it('should have special category commands', () => { + const all = registry.getAllCommands(); + const special = all.filter(c => c.category === 'special'); + expect(special.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/tests/geometry.test.ts b/frontend/tests/geometry.test.ts new file mode 100644 index 0000000..806d302 --- /dev/null +++ b/frontend/tests/geometry.test.ts @@ -0,0 +1,295 @@ +/** + * geometry.ts Tests – Pure transformation functions (move, rotate, scale, mirror) + */ +import { describe, it, expect } from 'vitest'; +import { + moveElement, + rotateElement, + scaleElement, + mirrorElement, + offsetElement, + getElementBBox, + distance, + angleBetween, +} from '../src/tools/modification/geometry'; +import type { CADElement } from '../src/types/cad.types'; + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + return { + id, + type: 'line', + layerId: 'layer-1', + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +function makeCircle(id: string, cx: number, cy: number, r: number): CADElement { + return { + id, + type: 'circle', + layerId: 'layer-1', + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r }, + }; +} + +function makePolyline(id: string, points: Array<{x:number;y:number}>): CADElement { + const xs = points.map(p => p.x); + const ys = points.map(p => p.y); + return { + id, + type: 'polyline', + layerId: 'layer-1', + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + properties: { points }, + }; +} + +describe('geometry – moveElement', () => { + it('should shift x and y by dx, dy', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const moved = moveElement(el, 50, 30); + expect(moved.x).toBe(150); + expect(moved.y).toBe(130); + }); + + it('should shift line endpoint properties', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const moved = moveElement(el, 50, 30); + expect(moved.properties.x1).toBe(50); + expect(moved.properties.y1).toBe(30); + expect(moved.properties.x2).toBe(150); + expect(moved.properties.y2).toBe(30); + }); + + it('should shift polyline points', () => { + const el = makePolyline('p1', [{ x: 0, y: 0 }, { x: 100, y: 50 }]); + const moved = moveElement(el, 10, 20); + expect(moved.properties.points![0].x).toBe(10); + expect(moved.properties.points![0].y).toBe(20); + expect(moved.properties.points![1].x).toBe(110); + expect(moved.properties.points![1].y).toBe(70); + }); + + it('should not modify the original element (immutability)', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const original = JSON.parse(JSON.stringify(el)); + moveElement(el, 50, 50); + expect(el).toEqual(original); + }); +}); + +describe('geometry – rotateElement', () => { + it('should rotate element position around center', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const rotated = rotateElement(el, 0, 0, 90); + expect(rotated.x).toBeCloseTo(0, 0); + expect(rotated.y).toBeCloseTo(100, 0); + }); + + it('should rotate line endpoints around center', () => { + const el = makeLine('l1', 100, 0, 200, 0); + const rotated = rotateElement(el, 0, 0, 90); + expect(rotated.properties.x1).toBeCloseTo(0, 0); + expect(rotated.properties.y1).toBeCloseTo(100, 0); + expect(rotated.properties.x2).toBeCloseTo(0, 0); + expect(rotated.properties.y2).toBeCloseTo(200, 0); + }); + + it('should rotate 180 degrees correctly', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const rotated = rotateElement(el, 0, 0, 180); + expect(rotated.x).toBeCloseTo(-100, 0); + expect(rotated.y).toBeCloseTo(0, 0); + }); + + it('should rotate 360 degrees back to original position', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const rotated = rotateElement(el, 0, 0, 360); + expect(rotated.x).toBeCloseTo(100, 5); + expect(rotated.y).toBeCloseTo(0, 5); + }); + + it('should swap width/height for 90-degree rotation on rect', () => { + const el = makeRect('r1', 100, 100, 80, 40); + const rotated = rotateElement(el, 100, 100, 90); + expect(rotated.width).toBe(40); + expect(rotated.height).toBe(80); + }); + + it('should add rotation to properties.rotation', () => { + const el = makeRect('r1', 100, 100, 40, 40); + el.properties.rotation = 30; + const rotated = rotateElement(el, 100, 100, 45); + expect(rotated.properties.rotation).toBe(75); + }); + + it('should not modify the original element', () => { + const el = makeLine('l1', 100, 0, 200, 0); + const original = JSON.parse(JSON.stringify(el)); + rotateElement(el, 0, 0, 90); + expect(el).toEqual(original); + }); +}); + +describe('geometry – scaleElement', () => { + it('should scale element position and dimensions', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const scaled = scaleElement(el, 100, 100, 2, 2); + expect(scaled.x).toBe(100); + expect(scaled.y).toBe(100); + expect(scaled.width).toBe(80); + expect(scaled.height).toBe(80); + }); + + it('should scale element away from center', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const scaled = scaleElement(el, 0, 0, 2, 2); + expect(scaled.x).toBe(200); + expect(scaled.y).toBe(0); + expect(scaled.width).toBe(80); + expect(scaled.height).toBe(80); + }); + + it('should scale line endpoints', () => { + const el = makeLine('l1', 100, 0, 200, 0); + const scaled = scaleElement(el, 0, 0, 2, 2); + expect(scaled.properties.x1).toBe(200); + expect(scaled.properties.x2).toBe(400); + }); + + it('should scale circle radius', () => { + const el = makeCircle('c1', 100, 100, 40); + const scaled = scaleElement(el, 100, 100, 2, 2); + expect(scaled.properties.radius).toBe(80); + }); + + it('should not modify the original element', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const original = JSON.parse(JSON.stringify(el)); + scaleElement(el, 100, 100, 2, 2); + expect(el).toEqual(original); + }); +}); + +describe('geometry – mirrorElement', () => { + it('should mirror across a vertical line', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const mirrored = mirrorElement(el, 200, 0, 200, 100); + expect(mirrored.x).toBe(300); + expect(mirrored.y).toBe(0); + }); + + it('should mirror across a horizontal line', () => { + const el = makeRect('r1', 0, 100, 40, 40); + const mirrored = mirrorElement(el, 0, 200, 100, 200); + expect(mirrored.x).toBe(0); + expect(mirrored.y).toBe(300); + }); + + it('should mirror line endpoints', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const mirrored = mirrorElement(el, 50, 0, 50, 100); + expect(mirrored.properties.x1).toBe(100); + expect(mirrored.properties.x2).toBe(0); + }); + + it('should return element unchanged for zero-length mirror axis', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const mirrored = mirrorElement(el, 50, 50, 50, 50); + expect(mirrored.x).toBe(100); + expect(mirrored.y).toBe(100); + }); + + it('should not modify the original element', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const original = JSON.parse(JSON.stringify(el)); + mirrorElement(el, 50, 0, 50, 100); + expect(el).toEqual(original); + }); +}); + +describe('geometry – offsetElement', () => { + it('should offset a line perpendicularly', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const offset = offsetElement(el, 10); + expect(offset.properties.y1).toBe(10); + expect(offset.properties.y2).toBe(10); + }); + + it('should offset a circle radius', () => { + const el = makeCircle('c1', 100, 100, 40); + const offset = offsetElement(el, 10); + expect(offset.properties.radius).toBe(50); + }); + + it('should return element for unknown type', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const offset = offsetElement(el, 10); + expect(offset.x).toBe(100); + expect(offset.y).toBe(100); + }); +}); + +describe('geometry – getElementBBox', () => { + it('should return bbox for rect element', () => { + const el = makeRect('r1', 100, 100, 40, 60); + const bb = getElementBBox(el); + expect(bb.minX).toBe(80); + expect(bb.minY).toBe(70); + expect(bb.maxX).toBe(120); + expect(bb.maxY).toBe(130); + }); + + it('should return bbox for polyline element from points', () => { + const el = makePolyline('p1', [{ x: 10, y: 20 }, { x: 100, y: 80 }]); + const bb = getElementBBox(el); + expect(bb.minX).toBe(10); + expect(bb.minY).toBe(20); + expect(bb.maxX).toBe(100); + expect(bb.maxY).toBe(80); + }); +}); + +describe('geometry – distance', () => { + it('should calculate distance between two points', () => { + expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5); + }); + + it('should return 0 for same point', () => { + expect(distance({ x: 5, y: 5 }, { x: 5, y: 5 })).toBe(0); + }); +}); + +describe('geometry – angleBetween', () => { + it('should calculate angle between two points in degrees', () => { + expect(angleBetween({ x: 0, y: 0 }, { x: 100, y: 0 })).toBe(0); + }); + + it('should calculate 90-degree angle', () => { + expect(angleBetween({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts index 7b0828b..0888088 100644 --- a/frontend/tests/setup.ts +++ b/frontend/tests/setup.ts @@ -1 +1,93 @@ import '@testing-library/jest-dom'; + +// Mock Canvas 2D context for jsdom (jsdom doesn't implement CanvasRenderingContext2D) +const noop = () => {}; +const mockCtx = { + fillRect: noop, + strokeRect: noop, + clearRect: noop, + beginPath: noop, + closePath: noop, + moveTo: noop, + lineTo: noop, + arc: noop, + arcTo: noop, + rect: noop, + ellipse: noop, + quadraticCurveTo: noop, + bezierCurveTo: noop, + fill: noop, + stroke: noop, + save: noop, + restore: noop, + scale: noop, + translate: noop, + rotate: noop, + transform: noop, + setTransform: noop, + resetTransform: noop, + setLineDash: noop, + getLineDash: () => [] as number[], + clip: noop, + fillText: noop, + strokeText: noop, + measureText: () => ({ width: 0 }) as TextMetrics, + drawImage: noop, + createImageData: () => ({ width: 0, height: 0, data: new Uint8ClampedArray(0) }) as ImageData, + getImageData: () => ({ width: 0, height: 0, data: new Uint8ClampedArray(0) }) as ImageData, + putImageData: noop, + createLinearGradient: () => ({ addColorStop: noop }) as CanvasGradient, + createRadialGradient: () => ({ addColorStop: noop }) as CanvasGradient, + createPattern: () => null as unknown as CanvasPattern, + isPointInPath: () => false, + isPointInStroke: () => false, + // Properties + canvas: null as unknown as HTMLCanvasElement, + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + lineCap: 'butt' as CanvasLineCap, + lineJoin: 'miter' as CanvasLineJoin, + miterLimit: 10, + lineDashOffset: 0, + font: '10px sans-serif', + textAlign: 'start' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline, + direction: 'ltr' as CanvasTextDirection, + globalAlpha: 1, + globalCompositeOperation: 'source-over' as GlobalCompositeOperation, + imageSmoothingEnabled: true, + imageSmoothingQuality: 'low' as ImageSmoothingQuality, + shadowBlur: 0, + shadowColor: 'rgba(0, 0, 0, 0)', + shadowOffsetX: 0, + shadowOffsetY: 0, + filter: 'none', +}; + +// Override getContext to return our mock for '2d' +HTMLCanvasElement.prototype.getContext = function (contextId: string) { + if (contextId === '2d') { + return mockCtx as unknown as CanvasRenderingContext2D; + } + return null; +} as typeof HTMLCanvasElement.prototype.getContext; + +// Mock getBoundingClientRect for canvas elements +const origGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; +HTMLElement.prototype.getBoundingClientRect = function () { + if (this instanceof HTMLCanvasElement) { + return { + left: 0, + top: 0, + right: this.width, + bottom: this.height, + width: this.width, + height: this.height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + } + return origGetBoundingClientRect.call(this); +};