@truedat/dd 8.11.4 → 8.11.7

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 (43) hide show
  1. package/package.json +4 -3
  2. package/src/components/Graphs.js +30 -4
  3. package/src/components/LineageContextMenu.js +185 -58
  4. package/src/components/LineageDownloadButton.js +16 -6
  5. package/src/components/LineageEventsPanel.js +80 -27
  6. package/src/components/LineageEventsTable.js +7 -3
  7. package/src/components/LineageGraph.js +5144 -306
  8. package/src/components/LineageGraphToolbar.js +896 -0
  9. package/src/components/LineageInspectorPanel.js +748 -0
  10. package/src/components/LineageResourceNode.js +501 -0
  11. package/src/components/LineageRoutes.js +1 -2
  12. package/src/components/LineageSelectionPanel.js +28 -12
  13. package/src/components/StructureGraph.js +8 -1
  14. package/src/components/StructureGraphView.js +14 -0
  15. package/src/components/StructureImpact.js +2 -22
  16. package/src/components/StructureLineage.js +2 -19
  17. package/src/components/StructuresUploadEventsTable.js +0 -3
  18. package/src/components/__tests__/LineageContextMenu.spec.js +42 -0
  19. package/src/components/__tests__/LineageGraph.spec.js +896 -0
  20. package/src/components/__tests__/LineageGraphInteractions.spec.js +660 -0
  21. package/src/components/__tests__/LineageGraphLayout.spec.js +620 -0
  22. package/src/components/__tests__/LineageGraphToolbar.spec.js +366 -0
  23. package/src/components/__tests__/LineageHierarchy.spec.js +116 -0
  24. package/src/components/__tests__/LineageInspectorPanel.spec.js +357 -0
  25. package/src/components/__tests__/LineageResourceNode.spec.js +338 -0
  26. package/src/components/__tests__/LineageRoutes.spec.js +27 -9
  27. package/src/components/__tests__/__snapshots__/LineageDownloadButton.spec.js.snap +23 -3
  28. package/src/components/__tests__/__snapshots__/LineageForm.spec.js.snap +23 -3
  29. package/src/components/__tests__/lineageEventHelpers.spec.js +60 -0
  30. package/src/components/lineageEventHelpers.js +53 -17
  31. package/src/reducers/__tests__/lineageEvents.spec.js +164 -0
  32. package/src/reducers/__tests__/lineageQuery.spec.js +11 -0
  33. package/src/reducers/lineageEvents.js +68 -2
  34. package/src/reducers/lineageQuery.js +2 -6
  35. package/src/styles/LineageGraphColors.less +105 -0
  36. package/src/styles/LineageGraphCore.less +20 -0
  37. package/src/styles/LineageResourceNode.less +711 -0
  38. package/src/styles/lineageGraph.less +2973 -84
  39. package/src/utils/__tests__/lineageGraphIndex.spec.js +52 -0
  40. package/src/utils/__tests__/lineageLayout.spec.js +234 -0
  41. package/src/utils/__tests__/lineageTopology.spec.js +256 -0
  42. package/src/utils/lineageGraphIndex.js +296 -0
  43. package/src/utils/lineageLayout.js +1047 -0
@@ -0,0 +1,896 @@
1
+ import {
2
+ collapseLineageNodes,
3
+ expandLineageDescendantsUntilOversized,
4
+ focusLineageEdges,
5
+ focusedLineageGraphForNodeSet,
6
+ lineageDirectChildCounts,
7
+ lineageEdge,
8
+ lineageCatalogState,
9
+ lineageNodeContentWidth,
10
+ lineageNodeMatchesToolbarFilters,
11
+ lineageOversizedGroupIds,
12
+ lineagePositionedLayoutNodes,
13
+ lineageShouldLoadExpanded,
14
+ lineageSystemType,
15
+ lineageToolbarSearchResults,
16
+ redesignedLineageNodeHeight,
17
+ selectedNodeFromGraphNode,
18
+ } from "../LineageGraph";
19
+
20
+ describe("focusLineageEdges", () => {
21
+ const edges = [
22
+ { id: "focused", data: { animated: false } },
23
+ { id: "dimmed", data: { animated: false } },
24
+ {
25
+ id: "containment",
26
+ data: { animated: false, containment: true },
27
+ },
28
+ ];
29
+ const focusedEdgeIds = new Set(["focused", "containment"]);
30
+
31
+ it("animates only focused dependencies after selecting a node", () => {
32
+ const result = focusLineageEdges(edges, focusedEdgeIds, true, true);
33
+
34
+ expect(result.find((edge) => edge.id === "focused").data.animated).toBe(
35
+ true,
36
+ );
37
+ expect(result.find((edge) => edge.id === "dimmed").data.animated).toBe(
38
+ false,
39
+ );
40
+ expect(result.find((edge) => edge.id === "containment").data.animated).toBe(
41
+ false,
42
+ );
43
+ });
44
+
45
+ it("raises selected-node dependencies above selected cards and restores their layer", () => {
46
+ const baseEdges = edges.map((edge) => ({ ...edge, zIndex: 100 }));
47
+ const result = focusLineageEdges(
48
+ baseEdges,
49
+ focusedEdgeIds,
50
+ true,
51
+ true,
52
+ "node",
53
+ );
54
+
55
+ expect(result.find(({ id }) => id === "focused").zIndex).toBeGreaterThan(
56
+ 300,
57
+ );
58
+ expect(result.find(({ id }) => id === "dimmed").zIndex).toBe(100);
59
+ expect(result.find(({ id }) => id === "containment").zIndex).toBe(100);
60
+ expect(focusLineageEdges(baseEdges, focusedEdgeIds, false)).toBe(baseEdges);
61
+ expect(
62
+ focusLineageEdges(baseEdges, focusedEdgeIds, true).every(
63
+ ({ zIndex }) => zIndex === 100,
64
+ ),
65
+ ).toBe(true);
66
+ });
67
+
68
+ it("raises a selected dependency without raising other edges", () => {
69
+ const baseEdges = edges.map((edge) => ({ ...edge, zIndex: 100 }));
70
+ const result = focusLineageEdges(
71
+ baseEdges,
72
+ focusedEdgeIds,
73
+ true,
74
+ false,
75
+ null,
76
+ "focused",
77
+ );
78
+
79
+ expect(result.find(({ id }) => id === "focused").zIndex).toBeGreaterThan(
80
+ 300,
81
+ );
82
+ expect(result.find(({ id }) => id === "dimmed").zIndex).toBe(100);
83
+ expect(result.find(({ id }) => id === "containment").zIndex).toBe(100);
84
+ expect(result.every(({ data }) => !data.animated)).toBe(true);
85
+ expect(focusLineageEdges(baseEdges, focusedEdgeIds, false)).toBe(baseEdges);
86
+ });
87
+
88
+ it("keeps animation disabled when focus comes from another state", () => {
89
+ const result = focusLineageEdges(edges, focusedEdgeIds, true, false);
90
+
91
+ expect(result.every((edge) => !edge.data.animated)).toBe(true);
92
+ });
93
+ });
94
+
95
+ const groupNode = (id, x, y, w, h, options = {}) => ({
96
+ id,
97
+ type: "lineageGroup",
98
+ width: w,
99
+ height: h,
100
+ position: { x, y },
101
+ data: {
102
+ absoluteGeometry: { x, y, w, h },
103
+ collapsed: options.collapsed ?? false,
104
+ variant: "absolute",
105
+ },
106
+ });
107
+
108
+ // Los nodos posicionados reales usan position relativa al padre y
109
+ // absoluteGeometry absoluta (ver lineagePositionedResourceNode), así que el
110
+ // helper deriva la geometría absoluta desde la posición relativa del padre.
111
+ const resourceNode = (id, parent, x, y, w, h) => {
112
+ const parentGeometry = parent?.data?.absoluteGeometry || { x: 0, y: 0 };
113
+
114
+ return {
115
+ id,
116
+ type: "lineageResource",
117
+ parentId: parent?.id,
118
+ width: w,
119
+ height: h,
120
+ position: { x, y },
121
+ data: {
122
+ absoluteGeometry: {
123
+ x: parentGeometry.x + x,
124
+ y: parentGeometry.y + y,
125
+ w,
126
+ h,
127
+ },
128
+ },
129
+ };
130
+ };
131
+
132
+ describe("redesignedLineageNodeHeight", () => {
133
+ it("uses actual rows up to the six-row collapsed limit", () => {
134
+ expect(redesignedLineageNodeHeight(0)).toBe(42);
135
+ expect(redesignedLineageNodeHeight(4)).toBe(186);
136
+ expect(redesignedLineageNodeHeight(6)).toBe(258);
137
+ });
138
+
139
+ it("reserves disclosure space and caps expanded geometry at ten rows", () => {
140
+ expect(redesignedLineageNodeHeight(7)).toBe(290);
141
+ expect(redesignedLineageNodeHeight(7, true)).toBe(326);
142
+ expect(redesignedLineageNodeHeight(12, true)).toBe(434);
143
+ });
144
+ });
145
+
146
+ describe("selectedNodeFromGraphNode", () => {
147
+ it("uses the full rendered parent chain and keeps the selected node once", () => {
148
+ const root = {
149
+ id: "root",
150
+ data: { label: "Root", lineageNode: { id: "root" } },
151
+ };
152
+ const domain = {
153
+ id: "domain",
154
+ parentId: "root",
155
+ data: { label: "Domain", lineageNode: { id: "domain" } },
156
+ };
157
+ const node = {
158
+ id: "node",
159
+ parentId: "domain",
160
+ data: {
161
+ fieldsKnown: true,
162
+ iconName: "table",
163
+ label: "Orders",
164
+ lineageNode: {
165
+ id: "node",
166
+ lineageKind: "resource",
167
+ name: "Orders",
168
+ path: ["Orders"],
169
+ },
170
+ totalFields: 0,
171
+ },
172
+ };
173
+ const selected = selectedNodeFromGraphNode(
174
+ node,
175
+ new Map([
176
+ [root.id, root],
177
+ [domain.id, domain],
178
+ [node.id, node],
179
+ ]),
180
+ );
181
+
182
+ expect(selected.path).toEqual(["Root", "Domain", "Orders"]);
183
+ expect(selected.iconName).toBe("table");
184
+ expect(selected.fieldsKnown).toBe(true);
185
+ });
186
+ });
187
+
188
+ describe("redesigned lineage layout", () => {
189
+ it("repacks positioned siblings and grows their ancestor", () => {
190
+ const [group, firstNode, secondNode] = collapseLineageNodes(
191
+ [
192
+ {
193
+ id: "group",
194
+ type: "lineageGroup",
195
+ width: 360,
196
+ height: 220,
197
+ position: { x: 0, y: 0 },
198
+ data: {
199
+ absoluteGeometry: { x: 0, y: 0, w: 360, h: 220 },
200
+ variant: "absolute",
201
+ },
202
+ },
203
+ {
204
+ id: "a",
205
+ parentId: "group",
206
+ type: "lineageResource",
207
+ width: 288,
208
+ height: 266,
209
+ position: { x: 30, y: 52 },
210
+ data: {
211
+ absoluteGeometry: { x: 30, y: 52, w: 288, h: 266 },
212
+ },
213
+ },
214
+ {
215
+ id: "b",
216
+ parentId: "group",
217
+ type: "lineageResource",
218
+ width: 288,
219
+ height: 394,
220
+ position: { x: 30, y: 100 },
221
+ data: {
222
+ absoluteGeometry: { x: 30, y: 100, w: 288, h: 394 },
223
+ },
224
+ },
225
+ ],
226
+ new Set(),
227
+ jest.fn(),
228
+ );
229
+
230
+ expect(secondNode.position.y).toBe(
231
+ firstNode.position.y + firstNode.height + 6,
232
+ );
233
+ expect(group.height).toBeGreaterThanOrEqual(
234
+ secondNode.position.y + secondNode.height,
235
+ );
236
+ });
237
+
238
+ it("expands only the selected positioned duplicate instance", () => {
239
+ const resources = [
240
+ {
241
+ fields: Array.from({ length: 12 }, (_, index) => ({
242
+ name: `field-${index}`,
243
+ })),
244
+ id: "duplicate",
245
+ name: "Duplicate A",
246
+ x: 0,
247
+ y: 0,
248
+ w: 120,
249
+ h: 60,
250
+ },
251
+ {
252
+ fields: Array.from({ length: 12 }, (_, index) => ({
253
+ name: `field-${index}`,
254
+ })),
255
+ id: "duplicate",
256
+ name: "Duplicate B",
257
+ x: 0,
258
+ y: 100,
259
+ w: 120,
260
+ h: 60,
261
+ },
262
+ ];
263
+ const collapsed = lineagePositionedLayoutNodes({
264
+ nodeDesign: "redesigned",
265
+ resources,
266
+ });
267
+ const expandedId = collapsed[0].id;
268
+ const expanded = lineagePositionedLayoutNodes({
269
+ expandedFieldNodeIds: new Set([expandedId]),
270
+ nodeDesign: "redesigned",
271
+ resources,
272
+ });
273
+
274
+ expect(expanded.find(({ id }) => id === expandedId).height).toBe(434);
275
+ expect(expanded.find(({ id }) => id !== expandedId).height).toBe(290);
276
+ });
277
+ });
278
+
279
+ describe("focusedLineageGraphForNodeSet", () => {
280
+ const edges = [
281
+ { id: "e1", source: "a", target: "b" },
282
+ { id: "e2", source: "b", target: "c" },
283
+ { id: "e3", source: "a", target: "c" },
284
+ ];
285
+
286
+ it("returns empty sets when nodeIds is null", () => {
287
+ expect(focusedLineageGraphForNodeSet(null, edges)).toEqual({
288
+ focusedNodeIds: new Set(),
289
+ focusedEdgeIds: new Set(),
290
+ });
291
+ });
292
+
293
+ it("returns empty sets when nodeIds is empty", () => {
294
+ expect(focusedLineageGraphForNodeSet(new Set(), edges)).toEqual({
295
+ focusedNodeIds: new Set(),
296
+ focusedEdgeIds: new Set(),
297
+ });
298
+ });
299
+
300
+ it("only includes edges whose both endpoints are in nodeIds", () => {
301
+ const result = focusedLineageGraphForNodeSet(new Set(["a", "b"]), edges);
302
+ expect(result.focusedNodeIds).toEqual(new Set(["a", "b"]));
303
+ expect(result.focusedEdgeIds).toEqual(new Set(["e1"]));
304
+ });
305
+
306
+ it("does not expand to neighbors outside nodeIds", () => {
307
+ const result = focusedLineageGraphForNodeSet(new Set(["a"]), edges);
308
+ expect(result.focusedEdgeIds).toEqual(new Set());
309
+ });
310
+ });
311
+
312
+ describe("lineageNodeContentWidth", () => {
313
+ it("returns the minimum width when no content is provided", () => {
314
+ expect(lineageNodeContentWidth()).toBe(240);
315
+ expect(lineageNodeContentWidth([])).toBe(240);
316
+ });
317
+
318
+ it("returns the minimum width for short content", () => {
319
+ expect(lineageNodeContentWidth([{ id: "t1", name: "orders" }])).toBe(240);
320
+ });
321
+
322
+ it("grows with the longest label or field line", () => {
323
+ expect(
324
+ lineageNodeContentWidth([
325
+ {
326
+ id: "t1",
327
+ name: "orders",
328
+ fields: [{ name: "customer_order_id", type: "BIGINT" }],
329
+ },
330
+ ]),
331
+ ).toBeGreaterThan(240);
332
+ });
333
+
334
+ it("clamps the width to the maximum", () => {
335
+ expect(
336
+ lineageNodeContentWidth([
337
+ {
338
+ id: "t1",
339
+ name: "a_very_long_table_name_that_covers_many_characters",
340
+ fields: Array.from({ length: 3 }, (_, index) => ({
341
+ name: `very_long_field_name_${index}_that_keeps_growing`,
342
+ type: "VARCHAR(512)",
343
+ })),
344
+ },
345
+ ]),
346
+ ).toBe(360);
347
+ });
348
+ });
349
+
350
+ describe("lineageSystemType", () => {
351
+ it("treats placeholder and empty system groups as missing without a visible label", () => {
352
+ [
353
+ {},
354
+ { system_group: "" },
355
+ { system_group: "Without System Group" },
356
+ { system_group: "without_system_group" },
357
+ { system_group: "sin grupo de sistema" },
358
+ { metadata: { system_group: "unknown" } },
359
+ { dynamic_content: { system_group: { value: "n/a" } } },
360
+ ].forEach((node) => {
361
+ expect(lineageSystemType(node)).toMatchObject({
362
+ missing: true,
363
+ label: "",
364
+ });
365
+ });
366
+ });
367
+
368
+ it("keeps SYSTEM as a real system type", () => {
369
+ expect(lineageSystemType({ system_group: "SYSTEM" })).toMatchObject({
370
+ missing: false,
371
+ label: "SYSTEM",
372
+ });
373
+ });
374
+ });
375
+
376
+ describe("lineageNodeMatchesToolbarFilters", () => {
377
+ const node = {
378
+ data: {
379
+ lineageNode: { id: "n1" },
380
+ label: "Customer Address",
381
+ systemType: "sys-oracle",
382
+ },
383
+ };
384
+ const groupWithoutLineageNode = { data: { label: "Orphan group" } };
385
+
386
+ it("matches when search term is a case-insensitive substring of the label", () => {
387
+ expect(lineageNodeMatchesToolbarFilters(node, "address", "")).toBe(true);
388
+ expect(lineageNodeMatchesToolbarFilters(node, "ADDRESS", "")).toBe(true);
389
+ expect(lineageNodeMatchesToolbarFilters(node, "invoice", "")).toBe(false);
390
+ });
391
+
392
+ it("matches when systemTypeFilter equals the node systemType key", () => {
393
+ expect(lineageNodeMatchesToolbarFilters(node, "", "sys-oracle")).toBe(true);
394
+ expect(lineageNodeMatchesToolbarFilters(node, "", "sys-teradata")).toBe(
395
+ false,
396
+ );
397
+ });
398
+
399
+ it("requires both search and system filter to match when both are set", () => {
400
+ expect(
401
+ lineageNodeMatchesToolbarFilters(node, "address", "sys-oracle"),
402
+ ).toBe(true);
403
+ expect(
404
+ lineageNodeMatchesToolbarFilters(node, "address", "sys-teradata"),
405
+ ).toBe(false);
406
+ });
407
+
408
+ it("returns false for nodes without data.lineageNode", () => {
409
+ expect(
410
+ lineageNodeMatchesToolbarFilters(groupWithoutLineageNode, "orphan", ""),
411
+ ).toBe(false);
412
+ });
413
+ });
414
+
415
+ describe("lineageToolbarSearchResults", () => {
416
+ const searchNode = (id, label, options = {}) => ({
417
+ id,
418
+ hidden: options.hidden,
419
+ data: {
420
+ label,
421
+ lineageNode: {
422
+ external_id: options.externalId,
423
+ id,
424
+ lineageKind: options.lineageKind || "resource",
425
+ name: label,
426
+ },
427
+ path: options.path,
428
+ systemType: options.systemType || "oracle",
429
+ systemTypeLabel: options.systemTypeLabel || "Oracle",
430
+ },
431
+ });
432
+
433
+ it("finds identifiers and hidden nodes while preserving duplicate labels", () => {
434
+ const { results, total } = lineageToolbarSearchResults(
435
+ [
436
+ searchNode("orders-a", "Orders", {
437
+ externalId: "warehouse.orders",
438
+ hidden: true,
439
+ path: ["Warehouse", "Sales"],
440
+ }),
441
+ searchNode("orders-b", "Orders", {
442
+ externalId: "lake.orders",
443
+ path: ["Lake", "Curated"],
444
+ }),
445
+ ],
446
+ "orders",
447
+ );
448
+
449
+ expect(total).toBe(2);
450
+ expect(results).toHaveLength(2);
451
+ expect(results.map(({ label }) => label)).toEqual(["Orders", "Orders"]);
452
+ expect(results.map(({ contextLabel }) => contextLabel)).toEqual([
453
+ "Warehouse / Sales",
454
+ "Lake / Curated",
455
+ ]);
456
+ });
457
+
458
+ it("applies the system filter and caps the visible list at 50", () => {
459
+ const nodes = Array.from({ length: 55 }, (_, index) =>
460
+ searchNode(`node-${index}`, `Node ${index}`, {
461
+ systemType: index === 54 ? "postgres" : "oracle",
462
+ }),
463
+ );
464
+ const allResults = lineageToolbarSearchResults(nodes, "node");
465
+ const postgresResults = lineageToolbarSearchResults(
466
+ nodes,
467
+ "node",
468
+ "postgres",
469
+ );
470
+
471
+ expect(allResults.total).toBe(55);
472
+ expect(allResults.results).toHaveLength(50);
473
+ expect(postgresResults.total).toBe(1);
474
+ expect(postgresResults.results[0].graphNodeId).toBe("node-54");
475
+ });
476
+ });
477
+
478
+ describe("lineageEdge inspector context", () => {
479
+ const nodeById = new Map([
480
+ ["source", { data: { label: "Orders" } }],
481
+ ["target", { data: { label: "Customers" } }],
482
+ ]);
483
+
484
+ it("provides endpoints, relation type and the complete metadata payload", () => {
485
+ const onEdgeSelect = jest.fn();
486
+ const metadata = {
487
+ columns: ["customer_id", "id"],
488
+ nested: { active: true },
489
+ };
490
+ const mappedEdge = lineageEdge(
491
+ onEdgeSelect,
492
+ (value) => value,
493
+ nodeById,
494
+ )(
495
+ {
496
+ id: "edge-1",
497
+ metadata,
498
+ relation_type: "depends_on",
499
+ source: "source",
500
+ target: "target",
501
+ },
502
+ 0,
503
+ );
504
+
505
+ mappedEdge.data.onClick();
506
+
507
+ expect(onEdgeSelect).toHaveBeenCalledWith({
508
+ type: "edge",
509
+ edge: {
510
+ id: "edge-1",
511
+ label: undefined,
512
+ metadata,
513
+ relationType: "depends_on",
514
+ source: "source",
515
+ sourceName: "Orders",
516
+ target: "target",
517
+ targetName: "Customers",
518
+ },
519
+ });
520
+ });
521
+
522
+ it("keeps an edge without metadata non-interactive", () => {
523
+ const mappedEdge = lineageEdge(
524
+ jest.fn(),
525
+ (value) => value,
526
+ nodeById,
527
+ )({ id: "edge-2", source: "source", target: "target" }, 0);
528
+
529
+ expect(mappedEdge.data.onClick).toBeUndefined();
530
+ });
531
+
532
+ it("points the arrow at the target for impact and at the source for traceability", () => {
533
+ const baseEdge = { id: "edge-3", source: "source", target: "target" };
534
+ const impactEdge = lineageEdge(
535
+ jest.fn(),
536
+ (value) => value,
537
+ nodeById,
538
+ )(baseEdge, 0);
539
+ const traceabilityEdge = lineageEdge(
540
+ jest.fn(),
541
+ (value) => value,
542
+ nodeById,
543
+ "lineage",
544
+ )(baseEdge, 1);
545
+
546
+ expect(impactEdge.data.markerPosition).toBe("target");
547
+ expect(traceabilityEdge.data.markerPosition).toBe("target"); // Flechas estáticas siempre en target; flowDirection controla animación
548
+ });
549
+ });
550
+
551
+ describe("lineageShouldLoadExpanded", () => {
552
+ const nestedGroup = (id, parent, x, y, w, h) => ({
553
+ ...groupNode(id, x, y, w, h),
554
+ parentId: parent?.id,
555
+ });
556
+
557
+ it("opens expanded a small nested graph of 4 boxes with 3 and 8 leaves", () => {
558
+ const outer1 = groupNode("outer1", 0, 0, 900, 500);
559
+ const inner1 = nestedGroup("inner1", outer1, 40, 40, 800, 400);
560
+ const outer2 = groupNode("outer2", 1100, 0, 900, 900);
561
+ const inner2 = nestedGroup("inner2", outer2, 40, 40, 800, 800);
562
+ const nodes = [
563
+ outer1,
564
+ inner1,
565
+ ...Array.from({ length: 3 }, (_, index) =>
566
+ resourceNode(
567
+ `inner1-leaf-${index}`,
568
+ inner1,
569
+ 20,
570
+ 60 + index * 120,
571
+ 288,
572
+ 120,
573
+ ),
574
+ ),
575
+ outer2,
576
+ inner2,
577
+ ...Array.from({ length: 8 }, (_, index) =>
578
+ resourceNode(
579
+ `inner2-leaf-${index}`,
580
+ inner2,
581
+ 20,
582
+ 60 + index * 90,
583
+ 288,
584
+ 90,
585
+ ),
586
+ ),
587
+ ];
588
+
589
+ expect(lineageShouldLoadExpanded(nodes)).toBe(true);
590
+ });
591
+
592
+ it("keeps collapsed when there are more than 5 collapsible boxes", () => {
593
+ const nodes = Array.from({ length: 6 }, (_, index) => {
594
+ const group = groupNode(`g-${index}`, index * 320, 0, 240, 120);
595
+
596
+ return [group, resourceNode(`r-${index}`, group, 20, 20, 288, 120)];
597
+ }).flat();
598
+
599
+ expect(lineageShouldLoadExpanded(nodes)).toBe(false);
600
+ });
601
+
602
+ it("keeps collapsed when a box has more than 20 leaf nodes", () => {
603
+ const groups = Array.from({ length: 5 }, (_, index) =>
604
+ groupNode(`g-${index}`, index * 320, 0, 240, 600),
605
+ );
606
+ const nodes = [
607
+ ...groups,
608
+ ...Array.from({ length: 21 }, (_, index) =>
609
+ resourceNode(
610
+ `g0-leaf-${index}`,
611
+ groups[0],
612
+ 20,
613
+ 60 + index * 20,
614
+ 288,
615
+ 20,
616
+ ),
617
+ ),
618
+ ...groups
619
+ .slice(1)
620
+ .flatMap((group, index) =>
621
+ Array.from({ length: 5 }, (_, leafIndex) =>
622
+ resourceNode(
623
+ `g${index + 1}-leaf-${leafIndex}`,
624
+ group,
625
+ 20,
626
+ 60 + leafIndex * 20,
627
+ 288,
628
+ 20,
629
+ ),
630
+ ),
631
+ ),
632
+ ];
633
+
634
+ expect(lineageShouldLoadExpanded(nodes)).toBe(false);
635
+ });
636
+
637
+ it("opens expanded when 5 boxes each have at most 20 leaf nodes", () => {
638
+ const groups = Array.from({ length: 5 }, (_, index) =>
639
+ groupNode(`g-${index}`, index * 320, 0, 240, 500),
640
+ );
641
+ const nodes = [
642
+ ...groups,
643
+ ...groups.flatMap((group, index) =>
644
+ Array.from({ length: 20 }, (_, leafIndex) =>
645
+ resourceNode(
646
+ `g${index}-leaf-${leafIndex}`,
647
+ group,
648
+ 20,
649
+ 60 + leafIndex * 20,
650
+ 288,
651
+ 20,
652
+ ),
653
+ ),
654
+ ),
655
+ ];
656
+
657
+ expect(lineageShouldLoadExpanded(nodes)).toBe(true);
658
+ });
659
+
660
+ it("keeps collapsed when an outer box accumulates more than 20 leaves through nesting", () => {
661
+ const outer = groupNode("outer", 0, 0, 900, 900);
662
+ const innerGroups = Array.from({ length: 3 }, (_, index) =>
663
+ nestedGroup(`inner-${index}`, outer, 40, 40 + index * 280, 800, 260),
664
+ );
665
+ const nodes = [
666
+ outer,
667
+ ...innerGroups,
668
+ ...innerGroups.flatMap((inner, index) =>
669
+ Array.from({ length: 8 }, (_, leafIndex) =>
670
+ resourceNode(
671
+ `inner${index}-leaf-${leafIndex}`,
672
+ inner,
673
+ 20,
674
+ 60 + leafIndex * 20,
675
+ 288,
676
+ 20,
677
+ ),
678
+ ),
679
+ ),
680
+ ];
681
+
682
+ expect(lineageShouldLoadExpanded(nodes)).toBe(false);
683
+ });
684
+
685
+ it("returns true for an empty node set", () => {
686
+ expect(lineageShouldLoadExpanded([])).toBe(true);
687
+ });
688
+ });
689
+
690
+ describe("lineageDirectChildCounts", () => {
691
+ it("counts only direct children, not descendants", () => {
692
+ const outer = groupNode("outer", 0, 0, 900, 900);
693
+ const inner = {
694
+ ...groupNode("inner", 40, 40, 800, 800),
695
+ parentId: "outer",
696
+ };
697
+ const nodes = [outer, inner, resourceNode("leaf", inner, 20, 60, 288, 20)];
698
+
699
+ const counts = lineageDirectChildCounts(nodes);
700
+ expect(counts.get("outer")).toBe(1);
701
+ expect(counts.get("inner")).toBe(1);
702
+ });
703
+
704
+ it("ignores nodes without parent", () => {
705
+ const counts = lineageDirectChildCounts([groupNode("g", 0, 0, 240, 120)]);
706
+ expect(counts.size).toBe(0);
707
+ });
708
+ });
709
+
710
+ describe("lineageOversizedGroupIds", () => {
711
+ it("flags groups with more than 20 direct children", () => {
712
+ const group = groupNode("g", 0, 0, 240, 600);
713
+ const nodes = [
714
+ group,
715
+ ...Array.from({ length: 21 }, (_, index) =>
716
+ resourceNode(`leaf-${index}`, group, 20, 60 + index * 20, 288, 20),
717
+ ),
718
+ ];
719
+
720
+ expect(lineageOversizedGroupIds(nodes).has("g")).toBe(true);
721
+ });
722
+
723
+ it("does not flag groups with at most 20 direct children", () => {
724
+ const group = groupNode("g", 0, 0, 240, 600);
725
+ const nodes = [
726
+ group,
727
+ ...Array.from({ length: 20 }, (_, index) =>
728
+ resourceNode(`leaf-${index}`, group, 20, 60 + index * 20, 288, 20),
729
+ ),
730
+ ];
731
+
732
+ expect(lineageOversizedGroupIds(nodes).has("g")).toBe(false);
733
+ });
734
+
735
+ it("does not count nested grandchildren as direct children", () => {
736
+ const outer = groupNode("outer", 0, 0, 900, 900);
737
+ const inner = {
738
+ ...groupNode("inner", 40, 40, 800, 800),
739
+ parentId: "outer",
740
+ };
741
+ const nodes = [
742
+ outer,
743
+ inner,
744
+ ...Array.from({ length: 25 }, (_, index) =>
745
+ resourceNode(`leaf-${index}`, inner, 20, 60 + index * 20, 288, 20),
746
+ ),
747
+ ];
748
+
749
+ const ids = lineageOversizedGroupIds(nodes);
750
+ expect(ids.has("outer")).toBe(false);
751
+ expect(ids.has("inner")).toBe(true);
752
+ });
753
+ });
754
+
755
+ describe("expandLineageDescendantsUntilOversized", () => {
756
+ it("expands the subtree but keeps nested oversized groups collapsed", () => {
757
+ const outer = groupNode("outer", 0, 0, 900, 1500);
758
+ const innerOversized = {
759
+ ...groupNode("inner-oversized", 40, 40, 800, 1400),
760
+ parentId: "outer",
761
+ };
762
+ const innerPlain = {
763
+ ...groupNode("inner-plain", 40, 900, 800, 500),
764
+ parentId: "outer",
765
+ };
766
+ const nodes = [
767
+ outer,
768
+ innerOversized,
769
+ innerPlain,
770
+ ...Array.from({ length: 25 }, (_, index) =>
771
+ resourceNode(
772
+ `oversized-leaf-${index}`,
773
+ innerOversized,
774
+ 20,
775
+ 40 + index * 40,
776
+ 288,
777
+ 30,
778
+ ),
779
+ ),
780
+ resourceNode("plain-leaf", innerPlain, 20, 40, 288, 30),
781
+ ];
782
+ const collapsedIds = new Set(["outer", "inner-oversized", "inner-plain"]);
783
+ const oversizedIds = new Set(["inner-oversized"]);
784
+
785
+ const next = expandLineageDescendantsUntilOversized(
786
+ collapsedIds,
787
+ "outer",
788
+ nodes,
789
+ oversizedIds,
790
+ );
791
+
792
+ expect(next.has("outer")).toBe(false);
793
+ expect(next.has("inner-oversized")).toBe(true);
794
+ expect(next.has("inner-plain")).toBe(false);
795
+ });
796
+
797
+ it("leaves non-descendant oversized groups untouched", () => {
798
+ const other = groupNode("other", 1000, 0, 900, 600);
799
+ const root = groupNode("root", 0, 0, 900, 600);
800
+ const nodes = [root, other];
801
+ const collapsedIds = new Set(["root", "other"]);
802
+ const oversizedIds = new Set(["other"]);
803
+
804
+ const next = expandLineageDescendantsUntilOversized(
805
+ collapsedIds,
806
+ "root",
807
+ nodes,
808
+ oversizedIds,
809
+ );
810
+
811
+ expect(next.has("root")).toBe(false);
812
+ expect(next.has("other")).toBe(true);
813
+ });
814
+ });
815
+
816
+ describe("collapseLineageNodes oversized flags", () => {
817
+ it("injects oversized data and pending warning handlers", () => {
818
+ const group = groupNode("g", 0, 0, 240, 600);
819
+ const nodes = [
820
+ group,
821
+ ...Array.from({ length: 21 }, (_, index) =>
822
+ resourceNode(`leaf-${index}`, group, 20, 60 + index * 20, 288, 20),
823
+ ),
824
+ ];
825
+ const oversizedIds = new Set(["g"]);
826
+ const pendingExpand = { groupId: "g", action: "toggle" };
827
+ const onConfirm = jest.fn();
828
+
829
+ const result = collapseLineageNodes(
830
+ nodes,
831
+ new Set(["g"]),
832
+ jest.fn(),
833
+ jest.fn(),
834
+ oversizedIds,
835
+ pendingExpand,
836
+ onConfirm,
837
+ );
838
+ const rendered = result.find((node) => node.id === "g");
839
+
840
+ expect(rendered.data.oversized).toBe(true);
841
+ expect(rendered.data.oversizedChildCount).toBe(21);
842
+ expect(rendered.data.oversizedWarningOpen).toBe(true);
843
+ rendered.data.onConfirmOversizedExpand();
844
+ expect(onConfirm).toHaveBeenCalledWith("g", "toggle");
845
+ });
846
+
847
+ it("does not flag non-oversized groups", () => {
848
+ const group = groupNode("g", 0, 0, 240, 600);
849
+ const nodes = [
850
+ group,
851
+ ...Array.from({ length: 3 }, (_, index) =>
852
+ resourceNode(`leaf-${index}`, group, 20, 60 + index * 20, 288, 20),
853
+ ),
854
+ ];
855
+
856
+ const result = collapseLineageNodes(
857
+ nodes,
858
+ new Set(["g"]),
859
+ jest.fn(),
860
+ jest.fn(),
861
+ );
862
+ const rendered = result.find((node) => node.id === "g");
863
+
864
+ expect(rendered.data.oversized).toBe(false);
865
+ expect(rendered.data.oversizedWarningOpen).toBe(false);
866
+ });
867
+ });
868
+
869
+ describe("lineageCatalogState", () => {
870
+ it("does not treat missing metadata as catalog absence", () => {
871
+ expect(lineageCatalogState({ id: "group" })).toBe("unknown");
872
+ expect(lineageCatalogState({ structure_id: null })).toBe("absent");
873
+ });
874
+ it("distinguishes deleted structures from absent structures", () => {
875
+ expect(
876
+ lineageCatalogState({ structure_id: null, deleted_at: "2026-09-11" }),
877
+ ).toBe("deleted");
878
+ expect(lineageCatalogState({ structure_id: 42, deleted: true })).toBe(
879
+ "deleted",
880
+ );
881
+ expect(lineageCatalogState({ structure_id: 42, deleted: false })).toBe(
882
+ "present",
883
+ );
884
+ });
885
+ it("preserves catalog membership from merged originals", () => {
886
+ expect(
887
+ lineageCatalogState({
888
+ structure_id: null,
889
+ originals: [{ structure_id: 42 }],
890
+ }),
891
+ ).toBe("present");
892
+ expect(
893
+ lineageCatalogState({ originals: [{ metadata: { status: "deleted" } }] }),
894
+ ).toBe("deleted");
895
+ });
896
+ });