@flighthq/node 0.3.1-next.861.5a56d7a → 0.3.1-next.948.85d9f30

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 (42) 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/hasAppearance.d.ts +2 -1
  6. package/dist/hasAppearance.d.ts.map +1 -1
  7. package/dist/hasAppearance.js +10 -0
  8. package/dist/hasAppearance.js.map +1 -1
  9. package/dist/hasBoundsRectangle.d.ts.map +1 -1
  10. package/dist/hasBoundsRectangle.js +4 -0
  11. package/dist/hasBoundsRectangle.js.map +1 -1
  12. package/dist/hasTransform3d.d.ts.map +1 -1
  13. package/dist/hasTransform3d.js +11 -4
  14. package/dist/hasTransform3d.js.map +1 -1
  15. package/dist/hierarchy.d.ts +3 -3
  16. package/dist/hierarchy.d.ts.map +1 -1
  17. package/dist/hierarchy.js +20 -20
  18. package/dist/hierarchy.js.map +1 -1
  19. package/dist/nodeOrderList.d.ts.map +1 -1
  20. package/dist/nodeOrderList.js +9 -2
  21. package/dist/nodeOrderList.js.map +1 -1
  22. package/dist/nodeTransform2d.d.ts.map +1 -1
  23. package/dist/nodeTransform2d.js +1 -1
  24. package/dist/nodeTransform2d.js.map +1 -1
  25. package/dist/revision.d.ts +2 -1
  26. package/dist/revision.d.ts.map +1 -1
  27. package/dist/revision.js +9 -4
  28. package/dist/revision.js.map +1 -1
  29. package/dist/stageFit.d.ts.map +1 -1
  30. package/dist/stageFit.js +8 -2
  31. package/dist/stageFit.js.map +1 -1
  32. package/package.json +8 -7
  33. package/src/boundsRectangle.test.ts +228 -5
  34. package/src/hasAppearance.test.ts +33 -3
  35. package/src/hasTransform2d.test.ts +6 -0
  36. package/src/hasTransform3d.test.ts +40 -8
  37. package/src/hierarchy.test.ts +67 -3
  38. package/src/nodeOrderList.test.ts +75 -18
  39. package/src/nodeTransform2d.test.ts +23 -0
  40. package/src/revision.test.ts +26 -0
  41. package/src/stageFit.test.ts +56 -1
  42. package/src/traversal.test.ts +6 -0
@@ -67,6 +67,15 @@ describe('addNodeChild', () => {
67
67
  expect(() => addNodeChild(container, container)).toThrow(TypeError);
68
68
  });
69
69
 
70
+ it('throws rather than creating a cycle', () => {
71
+ addNodeChild(container, childA);
72
+ addNodeChild(childA, childB);
73
+
74
+ expect(() => addNodeChild(childB, container)).toThrow(TypeError);
75
+ expect(getNodeParent(container)).toBeNull();
76
+ expect(getNodeRoot(childB)).toBe(container);
77
+ });
78
+
70
79
  it('rejects a child disallowed by the parent runtime', () => {
71
80
  getEntityRuntime(container).canAddChild = () => false;
72
81
 
@@ -771,27 +780,81 @@ describe('reparentNode', () => {
771
780
  expect(after.ty).toBeCloseTo(before.ty, 10);
772
781
  });
773
782
 
774
- it('preserves skewX and skewY values', () => {
783
+ it('preserves shear induced by rotation across non-uniformly scaled parents', () => {
784
+ const parentA = createTransformNode();
785
+ parentA.rotation = -15;
786
+ parentA.scaleX = 0.75;
787
+ parentA.scaleY = 2.5;
788
+ invalidateNodeLocalTransform(parentA);
789
+
790
+ const child = createTransformNode();
791
+ child.x = 10;
792
+ child.y = 5;
793
+ child.rotation = 37;
794
+ child.scaleX = 1.2;
795
+ child.scaleY = 0.9;
796
+ invalidateNodeLocalTransform(child);
797
+ addNodeChild(parentA, child);
798
+
799
+ expect(child.skewX).toBe(0);
800
+ expect(child.skewY).toBe(0);
801
+ const before = cloneMatrix(getNodeWorldMatrix(child));
802
+
803
+ const parentB = createTransformNode();
804
+ parentB.rotation = 22;
805
+ parentB.scaleX = 2.4;
806
+ parentB.scaleY = 0.6;
807
+ invalidateNodeLocalTransform(parentB);
808
+
809
+ reparentNode(child, parentB);
810
+ const after = getNodeWorldMatrix(child);
811
+
812
+ expect(after.a).toBeCloseTo(before.a, 10);
813
+ expect(after.b).toBeCloseTo(before.b, 10);
814
+ expect(after.c).toBeCloseTo(before.c, 10);
815
+ expect(after.d).toBeCloseTo(before.d, 10);
816
+ expect(after.tx).toBeCloseTo(before.tx, 10);
817
+ expect(after.ty).toBeCloseTo(before.ty, 10);
818
+ });
819
+
820
+ it('preserves a skewed world transform across asymmetric parents', () => {
775
821
  const parentA = createTransformNode();
776
822
  parentA.x = 50;
777
823
  parentA.rotation = 45;
824
+ parentA.scaleX = 2;
825
+ parentA.scaleY = 3;
778
826
  invalidateNodeLocalTransform(parentA);
779
827
 
780
828
  const child = createTransformNode();
781
829
  child.x = 10;
830
+ child.y = 5;
831
+ child.rotation = 12;
832
+ child.scaleX = 1.4;
833
+ child.scaleY = 0.8;
782
834
  child.skewX = 15;
783
835
  child.skewY = 20;
784
836
  invalidateNodeLocalTransform(child);
785
837
  addNodeChild(parentA, child);
786
838
 
839
+ const before = cloneMatrix(getNodeWorldMatrix(child));
840
+
787
841
  const parentB = createTransformNode();
788
842
  parentB.x = 100;
843
+ parentB.y = -20;
844
+ parentB.rotation = -10;
845
+ parentB.scaleX = 0.7;
846
+ parentB.scaleY = 1.6;
789
847
  invalidateNodeLocalTransform(parentB);
790
848
 
791
849
  reparentNode(child, parentB);
850
+ const after = getNodeWorldMatrix(child);
792
851
 
793
- expect(child.skewX).toBe(15);
794
- expect(child.skewY).toBe(20);
852
+ expect(after.a).toBeCloseTo(before.a, 10);
853
+ expect(after.b).toBeCloseTo(before.b, 10);
854
+ expect(after.c).toBeCloseTo(before.c, 10);
855
+ expect(after.d).toBeCloseTo(before.d, 10);
856
+ expect(after.tx).toBeCloseTo(before.tx, 10);
857
+ expect(after.ty).toBeCloseTo(before.ty, 10);
795
858
  });
796
859
 
797
860
  it('preserves world position with pivot', () => {
@@ -873,6 +936,7 @@ describe('reparentNode', () => {
873
936
  reparentNode(child, parentB);
874
937
  const after = getNodeWorldMatrix(child);
875
938
 
939
+ expect(child.scaleY).toBeLessThan(0);
876
940
  expect(after.a).toBeCloseTo(before.a, 10);
877
941
  expect(after.b).toBeCloseTo(before.b, 10);
878
942
  expect(after.c).toBeCloseTo(before.c, 10);
@@ -38,6 +38,12 @@ function getChildren(source: Node) {
38
38
  return getNodeRuntime(source).children as Node[];
39
39
  }
40
40
 
41
+ function expectChildren(source: Node, expected: readonly Node[]): void {
42
+ const actual = getChildren(source);
43
+ expect(actual).toHaveLength(expected.length);
44
+ for (let i = 0; i < expected.length; i++) expect(actual[i]).toBe(expected[i]);
45
+ }
46
+
41
47
  function attachAll() {
42
48
  addNodeChild(container, childA);
43
49
  addNodeChild(container, childB);
@@ -91,7 +97,7 @@ describe('applyNodeOrderList', () => {
91
97
 
92
98
  applyNodeOrderList(container, list);
93
99
 
94
- expect(getChildren(container)).toEqual([childC, childB, childA]);
100
+ expectChildren(container, [childC, childB, childA]);
95
101
  });
96
102
 
97
103
  it('never moves a child the list does not name', () => {
@@ -107,7 +113,7 @@ describe('applyNodeOrderList', () => {
107
113
 
108
114
  // The members swap across the slots they held (0 and 2); the foreign child keeps slot 1, so it
109
115
  // still sits between them rather than being compacted to one side.
110
- expect(getChildren(container)).toEqual([childB, foreign, childA]);
116
+ expectChildren(container, [childB, foreign, childA]);
111
117
  });
112
118
 
113
119
  it('ignores entries that are not children of the target', () => {
@@ -121,7 +127,7 @@ describe('applyNodeOrderList', () => {
121
127
 
122
128
  applyNodeOrderList(container, list);
123
129
 
124
- expect(getChildren(container)).toEqual([childB, childA]);
130
+ expectChildren(container, [childB, childA]);
125
131
  });
126
132
 
127
133
  it('ignores entries attached to a different parent', () => {
@@ -136,8 +142,8 @@ describe('applyNodeOrderList', () => {
136
142
 
137
143
  applyNodeOrderList(container, list);
138
144
 
139
- expect(getChildren(container)).toEqual([childB, childA]);
140
- expect(getChildren(other)).toEqual([childC]);
145
+ expectChildren(container, [childB, childA]);
146
+ expectChildren(other, [childC]);
141
147
  });
142
148
 
143
149
  it('is a no-op once every member has been removed', () => {
@@ -152,7 +158,7 @@ describe('applyNodeOrderList', () => {
152
158
 
153
159
  applyNodeOrderList(container, list);
154
160
 
155
- expect(getChildren(container)).toEqual([foreign]);
161
+ expectChildren(container, [foreign]);
156
162
  expect(getNodeChildrenRevision(container)).toBe(revision);
157
163
  });
158
164
 
@@ -166,7 +172,7 @@ describe('applyNodeOrderList', () => {
166
172
 
167
173
  applyNodeOrderList(container, list);
168
174
 
169
- expect(getChildren(container)).toEqual([childC, childA]);
175
+ expectChildren(container, [childC, childA]);
170
176
  });
171
177
 
172
178
  it('breaks equal sort keys by the order the entries were added', () => {
@@ -180,7 +186,7 @@ describe('applyNodeOrderList', () => {
180
186
 
181
187
  applyNodeOrderList(container, list);
182
188
 
183
- expect(getChildren(container)).toEqual([childA, childB, childC]);
189
+ expectChildren(container, [childA, childB, childC]);
184
190
  });
185
191
 
186
192
  it('resolves a node entered twice to its last entry', () => {
@@ -193,7 +199,7 @@ describe('applyNodeOrderList', () => {
193
199
 
194
200
  applyNodeOrderList(container, list);
195
201
 
196
- expect(getChildren(container)).toEqual([childB, childA]);
202
+ expectChildren(container, [childB, childA]);
197
203
  });
198
204
 
199
205
  it('is idempotent', () => {
@@ -207,7 +213,7 @@ describe('applyNodeOrderList', () => {
207
213
 
208
214
  applyNodeOrderList(container, list);
209
215
 
210
- expect(getChildren(container)).toEqual([childB, childA]);
216
+ expectChildren(container, [childB, childA]);
211
217
  expect(getNodeChildrenRevision(container)).toBe(revision);
212
218
  });
213
219
 
@@ -263,9 +269,31 @@ describe('applyNodeOrderList', () => {
263
269
 
264
270
  applyNodeOrderList(container, createNodeOrderList());
265
271
 
266
- expect(getChildren(container)).toEqual([childA, childB]);
272
+ expectChildren(container, [childA, childB]);
267
273
  expect(getNodeChildrenRevision(container)).toBe(revision);
268
274
  });
275
+
276
+ it('leaves a childless target alone when the list is nonempty', () => {
277
+ const list = createNodeOrderList();
278
+ addNodeOrderListEntry(list, childA, 1);
279
+
280
+ expect(() => applyNodeOrderList(container, list)).not.toThrow();
281
+ expect(getNodeRuntime(container).children).toBeNull();
282
+ });
283
+
284
+ it('does not apply retained entries outside the valid window', () => {
285
+ addNodeChild(container, childC);
286
+ addNodeChild(container, childB);
287
+ const list = createNodeOrderList();
288
+ addNodeOrderListEntry(list, childC, 10);
289
+ addNodeOrderListEntry(list, childB, 0);
290
+ clearNodeOrderList(list);
291
+ addNodeOrderListEntry(list, childC, 10);
292
+
293
+ applyNodeOrderList(container, list);
294
+
295
+ expectChildren(container, [childC, childB]);
296
+ });
269
297
  });
270
298
 
271
299
  describe('clearNodeOrderList', () => {
@@ -433,7 +461,7 @@ describe('removeNodeOrderListEntry', () => {
433
461
 
434
462
  // A keeps slot 0 — only its entry was removed, not the child. C was entered before B and stays
435
463
  // before it; a swap-with-last removal would have put B first and left the children untouched.
436
- expect(getChildren(container)).toEqual([childA, childC, childB]);
464
+ expectChildren(container, [childA, childC, childB]);
437
465
  });
438
466
  });
439
467
 
@@ -467,7 +495,7 @@ describe('setNodeOrderListEntryAbove', () => {
467
495
  setNodeOrderListEntryAbove(list, childA, childC);
468
496
  applyNodeOrderList(container, list);
469
497
 
470
- expect(getChildren(container)).toEqual([childB, childC, childA]);
498
+ expectChildren(container, [childB, childC, childA]);
471
499
  });
472
500
 
473
501
  it('places between neighbours whose keys leave no gap', () => {
@@ -477,11 +505,20 @@ describe('setNodeOrderListEntryAbove', () => {
477
505
  addNodeOrderListEntry(list, childC, 6);
478
506
 
479
507
  setNodeOrderListEntryAbove(list, childA, childB);
508
+
509
+ expect(list.entryCount).toBe(3);
510
+ expect(list.nodes).toHaveLength(3);
511
+ expect(list.sortKeys).toHaveLength(3);
512
+ expect(list.nodes[0]).toBe(childB);
513
+ expect(list.nodes[1]).toBe(childA);
514
+ expect(list.nodes[2]).toBe(childC);
515
+ expect(list.sortKeys.slice(0, 3)).toEqual([5, 5, 6]);
516
+
480
517
  applyNodeOrderList(container, list);
481
518
 
482
519
  // No midpoint exists between 5 and 6, so bisection could not express this; the equal-key plus
483
520
  // entry-position rule can.
484
- expect(getChildren(container)).toEqual([childB, childA, childC]);
521
+ expectChildren(container, [childB, childA, childC]);
485
522
  });
486
523
 
487
524
  it('moves a node that is already entered rather than duplicating it', () => {
@@ -503,6 +540,16 @@ describe('setNodeOrderListEntryAbove', () => {
503
540
  expect(list.entryCount).toBe(0);
504
541
  });
505
542
 
543
+ it('keeps an existing node entry when the target has no entry', () => {
544
+ const list = createNodeOrderList();
545
+ addNodeOrderListEntry(list, childA, 4);
546
+
547
+ setNodeOrderListEntryAbove(list, childA, childC);
548
+
549
+ expect(list.entryCount).toBe(1);
550
+ expect(getNodeOrderListEntrySortKey(list, childA)).toBe(4);
551
+ });
552
+
506
553
  it('does nothing when the node is its own target', () => {
507
554
  const list = createNodeOrderList();
508
555
  addNodeOrderListEntry(list, childA, 4);
@@ -523,7 +570,7 @@ describe('setNodeOrderListEntryBelow', () => {
523
570
  setNodeOrderListEntryBelow(list, childA, childC);
524
571
  applyNodeOrderList(container, list);
525
572
 
526
- expect(getChildren(container)).toEqual([childB, childA, childC]);
573
+ expectChildren(container, [childB, childA, childC]);
527
574
  });
528
575
 
529
576
  it('places between neighbours whose keys leave no gap', () => {
@@ -535,7 +582,7 @@ describe('setNodeOrderListEntryBelow', () => {
535
582
  setNodeOrderListEntryBelow(list, childA, childC);
536
583
  applyNodeOrderList(container, list);
537
584
 
538
- expect(getChildren(container)).toEqual([childB, childA, childC]);
585
+ expectChildren(container, [childB, childA, childC]);
539
586
  });
540
587
 
541
588
  it('does nothing when the target has no entry', () => {
@@ -545,6 +592,16 @@ describe('setNodeOrderListEntryBelow', () => {
545
592
 
546
593
  expect(list.entryCount).toBe(0);
547
594
  });
595
+
596
+ it('keeps an existing node entry when the target has no entry', () => {
597
+ const list = createNodeOrderList();
598
+ addNodeOrderListEntry(list, childA, 4);
599
+
600
+ setNodeOrderListEntryBelow(list, childA, childC);
601
+
602
+ expect(list.entryCount).toBe(1);
603
+ expect(getNodeOrderListEntrySortKey(list, childA)).toBe(4);
604
+ });
548
605
  });
549
606
 
550
607
  describe('setNodeOrderListFromNodeChildren', () => {
@@ -568,7 +625,7 @@ describe('setNodeOrderListFromNodeChildren', () => {
568
625
 
569
626
  applyNodeOrderList(container, list);
570
627
 
571
- expect(getChildren(container)).toEqual([childA, childB, childC]);
628
+ expectChildren(container, [childA, childB, childC]);
572
629
  expect(getNodeChildrenRevision(container)).toBe(revision);
573
630
  });
574
631
 
@@ -603,7 +660,7 @@ describe('swapNodeOrderListEntries', () => {
603
660
  swapNodeOrderListEntries(list, childA, childC);
604
661
  applyNodeOrderList(container, list);
605
662
 
606
- expect(getChildren(container)).toEqual([childC, childB, childA]);
663
+ expectChildren(container, [childC, childB, childA]);
607
664
  });
608
665
 
609
666
  it('does nothing unless both nodes are entered', () => {
@@ -304,6 +304,29 @@ describe('setNodeLocalMatrix', () => {
304
304
  expect(m.ty).toBeCloseTo(20);
305
305
  });
306
306
 
307
+ // Pins the losslessness this function's contract promises for a reflected (negative determinant)
308
+ // matrix. Positive-determinant cases round-tripped even while reflected ones silently lost the
309
+ // reflection, so a fixture that is not reflected cannot speak to this.
310
+ it('round-trips a reflected matrix, keeping the mirror', () => {
311
+ setNodeLocalMatrix(node, createMatrix(1, 0, 0, -1, 0, 0));
312
+ const m = getNodeLocalMatrix(node);
313
+ expect(m.a).toBeCloseTo(1);
314
+ expect(m.b).toBeCloseTo(0);
315
+ expect(m.c).toBeCloseTo(0);
316
+ expect(m.d).toBeCloseTo(-1);
317
+ });
318
+
319
+ it('round-trips a rotated reflection through all four matrix cells', () => {
320
+ setNodeLocalMatrix(node, createMatrix(0.5, 0.8, 0.8, -0.5, 3, 4));
321
+ const m = getNodeLocalMatrix(node);
322
+ expect(m.a).toBeCloseTo(0.5);
323
+ expect(m.b).toBeCloseTo(0.8);
324
+ expect(m.c).toBeCloseTo(0.8);
325
+ expect(m.d).toBeCloseTo(-0.5);
326
+ expect(m.tx).toBeCloseTo(3);
327
+ expect(m.ty).toBeCloseTo(4);
328
+ });
329
+
307
330
  it('resets pivot and absorbs its offset into translation', () => {
308
331
  node.pivotX = 5;
309
332
  node.pivotY = 5;
@@ -1,6 +1,7 @@
1
1
  import { createNode, getNodeRuntime } from '@flighthq/node/contract';
2
2
  import type { Node, NodeRuntime } from '@flighthq/types/contract';
3
3
 
4
+ import { addNodeChild } from './hierarchy';
4
5
  import {
5
6
  computeNodeWorldTransformRevision,
6
7
  getNodeAppearanceRevision,
@@ -41,6 +42,7 @@ describe('computeNodeWorldTransformRevision', () => {
41
42
  computeNodeWorldTransformRevision(runtime);
42
43
  expect(runtime.worldTransformUsingLocalTransformId).toBe(3);
43
44
  expect(runtime.worldTransformUsingParentTransformId).toBe(0);
45
+ expect(runtime.worldTransformId).not.toBe(0);
44
46
  });
45
47
 
46
48
  it('incorporates parent worldTransformId when provided', () => {
@@ -51,6 +53,17 @@ describe('computeNodeWorldTransformRevision', () => {
51
53
  computeNodeWorldTransformRevision(runtime, parentRuntime);
52
54
  expect(runtime.worldTransformUsingParentTransformId).toBe(7);
53
55
  });
56
+
57
+ it('assigns the next monotonic world revision on every recompute', () => {
58
+ const runtime = getEntityRuntime(node);
59
+ computeNodeWorldTransformRevision(runtime);
60
+ const first = runtime.worldTransformId;
61
+
62
+ computeNodeWorldTransformRevision(runtime);
63
+
64
+ const next = (first + 1) >>> 0;
65
+ expect(runtime.worldTransformId).toBe(next === 0 ? 1 : next);
66
+ });
54
67
  });
55
68
 
56
69
  describe('getNodeAppearanceRevision', () => {
@@ -246,6 +259,19 @@ describe('invalidateNodeWorldBounds', () => {
246
259
  expect(runtime.worldBoundsUsingWorldTransformId).toBe(-1);
247
260
  expect(runtime.worldBoundsUsingLocalBoundsId).toBe(-1);
248
261
  });
262
+
263
+ it('invalidates every ancestor whose aggregate includes the target', () => {
264
+ const parent = createTestNode();
265
+ addNodeChild(parent, node);
266
+ const parentRuntime = getEntityRuntime(parent);
267
+ parentRuntime.worldBoundsUsingWorldTransformId = 1;
268
+ parentRuntime.worldBoundsUsingLocalBoundsId = 1;
269
+
270
+ invalidateNodeWorldBounds(node);
271
+
272
+ expect(parentRuntime.worldBoundsUsingWorldTransformId).toBe(-1);
273
+ expect(parentRuntime.worldBoundsUsingLocalBoundsId).toBe(-1);
274
+ });
249
275
  });
250
276
 
251
277
  type TestNode = Node;
@@ -18,10 +18,12 @@ import {
18
18
 
19
19
  const TestNodeKind = 'TestNode';
20
20
 
21
- function makeNodeWithBounds(width: number, height: number) {
21
+ function makeNodeWithBounds(width: number, height: number, x = 0, y = 0) {
22
22
  const node = createNode(TestNodeKind);
23
23
  const runtime = getNodeRuntime(node) as unknown as HasBoundsRectangleRuntime;
24
24
  runtime.computeLocalBoundsRectangle = (out) => {
25
+ out.x = x;
26
+ out.y = y;
25
27
  out.width = width;
26
28
  out.height = height;
27
29
  };
@@ -137,6 +139,18 @@ describe('computeScene2DFitTransform', () => {
137
139
  expect(m.d).toBe(1);
138
140
  });
139
141
 
142
+ it('sets identity when only the content width is zero', () => {
143
+ const m = createMatrix(2, 3, 4, 5, 6, 7);
144
+ computeScene2DFitTransform(m, fit({ root: makeNodeWithBounds(0, 50), scaleMode: 'exactfit' }), 800, 600);
145
+ expect(m).toMatchObject({ a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 });
146
+ });
147
+
148
+ it('sets identity when only the content height is zero', () => {
149
+ const m = createMatrix(2, 3, 4, 5, 6, 7);
150
+ computeScene2DFitTransform(m, fit({ root: makeNodeWithBounds(100, 0), scaleMode: 'exactfit' }), 800, 600);
151
+ expect(m).toMatchObject({ a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 });
152
+ });
153
+
140
154
  it('noscale with topleft: identity scale at origin', () => {
141
155
  const m = createMatrix();
142
156
  computeScene2DFitTransform(
@@ -226,4 +240,45 @@ describe('computeScene2DFitTransform', () => {
226
240
  expect(m.b).toBe(0);
227
241
  expect(m.c).toBe(0);
228
242
  });
243
+
244
+ it('maps a nonzero content-bounds origin to the selected alignment', () => {
245
+ const m = createMatrix();
246
+ computeScene2DFitTransform(
247
+ m,
248
+ fit({ align: 'topleft', root: makeNodeWithBounds(100, 50, 10, -20), scaleMode: 'exactfit' }),
249
+ 200,
250
+ 100,
251
+ );
252
+
253
+ expect(m.a).toBe(2);
254
+ expect(m.d).toBe(2);
255
+ expect(m.tx).toBe(-20);
256
+ expect(m.ty).toBe(40);
257
+ });
258
+
259
+ it('uses scaled width before subtracting a nonzero origin on a centered horizontal axis', () => {
260
+ const m = createMatrix();
261
+ computeScene2DFitTransform(
262
+ m,
263
+ fit({ align: 'top', root: makeNodeWithBounds(100, 50, 10, -20), scaleMode: 'exactfit' }),
264
+ 200,
265
+ 100,
266
+ );
267
+
268
+ expect(m.tx).toBe(-20);
269
+ expect(m.ty).toBe(40);
270
+ });
271
+
272
+ it('uses scaled height before subtracting a nonzero origin on a centered vertical axis', () => {
273
+ const m = createMatrix();
274
+ computeScene2DFitTransform(
275
+ m,
276
+ fit({ align: 'left', root: makeNodeWithBounds(100, 50, 10, -20), scaleMode: 'exactfit' }),
277
+ 200,
278
+ 100,
279
+ );
280
+
281
+ expect(m.tx).toBe(-20);
282
+ expect(m.ty).toBe(40);
283
+ });
229
284
  });
@@ -196,6 +196,12 @@ describe('getNodeNextSibling', () => {
196
196
 
197
197
  expect(getNodeNextSibling(childA)).toBeNull();
198
198
  });
199
+
200
+ it('returns null for a stale parent whose nonempty child list omits the source', () => {
201
+ (getNodeRuntime(root) as NodeRuntime).children = [childB];
202
+
203
+ expect(getNodeNextSibling(childA)).toBeNull();
204
+ });
199
205
  });
200
206
 
201
207
  describe('getNodePreviousSibling', () => {