fix: replace hardcoded localhost:3001 with relative API URL + add canvas mock and integration test

- Fix frontend Network error: API_BASE now defaults to empty string (same-origin)
- Fix 3 files: api.ts, AuthContext.tsx, Dashboard.tsx
- Add Canvas 2D context mock in tests/setup.ts for jsdom
- Fix -0 vs +0 in ZoomPanController.getViewport()
- Add IntegrationWorkflow.test.ts (35 tests, full CAD workflow)
This commit is contained in:
2026-06-26 17:48:24 +02:00
parent 89b91a1050
commit 1bcfaffdd4
22 changed files with 4867 additions and 7 deletions
+188
View File
@@ -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());
});
});
});
+383
View File
@@ -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');
});
});
});
+140
View File
@@ -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;
}
});
});
});
+216
View File
@@ -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);
});
});
});
+191
View File
@@ -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);
});
});
});
+212
View File
@@ -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);
});
});
});
+199
View File
@@ -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);
});
});
});
+129
View File
@@ -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);
});
});
});
+187
View File
@@ -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);
});
});
});
+6 -4
View File
@@ -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,
};
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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<string, string> {
return {
+190
View File
@@ -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();
});
});
});
+911
View File
@@ -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> = {}): 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<GroupManager['toJSON']> = [],
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();
});
});
});
+352
View File
@@ -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> = {}): 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();
});
});
});
+394
View File
@@ -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> = {}): 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<typeof setup>;
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');
});
});
});
+340
View File
@@ -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<SnapMode>(),
});
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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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<SnapMode>(['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();
});
});
});
+180
View File
@@ -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);
});
});
});
+259
View File
@@ -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);
});
});
});
+295
View File
@@ -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);
});
});
+92
View File
@@ -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);
};