520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
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<string, Element>
|
|
): { id: string; action: 'upsert' | 'delete'; element?: Element }[] {
|
|
const changes: { id: string; action: 'upsert' | 'delete'; element?: Element }[] = [];
|
|
const currentMap = new Map<string, Element>();
|
|
|
|
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<string, Element>();
|
|
|
|
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<string, Element>([
|
|
['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<string, Element>([
|
|
['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<string, Element>([['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<string, Element>([
|
|
['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<string, Element>(); // 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<string, Element>([
|
|
['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);
|
|
});
|
|
});
|