Files
excalidraw-mcp-sentinel/tests/frontend/helpers.test.ts
T

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 });
});
});