@flighthq/node 0.3.0 → 0.3.1-next.1041.35b950c

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 (60) hide show
  1. package/dist/boundsRectangle.d.ts +3 -3
  2. package/dist/boundsRectangle.d.ts.map +1 -1
  3. package/dist/boundsRectangle.js +63 -11
  4. package/dist/boundsRectangle.js.map +1 -1
  5. package/dist/contract.d.ts +1 -0
  6. package/dist/contract.d.ts.map +1 -1
  7. package/dist/contract.js +1 -0
  8. package/dist/contract.js.map +1 -1
  9. package/dist/enableNodeGuards.d.ts +4 -0
  10. package/dist/enableNodeGuards.d.ts.map +1 -0
  11. package/dist/enableNodeGuards.js +29 -0
  12. package/dist/enableNodeGuards.js.map +1 -0
  13. package/dist/hasAppearance.d.ts +2 -1
  14. package/dist/hasAppearance.d.ts.map +1 -1
  15. package/dist/hasAppearance.js +10 -0
  16. package/dist/hasAppearance.js.map +1 -1
  17. package/dist/hasBoundsRectangle.d.ts.map +1 -1
  18. package/dist/hasBoundsRectangle.js +4 -0
  19. package/dist/hasBoundsRectangle.js.map +1 -1
  20. package/dist/hasTransform3d.d.ts.map +1 -1
  21. package/dist/hasTransform3d.js +11 -4
  22. package/dist/hasTransform3d.js.map +1 -1
  23. package/dist/hierarchy.d.ts +15 -7
  24. package/dist/hierarchy.d.ts.map +1 -1
  25. package/dist/hierarchy.js +46 -25
  26. package/dist/hierarchy.js.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/nodeColorAdjustment.d.ts.map +1 -1
  32. package/dist/nodeColorAdjustment.js +1 -7
  33. package/dist/nodeColorAdjustment.js.map +1 -1
  34. package/dist/nodeOrderList.d.ts.map +1 -1
  35. package/dist/nodeOrderList.js +9 -2
  36. package/dist/nodeOrderList.js.map +1 -1
  37. package/dist/nodeTransform2d.d.ts.map +1 -1
  38. package/dist/nodeTransform2d.js +1 -1
  39. package/dist/nodeTransform2d.js.map +1 -1
  40. package/dist/revision.d.ts +2 -1
  41. package/dist/revision.d.ts.map +1 -1
  42. package/dist/revision.js +9 -4
  43. package/dist/revision.js.map +1 -1
  44. package/dist/stageFit.d.ts.map +1 -1
  45. package/dist/stageFit.js +8 -2
  46. package/dist/stageFit.js.map +1 -1
  47. package/package.json +9 -7
  48. package/src/boundsRectangle.test.ts +320 -7
  49. package/src/enableNodeGuards.test.ts +78 -0
  50. package/src/hasAppearance.test.ts +33 -3
  51. package/src/hasTransform2d.test.ts +6 -0
  52. package/src/hasTransform3d.test.ts +40 -8
  53. package/src/hierarchy.test.ts +223 -3
  54. package/src/node.test.ts +11 -1
  55. package/src/nodeColorAdjustment.test.ts +11 -0
  56. package/src/nodeOrderList.test.ts +84 -18
  57. package/src/nodeTransform2d.test.ts +45 -0
  58. package/src/revision.test.ts +26 -0
  59. package/src/stageFit.test.ts +56 -1
  60. package/src/traversal.test.ts +32 -2
@@ -24,6 +24,7 @@ import {
24
24
  removeNodeChildAt,
25
25
  removeNodeChildren,
26
26
  reparentNode,
27
+ setReparentNodeGuard,
27
28
  replaceNodeChild,
28
29
  setNodeChildIndex,
29
30
  swapNodeChildren,
@@ -67,6 +68,22 @@ describe('addNodeChild', () => {
67
68
  expect(() => addNodeChild(container, container)).toThrow(TypeError);
68
69
  });
69
70
 
71
+ it('throws rather than creating a cycle', () => {
72
+ addNodeChild(container, childA);
73
+ addNodeChild(childA, childB);
74
+
75
+ expect(() => addNodeChild(childB, container)).toThrow(TypeError);
76
+ expect(getNodeParent(container)).toBeNull();
77
+ expect(getNodeRoot(childB)).toBe(container);
78
+ });
79
+
80
+ it('rejects a child disallowed by the parent runtime', () => {
81
+ getEntityRuntime(container).canAddChild = () => false;
82
+
83
+ expect(() => addNodeChild(container, childA)).toThrow('The specified parent object cannot add this child');
84
+ expect(getNodeParent(childA)).toBeNull();
85
+ });
86
+
70
87
  it('removes child from previous parent before adding', () => {
71
88
  const other = createNode(NodeKind);
72
89
 
@@ -222,6 +239,15 @@ describe('addNodeChildAt', () => {
222
239
  expect(getNodeParentReferenceRevision(childA)).toBe(parentReferenceId);
223
240
  });
224
241
 
242
+ it('repairs a stale parent reference whose child-list entry is absent', () => {
243
+ getEntityRuntime(childA).parent = container;
244
+
245
+ addNodeChildAt(container, childA, 0);
246
+
247
+ expect(getNodeChildAt(container, 0)).toBe(childA);
248
+ expect(getNodeParent(childA)).toBe(container);
249
+ });
250
+
225
251
  it('calls onParentChanged on the child', () => {
226
252
  let called = false;
227
253
  connectSignal(enableNodeSignals(childA).onParentChanged, () => {
@@ -531,6 +557,16 @@ describe('removeNodeChild', () => {
531
557
  expect(getNodeParent(childA)).toBeNull();
532
558
  });
533
559
 
560
+ it('clears a stale parent reference when the child-list entry is absent', () => {
561
+ getEntityRuntime(container).children = [];
562
+ getEntityRuntime(childA).parent = container;
563
+
564
+ removeNodeChild(container, childA);
565
+
566
+ expect(getNodeParent(childA)).toBeNull();
567
+ expect(getNodeChildCount(container)).toBe(0);
568
+ });
569
+
534
570
  it('calls onParentChanged on the child', () => {
535
571
  addNodeChild(container, childA);
536
572
  let called = false;
@@ -605,6 +641,11 @@ describe('removeNodeChildAt', () => {
605
641
  });
606
642
 
607
643
  describe('removeNodeChildren', () => {
644
+ it('does nothing when the node has never had children', () => {
645
+ expect(() => removeNodeChildren(container)).not.toThrow();
646
+ expect(getNodeChildCount(container)).toBe(0);
647
+ });
648
+
608
649
  it('removeChildren removes all children by default', () => {
609
650
  addNodeChild(container, childA);
610
651
  addNodeChild(container, childB);
@@ -681,6 +722,50 @@ describe('reparentNode', () => {
681
722
  return node;
682
723
  }
683
724
 
725
+ // The ruled contract: under a singular new parent, NO local transform exists that would preserve the
726
+ // child's world transform, so the function declines whole rather than inventing one. These assert
727
+ // BOTH halves stay unchanged — a partial success is the state the ruling exists to prevent.
728
+ it('declines whole when the new parent has no invertible world matrix', () => {
729
+ const parentA = createTransformNode();
730
+ const child = createTransformNode();
731
+ child.x = 20;
732
+ child.y = 10;
733
+ invalidateNodeLocalTransform(child);
734
+ addNodeChild(parentA, child);
735
+
736
+ const collapsed = createTransformNode();
737
+ collapsed.scaleX = 0;
738
+ invalidateNodeLocalTransform(collapsed);
739
+
740
+ expect(reparentNode(child, collapsed)).toBe(false);
741
+ // Neither half happened: the attachment is untouched...
742
+ expect(getNodeParent(child)).toBe(parentA);
743
+ expect(getNodeChildCount(collapsed)).toBe(0);
744
+ // ...and so is the transform, with no NaN written into it.
745
+ expect(child.x).toBe(20);
746
+ expect(child.y).toBe(10);
747
+ });
748
+
749
+ it('reports true and still preserves world position for an invertible parent', () => {
750
+ const parentA = createTransformNode();
751
+ const child = createTransformNode();
752
+ child.x = 20;
753
+ child.y = 10;
754
+ invalidateNodeLocalTransform(child);
755
+ addNodeChild(parentA, child);
756
+ const before = cloneMatrix(getNodeWorldMatrix(child));
757
+
758
+ const parentB = createTransformNode();
759
+ parentB.x = 200;
760
+ invalidateNodeLocalTransform(parentB);
761
+
762
+ expect(reparentNode(child, parentB)).toBe(true);
763
+ expect(getNodeParent(child)).toBe(parentB);
764
+ const after = getNodeWorldMatrix(child);
765
+ expect(after.tx).toBeCloseTo(before.tx, 6);
766
+ expect(after.ty).toBeCloseTo(before.ty, 6);
767
+ });
768
+
684
769
  it('preserves world position after reparenting', () => {
685
770
  const parentA = createTransformNode();
686
771
  parentA.x = 100;
@@ -740,27 +825,81 @@ describe('reparentNode', () => {
740
825
  expect(after.ty).toBeCloseTo(before.ty, 10);
741
826
  });
742
827
 
743
- it('preserves skewX and skewY values', () => {
828
+ it('preserves shear induced by rotation across non-uniformly scaled parents', () => {
829
+ const parentA = createTransformNode();
830
+ parentA.rotation = -15;
831
+ parentA.scaleX = 0.75;
832
+ parentA.scaleY = 2.5;
833
+ invalidateNodeLocalTransform(parentA);
834
+
835
+ const child = createTransformNode();
836
+ child.x = 10;
837
+ child.y = 5;
838
+ child.rotation = 37;
839
+ child.scaleX = 1.2;
840
+ child.scaleY = 0.9;
841
+ invalidateNodeLocalTransform(child);
842
+ addNodeChild(parentA, child);
843
+
844
+ expect(child.skewX).toBe(0);
845
+ expect(child.skewY).toBe(0);
846
+ const before = cloneMatrix(getNodeWorldMatrix(child));
847
+
848
+ const parentB = createTransformNode();
849
+ parentB.rotation = 22;
850
+ parentB.scaleX = 2.4;
851
+ parentB.scaleY = 0.6;
852
+ invalidateNodeLocalTransform(parentB);
853
+
854
+ reparentNode(child, parentB);
855
+ const after = getNodeWorldMatrix(child);
856
+
857
+ expect(after.a).toBeCloseTo(before.a, 10);
858
+ expect(after.b).toBeCloseTo(before.b, 10);
859
+ expect(after.c).toBeCloseTo(before.c, 10);
860
+ expect(after.d).toBeCloseTo(before.d, 10);
861
+ expect(after.tx).toBeCloseTo(before.tx, 10);
862
+ expect(after.ty).toBeCloseTo(before.ty, 10);
863
+ });
864
+
865
+ it('preserves a skewed world transform across asymmetric parents', () => {
744
866
  const parentA = createTransformNode();
745
867
  parentA.x = 50;
746
868
  parentA.rotation = 45;
869
+ parentA.scaleX = 2;
870
+ parentA.scaleY = 3;
747
871
  invalidateNodeLocalTransform(parentA);
748
872
 
749
873
  const child = createTransformNode();
750
874
  child.x = 10;
875
+ child.y = 5;
876
+ child.rotation = 12;
877
+ child.scaleX = 1.4;
878
+ child.scaleY = 0.8;
751
879
  child.skewX = 15;
752
880
  child.skewY = 20;
753
881
  invalidateNodeLocalTransform(child);
754
882
  addNodeChild(parentA, child);
755
883
 
884
+ const before = cloneMatrix(getNodeWorldMatrix(child));
885
+
756
886
  const parentB = createTransformNode();
757
887
  parentB.x = 100;
888
+ parentB.y = -20;
889
+ parentB.rotation = -10;
890
+ parentB.scaleX = 0.7;
891
+ parentB.scaleY = 1.6;
758
892
  invalidateNodeLocalTransform(parentB);
759
893
 
760
894
  reparentNode(child, parentB);
895
+ const after = getNodeWorldMatrix(child);
761
896
 
762
- expect(child.skewX).toBe(15);
763
- expect(child.skewY).toBe(20);
897
+ expect(after.a).toBeCloseTo(before.a, 10);
898
+ expect(after.b).toBeCloseTo(before.b, 10);
899
+ expect(after.c).toBeCloseTo(before.c, 10);
900
+ expect(after.d).toBeCloseTo(before.d, 10);
901
+ expect(after.tx).toBeCloseTo(before.tx, 10);
902
+ expect(after.ty).toBeCloseTo(before.ty, 10);
764
903
  });
765
904
 
766
905
  it('preserves world position with pivot', () => {
@@ -842,6 +981,7 @@ describe('reparentNode', () => {
842
981
  reparentNode(child, parentB);
843
982
  const after = getNodeWorldMatrix(child);
844
983
 
984
+ expect(child.scaleY).toBeLessThan(0);
845
985
  expect(after.a).toBeCloseTo(before.a, 10);
846
986
  expect(after.b).toBeCloseTo(before.b, 10);
847
987
  expect(after.c).toBeCloseTo(before.c, 10);
@@ -908,6 +1048,11 @@ describe('replaceNodeChild', () => {
908
1048
  });
909
1049
 
910
1050
  describe('setNodeChildIndex', () => {
1051
+ it('does nothing when the node has never had children', () => {
1052
+ expect(() => setNodeChildIndex(container, childA, 0)).not.toThrow();
1053
+ expect(getNodeChildCount(container)).toBe(0);
1054
+ });
1055
+
911
1056
  it('advances the child-list revision when order changes', () => {
912
1057
  addNodeChild(container, childA);
913
1058
  addNodeChild(container, childB);
@@ -948,6 +1093,25 @@ describe('setNodeChildIndex', () => {
948
1093
  expect(getChildren(container)[0]).toBe(childA);
949
1094
  });
950
1095
 
1096
+ it('does not advance the revision when the child is already at the destination', () => {
1097
+ addNodeChild(container, childA);
1098
+ const childrenId = getNodeChildrenRevision(container);
1099
+
1100
+ setNodeChildIndex(container, childA, 0);
1101
+
1102
+ expect(getNodeChildrenRevision(container)).toBe(childrenId);
1103
+ expect(getNodeChildAt(container, 0)).toBe(childA);
1104
+ });
1105
+
1106
+ it('ignores a stale parent reference whose child-list entry is absent', () => {
1107
+ addNodeChild(container, childB);
1108
+ getEntityRuntime(childA).parent = container;
1109
+
1110
+ setNodeChildIndex(container, childA, 0);
1111
+
1112
+ expect(getChildren(container)).toEqual([childB]);
1113
+ });
1114
+
951
1115
  it('calls onChildrenOrderChanged on the parent', () => {
952
1116
  addNodeChild(container, childA);
953
1117
  addNodeChild(container, childB);
@@ -961,7 +1125,48 @@ describe('setNodeChildIndex', () => {
961
1125
  });
962
1126
  });
963
1127
 
1128
+ describe('setReparentNodeGuard', () => {
1129
+ type TransformNode = Node<HasTransform2D> & HasTransform2D;
1130
+ function createTransformNode(): TransformNode {
1131
+ const node = createNode('TransformTest') as TransformNode;
1132
+ initTransform2DTrait(node);
1133
+ initTransform2DRuntimeTrait(getRawEntityRuntime(node) as HasTransform2DRuntime);
1134
+ return node;
1135
+ }
1136
+
1137
+ it('is notified on the decline and never on success', () => {
1138
+ const seen: string[] = [];
1139
+ setReparentNodeGuard((_child, newParent) => seen.push(newParent.name ?? '<unnamed>'));
1140
+ try {
1141
+ const child = createTransformNode();
1142
+ const collapsed = createTransformNode();
1143
+ collapsed.name = 'collapsed';
1144
+ collapsed.scaleX = 0;
1145
+ invalidateNodeLocalTransform(collapsed);
1146
+ const healthy = createTransformNode();
1147
+ healthy.name = 'healthy';
1148
+ invalidateNodeLocalTransform(healthy);
1149
+
1150
+ reparentNode(child, collapsed);
1151
+ reparentNode(child, healthy);
1152
+ expect(seen).toEqual(['collapsed']);
1153
+ } finally {
1154
+ setReparentNodeGuard(null);
1155
+ }
1156
+ });
1157
+ });
1158
+
964
1159
  describe('swapNodeChildren', () => {
1160
+ it('does not advance the revision when both arguments are the same child', () => {
1161
+ addNodeChild(container, childA);
1162
+ const childrenId = getNodeChildrenRevision(container);
1163
+
1164
+ swapNodeChildren(container, childA, childA);
1165
+
1166
+ expect(getNodeChildrenRevision(container)).toBe(childrenId);
1167
+ expect(getNodeChildAt(container, 0)).toBe(childA);
1168
+ });
1169
+
965
1170
  it('advances the child-list revision when order changes', () => {
966
1171
  addNodeChild(container, childA);
967
1172
  addNodeChild(container, childB);
@@ -1007,6 +1212,21 @@ describe('swapNodeChildren', () => {
1007
1212
  });
1008
1213
 
1009
1214
  describe('swapNodeChildrenAt', () => {
1215
+ it('does nothing when the node has never had children', () => {
1216
+ expect(() => swapNodeChildrenAt(container, 0, 1)).not.toThrow();
1217
+ expect(getNodeChildCount(container)).toBe(0);
1218
+ });
1219
+
1220
+ it('does not advance the revision when both indices are equal', () => {
1221
+ addNodeChild(container, childA);
1222
+ const childrenId = getNodeChildrenRevision(container);
1223
+
1224
+ swapNodeChildrenAt(container, 0, 0);
1225
+
1226
+ expect(getNodeChildrenRevision(container)).toBe(childrenId);
1227
+ expect(getNodeChildAt(container, 0)).toBe(childA);
1228
+ });
1229
+
1010
1230
  it('advances the child-list revision when order changes', () => {
1011
1231
  addNodeChild(container, childA);
1012
1232
  addNodeChild(container, childB);
package/src/node.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { connectSignal } from '@flighthq/signals/contract';
2
- import type { Node, NodeData, NodeRuntime, PartialNode } from '@flighthq/types/contract';
2
+ import type { InteractionSignals, Node, NodeData, NodeRuntime, PartialNode } from '@flighthq/types/contract';
3
3
  import { NodeKind } from '@flighthq/types/contract';
4
4
 
5
5
  import { addNodeChild, getNodeChildCount, getNodeParent } from './hierarchy';
@@ -165,6 +165,16 @@ describe('disposeNode', () => {
165
165
  expect(getNodeSignals(node)).toBeNull();
166
166
  });
167
167
 
168
+ it('releases the interaction signal registry after disposal', () => {
169
+ const node = createNode(NodeKind);
170
+ const runtime = getNodeRuntime(node) as NodeRuntime;
171
+ runtime.interactionSignals = {} as InteractionSignals;
172
+
173
+ disposeNode(node);
174
+
175
+ expect(runtime.interactionSignals).toBeNull();
176
+ });
177
+
168
178
  it('is safe to call on a root node with no parent', () => {
169
179
  const node = createNode(NodeKind);
170
180
  expect(() => disposeNode(node)).not.toThrow();
@@ -19,6 +19,17 @@ describe('addNodeColorAdjustment', () => {
19
19
  expect(getNodeColorAdjustments(node)).toEqual([adjustment]);
20
20
  expect(getNodeRuntime(node).resolvedColorScaleBias?.redScale).toBeCloseTo(0x7f / 255);
21
21
  });
22
+
23
+ it('appends to an existing stack without replacing earlier adjustments', () => {
24
+ const node = createNode(NodeKind);
25
+ const first = createTintAdjustment(0xff0000ff);
26
+ const second = createTintAdjustment(0x00ff00ff);
27
+
28
+ addNodeColorAdjustment(node, first);
29
+ addNodeColorAdjustment(node, second);
30
+
31
+ expect(getNodeColorAdjustments(node)).toEqual([first, second]);
32
+ });
22
33
  });
23
34
 
24
35
  describe('getNodeColorAdjustments', () => {
@@ -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', () => {
@@ -614,4 +671,13 @@ describe('swapNodeOrderListEntries', () => {
614
671
 
615
672
  expect(getNodeOrderListEntrySortKey(list, childA)).toBe(1);
616
673
  });
674
+
675
+ it('does nothing when the first node has no entry', () => {
676
+ const list = createNodeOrderList();
677
+ addNodeOrderListEntry(list, childB, 2);
678
+
679
+ swapNodeOrderListEntries(list, childA, childB);
680
+
681
+ expect(getNodeOrderListEntrySortKey(list, childB)).toBe(2);
682
+ });
617
683
  });
@@ -166,6 +166,28 @@ describe('ensureNodeLocalMatrix', () => {
166
166
  ensureNodeLocalMatrix(node);
167
167
  expect(equalsMatrix(runtime.localMatrix, cache)).toBe(true);
168
168
  });
169
+
170
+ it('normalizes rotations above 180 degrees', () => {
171
+ const runtime = getEntityRuntime(node) as HasTransform2DRuntime;
172
+ node.rotation = 270;
173
+ invalidateNodeLocalTransform(node);
174
+
175
+ ensureNodeLocalMatrix(node);
176
+
177
+ expect(runtime.rotationAngle).toBe(-90);
178
+ expect(runtime.localMatrix?.b).toBeCloseTo(-1);
179
+ });
180
+
181
+ it('normalizes rotations below -180 degrees', () => {
182
+ const runtime = getEntityRuntime(node) as HasTransform2DRuntime;
183
+ node.rotation = -270;
184
+ invalidateNodeLocalTransform(node);
185
+
186
+ ensureNodeLocalMatrix(node);
187
+
188
+ expect(runtime.rotationAngle).toBe(90);
189
+ expect(runtime.localMatrix?.b).toBeCloseTo(1);
190
+ });
169
191
  });
170
192
 
171
193
  describe('ensureNodeWorldMatrix', () => {
@@ -282,6 +304,29 @@ describe('setNodeLocalMatrix', () => {
282
304
  expect(m.ty).toBeCloseTo(20);
283
305
  });
284
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
+
285
330
  it('resets pivot and absorbs its offset into translation', () => {
286
331
  node.pivotX = 5;
287
332
  node.pivotY = 5;