collab/mermaid/packages/mermaid-layout-tidy-tree/src/layout.test.tsblame
View source
6dd74de1import { describe, it, expect, beforeEach, vi } from 'vitest';
6dd74de2import { executeTidyTreeLayout, validateLayoutData } from './layout.js';
6dd74de3import type { LayoutResult } from './types.js';
6dd74de4import type { LayoutData, MermaidConfig } from 'mermaid';
6dd74de5
6dd74de6// Mock non-layered-tidy-tree-layout
6dd74de7vi.mock('non-layered-tidy-tree-layout', () => ({
6dd74de8 BoundingBox: vi.fn().mockImplementation(() => ({})),
6dd74de9 Layout: vi.fn().mockImplementation(() => ({
6dd74de10 layout: vi.fn().mockImplementation((treeData) => {
6dd74de11 const result = { ...treeData };
6dd74de12
6dd74de13 if (result.id?.toString().startsWith('virtual-root')) {
6dd74de14 result.x = 0;
6dd74de15 result.y = 0;
6dd74de16 } else {
6dd74de17 result.x = 100;
6dd74de18 result.y = 50;
6dd74de19 }
6dd74de20
6dd74de21 if (result.children) {
6dd74de22 result.children.forEach((child: any, index: number) => {
6dd74de23 child.x = 50 + index * 100;
6dd74de24 child.y = 100;
6dd74de25
6dd74de26 if (child.children) {
6dd74de27 child.children.forEach((grandchild: any, gIndex: number) => {
6dd74de28 grandchild.x = 25 + gIndex * 50;
6dd74de29 grandchild.y = 200;
6dd74de30 });
6dd74de31 }
6dd74de32 });
6dd74de33 }
6dd74de34
6dd74de35 return {
6dd74de36 result,
6dd74de37 boundingBox: {
6dd74de38 left: 0,
6dd74de39 right: 200,
6dd74de40 top: 0,
6dd74de41 bottom: 250,
6dd74de42 },
6dd74de43 };
6dd74de44 }),
6dd74de45 })),
6dd74de46}));
6dd74de47
6dd74de48describe('Tidy-Tree Layout Algorithm', () => {
6dd74de49 let mockConfig: MermaidConfig;
6dd74de50 let mockLayoutData: LayoutData;
6dd74de51
6dd74de52 beforeEach(() => {
6dd74de53 mockConfig = {
6dd74de54 theme: 'default',
6dd74de55 } as MermaidConfig;
6dd74de56
6dd74de57 mockLayoutData = {
6dd74de58 nodes: [
6dd74de59 {
6dd74de60 id: 'root',
6dd74de61 label: 'Root',
6dd74de62 isGroup: false,
6dd74de63 shape: 'rect',
6dd74de64 width: 100,
6dd74de65 height: 50,
6dd74de66 padding: 10,
6dd74de67 x: 0,
6dd74de68 y: 0,
6dd74de69 cssClasses: '',
6dd74de70 cssStyles: [],
6dd74de71 look: 'default',
6dd74de72 },
6dd74de73 {
6dd74de74 id: 'child1',
6dd74de75 label: 'Child 1',
6dd74de76 isGroup: false,
6dd74de77 shape: 'rect',
6dd74de78 width: 80,
6dd74de79 height: 40,
6dd74de80 padding: 10,
6dd74de81 x: 0,
6dd74de82 y: 0,
6dd74de83 cssClasses: '',
6dd74de84 cssStyles: [],
6dd74de85 look: 'default',
6dd74de86 },
6dd74de87 {
6dd74de88 id: 'child2',
6dd74de89 label: 'Child 2',
6dd74de90 isGroup: false,
6dd74de91 shape: 'rect',
6dd74de92 width: 80,
6dd74de93 height: 40,
6dd74de94 padding: 10,
6dd74de95 x: 0,
6dd74de96 y: 0,
6dd74de97 cssClasses: '',
6dd74de98 cssStyles: [],
6dd74de99 look: 'default',
6dd74de100 },
6dd74de101 {
6dd74de102 id: 'child3',
6dd74de103 label: 'Child 3',
6dd74de104 isGroup: false,
6dd74de105 shape: 'rect',
6dd74de106 width: 80,
6dd74de107 height: 40,
6dd74de108 padding: 10,
6dd74de109 x: 0,
6dd74de110 y: 0,
6dd74de111 cssClasses: '',
6dd74de112 cssStyles: [],
6dd74de113 look: 'default',
6dd74de114 },
6dd74de115 {
6dd74de116 id: 'child4',
6dd74de117 label: 'Child 4',
6dd74de118 isGroup: false,
6dd74de119 shape: 'rect',
6dd74de120 width: 80,
6dd74de121 height: 40,
6dd74de122 padding: 10,
6dd74de123 x: 0,
6dd74de124 y: 0,
6dd74de125 cssClasses: '',
6dd74de126 cssStyles: [],
6dd74de127 look: 'default',
6dd74de128 },
6dd74de129 ],
6dd74de130 edges: [
6dd74de131 {
6dd74de132 id: 'root_child1',
6dd74de133 start: 'root',
6dd74de134 end: 'child1',
6dd74de135 type: 'edge',
6dd74de136 classes: '',
6dd74de137 style: [],
6dd74de138 animate: false,
6dd74de139 arrowTypeEnd: 'arrow_point',
6dd74de140 arrowTypeStart: 'none',
6dd74de141 },
6dd74de142 {
6dd74de143 id: 'root_child2',
6dd74de144 start: 'root',
6dd74de145 end: 'child2',
6dd74de146 type: 'edge',
6dd74de147 classes: '',
6dd74de148 style: [],
6dd74de149 animate: false,
6dd74de150 arrowTypeEnd: 'arrow_point',
6dd74de151 arrowTypeStart: 'none',
6dd74de152 },
6dd74de153 {
6dd74de154 id: 'root_child3',
6dd74de155 start: 'root',
6dd74de156 end: 'child3',
6dd74de157 type: 'edge',
6dd74de158 classes: '',
6dd74de159 style: [],
6dd74de160 animate: false,
6dd74de161 arrowTypeEnd: 'arrow_point',
6dd74de162 arrowTypeStart: 'none',
6dd74de163 },
6dd74de164 {
6dd74de165 id: 'root_child4',
6dd74de166 start: 'root',
6dd74de167 end: 'child4',
6dd74de168 type: 'edge',
6dd74de169 classes: '',
6dd74de170 style: [],
6dd74de171 animate: false,
6dd74de172 arrowTypeEnd: 'arrow_point',
6dd74de173 arrowTypeStart: 'none',
6dd74de174 },
6dd74de175 ],
6dd74de176 config: mockConfig,
6dd74de177 direction: 'TB',
6dd74de178 type: 'test',
6dd74de179 diagramId: 'test-diagram',
6dd74de180 markers: [],
6dd74de181 };
6dd74de182 });
6dd74de183
6dd74de184 describe('validateLayoutData', () => {
6dd74de185 it('should validate correct layout data', () => {
6dd74de186 expect(() => validateLayoutData(mockLayoutData)).not.toThrow();
6dd74de187 });
6dd74de188
6dd74de189 it('should throw error for missing data', () => {
6dd74de190 expect(() => validateLayoutData(null as any)).toThrow('Layout data is required');
6dd74de191 });
6dd74de192
6dd74de193 it('should throw error for missing config', () => {
6dd74de194 const invalidData = { ...mockLayoutData, config: null as any };
6dd74de195 expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required');
6dd74de196 });
6dd74de197
6dd74de198 it('should throw error for invalid nodes array', () => {
6dd74de199 const invalidData = { ...mockLayoutData, nodes: null as any };
6dd74de200 expect(() => validateLayoutData(invalidData)).toThrow('Nodes array is required');
6dd74de201 });
6dd74de202
6dd74de203 it('should throw error for invalid edges array', () => {
6dd74de204 const invalidData = { ...mockLayoutData, edges: null as any };
6dd74de205 expect(() => validateLayoutData(invalidData)).toThrow('Edges array is required');
6dd74de206 });
6dd74de207 });
6dd74de208
6dd74de209 describe('executeTidyTreeLayout function', () => {
6dd74de210 it('should execute layout algorithm successfully', async () => {
6dd74de211 const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
6dd74de212
6dd74de213 expect(result).toBeDefined();
6dd74de214 expect(result.nodes).toBeDefined();
6dd74de215 expect(result.edges).toBeDefined();
6dd74de216 expect(Array.isArray(result.nodes)).toBe(true);
6dd74de217 expect(Array.isArray(result.edges)).toBe(true);
6dd74de218 });
6dd74de219
6dd74de220 it('should return positioned nodes with coordinates', async () => {
6dd74de221 const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
6dd74de222
6dd74de223 expect(result.nodes.length).toBeGreaterThan(0);
6dd74de224 result.nodes.forEach((node) => {
6dd74de225 expect(node.x).toBeDefined();
6dd74de226 expect(node.y).toBeDefined();
6dd74de227 expect(typeof node.x).toBe('number');
6dd74de228 expect(typeof node.y).toBe('number');
6dd74de229 });
6dd74de230 });
6dd74de231
6dd74de232 it('should return positioned edges with coordinates', async () => {
6dd74de233 const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
6dd74de234
6dd74de235 expect(result.edges.length).toBeGreaterThan(0);
6dd74de236 result.edges.forEach((edge) => {
6dd74de237 expect(edge.startX).toBeDefined();
6dd74de238 expect(edge.startY).toBeDefined();
6dd74de239 expect(edge.midX).toBeDefined();
6dd74de240 expect(edge.midY).toBeDefined();
6dd74de241 expect(edge.endX).toBeDefined();
6dd74de242 expect(edge.endY).toBeDefined();
6dd74de243 });
6dd74de244 });
6dd74de245
6dd74de246 it('should handle empty layout data gracefully', async () => {
6dd74de247 const emptyData: LayoutData = {
6dd74de248 ...mockLayoutData,
6dd74de249 nodes: [],
6dd74de250 edges: [],
6dd74de251 };
6dd74de252
6dd74de253 await expect(executeTidyTreeLayout(emptyData)).rejects.toThrow(
6dd74de254 'No nodes found in layout data'
6dd74de255 );
6dd74de256 });
6dd74de257
6dd74de258 it('should throw error for missing nodes', async () => {
6dd74de259 const invalidData = { ...mockLayoutData, nodes: [] };
6dd74de260
6dd74de261 await expect(executeTidyTreeLayout(invalidData)).rejects.toThrow(
6dd74de262 'No nodes found in layout data'
6dd74de263 );
6dd74de264 });
6dd74de265
6dd74de266 it('should handle empty edges (single node tree)', async () => {
6dd74de267 const singleNodeData = {
6dd74de268 ...mockLayoutData,
6dd74de269 edges: [],
6dd74de270 nodes: [mockLayoutData.nodes[0]],
6dd74de271 };
6dd74de272
6dd74de273 const result = await executeTidyTreeLayout(singleNodeData);
6dd74de274 expect(result).toBeDefined();
6dd74de275 expect(result.nodes).toHaveLength(1);
6dd74de276 expect(result.edges).toHaveLength(0);
6dd74de277 });
6dd74de278
6dd74de279 it('should create bidirectional dual-tree layout with alternating left/right children', async () => {
6dd74de280 const result = await executeTidyTreeLayout(mockLayoutData);
6dd74de281
6dd74de282 expect(result).toBeDefined();
6dd74de283 expect(result.nodes).toHaveLength(5);
6dd74de284
6dd74de285 const rootNode = result.nodes.find((node) => node.id === 'root');
6dd74de286 expect(rootNode).toBeDefined();
6dd74de287 expect(rootNode!.x).toBe(0);
6dd74de288 expect(rootNode!.y).toBe(20);
6dd74de289
6dd74de290 const child1 = result.nodes.find((node) => node.id === 'child1');
6dd74de291 const child2 = result.nodes.find((node) => node.id === 'child2');
6dd74de292 const child3 = result.nodes.find((node) => node.id === 'child3');
6dd74de293 const child4 = result.nodes.find((node) => node.id === 'child4');
6dd74de294
6dd74de295 expect(child1).toBeDefined();
6dd74de296 expect(child2).toBeDefined();
6dd74de297 expect(child3).toBeDefined();
6dd74de298 expect(child4).toBeDefined();
6dd74de299
6dd74de300 expect(child1!.x).toBeLessThan(rootNode!.x);
6dd74de301 expect(child2!.x).toBeGreaterThan(rootNode!.x);
6dd74de302 expect(child3!.x).toBeLessThan(rootNode!.x);
6dd74de303 expect(child4!.x).toBeGreaterThan(rootNode!.x);
6dd74de304
6dd74de305 expect(child1!.x).toBeLessThan(-100);
6dd74de306 expect(child3!.x).toBeLessThan(-100);
6dd74de307
6dd74de308 expect(child2!.x).toBeGreaterThan(100);
6dd74de309 expect(child4!.x).toBeGreaterThan(100);
6dd74de310 });
6dd74de311
6dd74de312 it('should correctly transpose coordinates to prevent high nodes from covering nodes above them', async () => {
6dd74de313 const testData = {
6dd74de314 ...mockLayoutData,
6dd74de315 nodes: [
6dd74de316 {
6dd74de317 id: 'root',
6dd74de318 label: 'Root',
6dd74de319 isGroup: false,
6dd74de320 shape: 'rect' as const,
6dd74de321 width: 100,
6dd74de322 height: 50,
6dd74de323 padding: 10,
6dd74de324 x: 0,
6dd74de325 y: 0,
6dd74de326 cssClasses: '',
6dd74de327 cssStyles: [],
6dd74de328 look: 'default',
6dd74de329 },
6dd74de330 {
6dd74de331 id: 'tall-child',
6dd74de332 label: 'Tall Child',
6dd74de333 isGroup: false,
6dd74de334 shape: 'rect' as const,
6dd74de335 width: 80,
6dd74de336 height: 120,
6dd74de337 padding: 10,
6dd74de338 x: 0,
6dd74de339 y: 0,
6dd74de340 cssClasses: '',
6dd74de341 cssStyles: [],
6dd74de342 look: 'default',
6dd74de343 },
6dd74de344 {
6dd74de345 id: 'short-child',
6dd74de346 label: 'Short Child',
6dd74de347 isGroup: false,
6dd74de348 shape: 'rect' as const,
6dd74de349 width: 80,
6dd74de350 height: 30,
6dd74de351 padding: 10,
6dd74de352 x: 0,
6dd74de353 y: 0,
6dd74de354 cssClasses: '',
6dd74de355 cssStyles: [],
6dd74de356 look: 'default',
6dd74de357 },
6dd74de358 ],
6dd74de359 edges: [
6dd74de360 {
6dd74de361 id: 'root_tall',
6dd74de362 start: 'root',
6dd74de363 end: 'tall-child',
6dd74de364 type: 'edge',
6dd74de365 classes: '',
6dd74de366 style: [],
6dd74de367 animate: false,
6dd74de368 arrowTypeEnd: 'arrow_point',
6dd74de369 arrowTypeStart: 'none',
6dd74de370 },
6dd74de371 {
6dd74de372 id: 'root_short',
6dd74de373 start: 'root',
6dd74de374 end: 'short-child',
6dd74de375 type: 'edge',
6dd74de376 classes: '',
6dd74de377 style: [],
6dd74de378 animate: false,
6dd74de379 arrowTypeEnd: 'arrow_point',
6dd74de380 arrowTypeStart: 'none',
6dd74de381 },
6dd74de382 ],
6dd74de383 };
6dd74de384
6dd74de385 const result = await executeTidyTreeLayout(testData);
6dd74de386
6dd74de387 expect(result).toBeDefined();
6dd74de388 expect(result.nodes).toHaveLength(3);
6dd74de389
6dd74de390 const rootNode = result.nodes.find((node) => node.id === 'root');
6dd74de391 const tallChild = result.nodes.find((node) => node.id === 'tall-child');
6dd74de392 const shortChild = result.nodes.find((node) => node.id === 'short-child');
6dd74de393
6dd74de394 expect(rootNode).toBeDefined();
6dd74de395 expect(tallChild).toBeDefined();
6dd74de396 expect(shortChild).toBeDefined();
6dd74de397
6dd74de398 expect(tallChild!.x).not.toBe(shortChild!.x);
6dd74de399
6dd74de400 expect(tallChild!.width).toBe(80);
6dd74de401 expect(tallChild!.height).toBe(120);
6dd74de402 expect(shortChild!.width).toBe(80);
6dd74de403 expect(shortChild!.height).toBe(30);
6dd74de404
6dd74de405 const yDifference = Math.abs(tallChild!.y - shortChild!.y);
6dd74de406 expect(yDifference).toBeGreaterThanOrEqual(0);
6dd74de407 });
6dd74de408 });
6dd74de409});