11.3 KB410 lines
Blame
1import { describe, it, expect, beforeEach, vi } from 'vitest';
2import { executeTidyTreeLayout, validateLayoutData } from './layout.js';
3import type { LayoutResult } from './types.js';
4import type { LayoutData, MermaidConfig } from 'mermaid';
5
6// Mock non-layered-tidy-tree-layout
7vi.mock('non-layered-tidy-tree-layout', () => ({
8 BoundingBox: vi.fn().mockImplementation(() => ({})),
9 Layout: vi.fn().mockImplementation(() => ({
10 layout: vi.fn().mockImplementation((treeData) => {
11 const result = { ...treeData };
12
13 if (result.id?.toString().startsWith('virtual-root')) {
14 result.x = 0;
15 result.y = 0;
16 } else {
17 result.x = 100;
18 result.y = 50;
19 }
20
21 if (result.children) {
22 result.children.forEach((child: any, index: number) => {
23 child.x = 50 + index * 100;
24 child.y = 100;
25
26 if (child.children) {
27 child.children.forEach((grandchild: any, gIndex: number) => {
28 grandchild.x = 25 + gIndex * 50;
29 grandchild.y = 200;
30 });
31 }
32 });
33 }
34
35 return {
36 result,
37 boundingBox: {
38 left: 0,
39 right: 200,
40 top: 0,
41 bottom: 250,
42 },
43 };
44 }),
45 })),
46}));
47
48describe('Tidy-Tree Layout Algorithm', () => {
49 let mockConfig: MermaidConfig;
50 let mockLayoutData: LayoutData;
51
52 beforeEach(() => {
53 mockConfig = {
54 theme: 'default',
55 } as MermaidConfig;
56
57 mockLayoutData = {
58 nodes: [
59 {
60 id: 'root',
61 label: 'Root',
62 isGroup: false,
63 shape: 'rect',
64 width: 100,
65 height: 50,
66 padding: 10,
67 x: 0,
68 y: 0,
69 cssClasses: '',
70 cssStyles: [],
71 look: 'default',
72 },
73 {
74 id: 'child1',
75 label: 'Child 1',
76 isGroup: false,
77 shape: 'rect',
78 width: 80,
79 height: 40,
80 padding: 10,
81 x: 0,
82 y: 0,
83 cssClasses: '',
84 cssStyles: [],
85 look: 'default',
86 },
87 {
88 id: 'child2',
89 label: 'Child 2',
90 isGroup: false,
91 shape: 'rect',
92 width: 80,
93 height: 40,
94 padding: 10,
95 x: 0,
96 y: 0,
97 cssClasses: '',
98 cssStyles: [],
99 look: 'default',
100 },
101 {
102 id: 'child3',
103 label: 'Child 3',
104 isGroup: false,
105 shape: 'rect',
106 width: 80,
107 height: 40,
108 padding: 10,
109 x: 0,
110 y: 0,
111 cssClasses: '',
112 cssStyles: [],
113 look: 'default',
114 },
115 {
116 id: 'child4',
117 label: 'Child 4',
118 isGroup: false,
119 shape: 'rect',
120 width: 80,
121 height: 40,
122 padding: 10,
123 x: 0,
124 y: 0,
125 cssClasses: '',
126 cssStyles: [],
127 look: 'default',
128 },
129 ],
130 edges: [
131 {
132 id: 'root_child1',
133 start: 'root',
134 end: 'child1',
135 type: 'edge',
136 classes: '',
137 style: [],
138 animate: false,
139 arrowTypeEnd: 'arrow_point',
140 arrowTypeStart: 'none',
141 },
142 {
143 id: 'root_child2',
144 start: 'root',
145 end: 'child2',
146 type: 'edge',
147 classes: '',
148 style: [],
149 animate: false,
150 arrowTypeEnd: 'arrow_point',
151 arrowTypeStart: 'none',
152 },
153 {
154 id: 'root_child3',
155 start: 'root',
156 end: 'child3',
157 type: 'edge',
158 classes: '',
159 style: [],
160 animate: false,
161 arrowTypeEnd: 'arrow_point',
162 arrowTypeStart: 'none',
163 },
164 {
165 id: 'root_child4',
166 start: 'root',
167 end: 'child4',
168 type: 'edge',
169 classes: '',
170 style: [],
171 animate: false,
172 arrowTypeEnd: 'arrow_point',
173 arrowTypeStart: 'none',
174 },
175 ],
176 config: mockConfig,
177 direction: 'TB',
178 type: 'test',
179 diagramId: 'test-diagram',
180 markers: [],
181 };
182 });
183
184 describe('validateLayoutData', () => {
185 it('should validate correct layout data', () => {
186 expect(() => validateLayoutData(mockLayoutData)).not.toThrow();
187 });
188
189 it('should throw error for missing data', () => {
190 expect(() => validateLayoutData(null as any)).toThrow('Layout data is required');
191 });
192
193 it('should throw error for missing config', () => {
194 const invalidData = { ...mockLayoutData, config: null as any };
195 expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required');
196 });
197
198 it('should throw error for invalid nodes array', () => {
199 const invalidData = { ...mockLayoutData, nodes: null as any };
200 expect(() => validateLayoutData(invalidData)).toThrow('Nodes array is required');
201 });
202
203 it('should throw error for invalid edges array', () => {
204 const invalidData = { ...mockLayoutData, edges: null as any };
205 expect(() => validateLayoutData(invalidData)).toThrow('Edges array is required');
206 });
207 });
208
209 describe('executeTidyTreeLayout function', () => {
210 it('should execute layout algorithm successfully', async () => {
211 const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
212
213 expect(result).toBeDefined();
214 expect(result.nodes).toBeDefined();
215 expect(result.edges).toBeDefined();
216 expect(Array.isArray(result.nodes)).toBe(true);
217 expect(Array.isArray(result.edges)).toBe(true);
218 });
219
220 it('should return positioned nodes with coordinates', async () => {
221 const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
222
223 expect(result.nodes.length).toBeGreaterThan(0);
224 result.nodes.forEach((node) => {
225 expect(node.x).toBeDefined();
226 expect(node.y).toBeDefined();
227 expect(typeof node.x).toBe('number');
228 expect(typeof node.y).toBe('number');
229 });
230 });
231
232 it('should return positioned edges with coordinates', async () => {
233 const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData);
234
235 expect(result.edges.length).toBeGreaterThan(0);
236 result.edges.forEach((edge) => {
237 expect(edge.startX).toBeDefined();
238 expect(edge.startY).toBeDefined();
239 expect(edge.midX).toBeDefined();
240 expect(edge.midY).toBeDefined();
241 expect(edge.endX).toBeDefined();
242 expect(edge.endY).toBeDefined();
243 });
244 });
245
246 it('should handle empty layout data gracefully', async () => {
247 const emptyData: LayoutData = {
248 ...mockLayoutData,
249 nodes: [],
250 edges: [],
251 };
252
253 await expect(executeTidyTreeLayout(emptyData)).rejects.toThrow(
254 'No nodes found in layout data'
255 );
256 });
257
258 it('should throw error for missing nodes', async () => {
259 const invalidData = { ...mockLayoutData, nodes: [] };
260
261 await expect(executeTidyTreeLayout(invalidData)).rejects.toThrow(
262 'No nodes found in layout data'
263 );
264 });
265
266 it('should handle empty edges (single node tree)', async () => {
267 const singleNodeData = {
268 ...mockLayoutData,
269 edges: [],
270 nodes: [mockLayoutData.nodes[0]],
271 };
272
273 const result = await executeTidyTreeLayout(singleNodeData);
274 expect(result).toBeDefined();
275 expect(result.nodes).toHaveLength(1);
276 expect(result.edges).toHaveLength(0);
277 });
278
279 it('should create bidirectional dual-tree layout with alternating left/right children', async () => {
280 const result = await executeTidyTreeLayout(mockLayoutData);
281
282 expect(result).toBeDefined();
283 expect(result.nodes).toHaveLength(5);
284
285 const rootNode = result.nodes.find((node) => node.id === 'root');
286 expect(rootNode).toBeDefined();
287 expect(rootNode!.x).toBe(0);
288 expect(rootNode!.y).toBe(20);
289
290 const child1 = result.nodes.find((node) => node.id === 'child1');
291 const child2 = result.nodes.find((node) => node.id === 'child2');
292 const child3 = result.nodes.find((node) => node.id === 'child3');
293 const child4 = result.nodes.find((node) => node.id === 'child4');
294
295 expect(child1).toBeDefined();
296 expect(child2).toBeDefined();
297 expect(child3).toBeDefined();
298 expect(child4).toBeDefined();
299
300 expect(child1!.x).toBeLessThan(rootNode!.x);
301 expect(child2!.x).toBeGreaterThan(rootNode!.x);
302 expect(child3!.x).toBeLessThan(rootNode!.x);
303 expect(child4!.x).toBeGreaterThan(rootNode!.x);
304
305 expect(child1!.x).toBeLessThan(-100);
306 expect(child3!.x).toBeLessThan(-100);
307
308 expect(child2!.x).toBeGreaterThan(100);
309 expect(child4!.x).toBeGreaterThan(100);
310 });
311
312 it('should correctly transpose coordinates to prevent high nodes from covering nodes above them', async () => {
313 const testData = {
314 ...mockLayoutData,
315 nodes: [
316 {
317 id: 'root',
318 label: 'Root',
319 isGroup: false,
320 shape: 'rect' as const,
321 width: 100,
322 height: 50,
323 padding: 10,
324 x: 0,
325 y: 0,
326 cssClasses: '',
327 cssStyles: [],
328 look: 'default',
329 },
330 {
331 id: 'tall-child',
332 label: 'Tall Child',
333 isGroup: false,
334 shape: 'rect' as const,
335 width: 80,
336 height: 120,
337 padding: 10,
338 x: 0,
339 y: 0,
340 cssClasses: '',
341 cssStyles: [],
342 look: 'default',
343 },
344 {
345 id: 'short-child',
346 label: 'Short Child',
347 isGroup: false,
348 shape: 'rect' as const,
349 width: 80,
350 height: 30,
351 padding: 10,
352 x: 0,
353 y: 0,
354 cssClasses: '',
355 cssStyles: [],
356 look: 'default',
357 },
358 ],
359 edges: [
360 {
361 id: 'root_tall',
362 start: 'root',
363 end: 'tall-child',
364 type: 'edge',
365 classes: '',
366 style: [],
367 animate: false,
368 arrowTypeEnd: 'arrow_point',
369 arrowTypeStart: 'none',
370 },
371 {
372 id: 'root_short',
373 start: 'root',
374 end: 'short-child',
375 type: 'edge',
376 classes: '',
377 style: [],
378 animate: false,
379 arrowTypeEnd: 'arrow_point',
380 arrowTypeStart: 'none',
381 },
382 ],
383 };
384
385 const result = await executeTidyTreeLayout(testData);
386
387 expect(result).toBeDefined();
388 expect(result.nodes).toHaveLength(3);
389
390 const rootNode = result.nodes.find((node) => node.id === 'root');
391 const tallChild = result.nodes.find((node) => node.id === 'tall-child');
392 const shortChild = result.nodes.find((node) => node.id === 'short-child');
393
394 expect(rootNode).toBeDefined();
395 expect(tallChild).toBeDefined();
396 expect(shortChild).toBeDefined();
397
398 expect(tallChild!.x).not.toBe(shortChild!.x);
399
400 expect(tallChild!.width).toBe(80);
401 expect(tallChild!.height).toBe(120);
402 expect(shortChild!.width).toBe(80);
403 expect(shortChild!.height).toBe(30);
404
405 const yDifference = Math.abs(tallChild!.y - shortChild!.y);
406 expect(yDifference).toBeGreaterThanOrEqual(0);
407 });
408 });
409});
410