mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-09 18:39:41 +02:00
410 lines
11 KiB
TypeScript
410 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { executeTidyTreeLayout, validateLayoutData } from './layout.js';
|
|
import type { LayoutResult } from './types.js';
|
|
import type { LayoutData, MermaidConfig } from 'mermaid';
|
|
|
|
// Mock non-layered-tidy-tree-layout
|
|
vi.mock('non-layered-tidy-tree-layout', () => ({
|
|
BoundingBox: vi.fn().mockImplementation(() => ({})),
|
|
Layout: vi.fn().mockImplementation(() => ({
|
|
layout: vi.fn().mockImplementation((treeData) => {
|
|
const result = { ...treeData };
|
|
|
|
if (result.id?.toString().startsWith('virtual-root')) {
|
|
result.x = 0;
|
|
result.y = 0;
|
|
} else {
|
|
result.x = 100;
|
|
result.y = 50;
|
|
}
|
|
|
|
if (result.children) {
|
|
result.children.forEach((child: any, index: number) => {
|
|
child.x = 50 + index * 100;
|
|
child.y = 100;
|
|
|
|
if (child.children) {
|
|
child.children.forEach((grandchild: any, gIndex: number) => {
|
|
grandchild.x = 25 + gIndex * 50;
|
|
grandchild.y = 200;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
result,
|
|
boundingBox: {
|
|
left: 0,
|
|
right: 200,
|
|
top: 0,
|
|
bottom: 250,
|
|
},
|
|
};
|
|
}),
|
|
})),
|
|
}));
|
|
|
|
describe('Tidy-Tree Layout Algorithm', () => {
|
|
let mockConfig: MermaidConfig;
|
|
let mockLayoutData: LayoutData;
|
|
|
|
beforeEach(() => {
|
|
mockConfig = {
|
|
theme: 'default',
|
|
} as MermaidConfig;
|
|
|
|
mockLayoutData = {
|
|
nodes: [
|
|
{
|
|
id: 'root',
|
|
label: 'Root',
|
|
isGroup: false,
|
|
shape: 'rect',
|
|
width: 100,
|
|
height: 50,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
{
|
|
id: 'child1',
|
|
label: 'Child 1',
|
|
isGroup: false,
|
|
shape: 'rect',
|
|
width: 80,
|
|
height: 40,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
{
|
|
id: 'child2',
|
|
label: 'Child 2',
|
|
isGroup: false,
|
|
shape: 'rect',
|
|
width: 80,
|
|
height: 40,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
{
|
|
id: 'child3',
|
|
label: 'Child 3',
|
|
isGroup: false,
|
|
shape: 'rect',
|
|
width: 80,
|
|
height: 40,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
{
|
|
id: 'child4',
|
|
label: 'Child 4',
|
|
isGroup: false,
|
|
shape: 'rect',
|
|
width: 80,
|
|
height: 40,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
],
|
|
edges: [
|
|
{
|
|
id: 'root_child1',
|
|
start: 'root',
|
|
end: 'child1',
|
|
type: 'edge',
|
|
classes: '',
|
|
style: [],
|
|
animate: false,
|
|
arrowTypeEnd: 'arrow_point',
|
|
arrowTypeStart: 'none',
|
|
},
|
|
{
|
|
id: 'root_child2',
|
|
start: 'root',
|
|
end: 'child2',
|
|
type: 'edge',
|
|
classes: '',
|
|
style: [],
|
|
animate: false,
|
|
arrowTypeEnd: 'arrow_point',
|
|
arrowTypeStart: 'none',
|
|
},
|
|
{
|
|
id: 'root_child3',
|
|
start: 'root',
|
|
end: 'child3',
|
|
type: 'edge',
|
|
classes: '',
|
|
style: [],
|
|
animate: false,
|
|
arrowTypeEnd: 'arrow_point',
|
|
arrowTypeStart: 'none',
|
|
},
|
|
{
|
|
id: 'root_child4',
|
|
start: 'root',
|
|
end: 'child4',
|
|
type: 'edge',
|
|
classes: '',
|
|
style: [],
|
|
animate: false,
|
|
arrowTypeEnd: 'arrow_point',
|
|
arrowTypeStart: 'none',
|
|
},
|
|
],
|
|
config: mockConfig,
|
|
direction: 'TB',
|
|
type: 'test',
|
|
diagramId: 'test-diagram',
|
|
markers: [],
|
|
};
|
|
});
|
|
|
|
describe('validateLayoutData', () => {
|
|
it('should validate correct layout data', () => {
|
|
expect(() => validateLayoutData(mockLayoutData)).not.toThrow();
|
|
});
|
|
|
|
it('should throw error for missing data', () => {
|
|
expect(() => validateLayoutData(null as any)).toThrow('Layout data is required');
|
|
});
|
|
|
|
it('should throw error for missing config', () => {
|
|
const invalidData = { ...mockLayoutData, config: null as any };
|
|
expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required');
|
|
});
|
|
|
|
it('should throw error for invalid nodes array', () => {
|
|
const invalidData = { ...mockLayoutData, nodes: null as any };
|
|
expect(() => validateLayoutData(invalidData)).toThrow('Nodes array is required');
|
|
});
|
|
|
|
it('should throw error for invalid edges array', () => {
|
|
const invalidData = { ...mockLayoutData, edges: null as any };
|
|
expect(() => validateLayoutData(invalidData)).toThrow('Edges array is required');
|
|
});
|
|
});
|
|
|
|
describe('executeTidyTreeLayout function', () => {
|
|
it('should execute layout algorithm successfully', async () => {
|
|
const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.nodes).toBeDefined();
|
|
expect(result.edges).toBeDefined();
|
|
expect(Array.isArray(result.nodes)).toBe(true);
|
|
expect(Array.isArray(result.edges)).toBe(true);
|
|
});
|
|
|
|
it('should return positioned nodes with coordinates', async () => {
|
|
const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
|
|
|
|
expect(result.nodes.length).toBeGreaterThan(0);
|
|
result.nodes.forEach((node) => {
|
|
expect(node.x).toBeDefined();
|
|
expect(node.y).toBeDefined();
|
|
expect(typeof node.x).toBe('number');
|
|
expect(typeof node.y).toBe('number');
|
|
});
|
|
});
|
|
|
|
it('should return positioned edges with coordinates', async () => {
|
|
const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
|
|
|
|
expect(result.edges.length).toBeGreaterThan(0);
|
|
result.edges.forEach((edge) => {
|
|
expect(edge.startX).toBeDefined();
|
|
expect(edge.startY).toBeDefined();
|
|
expect(edge.midX).toBeDefined();
|
|
expect(edge.midY).toBeDefined();
|
|
expect(edge.endX).toBeDefined();
|
|
expect(edge.endY).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should handle empty layout data gracefully', async () => {
|
|
const emptyData: LayoutData = {
|
|
...mockLayoutData,
|
|
nodes: [],
|
|
edges: [],
|
|
};
|
|
|
|
await expect(executeTidyTreeLayout(emptyData)).rejects.toThrow(
|
|
'No nodes found in layout data'
|
|
);
|
|
});
|
|
|
|
it('should throw error for missing nodes', async () => {
|
|
const invalidData = { ...mockLayoutData, nodes: [] };
|
|
|
|
await expect(executeTidyTreeLayout(invalidData)).rejects.toThrow(
|
|
'No nodes found in layout data'
|
|
);
|
|
});
|
|
|
|
it('should handle empty edges (single node tree)', async () => {
|
|
const singleNodeData = {
|
|
...mockLayoutData,
|
|
edges: [],
|
|
nodes: [mockLayoutData.nodes[0]],
|
|
};
|
|
|
|
const result = await executeTidyTreeLayout(singleNodeData);
|
|
expect(result).toBeDefined();
|
|
expect(result.nodes).toHaveLength(1);
|
|
expect(result.edges).toHaveLength(0);
|
|
});
|
|
|
|
it('should create bidirectional dual-tree layout with alternating left/right children', async () => {
|
|
const result = await executeTidyTreeLayout(mockLayoutData);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.nodes).toHaveLength(5);
|
|
|
|
const rootNode = result.nodes.find((node) => node.id === 'root');
|
|
expect(rootNode).toBeDefined();
|
|
expect(rootNode!.x).toBe(0);
|
|
expect(rootNode!.y).toBe(20);
|
|
|
|
const child1 = result.nodes.find((node) => node.id === 'child1');
|
|
const child2 = result.nodes.find((node) => node.id === 'child2');
|
|
const child3 = result.nodes.find((node) => node.id === 'child3');
|
|
const child4 = result.nodes.find((node) => node.id === 'child4');
|
|
|
|
expect(child1).toBeDefined();
|
|
expect(child2).toBeDefined();
|
|
expect(child3).toBeDefined();
|
|
expect(child4).toBeDefined();
|
|
|
|
expect(child1!.x).toBeLessThan(rootNode!.x);
|
|
expect(child2!.x).toBeGreaterThan(rootNode!.x);
|
|
expect(child3!.x).toBeLessThan(rootNode!.x);
|
|
expect(child4!.x).toBeGreaterThan(rootNode!.x);
|
|
|
|
expect(child1!.x).toBeLessThan(-100);
|
|
expect(child3!.x).toBeLessThan(-100);
|
|
|
|
expect(child2!.x).toBeGreaterThan(100);
|
|
expect(child4!.x).toBeGreaterThan(100);
|
|
});
|
|
|
|
it('should correctly transpose coordinates to prevent high nodes from covering nodes above them', async () => {
|
|
const testData = {
|
|
...mockLayoutData,
|
|
nodes: [
|
|
{
|
|
id: 'root',
|
|
label: 'Root',
|
|
isGroup: false,
|
|
shape: 'rect' as const,
|
|
width: 100,
|
|
height: 50,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
{
|
|
id: 'tall-child',
|
|
label: 'Tall Child',
|
|
isGroup: false,
|
|
shape: 'rect' as const,
|
|
width: 80,
|
|
height: 120,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
{
|
|
id: 'short-child',
|
|
label: 'Short Child',
|
|
isGroup: false,
|
|
shape: 'rect' as const,
|
|
width: 80,
|
|
height: 30,
|
|
padding: 10,
|
|
x: 0,
|
|
y: 0,
|
|
cssClasses: '',
|
|
cssStyles: [],
|
|
look: 'default',
|
|
},
|
|
],
|
|
edges: [
|
|
{
|
|
id: 'root_tall',
|
|
start: 'root',
|
|
end: 'tall-child',
|
|
type: 'edge',
|
|
classes: '',
|
|
style: [],
|
|
animate: false,
|
|
arrowTypeEnd: 'arrow_point',
|
|
arrowTypeStart: 'none',
|
|
},
|
|
{
|
|
id: 'root_short',
|
|
start: 'root',
|
|
end: 'short-child',
|
|
type: 'edge',
|
|
classes: '',
|
|
style: [],
|
|
animate: false,
|
|
arrowTypeEnd: 'arrow_point',
|
|
arrowTypeStart: 'none',
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await executeTidyTreeLayout(testData);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.nodes).toHaveLength(3);
|
|
|
|
const rootNode = result.nodes.find((node) => node.id === 'root');
|
|
const tallChild = result.nodes.find((node) => node.id === 'tall-child');
|
|
const shortChild = result.nodes.find((node) => node.id === 'short-child');
|
|
|
|
expect(rootNode).toBeDefined();
|
|
expect(tallChild).toBeDefined();
|
|
expect(shortChild).toBeDefined();
|
|
|
|
expect(tallChild!.x).not.toBe(shortChild!.x);
|
|
|
|
expect(tallChild!.width).toBe(80);
|
|
expect(tallChild!.height).toBe(120);
|
|
expect(shortChild!.width).toBe(80);
|
|
expect(shortChild!.height).toBe(30);
|
|
|
|
const yDifference = Math.abs(tallChild!.y - shortChild!.y);
|
|
expect(yDifference).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
});
|