395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
cleanElementForExcalidraw,
|
|
validateAndFixBindings,
|
|
computeElementHash,
|
|
isImageElement,
|
|
isShapeContainerType,
|
|
normalizeImageElement,
|
|
restoreBindings,
|
|
} from '../../frontend/src/utils/elementHelpers.js';
|
|
import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js';
|
|
|
|
// ─── cleanElementForExcalidraw ───────────────────────────────
|
|
|
|
describe('cleanElementForExcalidraw', () => {
|
|
it('strips server metadata fields', () => {
|
|
const element: ServerElement = {
|
|
id: 'el1',
|
|
type: 'rectangle',
|
|
x: 10,
|
|
y: 20,
|
|
width: 100,
|
|
height: 50,
|
|
createdAt: '2024-01-01T00:00:00Z',
|
|
updatedAt: '2024-01-02T00:00:00Z',
|
|
version: 3,
|
|
syncedAt: '2024-01-02T01:00:00Z',
|
|
source: 'mcp',
|
|
syncTimestamp: '2024-01-02T01:00:00Z',
|
|
};
|
|
|
|
const cleaned = cleanElementForExcalidraw(element);
|
|
|
|
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', 3);
|
|
expect(cleaned).not.toHaveProperty('syncedAt');
|
|
expect(cleaned).not.toHaveProperty('source');
|
|
expect(cleaned).not.toHaveProperty('syncTimestamp');
|
|
});
|
|
|
|
it('preserves core element properties', () => {
|
|
const element: ServerElement = {
|
|
id: 'el2',
|
|
type: 'ellipse',
|
|
x: 50,
|
|
y: 100,
|
|
width: 80,
|
|
height: 80,
|
|
backgroundColor: '#ff0000',
|
|
strokeColor: '#000000',
|
|
strokeWidth: 2,
|
|
opacity: 0.8,
|
|
version: 1,
|
|
};
|
|
|
|
const cleaned = cleanElementForExcalidraw(element);
|
|
|
|
expect(cleaned.id).toBe('el2');
|
|
expect(cleaned.type).toBe('ellipse');
|
|
expect(cleaned.x).toBe(50);
|
|
expect(cleaned.y).toBe(100);
|
|
expect((cleaned as any).backgroundColor).toBe('#ff0000');
|
|
expect((cleaned as any).strokeColor).toBe('#000000');
|
|
expect((cleaned as any).opacity).toBe(0.8);
|
|
});
|
|
|
|
it('preserves label and text fields', () => {
|
|
const element: ServerElement = {
|
|
id: 'txt1',
|
|
type: 'text',
|
|
x: 0,
|
|
y: 0,
|
|
text: 'Hello',
|
|
fontSize: 20,
|
|
label: { text: 'Label' },
|
|
version: 1,
|
|
};
|
|
|
|
const cleaned = cleanElementForExcalidraw(element);
|
|
expect((cleaned as any).text).toBe('Hello');
|
|
expect((cleaned as any).label).toEqual({ text: 'Label' });
|
|
expect((cleaned as any).fontSize).toBe(20);
|
|
});
|
|
|
|
it('preserves arrow binding references', () => {
|
|
const element: ServerElement = {
|
|
id: 'arrow1',
|
|
type: 'arrow',
|
|
x: 0,
|
|
y: 0,
|
|
start: { id: 'box1' },
|
|
end: { id: 'box2' },
|
|
version: 1,
|
|
};
|
|
|
|
const cleaned = cleanElementForExcalidraw(element);
|
|
expect((cleaned as any).start).toEqual({ id: 'box1' });
|
|
expect((cleaned as any).end).toEqual({ id: 'box2' });
|
|
});
|
|
});
|
|
|
|
// ─── validateAndFixBindings ──────────────────────────────────
|
|
|
|
describe('validateAndFixBindings', () => {
|
|
it('keeps valid boundElements references', () => {
|
|
const elements = [
|
|
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'arrow1', type: 'arrow' }] },
|
|
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[0]!.boundElements).toEqual([{ id: 'arrow1', type: 'arrow' }]);
|
|
});
|
|
|
|
it('removes boundElements referencing non-existent elements', () => {
|
|
const elements = [
|
|
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'missing', type: 'arrow' }] },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[0]!.boundElements).toBeNull();
|
|
});
|
|
|
|
it('removes invalid binding objects', () => {
|
|
const elements = [
|
|
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [null, undefined, 'invalid', { id: 'a' }] },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[0]!.boundElements).toBeNull();
|
|
});
|
|
|
|
it('removes invalid binding types', () => {
|
|
const elements = [
|
|
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'other', type: 'invalid' }] },
|
|
{ id: 'other', type: 'rectangle', x: 0, y: 0 },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[0]!.boundElements).toBeNull();
|
|
});
|
|
|
|
it('sets non-array boundElements to null', () => {
|
|
const elements = [
|
|
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: 'not-an-array' },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[0]!.boundElements).toBeNull();
|
|
});
|
|
|
|
it('nullifies containerId when container does not exist', () => {
|
|
const elements = [
|
|
{ id: 'text1', type: 'text', x: 0, y: 0, containerId: 'missing-container' },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[0]!.containerId).toBeNull();
|
|
});
|
|
|
|
it('keeps valid containerId', () => {
|
|
const elements = [
|
|
{ id: 'container', type: 'rectangle', x: 0, y: 0 },
|
|
{ id: 'text1', type: 'text', x: 0, y: 0, containerId: 'container' },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[1]!.containerId).toBe('container');
|
|
});
|
|
|
|
it('handles empty array input', () => {
|
|
expect(validateAndFixBindings([])).toEqual([]);
|
|
});
|
|
|
|
it('handles elements with no bindings', () => {
|
|
const elements = [
|
|
{ id: 'simple', type: 'rectangle', x: 0, y: 0 },
|
|
] as any[];
|
|
|
|
const result = validateAndFixBindings(elements);
|
|
expect(result[0]!.id).toBe('simple');
|
|
});
|
|
});
|
|
|
|
// ─── computeElementHash ──────────────────────────────────────
|
|
|
|
describe('computeElementHash', () => {
|
|
it('produces consistent hash for same elements', () => {
|
|
const elements = [
|
|
{ id: 'a', version: 1 },
|
|
{ id: 'b', version: 2 },
|
|
];
|
|
|
|
const hash1 = computeElementHash(elements);
|
|
const hash2 = computeElementHash(elements);
|
|
expect(hash1).toBe(hash2);
|
|
});
|
|
|
|
it('produces different hash when element version changes', () => {
|
|
const v1 = [{ id: 'a', version: 1 }];
|
|
const v2 = [{ id: 'a', version: 2 }];
|
|
|
|
expect(computeElementHash(v1)).not.toBe(computeElementHash(v2));
|
|
});
|
|
|
|
it('produces different hash when element is added', () => {
|
|
const one = [{ id: 'a', version: 1 }];
|
|
const two = [{ id: 'a', version: 1 }, { id: 'b', version: 1 }];
|
|
|
|
expect(computeElementHash(one)).not.toBe(computeElementHash(two));
|
|
});
|
|
|
|
it('handles empty array', () => {
|
|
expect(computeElementHash([])).toBe('0');
|
|
});
|
|
|
|
it('includes element count in hash', () => {
|
|
const hash = computeElementHash([{ id: 'x', version: 1 }]);
|
|
expect(hash.startsWith('1')).toBe(true);
|
|
});
|
|
|
|
it('is order-stable for same id/version set', () => {
|
|
const a = [
|
|
{ id: 'a', version: 1 },
|
|
{ id: 'b', version: 3 },
|
|
{ id: 'c', version: 2 },
|
|
];
|
|
const b = [
|
|
{ id: 'c', version: 2 },
|
|
{ id: 'a', version: 1 },
|
|
{ id: 'b', version: 3 },
|
|
];
|
|
|
|
expect(computeElementHash(a)).toBe(computeElementHash(b));
|
|
});
|
|
});
|
|
|
|
// ─── isImageElement ─────────────────────────────────────────
|
|
|
|
describe('isImageElement', () => {
|
|
it('returns true for image type', () => {
|
|
expect(isImageElement({ type: 'image' } as any)).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-image types', () => {
|
|
expect(isImageElement({ type: 'rectangle' } as any)).toBe(false);
|
|
expect(isImageElement({ type: 'text' } as any)).toBe(false);
|
|
expect(isImageElement({ type: 'arrow' } as any)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── isShapeContainerType ───────────────────────────────────
|
|
|
|
describe('isShapeContainerType', () => {
|
|
it('returns true for container types', () => {
|
|
expect(isShapeContainerType('rectangle')).toBe(true);
|
|
expect(isShapeContainerType('ellipse')).toBe(true);
|
|
expect(isShapeContainerType('diamond')).toBe(true);
|
|
expect(isShapeContainerType('arrow')).toBe(true);
|
|
expect(isShapeContainerType('line')).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-container types', () => {
|
|
expect(isShapeContainerType('text')).toBe(false);
|
|
expect(isShapeContainerType('image')).toBe(false);
|
|
expect(isShapeContainerType('freedraw')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── normalizeImageElement ──────────────────────────────────
|
|
|
|
describe('normalizeImageElement', () => {
|
|
it('fills in default values for missing properties', () => {
|
|
const el = { id: 'img1', type: 'image', x: 0, y: 0, width: 100, height: 100 };
|
|
const result = normalizeImageElement(el);
|
|
|
|
expect(result.status).toBe('saved');
|
|
expect(result.fileId).toBeNull();
|
|
expect(result.scale).toEqual([1, 1]);
|
|
expect(result.angle).toBe(0);
|
|
expect(result.roughness).toBe(1);
|
|
expect(result.opacity).toBe(100);
|
|
expect(result.isDeleted).toBe(false);
|
|
expect(result.locked).toBe(false);
|
|
});
|
|
|
|
it('preserves existing values', () => {
|
|
const el = {
|
|
id: 'img2',
|
|
type: 'image',
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
status: 'pending',
|
|
fileId: 'abc',
|
|
scale: [2, 2] as [number, number],
|
|
opacity: 50,
|
|
};
|
|
const result = normalizeImageElement(el);
|
|
|
|
expect(result.status).toBe('pending');
|
|
expect(result.fileId).toBe('abc');
|
|
expect(result.scale).toEqual([2, 2]);
|
|
expect(result.opacity).toBe(50);
|
|
});
|
|
});
|
|
|
|
// ─── restoreBindings ────────────────────────────────────────
|
|
|
|
describe('restoreBindings', () => {
|
|
it('restores startBinding and endBinding from originals', () => {
|
|
const converted = [
|
|
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
|
|
];
|
|
const originals = [
|
|
{
|
|
id: 'arrow1',
|
|
type: 'arrow',
|
|
x: 0,
|
|
y: 0,
|
|
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
|
|
endBinding: { elementId: 'rect2', focus: 0, gap: 5 },
|
|
},
|
|
];
|
|
|
|
const result = restoreBindings(converted, originals);
|
|
expect(result[0].startBinding).toEqual({ elementId: 'rect1', focus: 0, gap: 5 });
|
|
expect(result[0].endBinding).toEqual({ elementId: 'rect2', focus: 0, gap: 5 });
|
|
});
|
|
|
|
it('restores boundElements from originals', () => {
|
|
const converted = [
|
|
{ id: 'rect1', type: 'rectangle', x: 0, y: 0 },
|
|
];
|
|
const originals = [
|
|
{
|
|
id: 'rect1',
|
|
type: 'rectangle',
|
|
x: 0,
|
|
y: 0,
|
|
boundElements: [{ id: 'arrow1', type: 'arrow' }],
|
|
},
|
|
];
|
|
|
|
const result = restoreBindings(converted, originals);
|
|
expect(result[0].boundElements).toEqual([{ id: 'arrow1', type: 'arrow' }]);
|
|
});
|
|
|
|
it('restores elbowed property from originals', () => {
|
|
const converted = [
|
|
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
|
|
];
|
|
const originals = [
|
|
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, elbowed: true },
|
|
];
|
|
|
|
const result = restoreBindings(converted, originals);
|
|
expect(result[0].elbowed).toBe(true);
|
|
});
|
|
|
|
it('does not overwrite existing bindings', () => {
|
|
const existingBinding = { elementId: 'rect99', focus: 1, gap: 10 };
|
|
const converted = [
|
|
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, startBinding: existingBinding },
|
|
];
|
|
const originals = [
|
|
{
|
|
id: 'arrow1',
|
|
type: 'arrow',
|
|
x: 0,
|
|
y: 0,
|
|
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
|
|
},
|
|
];
|
|
|
|
const result = restoreBindings(converted, originals);
|
|
expect(result[0].startBinding).toEqual(existingBinding);
|
|
});
|
|
|
|
it('handles elements not found in originals', () => {
|
|
const converted = [
|
|
{ id: 'new1', type: 'rectangle', x: 0, y: 0 },
|
|
];
|
|
const originals = [
|
|
{ id: 'other', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'a', type: 'arrow' }] },
|
|
];
|
|
|
|
const result = restoreBindings(converted, originals);
|
|
expect(result[0]).toEqual({ id: 'new1', type: 'rectangle', x: 0, y: 0 });
|
|
});
|
|
});
|