@nyaruka/temba-components 0.133.0 → 0.134.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/demo/components/webchat/example.html +1 -1
  3. package/dist/locales/es.js +5 -5
  4. package/dist/locales/es.js.map +1 -1
  5. package/dist/locales/fr.js +5 -5
  6. package/dist/locales/fr.js.map +1 -1
  7. package/dist/locales/locale-codes.js +2 -11
  8. package/dist/locales/locale-codes.js.map +1 -1
  9. package/dist/locales/pt.js +5 -5
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/temba-components.js +307 -259
  12. package/dist/temba-components.js.map +1 -1
  13. package/out-tsc/src/display/Chat.js +223 -90
  14. package/out-tsc/src/display/Chat.js.map +1 -1
  15. package/out-tsc/src/display/TembaUser.js +3 -3
  16. package/out-tsc/src/display/TembaUser.js.map +1 -1
  17. package/out-tsc/src/events.js.map +1 -1
  18. package/out-tsc/src/flow/CanvasNode.js +8 -0
  19. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  20. package/out-tsc/src/flow/Editor.js +117 -28
  21. package/out-tsc/src/flow/Editor.js.map +1 -1
  22. package/out-tsc/src/flow/utils.js +141 -0
  23. package/out-tsc/src/flow/utils.js.map +1 -1
  24. package/out-tsc/src/interfaces.js.map +1 -1
  25. package/out-tsc/src/live/ContactChat.js +122 -170
  26. package/out-tsc/src/live/ContactChat.js.map +1 -1
  27. package/out-tsc/src/locales/es.js +5 -5
  28. package/out-tsc/src/locales/es.js.map +1 -1
  29. package/out-tsc/src/locales/fr.js +5 -5
  30. package/out-tsc/src/locales/fr.js.map +1 -1
  31. package/out-tsc/src/locales/locale-codes.js +2 -11
  32. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  33. package/out-tsc/src/locales/pt.js +5 -5
  34. package/out-tsc/src/locales/pt.js.map +1 -1
  35. package/out-tsc/src/store/AppState.js +3 -0
  36. package/out-tsc/src/store/AppState.js.map +1 -1
  37. package/out-tsc/src/store/Store.js +5 -5
  38. package/out-tsc/src/store/Store.js.map +1 -1
  39. package/out-tsc/src/webchat/WebChat.js +22 -9
  40. package/out-tsc/src/webchat/WebChat.js.map +1 -1
  41. package/out-tsc/test/actions/send_broadcast.test.js +9 -4
  42. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  43. package/out-tsc/test/temba-flow-collision.test.js +673 -0
  44. package/out-tsc/test/temba-flow-collision.test.js.map +1 -0
  45. package/out-tsc/test/temba-flow-editor-node.test.js +128 -42
  46. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  47. package/package.json +1 -1
  48. package/screenshots/truth/contacts/chat-failure.png +0 -0
  49. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  50. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  51. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  52. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  53. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  54. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  55. package/src/display/Chat.ts +303 -129
  56. package/src/display/TembaUser.ts +3 -2
  57. package/src/events.ts +11 -8
  58. package/src/flow/CanvasNode.ts +10 -0
  59. package/src/flow/Editor.ts +156 -28
  60. package/src/flow/utils.ts +207 -1
  61. package/src/interfaces.ts +7 -0
  62. package/src/live/ContactChat.ts +129 -180
  63. package/src/locales/es.ts +13 -18
  64. package/src/locales/fr.ts +13 -18
  65. package/src/locales/locale-codes.ts +2 -11
  66. package/src/locales/pt.ts +13 -18
  67. package/src/store/AppState.ts +2 -0
  68. package/src/store/Store.ts +5 -5
  69. package/src/webchat/WebChat.ts +24 -10
  70. package/test/actions/send_broadcast.test.ts +2 -1
  71. package/test/temba-flow-collision.test.ts +833 -0
  72. package/test/temba-flow-editor-node.test.ts +142 -47
@@ -0,0 +1,833 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import {
3
+ getNodeBounds,
4
+ nodesOverlap,
5
+ detectCollisions,
6
+ calculateReflowPositions,
7
+ NodeBounds
8
+ } from '../src/flow/utils';
9
+
10
+ describe('Collision Detection Utilities', () => {
11
+ describe('getNodeBounds', () => {
12
+ it('returns null when element not found', () => {
13
+ const position = { left: 100, top: 200 };
14
+ const bounds = getNodeBounds('nonexistent-uuid', position);
15
+ expect(bounds).to.be.null;
16
+ });
17
+
18
+ it('calculates bounds correctly from element', () => {
19
+ // Create a mock element
20
+ const mockElement = document.createElement('div');
21
+ mockElement.id = 'test-node';
22
+ mockElement.style.width = '200px';
23
+ mockElement.style.height = '150px';
24
+ document.body.appendChild(mockElement);
25
+
26
+ const position = { left: 100, top: 200 };
27
+ const bounds = getNodeBounds('test-node', position, mockElement);
28
+
29
+ expect(bounds).to.not.be.null;
30
+ expect(bounds!.uuid).to.equal('test-node');
31
+ expect(bounds!.left).to.equal(100);
32
+ expect(bounds!.top).to.equal(200);
33
+ expect(bounds!.right).to.equal(300); // left + width
34
+ expect(bounds!.bottom).to.equal(350); // top + height
35
+ expect(bounds!.width).to.equal(200);
36
+ expect(bounds!.height).to.equal(150);
37
+
38
+ document.body.removeChild(mockElement);
39
+ });
40
+ });
41
+
42
+ describe('nodesOverlap', () => {
43
+ it('detects overlapping nodes horizontally and vertically', () => {
44
+ const bounds1: NodeBounds = {
45
+ uuid: 'node1',
46
+ left: 100,
47
+ top: 100,
48
+ right: 200,
49
+ bottom: 200,
50
+ width: 100,
51
+ height: 100
52
+ };
53
+
54
+ const bounds2: NodeBounds = {
55
+ uuid: 'node2',
56
+ left: 150,
57
+ top: 150,
58
+ right: 250,
59
+ bottom: 250,
60
+ width: 100,
61
+ height: 100
62
+ };
63
+
64
+ expect(nodesOverlap(bounds1, bounds2)).to.be.true;
65
+ });
66
+
67
+ it('detects non-overlapping nodes to the right', () => {
68
+ const bounds1: NodeBounds = {
69
+ uuid: 'node1',
70
+ left: 100,
71
+ top: 100,
72
+ right: 200,
73
+ bottom: 200,
74
+ width: 100,
75
+ height: 100
76
+ };
77
+
78
+ const bounds2: NodeBounds = {
79
+ uuid: 'node2',
80
+ left: 300,
81
+ top: 100,
82
+ right: 400,
83
+ bottom: 200,
84
+ width: 100,
85
+ height: 100
86
+ };
87
+
88
+ expect(nodesOverlap(bounds1, bounds2)).to.be.false;
89
+ });
90
+
91
+ it('detects non-overlapping nodes below', () => {
92
+ const bounds1: NodeBounds = {
93
+ uuid: 'node1',
94
+ left: 100,
95
+ top: 100,
96
+ right: 200,
97
+ bottom: 200,
98
+ width: 100,
99
+ height: 100
100
+ };
101
+
102
+ const bounds2: NodeBounds = {
103
+ uuid: 'node2',
104
+ left: 100,
105
+ top: 300,
106
+ right: 200,
107
+ bottom: 400,
108
+ width: 100,
109
+ height: 100
110
+ };
111
+
112
+ expect(nodesOverlap(bounds1, bounds2)).to.be.false;
113
+ });
114
+
115
+ it('detects edge touching as overlapping (within buffer)', () => {
116
+ const bounds1: NodeBounds = {
117
+ uuid: 'node1',
118
+ left: 100,
119
+ top: 100,
120
+ right: 200,
121
+ bottom: 200,
122
+ width: 100,
123
+ height: 100
124
+ };
125
+
126
+ const bounds2: NodeBounds = {
127
+ uuid: 'node2',
128
+ left: 200,
129
+ top: 100,
130
+ right: 300,
131
+ bottom: 200,
132
+ width: 100,
133
+ height: 100
134
+ };
135
+
136
+ // edges touching should be considered overlapping due to buffer
137
+ expect(nodesOverlap(bounds1, bounds2)).to.be.true;
138
+ });
139
+
140
+ it('detects partial vertical overlap', () => {
141
+ const bounds1: NodeBounds = {
142
+ uuid: 'node1',
143
+ left: 100,
144
+ top: 100,
145
+ right: 200,
146
+ bottom: 200,
147
+ width: 100,
148
+ height: 100
149
+ };
150
+
151
+ const bounds2: NodeBounds = {
152
+ uuid: 'node2',
153
+ left: 150,
154
+ top: 50,
155
+ right: 250,
156
+ bottom: 150,
157
+ width: 100,
158
+ height: 100
159
+ };
160
+
161
+ expect(nodesOverlap(bounds1, bounds2)).to.be.true;
162
+ });
163
+ });
164
+
165
+ describe('detectCollisions', () => {
166
+ it('returns empty array when no collisions', () => {
167
+ const targetBounds: NodeBounds = {
168
+ uuid: 'target',
169
+ left: 100,
170
+ top: 100,
171
+ right: 200,
172
+ bottom: 200,
173
+ width: 100,
174
+ height: 100
175
+ };
176
+
177
+ const allBounds: NodeBounds[] = [
178
+ {
179
+ uuid: 'node1',
180
+ left: 300,
181
+ top: 100,
182
+ right: 400,
183
+ bottom: 200,
184
+ width: 100,
185
+ height: 100
186
+ },
187
+ {
188
+ uuid: 'node2',
189
+ left: 100,
190
+ top: 300,
191
+ right: 200,
192
+ bottom: 400,
193
+ width: 100,
194
+ height: 100
195
+ }
196
+ ];
197
+
198
+ const collisions = detectCollisions(targetBounds, allBounds);
199
+ expect(collisions).to.have.length(0);
200
+ });
201
+
202
+ it('detects single collision', () => {
203
+ const targetBounds: NodeBounds = {
204
+ uuid: 'target',
205
+ left: 100,
206
+ top: 100,
207
+ right: 200,
208
+ bottom: 200,
209
+ width: 100,
210
+ height: 100
211
+ };
212
+
213
+ const allBounds: NodeBounds[] = [
214
+ {
215
+ uuid: 'node1',
216
+ left: 150,
217
+ top: 150,
218
+ right: 250,
219
+ bottom: 250,
220
+ width: 100,
221
+ height: 100
222
+ },
223
+ {
224
+ uuid: 'node2',
225
+ left: 300,
226
+ top: 300,
227
+ right: 400,
228
+ bottom: 400,
229
+ width: 100,
230
+ height: 100
231
+ }
232
+ ];
233
+
234
+ const collisions = detectCollisions(targetBounds, allBounds);
235
+ expect(collisions).to.have.length(1);
236
+ expect(collisions[0].uuid).to.equal('node1');
237
+ });
238
+
239
+ it('detects multiple collisions', () => {
240
+ const targetBounds: NodeBounds = {
241
+ uuid: 'target',
242
+ left: 100,
243
+ top: 100,
244
+ right: 200,
245
+ bottom: 200,
246
+ width: 100,
247
+ height: 100
248
+ };
249
+
250
+ const allBounds: NodeBounds[] = [
251
+ {
252
+ uuid: 'node1',
253
+ left: 150,
254
+ top: 150,
255
+ right: 250,
256
+ bottom: 250,
257
+ width: 100,
258
+ height: 100
259
+ },
260
+ {
261
+ uuid: 'node2',
262
+ left: 50,
263
+ top: 50,
264
+ right: 150,
265
+ bottom: 150,
266
+ width: 100,
267
+ height: 100
268
+ }
269
+ ];
270
+
271
+ const collisions = detectCollisions(targetBounds, allBounds);
272
+ expect(collisions).to.have.length(2);
273
+ });
274
+
275
+ it('excludes target node from collisions', () => {
276
+ const targetBounds: NodeBounds = {
277
+ uuid: 'target',
278
+ left: 100,
279
+ top: 100,
280
+ right: 200,
281
+ bottom: 200,
282
+ width: 100,
283
+ height: 100
284
+ };
285
+
286
+ const allBounds: NodeBounds[] = [
287
+ targetBounds,
288
+ {
289
+ uuid: 'node1',
290
+ left: 150,
291
+ top: 150,
292
+ right: 250,
293
+ bottom: 250,
294
+ width: 100,
295
+ height: 100
296
+ }
297
+ ];
298
+
299
+ const collisions = detectCollisions(targetBounds, allBounds);
300
+ expect(collisions).to.have.length(1);
301
+ expect(collisions[0].uuid).to.equal('node1');
302
+ });
303
+ });
304
+
305
+ describe('calculateReflowPositions', () => {
306
+ it('returns empty map when no collisions', () => {
307
+ const movedBounds: NodeBounds = {
308
+ uuid: 'moved',
309
+ left: 100,
310
+ top: 100,
311
+ right: 200,
312
+ bottom: 200,
313
+ width: 100,
314
+ height: 100
315
+ };
316
+
317
+ const allBounds: NodeBounds[] = [
318
+ movedBounds,
319
+ {
320
+ uuid: 'node1',
321
+ left: 300,
322
+ top: 100,
323
+ right: 400,
324
+ bottom: 200,
325
+ width: 100,
326
+ height: 100
327
+ }
328
+ ];
329
+
330
+ const positions = calculateReflowPositions(
331
+ 'moved',
332
+ movedBounds,
333
+ allBounds,
334
+ false
335
+ );
336
+ expect(positions.size).to.equal(0);
337
+ });
338
+
339
+ it('moves colliding node down', () => {
340
+ const movedBounds: NodeBounds = {
341
+ uuid: 'moved',
342
+ left: 100,
343
+ top: 100,
344
+ right: 200,
345
+ bottom: 200,
346
+ width: 100,
347
+ height: 100
348
+ };
349
+
350
+ const allBounds: NodeBounds[] = [
351
+ movedBounds,
352
+ {
353
+ uuid: 'node1',
354
+ left: 150,
355
+ top: 150,
356
+ right: 250,
357
+ bottom: 250,
358
+ width: 100,
359
+ height: 100
360
+ }
361
+ ];
362
+
363
+ const positions = calculateReflowPositions(
364
+ 'moved',
365
+ movedBounds,
366
+ allBounds,
367
+ false
368
+ );
369
+
370
+ expect(positions.size).to.equal(1);
371
+ expect(positions.has('node1')).to.be.true;
372
+
373
+ const newPos = positions.get('node1')!;
374
+ expect(newPos.left).to.equal(150); // left unchanged
375
+ expect(newPos.top).to.be.greaterThan(200); // moved below the moved node
376
+ });
377
+
378
+ it('handles droppedBelowMidpoint by moving dropped node down', () => {
379
+ const movedBounds: NodeBounds = {
380
+ uuid: 'moved',
381
+ left: 100,
382
+ top: 150,
383
+ right: 200,
384
+ bottom: 250,
385
+ width: 100,
386
+ height: 100
387
+ };
388
+
389
+ const allBounds: NodeBounds[] = [
390
+ movedBounds,
391
+ {
392
+ uuid: 'existing',
393
+ left: 100,
394
+ top: 100,
395
+ right: 200,
396
+ bottom: 200,
397
+ width: 100,
398
+ height: 100
399
+ }
400
+ ];
401
+
402
+ const positions = calculateReflowPositions(
403
+ 'moved',
404
+ movedBounds,
405
+ allBounds,
406
+ true // droppedBelowMidpoint
407
+ );
408
+
409
+ expect(positions.size).to.equal(1);
410
+ expect(positions.has('moved')).to.be.true;
411
+
412
+ const newPos = positions.get('moved')!;
413
+ expect(newPos.top).to.be.greaterThan(200); // moved below existing node
414
+ });
415
+
416
+ it('gives priority to dropped node when dropped above midpoint', () => {
417
+ // Dropped node overlaps with bottom of existing node
418
+ // Bottom of dropped (180) is above midpoint of existing (150)
419
+ // So dropped node should keep position, existing moves down
420
+ const movedBounds: NodeBounds = {
421
+ uuid: 'dropped',
422
+ left: 100,
423
+ top: 80,
424
+ right: 200,
425
+ bottom: 180,
426
+ width: 100,
427
+ height: 100
428
+ };
429
+
430
+ const allBounds: NodeBounds[] = [
431
+ movedBounds,
432
+ {
433
+ uuid: 'existing',
434
+ left: 100,
435
+ top: 100,
436
+ right: 200,
437
+ bottom: 200,
438
+ width: 100,
439
+ height: 100
440
+ }
441
+ ];
442
+
443
+ const positions = calculateReflowPositions(
444
+ 'dropped',
445
+ movedBounds,
446
+ allBounds,
447
+ false // dropped node's bottom is above target midpoint, dropped node gets priority
448
+ );
449
+
450
+ // Existing node should be moved down
451
+ expect(positions.has('existing')).to.be.true;
452
+ expect(positions.has('dropped')).to.be.false; // dropped keeps its position
453
+
454
+ const existingNewPos = positions.get('existing')!;
455
+ expect(existingNewPos.top).to.be.greaterThan(180); // moved below dropped node
456
+ });
457
+
458
+ it('gives priority to existing node when dropped below midpoint', () => {
459
+ // Dropped node overlaps with top of existing node
460
+ // Bottom of dropped (220) is below midpoint of existing (150)
461
+ // So existing node keeps position, dropped moves down
462
+ const movedBounds: NodeBounds = {
463
+ uuid: 'dropped',
464
+ left: 100,
465
+ top: 120,
466
+ right: 200,
467
+ bottom: 220,
468
+ width: 100,
469
+ height: 100
470
+ };
471
+
472
+ const allBounds: NodeBounds[] = [
473
+ movedBounds,
474
+ {
475
+ uuid: 'existing',
476
+ left: 100,
477
+ top: 100,
478
+ right: 200,
479
+ bottom: 200,
480
+ width: 100,
481
+ height: 100
482
+ }
483
+ ];
484
+
485
+ const positions = calculateReflowPositions(
486
+ 'dropped',
487
+ movedBounds,
488
+ allBounds,
489
+ true // dropped node's bottom is below target midpoint, target gets priority
490
+ );
491
+
492
+ // Dropped node should be moved down
493
+ expect(positions.has('dropped')).to.be.true;
494
+ expect(positions.has('existing')).to.be.false; // existing keeps its position
495
+
496
+ const droppedNewPos = positions.get('dropped')!;
497
+ expect(droppedNewPos.top).to.be.greaterThan(200); // moved below existing node
498
+ });
499
+
500
+ it('resolves cascading collisions', () => {
501
+ const movedBounds: NodeBounds = {
502
+ uuid: 'moved',
503
+ left: 100,
504
+ top: 100,
505
+ right: 200,
506
+ bottom: 200,
507
+ width: 100,
508
+ height: 100
509
+ };
510
+
511
+ const allBounds: NodeBounds[] = [
512
+ movedBounds,
513
+ {
514
+ uuid: 'node1',
515
+ left: 100,
516
+ top: 150,
517
+ right: 200,
518
+ bottom: 250,
519
+ width: 100,
520
+ height: 100
521
+ },
522
+ {
523
+ uuid: 'node2',
524
+ left: 100,
525
+ top: 200,
526
+ right: 200,
527
+ bottom: 300,
528
+ width: 100,
529
+ height: 100
530
+ }
531
+ ];
532
+
533
+ const positions = calculateReflowPositions(
534
+ 'moved',
535
+ movedBounds,
536
+ allBounds,
537
+ false
538
+ );
539
+
540
+ // Both nodes should be repositioned to avoid collision
541
+ expect(positions.size).to.be.greaterThan(0);
542
+
543
+ // Check that moved nodes maintain vertical order and spacing
544
+ if (positions.has('node1') && positions.has('node2')) {
545
+ const node1Pos = positions.get('node1')!;
546
+ const node2Pos = positions.get('node2')!;
547
+
548
+ // node2 should be below node1
549
+ expect(node2Pos.top).to.be.greaterThan(node1Pos.top);
550
+ }
551
+ });
552
+
553
+ it('handles multiple iterations for complex cascading collisions', () => {
554
+ // Test case that requires multiple iterations to resolve all collisions
555
+ // This scenario has nodes that initially don't collide with moved node,
556
+ // but will collide with other nodes that get moved
557
+ const movedBounds: NodeBounds = {
558
+ uuid: 'moved',
559
+ left: 100,
560
+ top: 50,
561
+ right: 200,
562
+ bottom: 150,
563
+ width: 100,
564
+ height: 100
565
+ };
566
+
567
+ const allBounds: NodeBounds[] = [
568
+ movedBounds,
569
+ {
570
+ uuid: 'node1',
571
+ left: 100,
572
+ top: 100,
573
+ right: 200,
574
+ bottom: 200,
575
+ width: 100,
576
+ height: 100
577
+ },
578
+ {
579
+ uuid: 'node2',
580
+ left: 100,
581
+ top: 180,
582
+ right: 200,
583
+ bottom: 280,
584
+ width: 100,
585
+ height: 100
586
+ },
587
+ {
588
+ uuid: 'node3',
589
+ left: 100,
590
+ top: 260,
591
+ right: 200,
592
+ bottom: 360,
593
+ width: 100,
594
+ height: 100
595
+ }
596
+ ];
597
+
598
+ const positions = calculateReflowPositions(
599
+ 'moved',
600
+ movedBounds,
601
+ allBounds,
602
+ false
603
+ );
604
+
605
+ // All three nodes should be repositioned to be stacked below the moved node
606
+ expect(positions.size).to.equal(3);
607
+ expect(positions.has('node1')).to.be.true;
608
+ expect(positions.has('node2')).to.be.true;
609
+ expect(positions.has('node3')).to.be.true;
610
+
611
+ const node1Pos = positions.get('node1')!;
612
+ const node2Pos = positions.get('node2')!;
613
+ const node3Pos = positions.get('node3')!;
614
+
615
+ // Nodes should be stacked vertically with proper spacing
616
+ expect(node1Pos.top).to.be.at.least(170); // 150 (bottom of moved) + 20
617
+ expect(node2Pos.top).to.be.at.least(node1Pos.top + 120); // node1 top + height + spacing
618
+ expect(node3Pos.top).to.be.at.least(node2Pos.top + 120); // node2 top + height + spacing
619
+ });
620
+
621
+ it('handles nodes that collide with already repositioned nodes', () => {
622
+ // This test creates a scenario where node3 doesn't collide with the moved node,
623
+ // but collides with node2 which itself got repositioned due to collision with node1
624
+ const movedBounds: NodeBounds = {
625
+ uuid: 'moved',
626
+ left: 100,
627
+ top: 100,
628
+ right: 200,
629
+ bottom: 200,
630
+ width: 100,
631
+ height: 100
632
+ };
633
+
634
+ const allBounds: NodeBounds[] = [
635
+ movedBounds,
636
+ {
637
+ uuid: 'node1',
638
+ left: 100,
639
+ top: 150,
640
+ right: 200,
641
+ bottom: 250,
642
+ width: 100,
643
+ height: 100
644
+ },
645
+ {
646
+ uuid: 'node2',
647
+ left: 100,
648
+ top: 240,
649
+ right: 200,
650
+ bottom: 340,
651
+ width: 100,
652
+ height: 100
653
+ }
654
+ ];
655
+
656
+ const positions = calculateReflowPositions(
657
+ 'moved',
658
+ movedBounds,
659
+ allBounds,
660
+ false
661
+ );
662
+
663
+ // Both nodes should be moved
664
+ expect(positions.size).to.equal(2);
665
+ expect(positions.has('node1')).to.be.true;
666
+ expect(positions.has('node2')).to.be.true;
667
+
668
+ const node1Pos = positions.get('node1')!;
669
+ const node2Pos = positions.get('node2')!;
670
+
671
+ // node1 should be moved below moved node
672
+ expect(node1Pos.top).to.be.at.least(220); // 200 + 20
673
+
674
+ // node2 should be moved below the new position of node1
675
+ // This tests the code path where we check against already repositioned nodes
676
+ expect(node2Pos.top).to.be.at.least(node1Pos.top + 120);
677
+ });
678
+
679
+ it('maintains horizontal position while moving vertically', () => {
680
+ const movedBounds: NodeBounds = {
681
+ uuid: 'moved',
682
+ left: 100,
683
+ top: 100,
684
+ right: 200,
685
+ bottom: 200,
686
+ width: 100,
687
+ height: 100
688
+ };
689
+
690
+ const allBounds: NodeBounds[] = [
691
+ movedBounds,
692
+ {
693
+ uuid: 'node1',
694
+ left: 150,
695
+ top: 150,
696
+ right: 250,
697
+ bottom: 250,
698
+ width: 100,
699
+ height: 100
700
+ }
701
+ ];
702
+
703
+ const positions = calculateReflowPositions(
704
+ 'moved',
705
+ movedBounds,
706
+ allBounds,
707
+ false
708
+ );
709
+
710
+ const newPos = positions.get('node1')!;
711
+ // Horizontal position should remain unchanged
712
+ expect(newPos.left).to.equal(150);
713
+ });
714
+
715
+ it('adds minimum spacing between nodes', () => {
716
+ const movedBounds: NodeBounds = {
717
+ uuid: 'moved',
718
+ left: 100,
719
+ top: 100,
720
+ right: 200,
721
+ bottom: 200,
722
+ width: 100,
723
+ height: 100
724
+ };
725
+
726
+ const allBounds: NodeBounds[] = [
727
+ movedBounds,
728
+ {
729
+ uuid: 'node1',
730
+ left: 100,
731
+ top: 150,
732
+ right: 200,
733
+ bottom: 250,
734
+ width: 100,
735
+ height: 100
736
+ }
737
+ ];
738
+
739
+ const positions = calculateReflowPositions(
740
+ 'moved',
741
+ movedBounds,
742
+ allBounds,
743
+ false
744
+ );
745
+
746
+ const newPos = positions.get('node1')!;
747
+ // Should have at least 20px spacing (MIN_NODE_SPACING)
748
+ expect(newPos.top).to.be.at.least(220); // 200 (bottom of moved) + 20
749
+ });
750
+ });
751
+
752
+ describe('edge cases', () => {
753
+ it('handles empty allBounds array', () => {
754
+ const movedBounds: NodeBounds = {
755
+ uuid: 'moved',
756
+ left: 100,
757
+ top: 100,
758
+ right: 200,
759
+ bottom: 200,
760
+ width: 100,
761
+ height: 100
762
+ };
763
+
764
+ const positions = calculateReflowPositions(
765
+ 'moved',
766
+ movedBounds,
767
+ [],
768
+ false
769
+ );
770
+
771
+ expect(positions.size).to.equal(0);
772
+ });
773
+
774
+ it('handles single node (no other nodes to collide with)', () => {
775
+ const movedBounds: NodeBounds = {
776
+ uuid: 'moved',
777
+ left: 100,
778
+ top: 100,
779
+ right: 200,
780
+ bottom: 200,
781
+ width: 100,
782
+ height: 100
783
+ };
784
+
785
+ const positions = calculateReflowPositions(
786
+ 'moved',
787
+ movedBounds,
788
+ [movedBounds],
789
+ false
790
+ );
791
+
792
+ expect(positions.size).to.equal(0);
793
+ });
794
+
795
+ it('prevents infinite loops with complex collisions', () => {
796
+ const movedBounds: NodeBounds = {
797
+ uuid: 'moved',
798
+ left: 100,
799
+ top: 100,
800
+ right: 200,
801
+ bottom: 200,
802
+ width: 100,
803
+ height: 100
804
+ };
805
+
806
+ // Create a complex scenario with many overlapping nodes
807
+ const allBounds: NodeBounds[] = [movedBounds];
808
+
809
+ for (let i = 0; i < 20; i++) {
810
+ allBounds.push({
811
+ uuid: `node${i}`,
812
+ left: 100 + i * 10,
813
+ top: 100 + i * 10,
814
+ right: 200 + i * 10,
815
+ bottom: 200 + i * 10,
816
+ width: 100,
817
+ height: 100
818
+ });
819
+ }
820
+
821
+ // Should complete without hanging
822
+ const positions = calculateReflowPositions(
823
+ 'moved',
824
+ movedBounds,
825
+ allBounds,
826
+ false
827
+ );
828
+
829
+ // Should have resolved some collisions
830
+ expect(positions.size).to.be.greaterThan(0);
831
+ });
832
+ });
833
+ });