@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
@@ -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,
@@ -26,6 +28,7 @@ import type {
26
28
 
27
29
  import {
28
30
  computeNodeBoundsRectangle,
31
+ computeNodeRootLocalBoundsRectangle,
29
32
  ensureNodeLocalBoundsRectangle,
30
33
  ensureNodeParentBoundsRectangle,
31
34
  ensureNodeWorldBoundsRectangle,
@@ -41,8 +44,10 @@ import { initBoundsRectangleRuntimeTrait, initBoundsRectangleTrait } from './has
41
44
 
42
45
  function getEntityRuntime(
43
46
  source: TestNode,
44
- ): NodeRuntime<HasBoundsRectangle & HasTransform2D> & HasBoundsRectangleRuntime {
45
- return _getRuntime(source) as NodeRuntime<HasBoundsRectangle & HasTransform2D> & HasBoundsRectangleRuntime;
47
+ ): NodeRuntime<HasBoundsRectangle & HasTransform2D> & HasBoundsRectangleRuntime & HasTransform2DRuntime {
48
+ return _getRuntime(source) as NodeRuntime<HasBoundsRectangle & HasTransform2D> &
49
+ HasBoundsRectangleRuntime &
50
+ HasTransform2DRuntime;
46
51
  }
47
52
 
48
53
  function createTestNode(): TestNode {
@@ -89,6 +94,14 @@ describe('computeNodeBoundsRectangle', () => {
89
94
  expect(equalsRectangle(out, { x: 5, y: 5, width: 100, height: 100 })).toBe(true);
90
95
  });
91
96
 
97
+ it('defaults a null target coordinate space to the source', () => {
98
+ const out = createRectangle();
99
+
100
+ computeNodeBoundsRectangle(out, child, null);
101
+
102
+ expect(equalsRectangle(out, { x: 5, y: 5, width: 100, height: 100 })).toBe(true);
103
+ });
104
+
92
105
  it('should compute bounds relative to parent', () => {
93
106
  const out = createRectangle();
94
107
  computeNodeBoundsRectangle(out, child, root);
@@ -98,12 +111,21 @@ describe('computeNodeBoundsRectangle', () => {
98
111
  expect(out.height).toBeCloseTo(100);
99
112
  });
100
113
 
114
+ it('uses parent-space bounds directly when the parent is nested', () => {
115
+ const out = createRectangle();
116
+
117
+ computeNodeBoundsRectangle(out, grandChild, child);
118
+
119
+ expect(equalsRectangle(out, getNodeParentBoundsRectangle(grandChild))).toBe(true);
120
+ });
121
+
101
122
  it('should compute bounds relative to nested child', () => {
102
123
  const out = createRectangle();
103
124
  computeNodeBoundsRectangle(out, root, grandChild);
104
- expect(out.width).toBeGreaterThan(0);
105
- expect(out.height).toBeGreaterThan(0);
106
- // 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);
107
129
  });
108
130
 
109
131
  it('should account for scaling in parent transforms', () => {
@@ -152,14 +174,129 @@ describe('computeNodeBoundsRectangle', () => {
152
174
  expect(equalsRectangle(out, getNodeWorldBoundsRectangle(child))).toBe(true);
153
175
  });
154
176
 
177
+ it('converts through world space for a nested unrelated target', () => {
178
+ const unrelatedRoot = createTestNode();
179
+ const unrelatedTarget = createTestNode();
180
+ addNodeChild(unrelatedRoot, unrelatedTarget);
181
+ const out = createRectangle();
182
+
183
+ computeNodeBoundsRectangle(out, grandChild, unrelatedTarget);
184
+
185
+ expect(equalsRectangle(out, getNodeWorldBoundsRectangle(grandChild))).toBe(true);
186
+ });
187
+
155
188
  it('should return world bounds if the target coordinate space is root', () => {
156
189
  const out = createRectangle();
157
190
  computeNodeBoundsRectangle(out, child, root);
158
191
  expect(equalsRectangle(out, getNodeWorldBoundsRectangle(child))).toBe(true);
159
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
+ });
207
+ });
208
+
209
+ describe('computeNodeRootLocalBoundsRectangle', () => {
210
+ it('ignores the detached root transform while preserving negative local extents', () => {
211
+ const root = createTestNode();
212
+ root.x = 120;
213
+ root.y = -35;
214
+ root.rotation = 37;
215
+ root.scaleX = 2;
216
+ root.scaleY = 0.5;
217
+ setRectangle(getNodeLocalBoundsRectangle(root), -10, -5, 30, 20);
218
+
219
+ const out = createRectangle();
220
+ computeNodeRootLocalBoundsRectangle(out, root);
221
+
222
+ expect(out).toMatchObject({ x: -10, y: -5, width: 30, height: 20 });
223
+ });
224
+
225
+ it('composes nested transforms relative to an identity root', () => {
226
+ const root = createTestNode();
227
+ const child = createTestNode();
228
+ root.x = 500;
229
+ root.rotation = 90;
230
+ child.x = 20;
231
+ child.y = 3;
232
+ child.scaleX = 2;
233
+ child.scaleY = 3;
234
+ setRectangle(getNodeLocalBoundsRectangle(root), 0, 0, 10, 10);
235
+ setRectangle(getNodeLocalBoundsRectangle(child), -5, -2, 4, 8);
236
+ addNodeChild(root, child);
237
+
238
+ const out = createRectangle();
239
+ computeNodeRootLocalBoundsRectangle(out, root);
240
+
241
+ expect(out.x).toBeCloseTo(0);
242
+ expect(out.y).toBeCloseTo(-3);
243
+ expect(out.width).toBeCloseTo(18);
244
+ expect(out.height).toBeCloseTo(24);
245
+ });
246
+
247
+ it('composes transforms at every descendant depth', () => {
248
+ const root = createTestNode();
249
+ const child = createTestNode();
250
+ const grandchild = createTestNode();
251
+ setRectangle(getNodeLocalBoundsRectangle(root), 0, 0, 1, 1);
252
+ setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 1, 1);
253
+ setRectangle(getNodeLocalBoundsRectangle(grandchild), 0, 0, 2, 2);
254
+ child.x = 10;
255
+ grandchild.x = 20;
256
+ addNodeChild(root, child);
257
+ addNodeChild(child, grandchild);
258
+
259
+ const out = createRectangle();
260
+ computeNodeRootLocalBoundsRectangle(out, root);
261
+
262
+ expect(out).toMatchObject({ x: 0, y: 0, width: 32, height: 2 });
263
+ });
264
+
265
+ it('excludes disabled descendants', () => {
266
+ const root = createTestNode();
267
+ const child = createTestNode();
268
+ setRectangle(getNodeLocalBoundsRectangle(root), 0, 0, 10, 10);
269
+ setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 20, 20);
270
+ child.x = 100;
271
+ child.enabled = false;
272
+ addNodeChild(root, child);
273
+
274
+ const out = createRectangle();
275
+ computeNodeRootLocalBoundsRectangle(out, root);
276
+
277
+ expect(out).toMatchObject({ x: 0, y: 0, width: 10, height: 10 });
278
+ });
160
279
  });
161
280
 
162
281
  describe('ensureNodeLocalBoundsRectangle', () => {
282
+ it('recalculates when the kind-owned validity hook rejects an unchanged revision', () => {
283
+ const object = createTestNode();
284
+ const runtime = getEntityRuntime(object);
285
+ let input = { width: 10 };
286
+ let stampedInput = input;
287
+ runtime.computeLocalBoundsRectangle = (out: Rectangle) => {
288
+ out.width = input.width;
289
+ stampedInput = input;
290
+ };
291
+ runtime.isLocalBoundsRectangleValid = () => stampedInput === input;
292
+ ensureNodeLocalBoundsRectangle(object);
293
+
294
+ input = { width: 20 };
295
+ ensureNodeLocalBoundsRectangle(object);
296
+
297
+ expect(runtime.localBoundsRectangle?.width).toBe(20);
298
+ });
299
+
163
300
  it('should ensure localBoundsRectangle is defined', () => {
164
301
  const object = createTestNode();
165
302
  const runtime = getEntityRuntime(object);
@@ -258,6 +395,20 @@ describe('ensureNodeWorldBoundsRectangle', () => {
258
395
  expect(runtime.worldBoundsRectangle).not.toBeNull();
259
396
  });
260
397
 
398
+ it('recomputes when bounds are cached before the world matrix exists', () => {
399
+ const object = createTestNode();
400
+ const runtime = getEntityRuntime(object);
401
+ getNodeLocalBoundsRectangle(object);
402
+ runtime.worldBoundsRectangle = createRectangle(5, 5, 10, 10);
403
+ runtime.worldBoundsUsingLocalBoundsId = runtime.localBoundsId;
404
+ expect(runtime.worldMatrix).toBeNull();
405
+
406
+ ensureNodeWorldBoundsRectangle(object);
407
+
408
+ expect(runtime.worldMatrix).not.toBeNull();
409
+ expect(runtime.worldBoundsRectangle).toMatchObject({ x: 0, y: 0, width: 0, height: 0 });
410
+ });
411
+
261
412
  it('should not recalculate if localBoundsId and worldTransformId are unchanged', () => {
262
413
  const object = createTestNode();
263
414
  const runtime = getEntityRuntime(object);
@@ -439,8 +590,85 @@ describe('getNodeWorldBoundsRectangle', () => {
439
590
  invalidateNodeLocalTransform(child);
440
591
 
441
592
  const bounds = getNodeWorldBoundsRectangle(parent);
442
- expect(bounds.width).toBeGreaterThan(10);
443
- 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);
658
+ });
659
+
660
+ it('ignores an enabled child with zero-area world bounds', () => {
661
+ const parent = createTestNode();
662
+ const child = createTestNode();
663
+ addNodeChild(parent, child);
664
+ setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
665
+ setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 0, 0, 0, 5);
666
+ child.x = 100;
667
+ invalidateNodeLocalTransform(child);
668
+
669
+ const bounds = getNodeWorldBoundsRectangle(parent);
670
+
671
+ expect(bounds).toMatchObject({ x: 0, y: 0, width: 10, height: 10 });
444
672
  });
445
673
  });
446
674
 
@@ -461,6 +689,74 @@ describe('setNodeHeight', () => {
461
689
  setNodeHeight(node, 100);
462
690
  expect(node.scaleY).toBe(0);
463
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
+ });
464
760
  });
465
761
 
466
762
  describe('setNodeWidth', () => {
@@ -480,6 +776,74 @@ describe('setNodeWidth', () => {
480
776
  setNodeWidth(node, 100);
481
777
  expect(node.scaleX).toBe(0);
482
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
+ });
483
847
  });
484
848
 
485
849
  function cloneAndInvalidateRect(rect: Rectangle): Rectangle {
@@ -494,4 +858,11 @@ function invalidateRect(rect: Rectangle | null): void {
494
858
 
495
859
  type TestNode = Node<HasTransform2D & HasBoundsRectangle> & HasTransform2D & HasBoundsRectangle;
496
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
+
497
868
  const TestKind = 'Test';
@@ -0,0 +1,78 @@
1
+ import { getEntityRuntime as getRawEntityRuntime } from '@flighthq/entity/contract';
2
+ import { setLogSink } from '@flighthq/log/contract';
3
+ import type { HasTransform2D, HasTransform2DRuntime, LogEntry, Node } from '@flighthq/types/contract';
4
+ import { afterEach, describe, expect, test } from 'vitest';
5
+
6
+ import { areNodeGuardsEnabled, disableNodeGuards, enableNodeGuards } from './enableNodeGuards';
7
+ import { initTransform2DRuntimeTrait, initTransform2DTrait } from './hasTransform2d';
8
+ import { reparentNode } from './hierarchy';
9
+ import { createNode } from './node';
10
+ import { invalidateNodeLocalTransform } from './revision';
11
+
12
+ describe('areNodeGuardsEnabled', () => {
13
+ test('reports whether the guards are installed', () => {
14
+ expect(areNodeGuardsEnabled()).toBe(false);
15
+ enableNodeGuards();
16
+ expect(areNodeGuardsEnabled()).toBe(true);
17
+ disableNodeGuards();
18
+ expect(areNodeGuardsEnabled()).toBe(false);
19
+ });
20
+ });
21
+
22
+ describe('disableNodeGuards', () => {
23
+ test('leaves the core silent again once removed', () => {
24
+ enableNodeGuards();
25
+ disableNodeGuards();
26
+ expect(captureLog(() => reparentNode(transformNode(), collapsedParent('gone')))).toHaveLength(0);
27
+ });
28
+ });
29
+
30
+ describe('enableNodeGuards', () => {
31
+ // `logOnce` keys on the parent name and has no reset, so each triggering test must use a DISTINCT
32
+ // name — a repeat would be deduplicated away and assert nothing while still looking green.
33
+ test('warns, naming both nodes, when a reparent is declined', () => {
34
+ enableNodeGuards();
35
+ const child = transformNode();
36
+ child.name = 'child';
37
+ const entries = captureLog(() => reparentNode(child, collapsedParent('collapsed-warns')));
38
+ expect(entries).toHaveLength(1);
39
+ expect(entries[0].data).toMatchObject({ child: 'child', newParent: 'collapsed-warns' });
40
+ });
41
+
42
+ test('stays silent when the reparent succeeds', () => {
43
+ enableNodeGuards();
44
+ const healthy = transformNode();
45
+ invalidateNodeLocalTransform(healthy);
46
+ expect(captureLog(() => reparentNode(transformNode(), healthy))).toHaveLength(0);
47
+ });
48
+ });
49
+
50
+ afterEach(() => {
51
+ disableNodeGuards();
52
+ });
53
+
54
+ function captureLog(run: () => void): LogEntry[] {
55
+ const entries: LogEntry[] = [];
56
+ setLogSink((entry) => entries.push(entry));
57
+ try {
58
+ run();
59
+ } finally {
60
+ setLogSink(null);
61
+ }
62
+ return entries;
63
+ }
64
+
65
+ function collapsedParent(name: string): Node<HasTransform2D> & HasTransform2D {
66
+ const parent = transformNode();
67
+ parent.name = name;
68
+ parent.scaleX = 0;
69
+ invalidateNodeLocalTransform(parent);
70
+ return parent;
71
+ }
72
+
73
+ function transformNode(): Node<HasTransform2D> & HasTransform2D {
74
+ const node = createNode('NodeGuardTest') as Node<HasTransform2D> & HasTransform2D;
75
+ initTransform2DTrait(node);
76
+ initTransform2DRuntimeTrait(getRawEntityRuntime(node) as HasTransform2DRuntime);
77
+ return node;
78
+ }
@@ -1,7 +1,7 @@
1
- import type { HasAppearance } from '@flighthq/types/contract';
1
+ import type { HasAppearance, HasAppearanceRuntime, NodeRuntime } from '@flighthq/types/contract';
2
2
 
3
- import { initAppearanceTrait } from './hasAppearance';
4
- import { createNode } from './node';
3
+ import { initAppearanceRuntimeTrait, initAppearanceTrait } from './hasAppearance';
4
+ import { createNode, createNodeRuntime } from './node';
5
5
 
6
6
  const TestKind = 'Test';
7
7
 
@@ -10,6 +10,36 @@ function makeTarget(): HasAppearance {
10
10
  return node;
11
11
  }
12
12
 
13
+ describe('initAppearanceRuntimeTrait', () => {
14
+ let runtime: NodeRuntime<HasAppearance> & HasAppearanceRuntime;
15
+
16
+ beforeEach(() => {
17
+ runtime = createNodeRuntime() as NodeRuntime<HasAppearance> & HasAppearanceRuntime;
18
+ });
19
+
20
+ it('initializes default values', () => {
21
+ initAppearanceRuntimeTrait(runtime);
22
+
23
+ expect(runtime.worldAlpha).toBeNull();
24
+ expect(runtime.worldAlphaUsingAppearanceId).toStrictEqual(-1);
25
+ expect(runtime.worldAlphaUsingParentAppearanceId).toStrictEqual(-1);
26
+ expect(runtime.worldAppearanceId).toStrictEqual(0);
27
+ });
28
+
29
+ it('resets a resolved cache back to the unresolved state', () => {
30
+ runtime.worldAlpha = 0.25;
31
+ runtime.worldAlphaUsingAppearanceId = 7;
32
+ runtime.worldAlphaUsingParentAppearanceId = 9;
33
+ runtime.worldAppearanceId = 11;
34
+ initAppearanceRuntimeTrait(runtime);
35
+
36
+ expect(runtime.worldAlpha).toBeNull();
37
+ expect(runtime.worldAlphaUsingAppearanceId).toStrictEqual(-1);
38
+ expect(runtime.worldAlphaUsingParentAppearanceId).toStrictEqual(-1);
39
+ expect(runtime.worldAppearanceId).toStrictEqual(0);
40
+ });
41
+ });
42
+
13
43
  describe('initAppearanceTrait', () => {
14
44
  it('sets default values when called with no options', () => {
15
45
  const target = makeTarget();
@@ -34,6 +34,14 @@ describe('initBoundsRectangleRuntimeTrait', () => {
34
34
  expect(runtime.localBoundsRectangle).toBeNull();
35
35
  expect(runtime.worldBoundsRectangle).toBeNull();
36
36
  expect(runtime.computeLocalBoundsRectangle).toStrictEqual(defaultComputeLocalBoundsRectangle);
37
+ expect(runtime.isLocalBoundsRectangleValid).toBeNull();
38
+ });
39
+
40
+ it('installs a kind-owned local-bounds validity hook', () => {
41
+ const isLocalBoundsRectangleValid = vi.fn(() => true);
42
+ initBoundsRectangleRuntimeTrait(runtime, { isLocalBoundsRectangleValid });
43
+
44
+ expect(runtime.isLocalBoundsRectangleValid).toBe(isLocalBoundsRectangleValid);
37
45
  });
38
46
  });
39
47
 
@@ -34,6 +34,8 @@ describe('initTransform2DTrait', () => {
34
34
  it('initializes default values', () => {
35
35
  initTransform2DTrait(node);
36
36
 
37
+ expect(node.pivotX).toStrictEqual(0);
38
+ expect(node.pivotY).toStrictEqual(0);
37
39
  expect(node.rotation).toStrictEqual(0);
38
40
  expect(node.scaleX).toStrictEqual(1);
39
41
  expect(node.scaleY).toStrictEqual(1);
@@ -45,6 +47,8 @@ describe('initTransform2DTrait', () => {
45
47
 
46
48
  it('allows pre-defined values', () => {
47
49
  const base = {
50
+ pivotX: 4,
51
+ pivotY: 8,
48
52
  scaleX: 2,
49
53
  scaleY: 3,
50
54
  skewX: 15,
@@ -54,6 +58,8 @@ describe('initTransform2DTrait', () => {
54
58
  y: 200,
55
59
  };
56
60
  initTransform2DTrait(node, base);
61
+ expect(node.pivotX).toStrictEqual(base.pivotX);
62
+ expect(node.pivotY).toStrictEqual(base.pivotY);
57
63
  expect(node.scaleX).toStrictEqual(base.scaleX);
58
64
  expect(node.scaleY).toStrictEqual(base.scaleY);
59
65
  expect(node.skewX).toStrictEqual(base.skewX);