/** * SpatialIndex Tests – rbush-based spatial search */ import { describe, it, expect, beforeEach } from 'vitest'; import { SpatialIndex } from '../src/canvas/SpatialIndex'; import type { CADElement } from '../src/types/cad.types'; function makeElement(id: string, x: number, y: number, width: number, height: number): CADElement { return { id, type: 'rect', layerId: 'layer-1', x, y, width, height, properties: {}, }; } describe('SpatialIndex', () => { let index: SpatialIndex; beforeEach(() => { index = new SpatialIndex(); }); describe('insert & search', () => { it('should find an inserted element within its bounding box', () => { const el = makeElement('el-1', 50, 50, 20, 20); index.insert(el); const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); expect(results.length).toBe(1); expect(results[0].id).toBe('el-1'); }); it('should not find an element outside the search viewport', () => { const el = makeElement('el-1', 100, 100, 20, 20); index.insert(el); const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 }); expect(results.length).toBe(0); }); it('should find multiple elements within viewport', () => { index.insert(makeElement('el-1', 10, 10, 10, 10)); index.insert(makeElement('el-2', 20, 20, 10, 10)); index.insert(makeElement('el-3', 100, 100, 10, 10)); const results = index.search({ minX: 0, minY: 0, maxX: 30, maxY: 30 }); expect(results.length).toBe(2); const ids = results.map(e => e.id).sort(); expect(ids).toEqual(['el-1', 'el-2']); }); it('should handle elements at origin', () => { index.insert(makeElement('el-origin', 0, 0, 10, 10)); const results = index.search({ minX: -5, minY: -5, maxX: 5, maxY: 5 }); expect(results.length).toBe(1); expect(results[0].id).toBe('el-origin'); }); }); describe('bulkInsert', () => { it('should bulk insert and find all elements', () => { const elements = [ makeElement('bulk-1', 10, 10, 5, 5), makeElement('bulk-2', 20, 20, 5, 5), makeElement('bulk-3', 30, 30, 5, 5), makeElement('bulk-4', 200, 200, 5, 5), ]; index.bulkInsert(elements); const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 }); expect(results.length).toBe(3); }); it('should work with empty array', () => { index.bulkInsert([]); const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); expect(results.length).toBe(0); }); }); describe('remove', () => { it('should remove an element so it is no longer found', () => { const el = makeElement('el-rm', 50, 50, 10, 10); index.insert(el); let results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); expect(results.length).toBe(1); index.remove(el); results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); expect(results.length).toBe(0); }); it('should remove only the specified element', () => { const el1 = makeElement('el-keep', 50, 50, 10, 10); const el2 = makeElement('el-rm', 50, 50, 10, 10); index.insert(el1); index.insert(el2); index.remove(el2); const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); expect(results.length).toBe(1); expect(results[0].id).toBe('el-keep'); }); }); describe('clear', () => { it('should clear all elements from the index', () => { index.insert(makeElement('el-1', 10, 10, 5, 5)); index.insert(makeElement('el-2', 20, 20, 5, 5)); index.insert(makeElement('el-3', 30, 30, 5, 5)); index.clear(); const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); expect(results.length).toBe(0); }); }); describe('edge cases', () => { it('should handle overlapping bounding boxes correctly', () => { index.insert(makeElement('el-1', 25, 25, 30, 30)); // bbox: 10,10 to 40,40 index.insert(makeElement('el-2', 30, 30, 30, 30)); // bbox: 15,15 to 45,45 const results = index.search({ minX: 10, minY: 10, maxX: 40, maxY: 40 }); expect(results.length).toBe(2); }); it('should handle zero-size elements', () => { index.insert(makeElement('el-zero', 50, 50, 0, 0)); const results = index.search({ minX: 49, minY: 49, maxX: 51, maxY: 51 }); expect(results.length).toBe(1); }); it('should handle negative coordinates', () => { index.insert(makeElement('el-neg', -50, -50, 20, 20)); const results = index.search({ minX: -60, minY: -60, maxX: -40, maxY: -40 }); expect(results.length).toBe(1); expect(results[0].id).toBe('el-neg'); }); it('should search with a large viewport containing all elements', () => { index.insert(makeElement('el-1', 10, 10, 5, 5)); index.insert(makeElement('el-2', 1000, 1000, 5, 5)); const results = index.search({ minX: -10000, minY: -10000, maxX: 10000, maxY: 10000 }); expect(results.length).toBe(2); }); }); });