import { describe, it, expect } from 'vitest'; import { cleanElementForExcalidraw, computeElementHash, isImageElement, isShapeContainerType, normalizeImageElement, validateAndFixBindings, restoreBindings, } from '../../frontend/src/utils/elementHelpers.js'; // ─── cleanElementForExcalidraw comprehensive ──────────────── describe('cleanElementForExcalidraw - comprehensive', () => { it('strips server-only metadata fields but preserves Excalidraw version', () => { const serverEl = { id: 'el-1', type: 'rectangle', x: 100, y: 200, width: 150, height: 80, version: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01', syncedAt: '2024-01-01', source: 'mcp', syncTimestamp: 12345, }; const cleaned = cleanElementForExcalidraw(serverEl); expect(cleaned).not.toHaveProperty('createdAt'); expect(cleaned).not.toHaveProperty('updatedAt'); // version is kept — it is the Excalidraw element version, not a DB field expect(cleaned).toHaveProperty('version', 1); expect(cleaned).not.toHaveProperty('syncedAt'); expect(cleaned).not.toHaveProperty('source'); expect(cleaned).not.toHaveProperty('syncTimestamp'); // Core props preserved expect(cleaned.id).toBe('el-1'); expect(cleaned.type).toBe('rectangle'); expect(cleaned.x).toBe(100); }); it('preserves label text on container elements', () => { const el = { id: 'cont-1', type: 'rectangle', x: 0, y: 0, width: 200, height: 100, label: { text: 'My Label' }, }; const cleaned = cleanElementForExcalidraw(el); expect(cleaned.label?.text || (cleaned as any).text).toBeDefined(); }); it('preserves arrow binding properties', () => { const arrow = { id: 'arrow-1', type: 'arrow', x: 0, y: 0, width: 200, height: 0, start: { id: 'rect-1' }, end: { id: 'rect-2' }, startElementId: 'rect-1', endElementId: 'rect-2', }; const cleaned = cleanElementForExcalidraw(arrow); // Should preserve binding references expect(cleaned.type).toBe('arrow'); }); it('handles elements with no optional properties', () => { const minimal = { id: 'min-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, }; const cleaned = cleanElementForExcalidraw(minimal); expect(cleaned.id).toBe('min-1'); expect(cleaned.type).toBe('rectangle'); }); it('handles text element with originalText', () => { const textEl = { id: 'text-1', type: 'text', x: 0, y: 0, text: 'Hello', originalText: 'Hello', fontSize: 20, fontFamily: 1, }; const cleaned = cleanElementForExcalidraw(textEl); expect(cleaned.type).toBe('text'); }); }); // ─── computeElementHash ───────────────────────────────────── describe('computeElementHash - edge cases', () => { it('hash changes when element position changes', () => { const elements = [{ id: 'h1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, version: 1 }] as any; const hash1 = computeElementHash(elements); const moved = [{ id: 'h1', type: 'rectangle', x: 50, y: 50, width: 100, height: 50, version: 2 }] as any; const hash2 = computeElementHash(moved); expect(hash1).not.toBe(hash2); }); it('hash changes when element is deleted (removed from array)', () => { const full = [ { id: 'h1', type: 'rectangle', version: 1 }, { id: 'h2', type: 'ellipse', version: 1 }, ] as any; const partial = [{ id: 'h1', type: 'rectangle', version: 1 }] as any; expect(computeElementHash(full)).not.toBe(computeElementHash(partial)); }); it('hash is stable for same input', () => { const elements = [ { id: 'stable-1', type: 'rectangle', version: 1 }, { id: 'stable-2', type: 'ellipse', version: 1 }, ] as any; expect(computeElementHash(elements)).toBe(computeElementHash(elements)); }); it('hash uses id+version (type changes without version bump are not detected)', () => { // Hash formula is: count + join(id+version) — type is NOT included const rect = [{ id: 'morph', type: 'rectangle', version: 1 }] as any; const ellipse = [{ id: 'morph', type: 'ellipse', version: 1 }] as any; // Same id+version → same hash (this is expected behavior) expect(computeElementHash(rect)).toBe(computeElementHash(ellipse)); // Version bump makes them different const updated = [{ id: 'morph', type: 'ellipse', version: 2 }] as any; expect(computeElementHash(rect)).not.toBe(computeElementHash(updated)); }); }); // ─── validateAndFixBindings comprehensive ─────────────────── describe('validateAndFixBindings - comprehensive', () => { it('preserves valid container + bound text relationship', () => { const elements = [ { id: 'container', type: 'rectangle', boundElements: [{ id: 'bound-text', type: 'text' }], }, { id: 'bound-text', type: 'text', containerId: 'container', }, ]; const result = validateAndFixBindings(elements); const container = result.find((e: any) => e.id === 'container'); const text = result.find((e: any) => e.id === 'bound-text'); expect(container.boundElements).toHaveLength(1); expect(text.containerId).toBe('container'); }); it('removes orphaned boundElements references', () => { const elements = [ { id: 'container', type: 'rectangle', boundElements: [ { id: 'exists', type: 'text' }, { id: 'ghost', type: 'text' }, ], }, { id: 'exists', type: 'text', containerId: 'container' }, ]; const result = validateAndFixBindings(elements); const container = result.find((e: any) => e.id === 'container'); expect(container.boundElements).toHaveLength(1); expect(container.boundElements[0].id).toBe('exists'); }); it('nullifies containerId when container does not exist', () => { const elements = [ { id: 'orphan', type: 'text', containerId: 'nonexistent' }, ]; const result = validateAndFixBindings(elements); expect(result[0].containerId).toBeNull(); }); it('handles arrow boundElements correctly', () => { const elements = [ { id: 'shape', type: 'rectangle', boundElements: [{ id: 'arrow-1', type: 'arrow' }], }, { id: 'arrow-1', type: 'arrow', startBinding: { elementId: 'shape' }, }, ]; const result = validateAndFixBindings(elements); const shape = result.find((e: any) => e.id === 'shape'); expect(shape.boundElements).toHaveLength(1); }); it('handles empty boundElements array (converts to null)', () => { const elements = [{ id: 'empty', type: 'rectangle', boundElements: [] }]; const result = validateAndFixBindings(elements); // Implementation converts empty filtered arrays to null expect(result[0].boundElements).toBeNull(); }); it('handles null boundElements', () => { const elements = [{ id: 'null-bound', type: 'rectangle', boundElements: null }]; const result = validateAndFixBindings(elements); expect(result[0].boundElements).toBeNull(); }); }); // ─── isImageElement ───────────────────────────────────────── describe('isImageElement - comprehensive', () => { it('returns true for image type', () => { expect(isImageElement({ type: 'image', fileId: 'f1' })).toBe(true); }); it('returns false for all other types', () => { const nonImageTypes = ['rectangle', 'ellipse', 'diamond', 'arrow', 'line', 'text', 'freedraw']; for (const type of nonImageTypes) { expect(isImageElement({ type })).toBe(false); } }); it('returns true when fileId is present regardless of type', () => { // Some implementations check fileId as fallback const result = isImageElement({ type: 'image', fileId: 'some-file' }); expect(result).toBe(true); }); }); // ─── isShapeContainerType ─────────────────────────────────── describe('isShapeContainerType - comprehensive', () => { it('returns true for all container types', () => { const containerTypes = ['rectangle', 'ellipse', 'diamond']; for (const type of containerTypes) { expect(isShapeContainerType(type)).toBe(true); } }); it('returns true for arrow and line (they are container types)', () => { // arrow and line are included in SHAPE_CONTAINER_TYPES expect(isShapeContainerType('arrow')).toBe(true); expect(isShapeContainerType('line')).toBe(true); }); it('returns false for non-container types', () => { const nonContainer = ['text', 'freedraw', 'image', 'frame']; for (const type of nonContainer) { expect(isShapeContainerType(type)).toBe(false); } }); }); // ─── normalizeImageElement ────────────────────────────────── describe('normalizeImageElement - comprehensive', () => { it('fills in all required defaults for minimal image', () => { const minimal = { id: 'img-1', type: 'image', x: 0, y: 0, width: 100, height: 100, fileId: 'file-1', }; const normalized = normalizeImageElement(minimal); expect(normalized.type).toBe('image'); expect(normalized.fileId).toBe('file-1'); // Should have all required Excalidraw properties expect(normalized).toHaveProperty('strokeColor'); expect(normalized).toHaveProperty('backgroundColor'); expect(normalized).toHaveProperty('fillStyle'); expect(normalized).toHaveProperty('opacity'); }); it('preserves explicit values over defaults', () => { const custom = { id: 'img-2', type: 'image', x: 50, y: 50, width: 200, height: 150, fileId: 'file-2', opacity: 50, angle: 1.5, }; const normalized = normalizeImageElement(custom); expect(normalized.opacity).toBe(50); expect(normalized.angle).toBe(1.5); expect(normalized.x).toBe(50); expect(normalized.y).toBe(50); }); }); // ─── restoreBindings ──────────────────────────────────────── describe('restoreBindings - comprehensive', () => { it('restores startBinding and endBinding from originals', () => { const converted = [ { id: 'arrow-1', type: 'arrow' }, ]; const originals = [ { id: 'arrow-1', type: 'arrow', startBinding: { elementId: 'rect-1', focus: 0, gap: 5, fixedPoint: null }, endBinding: { elementId: 'rect-2', focus: 0, gap: 5, fixedPoint: null }, }, ]; const result = restoreBindings(converted, originals); expect(result[0].startBinding.elementId).toBe('rect-1'); expect(result[0].endBinding.elementId).toBe('rect-2'); }); it('restores boundElements on shapes', () => { const converted = [ { id: 'shape-1', type: 'rectangle' }, ]; const originals = [ { id: 'shape-1', type: 'rectangle', boundElements: [{ id: 'arrow-1', type: 'arrow' }], }, ]; const result = restoreBindings(converted, originals); expect(result[0].boundElements).toHaveLength(1); }); it('does not overwrite existing bindings', () => { const converted = [ { id: 'arrow-1', type: 'arrow', startBinding: { elementId: 'already-set', focus: 0, gap: 3, fixedPoint: null }, }, ]; const originals = [ { id: 'arrow-1', type: 'arrow', startBinding: { elementId: 'original', focus: 0, gap: 5, fixedPoint: null }, }, ]; const result = restoreBindings(converted, originals); expect(result[0].startBinding.elementId).toBe('already-set'); }); it('handles element not found in originals', () => { const converted = [{ id: 'new-1', type: 'rectangle' }]; const originals = [{ id: 'other', type: 'ellipse' }]; const result = restoreBindings(converted, originals); expect(result[0].id).toBe('new-1'); // Should not crash }); it('restores elbowed property on arrows', () => { const converted = [{ id: 'elb-arrow', type: 'arrow' }]; const originals = [ { id: 'elb-arrow', type: 'arrow', elbowed: true }, ]; const result = restoreBindings(converted, originals); expect(result[0].elbowed).toBe(true); }); }); // ─── Delta Computation Logic (simulated) ──────────────────── // Tests the algorithm used in syncToBackend for detecting changes describe('Delta computation (simulated syncToBackend logic)', () => { type Element = { id: string; type: string; x: number; version: number }; function computeDelta( currentElements: Element[], lastSynced: Map ): { id: string; action: 'upsert' | 'delete'; element?: Element }[] { const changes: { id: string; action: 'upsert' | 'delete'; element?: Element }[] = []; const currentMap = new Map(); for (const el of currentElements) { currentMap.set(el.id, el); const prev = lastSynced.get(el.id); if (!prev || JSON.stringify(prev) !== JSON.stringify(el)) { changes.push({ id: el.id, action: 'upsert', element: el }); } } for (const [id] of lastSynced) { if (!currentMap.has(id)) { changes.push({ id, action: 'delete' }); } } return changes; } it('detects new elements as upserts', () => { const current = [{ id: 'a', type: 'rect', x: 0, version: 1 }]; const lastSynced = new Map(); const delta = computeDelta(current, lastSynced); expect(delta).toHaveLength(1); expect(delta[0].action).toBe('upsert'); expect(delta[0].id).toBe('a'); }); it('detects removed elements as deletes', () => { const current: Element[] = []; const lastSynced = new Map([ ['a', { id: 'a', type: 'rect', x: 0, version: 1 }], ['b', { id: 'b', type: 'rect', x: 100, version: 1 }], ]); const delta = computeDelta(current, lastSynced); expect(delta).toHaveLength(2); expect(delta.every(d => d.action === 'delete')).toBe(true); }); it('detects updated elements as upserts', () => { const current = [{ id: 'a', type: 'rect', x: 50, version: 2 }]; const lastSynced = new Map([ ['a', { id: 'a', type: 'rect', x: 0, version: 1 }], ]); const delta = computeDelta(current, lastSynced); expect(delta).toHaveLength(1); expect(delta[0].action).toBe('upsert'); }); it('returns empty when nothing changed', () => { const el = { id: 'a', type: 'rect', x: 0, version: 1 }; const current = [el]; const lastSynced = new Map([['a', { ...el }]]); const delta = computeDelta(current, lastSynced); expect(delta).toHaveLength(0); }); it('handles mixed operations correctly', () => { const current = [ { id: 'a', type: 'rect', x: 50, version: 2 }, // updated { id: 'c', type: 'rect', x: 200, version: 1 }, // new ]; const lastSynced = new Map([ ['a', { id: 'a', type: 'rect', x: 0, version: 1 }], ['b', { id: 'b', type: 'rect', x: 100, version: 1 }], // deleted ]); const delta = computeDelta(current, lastSynced); expect(delta).toHaveLength(3); const upserts = delta.filter(d => d.action === 'upsert'); const deletes = delta.filter(d => d.action === 'delete'); expect(upserts).toHaveLength(2); // a (updated) + c (new) expect(deletes).toHaveLength(1); // b expect(deletes[0].id).toBe('b'); }); it('THE BUG: empty lastSynced means no deletions detected', () => { // This is the exact bug scenario: elements loaded from server but lastSynced not populated const current: Element[] = []; // User deleted everything const lastSynced = new Map(); // Bug: was never populated const delta = computeDelta(current, lastSynced); // With empty lastSynced, no deletions are detected - this was the regression expect(delta).toHaveLength(0); }); it('FIXED: populated lastSynced detects all deletions', () => { // After fix: lastSynced is populated on load const current: Element[] = []; // User deleted everything const lastSynced = new Map([ ['a', { id: 'a', type: 'rect', x: 0, version: 1 }], ['b', { id: 'b', type: 'rect', x: 100, version: 1 }], ]); const delta = computeDelta(current, lastSynced); expect(delta).toHaveLength(2); expect(delta.every(d => d.action === 'delete')).toBe(true); }); });