@flighthq/node 0.3.1-next.847.2e6f8bf → 0.3.1-next.887.9f59a84

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.
@@ -13,6 +13,8 @@ import {
13
13
  initTransform2DRuntimeTrait,
14
14
  initTransform2DTrait,
15
15
  invalidateNodeLocalTransform,
16
+ removeNodeChild,
17
+ setNodeEnabled,
16
18
  } from '@flighthq/node/contract';
17
19
  import type {
18
20
  HasBoundsRectangle,
@@ -120,9 +122,10 @@ describe('computeNodeBoundsRectangle', () => {
120
122
  it('should compute bounds relative to nested child', () => {
121
123
  const out = createRectangle();
122
124
  computeNodeBoundsRectangle(out, root, grandChild);
123
- expect(out.width).toBeGreaterThan(0);
124
- expect(out.height).toBeGreaterThan(0);
125
- // exact numbers depend on transforms
125
+ expect(out.x).toBeCloseTo(-100);
126
+ expect(out.y).toBeCloseTo(-100);
127
+ expect(out.width).toBeCloseTo(205);
128
+ expect(out.height).toBeCloseTo(205);
126
129
  });
127
130
 
128
131
  it('should account for scaling in parent transforms', () => {
@@ -187,6 +190,20 @@ describe('computeNodeBoundsRectangle', () => {
187
190
  computeNodeBoundsRectangle(out, child, root);
188
191
  expect(equalsRectangle(out, getNodeWorldBoundsRectangle(child))).toBe(true);
189
192
  });
193
+
194
+ it('removes a detached target transform from target-relative bounds', () => {
195
+ root.x = 40;
196
+ root.y = -25;
197
+ invalidateNodeLocalTransform(root);
198
+ const out = createRectangle();
199
+
200
+ computeNodeBoundsRectangle(out, child, root);
201
+
202
+ expect(out.x).toBeCloseTo(105);
203
+ expect(out.y).toBeCloseTo(105);
204
+ expect(out.width).toBeCloseTo(100);
205
+ expect(out.height).toBeCloseTo(100);
206
+ });
190
207
  });
191
208
 
192
209
  describe('computeNodeRootLocalBoundsRectangle', () => {
@@ -573,8 +590,71 @@ describe('getNodeWorldBoundsRectangle', () => {
573
590
  invalidateNodeLocalTransform(child);
574
591
 
575
592
  const bounds = getNodeWorldBoundsRectangle(parent);
576
- expect(bounds.width).toBeGreaterThan(10);
577
- expect(bounds.height).toBeGreaterThan(10);
593
+ expect(bounds).toMatchObject({ x: 0, y: 0, width: 300, height: 200 });
594
+ });
595
+
596
+ it('refreshes cached aggregate bounds when a child moves', () => {
597
+ const parent = createTestNode();
598
+ const child = createTestNode();
599
+ addNodeChild(parent, child);
600
+ setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
601
+ setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 0, 0, 20, 20);
602
+ child.x = 30;
603
+ invalidateNodeLocalTransform(child);
604
+ expect(getNodeWorldBoundsRectangle(parent)).toMatchObject({ x: 0, y: 0, width: 50, height: 20 });
605
+
606
+ child.x = 80;
607
+ invalidateNodeLocalTransform(child);
608
+
609
+ expect(getNodeWorldBoundsRectangle(parent)).toMatchObject({ x: 0, y: 0, width: 100, height: 20 });
610
+ });
611
+
612
+ it('consults a descendant kind-owned bounds validity hook after the aggregate is cached', () => {
613
+ const parent = createTestNode();
614
+ const child = createTestNode();
615
+ const runtime = getEntityRuntime(child);
616
+ let input = { width: 20 };
617
+ let stampedInput = input;
618
+ runtime.computeLocalBoundsRectangle = (out: Rectangle) => {
619
+ setRectangle(out, 100, 0, input.width, 10);
620
+ stampedInput = input;
621
+ };
622
+ runtime.isLocalBoundsRectangleValid = () => stampedInput === input;
623
+ addNodeChild(parent, child);
624
+ setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
625
+ expect(getNodeWorldBoundsRectangle(parent).width).toBe(120);
626
+
627
+ input = { width: 50 };
628
+
629
+ expect(getNodeWorldBoundsRectangle(parent).width).toBe(150);
630
+ });
631
+
632
+ it('refreshes cached aggregate bounds when a child is removed', () => {
633
+ const parent = createTestNode();
634
+ const child = createTestNode();
635
+ addNodeChild(parent, child);
636
+ setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
637
+ setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 100, 0, 200, 200);
638
+ expect(getNodeWorldBoundsRectangle(parent).width).toBe(300);
639
+
640
+ removeNodeChild(parent, child);
641
+
642
+ expect(getNodeWorldBoundsRectangle(parent)).toMatchObject({ x: 0, y: 0, width: 10, height: 10 });
643
+ });
644
+
645
+ it('refreshes cached aggregate bounds when a child is disabled or re-enabled', () => {
646
+ const parent = createTestNode();
647
+ const child = createTestNode();
648
+ addNodeChild(parent, child);
649
+ setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
650
+ setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 100, 0, 200, 200);
651
+ expect(getNodeWorldBoundsRectangle(parent).width).toBe(300);
652
+
653
+ setNodeEnabled(child, false);
654
+ expect(getNodeWorldBoundsRectangle(parent).width).toBe(10);
655
+
656
+ setNodeEnabled(child, true);
657
+ expect(getNodeWorldBoundsRectangle(parent).width).toBe(300);
578
658
  });
579
659
 
580
660
  it('ignores an enabled child with zero-area world bounds', () => {
@@ -609,6 +689,74 @@ describe('setNodeHeight', () => {
609
689
  setNodeHeight(node, 100);
610
690
  expect(node.scaleY).toBe(0);
611
691
  });
692
+
693
+ it('keeps a finite scale when the current height is zero', () => {
694
+ const node = createTestNode();
695
+ setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 0);
696
+
697
+ setNodeHeight(node, 100);
698
+
699
+ expect(node.scaleY).toBe(1);
700
+ expect(Number.isFinite(node.scaleY)).toBe(true);
701
+ });
702
+
703
+ for (const [name, transform] of sizeRoundTripTransforms) {
704
+ it(`round-trips parent-space height through ${name}`, () => {
705
+ const parent = createTestNode();
706
+ const node = createTestNode();
707
+ addNodeChild(parent, node);
708
+ setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
709
+ Object.assign(node, transform);
710
+ invalidateNodeLocalTransform(node);
711
+ const sign = Math.sign(node.scaleY);
712
+
713
+ setNodeHeight(node, 100);
714
+
715
+ expect(getNodeHeight(node)).toBeCloseTo(100, 10);
716
+ expect(Math.sign(node.scaleY)).toBe(sign);
717
+ });
718
+ }
719
+
720
+ it('declines an unsatisfiable height below the fixed x-axis contribution', () => {
721
+ const parent = createTestNode();
722
+ const node = createTestNode();
723
+ addNodeChild(parent, node);
724
+ setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
725
+ node.rotation = 30;
726
+ invalidateNodeLocalTransform(node);
727
+ const scaleY = node.scaleY;
728
+
729
+ setNodeHeight(node, 4);
730
+
731
+ expect(node.scaleY).toBe(scaleY);
732
+ });
733
+
734
+ it('does not mutate at a singular rotation and still solves near it', () => {
735
+ const singularParent = createTestNode();
736
+ const singular = createTestNode();
737
+ addNodeChild(singularParent, singular);
738
+ setRectangle(getNodeLocalBoundsRectangle(singular) as Rectangle, 0, 0, 10, 20);
739
+ singular.rotation = 90;
740
+ singular.scaleY = 2;
741
+ invalidateNodeLocalTransform(singular);
742
+
743
+ setNodeHeight(singular, 50);
744
+
745
+ expect(singular.scaleY).toBe(2);
746
+ expect(Number.isFinite(singular.scaleY)).toBe(true);
747
+
748
+ const nearParent = createTestNode();
749
+ const near = createTestNode();
750
+ addNodeChild(nearParent, near);
751
+ setRectangle(getNodeLocalBoundsRectangle(near) as Rectangle, 0, 0, 10, 20);
752
+ near.rotation = 89.999;
753
+ invalidateNodeLocalTransform(near);
754
+
755
+ setNodeHeight(near, 50);
756
+
757
+ expect(Number.isFinite(near.scaleY)).toBe(true);
758
+ expect(getNodeHeight(near)).toBeCloseTo(50, 8);
759
+ });
612
760
  });
613
761
 
614
762
  describe('setNodeWidth', () => {
@@ -628,6 +776,74 @@ describe('setNodeWidth', () => {
628
776
  setNodeWidth(node, 100);
629
777
  expect(node.scaleX).toBe(0);
630
778
  });
779
+
780
+ it('keeps a finite scale when the current width is zero', () => {
781
+ const node = createTestNode();
782
+ setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 0, 10);
783
+
784
+ setNodeWidth(node, 100);
785
+
786
+ expect(node.scaleX).toBe(1);
787
+ expect(Number.isFinite(node.scaleX)).toBe(true);
788
+ });
789
+
790
+ for (const [name, transform] of sizeRoundTripTransforms) {
791
+ it(`round-trips parent-space width through ${name}`, () => {
792
+ const parent = createTestNode();
793
+ const node = createTestNode();
794
+ addNodeChild(parent, node);
795
+ setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
796
+ Object.assign(node, transform);
797
+ invalidateNodeLocalTransform(node);
798
+ const sign = Math.sign(node.scaleX);
799
+
800
+ setNodeWidth(node, 100);
801
+
802
+ expect(getNodeWidth(node)).toBeCloseTo(100, 10);
803
+ expect(Math.sign(node.scaleX)).toBe(sign);
804
+ });
805
+ }
806
+
807
+ it('declines an unsatisfiable width below the fixed y-axis contribution', () => {
808
+ const parent = createTestNode();
809
+ const node = createTestNode();
810
+ addNodeChild(parent, node);
811
+ setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
812
+ node.rotation = 30;
813
+ invalidateNodeLocalTransform(node);
814
+ const scaleX = node.scaleX;
815
+
816
+ setNodeWidth(node, 5);
817
+
818
+ expect(node.scaleX).toBe(scaleX);
819
+ });
820
+
821
+ it('does not mutate at a singular rotation and still solves near it', () => {
822
+ const singularParent = createTestNode();
823
+ const singular = createTestNode();
824
+ addNodeChild(singularParent, singular);
825
+ setRectangle(getNodeLocalBoundsRectangle(singular) as Rectangle, 0, 0, 10, 20);
826
+ singular.rotation = 90;
827
+ singular.scaleX = 2;
828
+ invalidateNodeLocalTransform(singular);
829
+
830
+ setNodeWidth(singular, 50);
831
+
832
+ expect(singular.scaleX).toBe(2);
833
+ expect(Number.isFinite(singular.scaleX)).toBe(true);
834
+
835
+ const nearParent = createTestNode();
836
+ const near = createTestNode();
837
+ addNodeChild(nearParent, near);
838
+ setRectangle(getNodeLocalBoundsRectangle(near) as Rectangle, 0, 0, 10, 20);
839
+ near.rotation = 89.999;
840
+ invalidateNodeLocalTransform(near);
841
+
842
+ setNodeWidth(near, 50);
843
+
844
+ expect(Number.isFinite(near.scaleX)).toBe(true);
845
+ expect(getNodeWidth(near)).toBeCloseTo(50, 8);
846
+ });
631
847
  });
632
848
 
633
849
  function cloneAndInvalidateRect(rect: Rectangle): Rectangle {
@@ -642,4 +858,11 @@ function invalidateRect(rect: Rectangle | null): void {
642
858
 
643
859
  type TestNode = Node<HasTransform2D & HasBoundsRectangle> & HasTransform2D & HasBoundsRectangle;
644
860
 
861
+ const sizeRoundTripTransforms = [
862
+ ['rotation', { rotation: 30, scaleX: 1, scaleY: 1, skewX: 0, skewY: 0 }],
863
+ ['skew and non-uniform scale', { rotation: 10, scaleX: 0.7, scaleY: 2.3, skewX: 25, skewY: -15 }],
864
+ ['mirrored x scale', { rotation: -25, scaleX: -1.4, scaleY: 0.6, skewX: -10, skewY: 20 }],
865
+ ['mirrored y scale', { rotation: 40, scaleX: 1.8, scaleY: -1.2, skewX: 12, skewY: -18 }],
866
+ ] as const;
867
+
645
868
  const TestKind = 'Test';
@@ -67,6 +67,15 @@ describe('addNodeChild', () => {
67
67
  expect(() => addNodeChild(container, container)).toThrow(TypeError);
68
68
  });
69
69
 
70
+ it('throws rather than creating a cycle', () => {
71
+ addNodeChild(container, childA);
72
+ addNodeChild(childA, childB);
73
+
74
+ expect(() => addNodeChild(childB, container)).toThrow(TypeError);
75
+ expect(getNodeParent(container)).toBeNull();
76
+ expect(getNodeRoot(childB)).toBe(container);
77
+ });
78
+
70
79
  it('rejects a child disallowed by the parent runtime', () => {
71
80
  getEntityRuntime(container).canAddChild = () => false;
72
81
 
@@ -771,27 +780,81 @@ describe('reparentNode', () => {
771
780
  expect(after.ty).toBeCloseTo(before.ty, 10);
772
781
  });
773
782
 
774
- it('preserves skewX and skewY values', () => {
783
+ it('preserves shear induced by rotation across non-uniformly scaled parents', () => {
784
+ const parentA = createTransformNode();
785
+ parentA.rotation = -15;
786
+ parentA.scaleX = 0.75;
787
+ parentA.scaleY = 2.5;
788
+ invalidateNodeLocalTransform(parentA);
789
+
790
+ const child = createTransformNode();
791
+ child.x = 10;
792
+ child.y = 5;
793
+ child.rotation = 37;
794
+ child.scaleX = 1.2;
795
+ child.scaleY = 0.9;
796
+ invalidateNodeLocalTransform(child);
797
+ addNodeChild(parentA, child);
798
+
799
+ expect(child.skewX).toBe(0);
800
+ expect(child.skewY).toBe(0);
801
+ const before = cloneMatrix(getNodeWorldMatrix(child));
802
+
803
+ const parentB = createTransformNode();
804
+ parentB.rotation = 22;
805
+ parentB.scaleX = 2.4;
806
+ parentB.scaleY = 0.6;
807
+ invalidateNodeLocalTransform(parentB);
808
+
809
+ reparentNode(child, parentB);
810
+ const after = getNodeWorldMatrix(child);
811
+
812
+ expect(after.a).toBeCloseTo(before.a, 10);
813
+ expect(after.b).toBeCloseTo(before.b, 10);
814
+ expect(after.c).toBeCloseTo(before.c, 10);
815
+ expect(after.d).toBeCloseTo(before.d, 10);
816
+ expect(after.tx).toBeCloseTo(before.tx, 10);
817
+ expect(after.ty).toBeCloseTo(before.ty, 10);
818
+ });
819
+
820
+ it('preserves a skewed world transform across asymmetric parents', () => {
775
821
  const parentA = createTransformNode();
776
822
  parentA.x = 50;
777
823
  parentA.rotation = 45;
824
+ parentA.scaleX = 2;
825
+ parentA.scaleY = 3;
778
826
  invalidateNodeLocalTransform(parentA);
779
827
 
780
828
  const child = createTransformNode();
781
829
  child.x = 10;
830
+ child.y = 5;
831
+ child.rotation = 12;
832
+ child.scaleX = 1.4;
833
+ child.scaleY = 0.8;
782
834
  child.skewX = 15;
783
835
  child.skewY = 20;
784
836
  invalidateNodeLocalTransform(child);
785
837
  addNodeChild(parentA, child);
786
838
 
839
+ const before = cloneMatrix(getNodeWorldMatrix(child));
840
+
787
841
  const parentB = createTransformNode();
788
842
  parentB.x = 100;
843
+ parentB.y = -20;
844
+ parentB.rotation = -10;
845
+ parentB.scaleX = 0.7;
846
+ parentB.scaleY = 1.6;
789
847
  invalidateNodeLocalTransform(parentB);
790
848
 
791
849
  reparentNode(child, parentB);
850
+ const after = getNodeWorldMatrix(child);
792
851
 
793
- expect(child.skewX).toBe(15);
794
- expect(child.skewY).toBe(20);
852
+ expect(after.a).toBeCloseTo(before.a, 10);
853
+ expect(after.b).toBeCloseTo(before.b, 10);
854
+ expect(after.c).toBeCloseTo(before.c, 10);
855
+ expect(after.d).toBeCloseTo(before.d, 10);
856
+ expect(after.tx).toBeCloseTo(before.tx, 10);
857
+ expect(after.ty).toBeCloseTo(before.ty, 10);
795
858
  });
796
859
 
797
860
  it('preserves world position with pivot', () => {
@@ -873,6 +936,7 @@ describe('reparentNode', () => {
873
936
  reparentNode(child, parentB);
874
937
  const after = getNodeWorldMatrix(child);
875
938
 
939
+ expect(child.scaleY).toBeLessThan(0);
876
940
  expect(after.a).toBeCloseTo(before.a, 10);
877
941
  expect(after.b).toBeCloseTo(before.b, 10);
878
942
  expect(after.c).toBeCloseTo(before.c, 10);
@@ -38,6 +38,12 @@ function getChildren(source: Node) {
38
38
  return getNodeRuntime(source).children as Node[];
39
39
  }
40
40
 
41
+ function expectChildren(source: Node, expected: readonly Node[]): void {
42
+ const actual = getChildren(source);
43
+ expect(actual).toHaveLength(expected.length);
44
+ for (let i = 0; i < expected.length; i++) expect(actual[i]).toBe(expected[i]);
45
+ }
46
+
41
47
  function attachAll() {
42
48
  addNodeChild(container, childA);
43
49
  addNodeChild(container, childB);
@@ -91,7 +97,7 @@ describe('applyNodeOrderList', () => {
91
97
 
92
98
  applyNodeOrderList(container, list);
93
99
 
94
- expect(getChildren(container)).toEqual([childC, childB, childA]);
100
+ expectChildren(container, [childC, childB, childA]);
95
101
  });
96
102
 
97
103
  it('never moves a child the list does not name', () => {
@@ -107,7 +113,7 @@ describe('applyNodeOrderList', () => {
107
113
 
108
114
  // The members swap across the slots they held (0 and 2); the foreign child keeps slot 1, so it
109
115
  // still sits between them rather than being compacted to one side.
110
- expect(getChildren(container)).toEqual([childB, foreign, childA]);
116
+ expectChildren(container, [childB, foreign, childA]);
111
117
  });
112
118
 
113
119
  it('ignores entries that are not children of the target', () => {
@@ -121,7 +127,7 @@ describe('applyNodeOrderList', () => {
121
127
 
122
128
  applyNodeOrderList(container, list);
123
129
 
124
- expect(getChildren(container)).toEqual([childB, childA]);
130
+ expectChildren(container, [childB, childA]);
125
131
  });
126
132
 
127
133
  it('ignores entries attached to a different parent', () => {
@@ -136,8 +142,8 @@ describe('applyNodeOrderList', () => {
136
142
 
137
143
  applyNodeOrderList(container, list);
138
144
 
139
- expect(getChildren(container)).toEqual([childB, childA]);
140
- expect(getChildren(other)).toEqual([childC]);
145
+ expectChildren(container, [childB, childA]);
146
+ expectChildren(other, [childC]);
141
147
  });
142
148
 
143
149
  it('is a no-op once every member has been removed', () => {
@@ -152,7 +158,7 @@ describe('applyNodeOrderList', () => {
152
158
 
153
159
  applyNodeOrderList(container, list);
154
160
 
155
- expect(getChildren(container)).toEqual([foreign]);
161
+ expectChildren(container, [foreign]);
156
162
  expect(getNodeChildrenRevision(container)).toBe(revision);
157
163
  });
158
164
 
@@ -166,7 +172,7 @@ describe('applyNodeOrderList', () => {
166
172
 
167
173
  applyNodeOrderList(container, list);
168
174
 
169
- expect(getChildren(container)).toEqual([childC, childA]);
175
+ expectChildren(container, [childC, childA]);
170
176
  });
171
177
 
172
178
  it('breaks equal sort keys by the order the entries were added', () => {
@@ -180,7 +186,7 @@ describe('applyNodeOrderList', () => {
180
186
 
181
187
  applyNodeOrderList(container, list);
182
188
 
183
- expect(getChildren(container)).toEqual([childA, childB, childC]);
189
+ expectChildren(container, [childA, childB, childC]);
184
190
  });
185
191
 
186
192
  it('resolves a node entered twice to its last entry', () => {
@@ -193,7 +199,7 @@ describe('applyNodeOrderList', () => {
193
199
 
194
200
  applyNodeOrderList(container, list);
195
201
 
196
- expect(getChildren(container)).toEqual([childB, childA]);
202
+ expectChildren(container, [childB, childA]);
197
203
  });
198
204
 
199
205
  it('is idempotent', () => {
@@ -207,7 +213,7 @@ describe('applyNodeOrderList', () => {
207
213
 
208
214
  applyNodeOrderList(container, list);
209
215
 
210
- expect(getChildren(container)).toEqual([childB, childA]);
216
+ expectChildren(container, [childB, childA]);
211
217
  expect(getNodeChildrenRevision(container)).toBe(revision);
212
218
  });
213
219
 
@@ -263,9 +269,31 @@ describe('applyNodeOrderList', () => {
263
269
 
264
270
  applyNodeOrderList(container, createNodeOrderList());
265
271
 
266
- expect(getChildren(container)).toEqual([childA, childB]);
272
+ expectChildren(container, [childA, childB]);
267
273
  expect(getNodeChildrenRevision(container)).toBe(revision);
268
274
  });
275
+
276
+ it('leaves a childless target alone when the list is nonempty', () => {
277
+ const list = createNodeOrderList();
278
+ addNodeOrderListEntry(list, childA, 1);
279
+
280
+ expect(() => applyNodeOrderList(container, list)).not.toThrow();
281
+ expect(getNodeRuntime(container).children).toBeNull();
282
+ });
283
+
284
+ it('does not apply retained entries outside the valid window', () => {
285
+ addNodeChild(container, childC);
286
+ addNodeChild(container, childB);
287
+ const list = createNodeOrderList();
288
+ addNodeOrderListEntry(list, childC, 10);
289
+ addNodeOrderListEntry(list, childB, 0);
290
+ clearNodeOrderList(list);
291
+ addNodeOrderListEntry(list, childC, 10);
292
+
293
+ applyNodeOrderList(container, list);
294
+
295
+ expectChildren(container, [childC, childB]);
296
+ });
269
297
  });
270
298
 
271
299
  describe('clearNodeOrderList', () => {
@@ -433,7 +461,7 @@ describe('removeNodeOrderListEntry', () => {
433
461
 
434
462
  // A keeps slot 0 — only its entry was removed, not the child. C was entered before B and stays
435
463
  // before it; a swap-with-last removal would have put B first and left the children untouched.
436
- expect(getChildren(container)).toEqual([childA, childC, childB]);
464
+ expectChildren(container, [childA, childC, childB]);
437
465
  });
438
466
  });
439
467
 
@@ -467,7 +495,7 @@ describe('setNodeOrderListEntryAbove', () => {
467
495
  setNodeOrderListEntryAbove(list, childA, childC);
468
496
  applyNodeOrderList(container, list);
469
497
 
470
- expect(getChildren(container)).toEqual([childB, childC, childA]);
498
+ expectChildren(container, [childB, childC, childA]);
471
499
  });
472
500
 
473
501
  it('places between neighbours whose keys leave no gap', () => {
@@ -477,11 +505,20 @@ describe('setNodeOrderListEntryAbove', () => {
477
505
  addNodeOrderListEntry(list, childC, 6);
478
506
 
479
507
  setNodeOrderListEntryAbove(list, childA, childB);
508
+
509
+ expect(list.entryCount).toBe(3);
510
+ expect(list.nodes).toHaveLength(3);
511
+ expect(list.sortKeys).toHaveLength(3);
512
+ expect(list.nodes[0]).toBe(childB);
513
+ expect(list.nodes[1]).toBe(childA);
514
+ expect(list.nodes[2]).toBe(childC);
515
+ expect(list.sortKeys.slice(0, 3)).toEqual([5, 5, 6]);
516
+
480
517
  applyNodeOrderList(container, list);
481
518
 
482
519
  // No midpoint exists between 5 and 6, so bisection could not express this; the equal-key plus
483
520
  // entry-position rule can.
484
- expect(getChildren(container)).toEqual([childB, childA, childC]);
521
+ expectChildren(container, [childB, childA, childC]);
485
522
  });
486
523
 
487
524
  it('moves a node that is already entered rather than duplicating it', () => {
@@ -503,6 +540,16 @@ describe('setNodeOrderListEntryAbove', () => {
503
540
  expect(list.entryCount).toBe(0);
504
541
  });
505
542
 
543
+ it('keeps an existing node entry when the target has no entry', () => {
544
+ const list = createNodeOrderList();
545
+ addNodeOrderListEntry(list, childA, 4);
546
+
547
+ setNodeOrderListEntryAbove(list, childA, childC);
548
+
549
+ expect(list.entryCount).toBe(1);
550
+ expect(getNodeOrderListEntrySortKey(list, childA)).toBe(4);
551
+ });
552
+
506
553
  it('does nothing when the node is its own target', () => {
507
554
  const list = createNodeOrderList();
508
555
  addNodeOrderListEntry(list, childA, 4);
@@ -523,7 +570,7 @@ describe('setNodeOrderListEntryBelow', () => {
523
570
  setNodeOrderListEntryBelow(list, childA, childC);
524
571
  applyNodeOrderList(container, list);
525
572
 
526
- expect(getChildren(container)).toEqual([childB, childA, childC]);
573
+ expectChildren(container, [childB, childA, childC]);
527
574
  });
528
575
 
529
576
  it('places between neighbours whose keys leave no gap', () => {
@@ -535,7 +582,7 @@ describe('setNodeOrderListEntryBelow', () => {
535
582
  setNodeOrderListEntryBelow(list, childA, childC);
536
583
  applyNodeOrderList(container, list);
537
584
 
538
- expect(getChildren(container)).toEqual([childB, childA, childC]);
585
+ expectChildren(container, [childB, childA, childC]);
539
586
  });
540
587
 
541
588
  it('does nothing when the target has no entry', () => {
@@ -545,6 +592,16 @@ describe('setNodeOrderListEntryBelow', () => {
545
592
 
546
593
  expect(list.entryCount).toBe(0);
547
594
  });
595
+
596
+ it('keeps an existing node entry when the target has no entry', () => {
597
+ const list = createNodeOrderList();
598
+ addNodeOrderListEntry(list, childA, 4);
599
+
600
+ setNodeOrderListEntryBelow(list, childA, childC);
601
+
602
+ expect(list.entryCount).toBe(1);
603
+ expect(getNodeOrderListEntrySortKey(list, childA)).toBe(4);
604
+ });
548
605
  });
549
606
 
550
607
  describe('setNodeOrderListFromNodeChildren', () => {
@@ -568,7 +625,7 @@ describe('setNodeOrderListFromNodeChildren', () => {
568
625
 
569
626
  applyNodeOrderList(container, list);
570
627
 
571
- expect(getChildren(container)).toEqual([childA, childB, childC]);
628
+ expectChildren(container, [childA, childB, childC]);
572
629
  expect(getNodeChildrenRevision(container)).toBe(revision);
573
630
  });
574
631
 
@@ -603,7 +660,7 @@ describe('swapNodeOrderListEntries', () => {
603
660
  swapNodeOrderListEntries(list, childA, childC);
604
661
  applyNodeOrderList(container, list);
605
662
 
606
- expect(getChildren(container)).toEqual([childC, childB, childA]);
663
+ expectChildren(container, [childC, childB, childA]);
607
664
  });
608
665
 
609
666
  it('does nothing unless both nodes are entered', () => {
@@ -304,6 +304,29 @@ describe('setNodeLocalMatrix', () => {
304
304
  expect(m.ty).toBeCloseTo(20);
305
305
  });
306
306
 
307
+ // Pins the losslessness this function's contract promises for a reflected (negative determinant)
308
+ // matrix. Positive-determinant cases round-tripped even while reflected ones silently lost the
309
+ // reflection, so a fixture that is not reflected cannot speak to this.
310
+ it('round-trips a reflected matrix, keeping the mirror', () => {
311
+ setNodeLocalMatrix(node, createMatrix(1, 0, 0, -1, 0, 0));
312
+ const m = getNodeLocalMatrix(node);
313
+ expect(m.a).toBeCloseTo(1);
314
+ expect(m.b).toBeCloseTo(0);
315
+ expect(m.c).toBeCloseTo(0);
316
+ expect(m.d).toBeCloseTo(-1);
317
+ });
318
+
319
+ it('round-trips a rotated reflection through all four matrix cells', () => {
320
+ setNodeLocalMatrix(node, createMatrix(0.5, 0.8, 0.8, -0.5, 3, 4));
321
+ const m = getNodeLocalMatrix(node);
322
+ expect(m.a).toBeCloseTo(0.5);
323
+ expect(m.b).toBeCloseTo(0.8);
324
+ expect(m.c).toBeCloseTo(0.8);
325
+ expect(m.d).toBeCloseTo(-0.5);
326
+ expect(m.tx).toBeCloseTo(3);
327
+ expect(m.ty).toBeCloseTo(4);
328
+ });
329
+
307
330
  it('resets pivot and absorbs its offset into translation', () => {
308
331
  node.pivotX = 5;
309
332
  node.pivotY = 5;