mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-11 19:39:43 +02:00
extract tidy-tree layout into separate package
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
This commit is contained in:
410
packages/mermaid-layout-tidy-tree/src/layout.test.ts
Normal file
410
packages/mermaid-layout-tidy-tree/src/layout.test.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { executeTidyTreeLayout, validateLayoutData } from './layout.js';
|
||||
import type { LayoutResult } from './types.js';
|
||||
import type { LayoutData, MermaidConfig } from 'mermaid';
|
||||
|
||||
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, mockConfig);
|
||||
|
||||
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, mockConfig);
|
||||
|
||||
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, mockConfig);
|
||||
|
||||
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, mockConfig)).rejects.toThrow(
|
||||
'No nodes found in layout data'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for missing nodes', async () => {
|
||||
const invalidData = { ...mockLayoutData, nodes: [] };
|
||||
|
||||
await expect(executeTidyTreeLayout(invalidData, mockConfig)).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, mockConfig);
|
||||
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, mockConfig);
|
||||
|
||||
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, mockConfig);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user