@flighthq/node 0.3.0-next.906.07cea63 → 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 (70) hide show
  1. package/dist/boundsRectangle.d.ts +11 -0
  2. package/dist/boundsRectangle.d.ts.map +1 -1
  3. package/dist/boundsRectangle.js +110 -12
  4. package/dist/boundsRectangle.js.map +1 -1
  5. package/dist/contract.d.ts +2 -0
  6. package/dist/contract.d.ts.map +1 -1
  7. package/dist/contract.js +2 -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 +1 -1
  18. package/dist/hasBoundsRectangle.d.ts.map +1 -1
  19. package/dist/hasBoundsRectangle.js +5 -0
  20. package/dist/hasBoundsRectangle.js.map +1 -1
  21. package/dist/hasTransform3d.d.ts.map +1 -1
  22. package/dist/hasTransform3d.js +11 -4
  23. package/dist/hasTransform3d.js.map +1 -1
  24. package/dist/hierarchy.d.ts +15 -7
  25. package/dist/hierarchy.d.ts.map +1 -1
  26. package/dist/hierarchy.js +65 -28
  27. package/dist/hierarchy.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/node.d.ts.map +1 -1
  33. package/dist/node.js +2 -0
  34. package/dist/node.js.map +1 -1
  35. package/dist/nodeColorAdjustment.d.ts.map +1 -1
  36. package/dist/nodeColorAdjustment.js +1 -7
  37. package/dist/nodeColorAdjustment.js.map +1 -1
  38. package/dist/nodeOrderList.d.ts +88 -0
  39. package/dist/nodeOrderList.d.ts.map +1 -0
  40. package/dist/nodeOrderList.js +257 -0
  41. package/dist/nodeOrderList.js.map +1 -0
  42. package/dist/nodeTransform2d.d.ts.map +1 -1
  43. package/dist/nodeTransform2d.js +1 -1
  44. package/dist/nodeTransform2d.js.map +1 -1
  45. package/dist/revision.d.ts +5 -2
  46. package/dist/revision.d.ts.map +1 -1
  47. package/dist/revision.js +17 -5
  48. package/dist/revision.js.map +1 -1
  49. package/dist/stageFit.d.ts.map +1 -1
  50. package/dist/stageFit.js +8 -4
  51. package/dist/stageFit.js.map +1 -1
  52. package/dist/traversal.d.ts +1 -1
  53. package/dist/traversal.d.ts.map +1 -1
  54. package/dist/traversal.js +1 -1
  55. package/dist/traversal.js.map +1 -1
  56. package/package.json +11 -7
  57. package/src/boundsRectangle.test.ts +378 -7
  58. package/src/enableNodeGuards.test.ts +78 -0
  59. package/src/hasAppearance.test.ts +33 -3
  60. package/src/hasBoundsRectangle.test.ts +8 -0
  61. package/src/hasTransform2d.test.ts +6 -0
  62. package/src/hasTransform3d.test.ts +40 -8
  63. package/src/hierarchy.test.ts +298 -4
  64. package/src/node.test.ts +11 -1
  65. package/src/nodeColorAdjustment.test.ts +11 -0
  66. package/src/nodeOrderList.test.ts +683 -0
  67. package/src/nodeTransform2d.test.ts +45 -0
  68. package/src/revision.test.ts +51 -0
  69. package/src/stageFit.test.ts +56 -1
  70. package/src/traversal.test.ts +32 -3
@@ -1,13 +1,19 @@
1
1
  import { createQuaternion, createVector3 } from '@flighthq/geometry/contract';
2
- import type { HasTransform3D, HasTransform3DRuntime } from '@flighthq/types/contract';
3
- import { describe, expect, it } from 'vitest';
2
+ import type { HasTransform3D, HasTransform3DRuntime, Node, NodeRuntime } from '@flighthq/types/contract';
4
3
 
5
4
  import { initTransform3DRuntimeTrait, initTransform3DTrait } from './hasTransform3d';
5
+ import { createNode, createNodeRuntime } from './node';
6
6
 
7
7
  describe('initTransform3DRuntimeTrait', () => {
8
+ let runtime: NodeRuntime<HasTransform3D> & HasTransform3DRuntime;
9
+
10
+ beforeEach(() => {
11
+ runtime = createNodeRuntime() as NodeRuntime<HasTransform3D> & HasTransform3DRuntime;
12
+ });
13
+
8
14
  it('nulls the matrix caches and clears the detached flag', () => {
9
- const runtime = {} as HasTransform3DRuntime;
10
15
  initTransform3DRuntimeTrait(runtime);
16
+
11
17
  expect(runtime.localMatrix4).toBeNull();
12
18
  expect(runtime.worldMatrix4).toBeNull();
13
19
  expect(runtime.localMatrix4Detached).toBe(false);
@@ -15,22 +21,48 @@ describe('initTransform3DRuntimeTrait', () => {
15
21
  });
16
22
 
17
23
  describe('initTransform3DTrait', () => {
24
+ let node: HasTransform3D;
25
+
26
+ beforeEach(() => {
27
+ node = createNode(NodeTestKind) as Node<HasTransform3D> & HasTransform3D;
28
+ });
29
+
18
30
  it('defaults to identity position/rotation/scale', () => {
19
- const node = {} as HasTransform3D;
20
31
  initTransform3DTrait(node);
32
+
21
33
  expect(node.position).toMatchObject({ x: 0, y: 0, z: 0 });
22
34
  expect(node.rotation).toMatchObject({ x: 0, y: 0, z: 0, w: 1 });
23
35
  expect(node.scale).toMatchObject({ x: 1, y: 1, z: 1 });
24
36
  });
25
37
 
26
38
  it('accepts existing position/rotation/scale', () => {
27
- const node = {} as HasTransform3D;
28
39
  const position = createVector3(7, 0, 0);
29
40
  const rotation = createQuaternion();
30
41
  const scale = createVector3(2, 2, 2);
31
42
  initTransform3DTrait(node, { position, rotation, scale });
32
- expect(node.position).toBe(position);
33
- expect(node.rotation).toBe(rotation);
34
- expect(node.scale).toBe(scale);
43
+
44
+ expect(node.position).toMatchObject({ x: 7, y: 0, z: 0 });
45
+ expect(node.rotation).toMatchObject({ x: 0, y: 0, z: 0, w: 1 });
46
+ expect(node.scale).toMatchObject({ x: 2, y: 2, z: 2 });
47
+ });
48
+
49
+ it('copies out of the options object so two nodes never share storage', () => {
50
+ const options = { position: createVector3(1, 2, 3), rotation: createQuaternion(), scale: createVector3(2, 2, 2) };
51
+ const second = createNode(NodeTestKind) as Node<HasTransform3D> & HasTransform3D;
52
+ initTransform3DTrait(node, options);
53
+ initTransform3DTrait(second, options);
54
+
55
+ node.position.x = 999;
56
+ node.scale.y = 999;
57
+ node.rotation.w = 0;
58
+
59
+ expect(second.position.x).toBe(1);
60
+ expect(second.scale.y).toBe(2);
61
+ expect(second.rotation.w).toBe(1);
62
+ expect(options.position.x).toBe(1);
63
+ expect(options.scale.y).toBe(2);
64
+ expect(options.rotation.w).toBe(1);
35
65
  });
36
66
  });
67
+
68
+ const NodeTestKind = 'NodeTest';
@@ -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,
@@ -31,7 +32,7 @@ import {
31
32
  } from './hierarchy';
32
33
  import { createNode, enableNodeSignals, getNodeRuntime } from './node';
33
34
  import { getNodeWorldMatrix } from './nodeTransform2d';
34
- import { invalidateNodeLocalTransform } from './revision';
35
+ import { getNodeChildrenRevision, getNodeParentReferenceRevision, invalidateNodeLocalTransform } from './revision';
35
36
 
36
37
  let container: Node<NodeTraits>;
37
38
  let childA: Node<NodeTraits>;
@@ -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
 
@@ -153,6 +170,16 @@ describe('addNodeChild', () => {
153
170
  });
154
171
 
155
172
  describe('addNodeChildAt', () => {
173
+ it('advances structural revisions when attaching a child', () => {
174
+ const childrenId = getNodeChildrenRevision(container);
175
+ const parentReferenceId = getNodeParentReferenceRevision(childA);
176
+
177
+ addNodeChildAt(container, childA, 0);
178
+
179
+ expect(getNodeChildrenRevision(container)).toBe(childrenId + 1);
180
+ expect(getNodeParentReferenceRevision(childA)).toBe(parentReferenceId + 1);
181
+ });
182
+
156
183
  it('addNodeChildAt inserts a child at the given index', () => {
157
184
  addNodeChild(container, childA);
158
185
  addNodeChildAt(container, childB, 0);
@@ -189,6 +216,38 @@ describe('addNodeChildAt', () => {
189
216
  expect(getChildren(container)[1]).toBe(childA);
190
217
  });
191
218
 
219
+ it('advances only the child-list revision when reordering within one parent', () => {
220
+ addNodeChild(container, childA);
221
+ addNodeChild(container, childB);
222
+ const childrenId = getNodeChildrenRevision(container);
223
+ const parentReferenceId = getNodeParentReferenceRevision(childA);
224
+
225
+ addNodeChildAt(container, childA, 1);
226
+
227
+ expect(getNodeChildrenRevision(container)).toBe(childrenId + 1);
228
+ expect(getNodeParentReferenceRevision(childA)).toBe(parentReferenceId);
229
+ });
230
+
231
+ it('does not advance structural revisions for an unchanged index', () => {
232
+ addNodeChild(container, childA);
233
+ const childrenId = getNodeChildrenRevision(container);
234
+ const parentReferenceId = getNodeParentReferenceRevision(childA);
235
+
236
+ addNodeChildAt(container, childA, 0);
237
+
238
+ expect(getNodeChildrenRevision(container)).toBe(childrenId);
239
+ expect(getNodeParentReferenceRevision(childA)).toBe(parentReferenceId);
240
+ });
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
+
192
251
  it('calls onParentChanged on the child', () => {
193
252
  let called = false;
194
253
  connectSignal(enableNodeSignals(childA).onParentChanged, () => {
@@ -456,6 +515,17 @@ describe('isNodeAncestorOf', () => {
456
515
  });
457
516
 
458
517
  describe('removeNodeChild', () => {
518
+ it('advances structural revisions when detaching a child', () => {
519
+ addNodeChild(container, childA);
520
+ const childrenId = getNodeChildrenRevision(container);
521
+ const parentReferenceId = getNodeParentReferenceRevision(childA);
522
+
523
+ removeNodeChild(container, childA);
524
+
525
+ expect(getNodeChildrenRevision(container)).toBe(childrenId + 1);
526
+ expect(getNodeParentReferenceRevision(childA)).toBe(parentReferenceId + 1);
527
+ });
528
+
459
529
  it('removes the child and clears its parent', () => {
460
530
  addNodeChild(container, childA);
461
531
  expect(getNodeChildCount(container)).toBe(1);
@@ -487,6 +557,16 @@ describe('removeNodeChild', () => {
487
557
  expect(getNodeParent(childA)).toBeNull();
488
558
  });
489
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
+
490
570
  it('calls onParentChanged on the child', () => {
491
571
  addNodeChild(container, childA);
492
572
  let called = false;
@@ -561,6 +641,11 @@ describe('removeNodeChildAt', () => {
561
641
  });
562
642
 
563
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
+
564
649
  it('removeChildren removes all children by default', () => {
565
650
  addNodeChild(container, childA);
566
651
  addNodeChild(container, childB);
@@ -637,6 +722,50 @@ describe('reparentNode', () => {
637
722
  return node;
638
723
  }
639
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
+
640
769
  it('preserves world position after reparenting', () => {
641
770
  const parentA = createTransformNode();
642
771
  parentA.x = 100;
@@ -696,27 +825,81 @@ describe('reparentNode', () => {
696
825
  expect(after.ty).toBeCloseTo(before.ty, 10);
697
826
  });
698
827
 
699
- 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', () => {
700
866
  const parentA = createTransformNode();
701
867
  parentA.x = 50;
702
868
  parentA.rotation = 45;
869
+ parentA.scaleX = 2;
870
+ parentA.scaleY = 3;
703
871
  invalidateNodeLocalTransform(parentA);
704
872
 
705
873
  const child = createTransformNode();
706
874
  child.x = 10;
875
+ child.y = 5;
876
+ child.rotation = 12;
877
+ child.scaleX = 1.4;
878
+ child.scaleY = 0.8;
707
879
  child.skewX = 15;
708
880
  child.skewY = 20;
709
881
  invalidateNodeLocalTransform(child);
710
882
  addNodeChild(parentA, child);
711
883
 
884
+ const before = cloneMatrix(getNodeWorldMatrix(child));
885
+
712
886
  const parentB = createTransformNode();
713
887
  parentB.x = 100;
888
+ parentB.y = -20;
889
+ parentB.rotation = -10;
890
+ parentB.scaleX = 0.7;
891
+ parentB.scaleY = 1.6;
714
892
  invalidateNodeLocalTransform(parentB);
715
893
 
716
894
  reparentNode(child, parentB);
895
+ const after = getNodeWorldMatrix(child);
717
896
 
718
- expect(child.skewX).toBe(15);
719
- 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);
720
903
  });
721
904
 
722
905
  it('preserves world position with pivot', () => {
@@ -798,6 +981,7 @@ describe('reparentNode', () => {
798
981
  reparentNode(child, parentB);
799
982
  const after = getNodeWorldMatrix(child);
800
983
 
984
+ expect(child.scaleY).toBeLessThan(0);
801
985
  expect(after.a).toBeCloseTo(before.a, 10);
802
986
  expect(after.b).toBeCloseTo(before.b, 10);
803
987
  expect(after.c).toBeCloseTo(before.c, 10);
@@ -864,6 +1048,21 @@ describe('replaceNodeChild', () => {
864
1048
  });
865
1049
 
866
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
+
1056
+ it('advances the child-list revision when order changes', () => {
1057
+ addNodeChild(container, childA);
1058
+ addNodeChild(container, childB);
1059
+ const childrenId = getNodeChildrenRevision(container);
1060
+
1061
+ setNodeChildIndex(container, childA, 1);
1062
+
1063
+ expect(getNodeChildrenRevision(container)).toBe(childrenId + 1);
1064
+ });
1065
+
867
1066
  it('setChildIndex moves an existing child to a new index', () => {
868
1067
  addNodeChild(container, childA);
869
1068
  addNodeChild(container, childB);
@@ -894,6 +1093,25 @@ describe('setNodeChildIndex', () => {
894
1093
  expect(getChildren(container)[0]).toBe(childA);
895
1094
  });
896
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
+
897
1115
  it('calls onChildrenOrderChanged on the parent', () => {
898
1116
  addNodeChild(container, childA);
899
1117
  addNodeChild(container, childB);
@@ -907,7 +1125,58 @@ describe('setNodeChildIndex', () => {
907
1125
  });
908
1126
  });
909
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
+
910
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
+
1170
+ it('advances the child-list revision when order changes', () => {
1171
+ addNodeChild(container, childA);
1172
+ addNodeChild(container, childB);
1173
+ const childrenId = getNodeChildrenRevision(container);
1174
+
1175
+ swapNodeChildren(container, childA, childB);
1176
+
1177
+ expect(getNodeChildrenRevision(container)).toBe(childrenId + 1);
1178
+ });
1179
+
911
1180
  it('swapNodeChildren swaps two children', () => {
912
1181
  addNodeChild(container, childA);
913
1182
  addNodeChild(container, childB);
@@ -943,6 +1212,31 @@ describe('swapNodeChildren', () => {
943
1212
  });
944
1213
 
945
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
+
1230
+ it('advances the child-list revision when order changes', () => {
1231
+ addNodeChild(container, childA);
1232
+ addNodeChild(container, childB);
1233
+ const childrenId = getNodeChildrenRevision(container);
1234
+
1235
+ swapNodeChildrenAt(container, 0, 1);
1236
+
1237
+ expect(getNodeChildrenRevision(container)).toBe(childrenId + 1);
1238
+ });
1239
+
946
1240
  it('swapNodeChildrenAt swaps children by index', () => {
947
1241
  addNodeChild(container, childA);
948
1242
  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', () => {