@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
@@ -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,
@@ -42,8 +44,10 @@ import { initBoundsRectangleRuntimeTrait, initBoundsRectangleTrait } from './has
42
44
 
43
45
  function getEntityRuntime(
44
46
  source: TestNode,
45
- ): NodeRuntime<HasBoundsRectangle & HasTransform2D> & HasBoundsRectangleRuntime {
46
- 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;
47
51
  }
48
52
 
49
53
  function createTestNode(): TestNode {
@@ -90,6 +94,14 @@ describe('computeNodeBoundsRectangle', () => {
90
94
  expect(equalsRectangle(out, { x: 5, y: 5, width: 100, height: 100 })).toBe(true);
91
95
  });
92
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
+
93
105
  it('should compute bounds relative to parent', () => {
94
106
  const out = createRectangle();
95
107
  computeNodeBoundsRectangle(out, child, root);
@@ -99,12 +111,21 @@ describe('computeNodeBoundsRectangle', () => {
99
111
  expect(out.height).toBeCloseTo(100);
100
112
  });
101
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
+
102
122
  it('should compute bounds relative to nested child', () => {
103
123
  const out = createRectangle();
104
124
  computeNodeBoundsRectangle(out, root, grandChild);
105
- expect(out.width).toBeGreaterThan(0);
106
- expect(out.height).toBeGreaterThan(0);
107
- // 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);
108
129
  });
109
130
 
110
131
  it('should account for scaling in parent transforms', () => {
@@ -153,11 +174,36 @@ describe('computeNodeBoundsRectangle', () => {
153
174
  expect(equalsRectangle(out, getNodeWorldBoundsRectangle(child))).toBe(true);
154
175
  });
155
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
+
156
188
  it('should return world bounds if the target coordinate space is root', () => {
157
189
  const out = createRectangle();
158
190
  computeNodeBoundsRectangle(out, child, root);
159
191
  expect(equalsRectangle(out, getNodeWorldBoundsRectangle(child))).toBe(true);
160
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
+ });
161
207
  });
162
208
 
163
209
  describe('computeNodeRootLocalBoundsRectangle', () => {
@@ -197,6 +243,39 @@ describe('computeNodeRootLocalBoundsRectangle', () => {
197
243
  expect(out.width).toBeCloseTo(18);
198
244
  expect(out.height).toBeCloseTo(24);
199
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
+ });
200
279
  });
201
280
 
202
281
  describe('ensureNodeLocalBoundsRectangle', () => {
@@ -316,6 +395,20 @@ describe('ensureNodeWorldBoundsRectangle', () => {
316
395
  expect(runtime.worldBoundsRectangle).not.toBeNull();
317
396
  });
318
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
+
319
412
  it('should not recalculate if localBoundsId and worldTransformId are unchanged', () => {
320
413
  const object = createTestNode();
321
414
  const runtime = getEntityRuntime(object);
@@ -497,8 +590,85 @@ describe('getNodeWorldBoundsRectangle', () => {
497
590
  invalidateNodeLocalTransform(child);
498
591
 
499
592
  const bounds = getNodeWorldBoundsRectangle(parent);
500
- expect(bounds.width).toBeGreaterThan(10);
501
- 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 });
502
672
  });
503
673
  });
504
674
 
@@ -519,6 +689,74 @@ describe('setNodeHeight', () => {
519
689
  setNodeHeight(node, 100);
520
690
  expect(node.scaleY).toBe(0);
521
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
+ });
522
760
  });
523
761
 
524
762
  describe('setNodeWidth', () => {
@@ -538,6 +776,74 @@ describe('setNodeWidth', () => {
538
776
  setNodeWidth(node, 100);
539
777
  expect(node.scaleX).toBe(0);
540
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
+ });
541
847
  });
542
848
 
543
849
  function cloneAndInvalidateRect(rect: Rectangle): Rectangle {
@@ -552,4 +858,11 @@ function invalidateRect(rect: Rectangle | null): void {
552
858
 
553
859
  type TestNode = Node<HasTransform2D & HasBoundsRectangle> & HasTransform2D & HasBoundsRectangle;
554
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
+
555
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,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);
@@ -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';