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:
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user