@truedat/dd 8.11.6 → 8.11.9

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 +5166 -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__/LineageGraphLayout.spec.js +639 -0
  21. package/src/components/__tests__/LineageGraphToolbar.spec.js +366 -0
  22. package/src/components/__tests__/LineageHierarchy.spec.js +116 -0
  23. package/src/components/__tests__/LineageInspectorPanel.spec.js +364 -0
  24. package/src/components/__tests__/LineageResourceNode.spec.js +338 -0
  25. package/src/components/__tests__/LineageRoutes.spec.js +27 -9
  26. package/src/components/__tests__/__snapshots__/LineageDownloadButton.spec.js.snap +23 -3
  27. package/src/components/__tests__/__snapshots__/LineageForm.spec.js.snap +23 -3
  28. package/src/components/__tests__/lineageEventHelpers.spec.js +60 -0
  29. package/src/components/lineageEventHelpers.js +53 -17
  30. package/src/reducers/__tests__/lineageEvents.spec.js +164 -0
  31. package/src/reducers/__tests__/lineageQuery.spec.js +11 -0
  32. package/src/reducers/lineageEvents.js +68 -2
  33. package/src/reducers/lineageQuery.js +2 -6
  34. package/src/styles/LineageGraph.less +4094 -0
  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/utils/__tests__/lineageGraphIndex.spec.js +52 -0
  39. package/src/utils/__tests__/lineageLayout.spec.js +252 -0
  40. package/src/utils/__tests__/lineageTopology.spec.js +256 -0
  41. package/src/utils/lineageGraphIndex.js +296 -0
  42. package/src/utils/lineageLayout.js +1050 -0
  43. package/src/styles/lineageGraph.less +0 -1236
@@ -0,0 +1,52 @@
1
+ import {
2
+ createLineageGraphIndex,
3
+ focusLineageFromSeeds,
4
+ projectLineageNodeIds,
5
+ } from "../lineageGraphIndex";
6
+
7
+ describe("lineageGraphIndex", () => {
8
+ const nodes = ["a", "b", "c", "d"].map((id) => ({ id }));
9
+ const edges = [
10
+ { id: "ab", source: "a", target: "b" },
11
+ { id: "bc", source: "b", target: "c" },
12
+ { id: "cd", source: "c", target: "d" },
13
+ ];
14
+
15
+ it("keeps the complete upstream and downstream path", () => {
16
+ const focus = focusLineageFromSeeds(
17
+ new Set(["b"]),
18
+ createLineageGraphIndex(nodes, edges),
19
+ );
20
+
21
+ expect([...focus.focusedNodeIds]).toEqual(["b", "a", "c", "d"]);
22
+ expect([...focus.focusedEdgeIds]).toEqual(["ab", "bc", "cd"]);
23
+ });
24
+
25
+ it("uses all descendants of a selected box as focus seeds", () => {
26
+ const groupedNodes = [
27
+ { id: "box" },
28
+ { id: "b", parentId: "box" },
29
+ ...nodes.filter((node) => node.id !== "b"),
30
+ ];
31
+ const index = createLineageGraphIndex(groupedNodes, edges);
32
+ const seeds = new Set(["box", ...index.descendantIds("box")]);
33
+ const focus = focusLineageFromSeeds(seeds, index);
34
+
35
+ expect(focus.focusedEdgeIds).toEqual(new Set(["ab", "bc", "cd"]));
36
+ });
37
+
38
+ it("projects hidden focused nodes onto their visible collapsed ancestor", () => {
39
+ const parentById = new Map([
40
+ ["field", "table"],
41
+ ["table", "database"],
42
+ ]);
43
+
44
+ expect(
45
+ projectLineageNodeIds(
46
+ new Set(["field"]),
47
+ parentById,
48
+ new Set(["database"]),
49
+ ),
50
+ ).toEqual(new Set(["database"]));
51
+ });
52
+ });
@@ -0,0 +1,252 @@
1
+ const freshLayout = () => {
2
+ let layout;
3
+ jest.isolateModules(() => {
4
+ layout = require("../lineageLayout").layoutVisibleLineageGraph;
5
+ });
6
+ return layout;
7
+ };
8
+ const node = (id) => ({
9
+ id,
10
+ width: 288,
11
+ height: 82,
12
+ position: { x: 0, y: 0 },
13
+ data: { label: id },
14
+ });
15
+ const geometry = (nodes) =>
16
+ Object.fromEntries(nodes.map(({ id, data }) => [id, data.absoluteGeometry]));
17
+ const crossings = (nodes, edges) => {
18
+ const byId = new Map(
19
+ nodes.map((item) => [item.id, item.data.absoluteGeometry]),
20
+ );
21
+ let count = 0;
22
+ edges.forEach((a, i) =>
23
+ edges.slice(i + 1).forEach((b) => {
24
+ const as = byId.get(a.source),
25
+ at = byId.get(a.target);
26
+ const bs = byId.get(b.source),
27
+ bt = byId.get(b.target);
28
+ if (as.x === bs.x && at.x === bt.x && (as.y - bs.y) * (at.y - bt.y) < 0)
29
+ count += 1;
30
+ }),
31
+ );
32
+ return count;
33
+ };
34
+
35
+ describe("lineage ordering with independent caches", () => {
36
+ it("keeps legacy Global Data fields in one column despite different dependency depths", () => {
37
+ const field = (id, parentId) => ({
38
+ ...node(id),
39
+ parentId,
40
+ height: 32,
41
+ data: { label: id, variant: "lineage-field" },
42
+ });
43
+ const nodes = [
44
+ node("powerbi"),
45
+ { ...node("source"), parentId: "powerbi" },
46
+ { ...node("global-data"), parentId: "powerbi" },
47
+ { ...node("dashboard"), parentId: "powerbi" },
48
+ field("source/country", "source"),
49
+ field("global-data/country", "global-data"),
50
+ field("global-data/region", "global-data"),
51
+ field("global-data/income", "global-data"),
52
+ ];
53
+ const edges = [
54
+ { source: "source/country", target: "global-data/country" },
55
+ { source: "global-data/country", target: "global-data/region" },
56
+ { source: "global-data/region", target: "dashboard" },
57
+ { source: "global-data/income", target: "dashboard" },
58
+ ];
59
+ const layout = freshLayout();
60
+ const first = layout({ nodes, edges });
61
+ const positions = geometry(first);
62
+ const fields = nodes.filter(({ parentId }) => parentId === "global-data");
63
+ expect(new Set(fields.map(({ id }) => positions[id].x)).size).toBe(1);
64
+ expect(new Set(fields.map(({ id }) => positions[id].y)).size).toBe(3);
65
+ expect(positions.source.x + positions.source.w).toBeLessThan(
66
+ positions["global-data"].x,
67
+ );
68
+ expect(
69
+ positions["global-data"].x + positions["global-data"].w,
70
+ ).toBeLessThan(positions.dashboard.x);
71
+ expect(
72
+ geometry(
73
+ layout({ nodes: [...nodes].reverse(), edges: [...edges].reverse() }),
74
+ ),
75
+ ).toEqual(positions);
76
+ expect(
77
+ geometry(
78
+ layout({
79
+ nodes,
80
+ edges,
81
+ previousLayout: first,
82
+ anchorNodeId: "global-data",
83
+ }),
84
+ ),
85
+ ).toEqual(positions);
86
+ });
87
+
88
+ it("keeps equal neighbour scores stable in fresh, warm and permuted layouts", () => {
89
+ const nodes = ["a", "b", "c", "d"].map(node);
90
+ const edges = [
91
+ { source: "a", target: "c" },
92
+ { source: "a", target: "d" },
93
+ { source: "b", target: "c" },
94
+ { source: "b", target: "d" },
95
+ ];
96
+ const layout = freshLayout();
97
+ const first = layout({ nodes, edges });
98
+ const warm = layout({
99
+ nodes: [...nodes].reverse(),
100
+ edges: [...edges].reverse(),
101
+ });
102
+ const cold = freshLayout()({
103
+ nodes: [...nodes].reverse(),
104
+ edges: [...edges].reverse(),
105
+ });
106
+ expect(geometry(first)).toEqual(geometry(warm));
107
+ expect(geometry(first)).toEqual(geometry(cold));
108
+ expect(geometry(first).a.y).toBeLessThan(geometry(first).b.y);
109
+ expect(geometry(first).c.y).toBeLessThan(geometry(first).d.y);
110
+ expect(crossings(first, edges)).toBe(1);
111
+ });
112
+
113
+ it("propagates the ordering through successive columns without avoidable crossings", () => {
114
+ const nodes = ["a", "b", "c", "d", "e", "f", "g", "h"].map(node);
115
+ const edges = [
116
+ { source: "a", target: "d" },
117
+ { source: "b", target: "c" },
118
+ { source: "c", target: "f" },
119
+ { source: "d", target: "e" },
120
+ { source: "e", target: "h" },
121
+ { source: "f", target: "g" },
122
+ ];
123
+ const first = freshLayout()({ nodes, edges });
124
+ const permuted = freshLayout()({
125
+ nodes: [...nodes].reverse(),
126
+ edges: [...edges].reverse(),
127
+ });
128
+ expect(crossings(first, edges)).toBe(0);
129
+ expect(geometry(first)).toEqual(geometry(permuted));
130
+ });
131
+
132
+ it("keeps cached topology while applying changed dimensions and anchoring", () => {
133
+ const layout = freshLayout();
134
+ const nodes = [node("a"), node("b")];
135
+ const edges = [{ source: "a", target: "b" }];
136
+ const previousLayout = layout({ nodes, edges });
137
+ const expanded = layout({
138
+ nodes: [nodes[0], { ...nodes[1], height: 320 }],
139
+ edges,
140
+ previousLayout,
141
+ anchorNodeId: "b",
142
+ });
143
+ expect(geometry(expanded).b.h).toBe(320);
144
+ expect(geometry(expanded).b.x).toBe(geometry(previousLayout).b.x);
145
+ expect(geometry(expanded).b.y).toBe(geometry(previousLayout).b.y);
146
+ expect(layout({ nodes: [], edges })).toEqual([]);
147
+ });
148
+ });
149
+
150
+ describe("compound root columns", () => {
151
+ const compoundNode = (id) => ({
152
+ ...node(id),
153
+ data: { label: id, topology: true },
154
+ });
155
+
156
+ it("stacks parallel roots and converges in the next column", () => {
157
+ const nodes = ["a", "b", "c", "d"].map(compoundNode);
158
+ const edges = [
159
+ { source: "a", target: "c" },
160
+ { source: "b", target: "c" },
161
+ { source: "c", target: "d" },
162
+ ];
163
+ const positions = geometry(freshLayout()({ nodes, edges }));
164
+ expect(positions.a.x).toBe(positions.b.x);
165
+ expect(positions.b.y).toBe(positions.a.y + positions.a.h + 44);
166
+ expect(positions.c.x).toBe(positions.a.x + positions.a.w + 152);
167
+ expect(positions.d.x).toBe(positions.c.x + positions.c.w + 152);
168
+ expect(positions.c.y).toBe(positions.a.y);
169
+ expect(
170
+ geometry(
171
+ freshLayout()({
172
+ nodes: [...nodes].reverse(),
173
+ edges: [...edges].reverse(),
174
+ }),
175
+ ),
176
+ ).toEqual(positions);
177
+ });
178
+
179
+ it("packs cycles and disconnected roots without overlaps", () => {
180
+ const nodes = ["a", "b", "isolated"].map(compoundNode);
181
+ const edges = [
182
+ { source: "a", target: "b" },
183
+ { source: "b", target: "a" },
184
+ ];
185
+ const positions = Object.values(geometry(freshLayout()({ nodes, edges })));
186
+ expect(new Set(positions.map(({ x }) => x)).size).toBe(1);
187
+ positions.slice(1).forEach((current, i) => {
188
+ expect(current.y).toBeGreaterThanOrEqual(
189
+ positions[i].y + positions[i].h + 44,
190
+ );
191
+ });
192
+ });
193
+
194
+ it("keeps compound blocks contained and preserves an expanded root anchor", () => {
195
+ const fields = Array.from({ length: 17 }, (_, i) => ({
196
+ ...node(`global/field-${String(i).padStart(2, "0")}`),
197
+ parentId: "global",
198
+ height: 32,
199
+ data: { variant: "lineage-field" },
200
+ }));
201
+ const roots = [
202
+ compoundNode("global"),
203
+ compoundNode("peer"),
204
+ compoundNode("sink"),
205
+ ];
206
+ const edges = [
207
+ { source: "global", target: "sink" },
208
+ { source: "peer", target: "sink" },
209
+ ];
210
+ const layout = freshLayout();
211
+ const collapsed = layout({ nodes: roots, edges });
212
+ const expanded = geometry(
213
+ layout({
214
+ nodes: [...roots, ...fields],
215
+ edges,
216
+ previousLayout: collapsed,
217
+ anchorNodeId: "peer",
218
+ }),
219
+ );
220
+ expect(expanded.peer.x).toBe(geometry(collapsed).peer.x);
221
+ expect(expanded.peer.y).toBe(geometry(collapsed).peer.y);
222
+ expect(expanded.peer.y).toBeGreaterThanOrEqual(
223
+ expanded.global.y + expanded.global.h + 44,
224
+ );
225
+ expect(new Set(fields.map(({ id }) => expanded[id].x)).size).toBe(1);
226
+ fields.forEach(({ id }) => {
227
+ expect(expanded[id].y).toBeGreaterThan(expanded.global.y);
228
+ expect(expanded[id].y + expanded[id].h).toBeLessThan(
229
+ expanded.global.y + expanded.global.h,
230
+ );
231
+ });
232
+ expect(expanded.sink.x).toBe(expanded.global.x + expanded.global.w + 152);
233
+ });
234
+ });
235
+
236
+ it.each([undefined, "lineage-field"])(
237
+ "leaves only bottom padding after the last child (%s)",
238
+ (variant) => {
239
+ const nodes = [
240
+ { ...node("group"), type: "lineageGroup" },
241
+ {
242
+ ...node("child"),
243
+ parentId: "group",
244
+ data: { label: "child", variant },
245
+ },
246
+ ];
247
+ const result = geometry(freshLayout()({ nodes, edges: [] }));
248
+ expect(
249
+ result.group.y + result.group.h - result.child.y - result.child.h,
250
+ ).toBe(12);
251
+ },
252
+ );
@@ -0,0 +1,256 @@
1
+ import {
2
+ prepareLineageTopology,
3
+ createLineageGraphIndex,
4
+ focusLineageFromSeeds,
5
+ projectLineageNodeIds,
6
+ } from "../lineageGraphIndex";
7
+ import { layoutVisibleLineageGraph } from "../lineageLayout";
8
+ import {
9
+ lineageTopologyNodes,
10
+ lineageEdge,
11
+ collapseLineageNodes,
12
+ aggregateCollapsedEdges,
13
+ } from "../../components/LineageGraph";
14
+
15
+ // Synthetic multi-stage lineage with nested containers and Glue field aliases.
16
+ // Keep this fixture self-contained so CI never depends on a local data export.
17
+ const stages = [
18
+ ["source", "Source Economy", "ds_a_raw_economy"],
19
+ ["prepared", "Global Data", "ds_a2_prepared_economy"],
20
+ ["user", "User Economy", "ds_a3_user_economy"],
21
+ ];
22
+ const fieldId = (table, field, alias = false) =>
23
+ alias
24
+ ? `https://glue.eu-west-1.amazonaws.com/#/Glue/${table}/${field}`
25
+ : `glue:/Glue/${table}/${field}`;
26
+ const nodes = [
27
+ ...stages.flatMap(([id, name, table]) => [
28
+ { id, name, class: "Group" },
29
+ { id: table, name: table, class: "Resource" },
30
+ ...["country", "region"].flatMap((field) => [
31
+ { id: fieldId(table, field), name: field, class: "Resource" },
32
+ { id: fieldId(table, field, true), name: field, class: "Resource" },
33
+ ]),
34
+ ]),
35
+ { id: "dashboard", name: "Dashboard Country VS Region", class: "Resource" },
36
+ ];
37
+ const contains = stages.flatMap(([id, , table]) => [
38
+ { source: id, target: table },
39
+ ...["country", "region"].flatMap((field) => [
40
+ { source: table, target: fieldId(table, field) },
41
+ { source: table, target: fieldId(table, field, true) },
42
+ ]),
43
+ ]);
44
+ const depends = stages.flatMap(([, , table], index) =>
45
+ ["country", "region"].flatMap((field) =>
46
+ [false, true].map((alias) => ({
47
+ source: fieldId(table, field, alias),
48
+ target:
49
+ index < stages.length - 1
50
+ ? fieldId(stages[index + 1][2], field, alias)
51
+ : "dashboard",
52
+ })),
53
+ ),
54
+ );
55
+ const topology = { nodes, contains, depends };
56
+ const prepared = prepareLineageTopology(topology);
57
+ const rendered = lineageTopologyNodes(prepared);
58
+ const renderedById = new Map(rendered.map((node) => [node.id, node]));
59
+ const edges = prepared.edges.map(
60
+ lineageEdge(
61
+ () => {},
62
+ (id) => id,
63
+ renderedById,
64
+ ),
65
+ );
66
+ const geometry = (laid) =>
67
+ Object.fromEntries(laid.map(({ id, data }) => [id, data.absoluteGeometry]));
68
+
69
+ describe("explicit lineage topology", () => {
70
+ it("preserves all original identities and all dependencies from the synthetic topology", () => {
71
+ expect(topology.nodes).toHaveLength(19);
72
+ expect(contains).toHaveLength(15);
73
+ expect(depends).toHaveLength(12);
74
+ expect(prepared.nodeById.size).toBe(19);
75
+ expect(prepared.originalToVisibleId.size).toBe(19);
76
+ expect(prepared.nodes).toHaveLength(13);
77
+ expect(
78
+ prepared.nodes.filter((node) => node.originals.length === 2),
79
+ ).toHaveLength(6);
80
+ expect(
81
+ prepared.edges.map(({ originalSource, originalTarget }) => [
82
+ originalSource,
83
+ originalTarget,
84
+ ]),
85
+ ).toEqual(depends.map(({ source, target }) => [source, target]));
86
+ expect(edges.filter(Boolean)).toHaveLength(12);
87
+ expect(prepared.nodes.filter((node) => node.field)).toHaveLength(6);
88
+ });
89
+
90
+ it("renders the dashboard as one entity without fabricated fields or types", () => {
91
+ const dashboard = prepared.nodes.find(
92
+ ({ name }) => name === "Dashboard Country VS Region",
93
+ );
94
+ expect(dashboard.field).toBe(false);
95
+ expect(dashboard.container).toBe(false);
96
+ expect(
97
+ prepared.edges.filter(({ target }) => target === dashboard.id),
98
+ ).toHaveLength(4);
99
+ expect(renderedById.get(dashboard.id).data.fields).toEqual([]);
100
+ expect(renderedById.get(dashboard.id).data.entityType).toBe("");
101
+ });
102
+
103
+ it("does not reverse normalized dependencies in traceability", () => {
104
+ const traced = prepared.edges.map(
105
+ lineageEdge(
106
+ () => {},
107
+ (id) => id,
108
+ renderedById,
109
+ "lineage",
110
+ ),
111
+ );
112
+ expect(traced.map(({ source, target }) => [source, target])).toEqual(
113
+ edges.map(({ source, target }) => [source, target]),
114
+ );
115
+ });
116
+
117
+ it("stacks fields inside their entity and packs all sibling containers without overlaps", () => {
118
+ const laid = layoutVisibleLineageGraph({ nodes: rendered, edges });
119
+ const byId = geometry(laid);
120
+ laid.forEach((node) => {
121
+ const box = byId[node.id];
122
+ expect(Object.values(box).every(Number.isFinite)).toBe(true);
123
+ if (node.parentId) {
124
+ const parent = byId[node.parentId];
125
+ expect(box.x).toBeGreaterThanOrEqual(parent.x);
126
+ expect(box.y).toBeGreaterThanOrEqual(parent.y + 58);
127
+ expect(box.x + box.w).toBeLessThanOrEqual(parent.x + parent.w);
128
+ expect(box.y + box.h).toBeLessThanOrEqual(parent.y + parent.h - 12);
129
+ }
130
+ laid
131
+ .filter(
132
+ (sibling) =>
133
+ sibling.parentId === node.parentId && sibling.id !== node.id,
134
+ )
135
+ .forEach((sibling) => {
136
+ const other = byId[sibling.id];
137
+ expect(
138
+ box.x < other.x + other.w &&
139
+ other.x < box.x + box.w &&
140
+ box.y < other.y + other.h &&
141
+ other.y < box.y + box.h,
142
+ ).toBe(false);
143
+ if (
144
+ node.data.variant === "lineage-field" &&
145
+ sibling.data.variant === "lineage-field"
146
+ )
147
+ expect(box.x).toBe(other.x);
148
+ });
149
+ });
150
+ const named = (name) =>
151
+ byId[prepared.nodes.find((node) => node.name === name).id];
152
+ expect(named("ds_a_raw_economy").x).toBeLessThan(
153
+ named("ds_a2_prepared_economy").x,
154
+ );
155
+ expect(named("ds_a2_prepared_economy").x).toBeLessThan(
156
+ named("ds_a3_user_economy").x,
157
+ );
158
+ expect(named("Source Economy").x).toBeLessThan(named("Global Data").x);
159
+ expect(named("Global Data").x).toBeLessThan(
160
+ named("Dashboard Country VS Region").x,
161
+ );
162
+ const permuted = prepareLineageTopology({
163
+ nodes: [...topology.nodes].reverse(),
164
+ contains: [...contains].reverse(),
165
+ depends: [...depends].reverse(),
166
+ });
167
+ const permutedNodes = lineageTopologyNodes(permuted);
168
+ expect(
169
+ geometry(
170
+ layoutVisibleLineageGraph({
171
+ nodes: permutedNodes,
172
+ edges: permuted.edges,
173
+ }),
174
+ ),
175
+ ).toEqual(byId);
176
+ });
177
+
178
+ it("retains dependency identities when fields are hidden and keeps an expanded anchor", () => {
179
+ const previousLayout = layoutVisibleLineageGraph({
180
+ nodes: rendered,
181
+ edges,
182
+ });
183
+ const tableId = "ds_a_raw_economy";
184
+ const collapsed = collapseLineageNodes(
185
+ rendered,
186
+ new Set([tableId]),
187
+ () => {},
188
+ );
189
+ const visibleEdges = aggregateCollapsedEdges(edges, rendered, collapsed);
190
+ expect(
191
+ new Set(visibleEdges.flatMap((edge) => edge.data.originalEdgeIds)).size,
192
+ ).toBe(12);
193
+ const laid = layoutVisibleLineageGraph({
194
+ nodes: collapsed,
195
+ edges: visibleEdges,
196
+ previousLayout,
197
+ anchorNodeId: tableId,
198
+ });
199
+ expect(geometry(laid)[tableId].x).toBe(geometry(previousLayout)[tableId].x);
200
+ expect(geometry(laid)[tableId].y).toBe(geometry(previousLayout)[tableId].y);
201
+ });
202
+
203
+ it("focuses original aliases without creating false paths through the shared row", () => {
204
+ const pair = prepared.nodes.find((node) => node.originals.length === 2);
205
+ const [a, b] = pair.originals.map(({ id }) => id);
206
+ const index = createLineageGraphIndex(
207
+ [{ id: "in" }, { id: a }, { id: b }, { id: "out" }],
208
+ [
209
+ { id: "e1", source: "in", target: a },
210
+ { id: "e2", source: b, target: "out" },
211
+ ],
212
+ );
213
+ expect(focusLineageFromSeeds(["in"], index).focusedEdgeIds).toEqual(
214
+ new Set(["e1"]),
215
+ );
216
+ const focus = focusLineageFromSeeds([a, b], index);
217
+ expect(focus.focusedEdgeIds).toEqual(new Set(["e1", "e2"]));
218
+ expect(
219
+ projectLineageNodeIds(
220
+ focus.focusedNodeIds,
221
+ new Map(),
222
+ new Set([pair.id, "in", "out"]),
223
+ prepared.originalToVisibleId,
224
+ ),
225
+ ).toEqual(new Set([pair.id, "in", "out"]));
226
+ });
227
+
228
+ it("rejects invalid containment and keeps same-named resources with unrelated IDs separate", () => {
229
+ expect(prepareLineageTopology(undefined)).toBeUndefined();
230
+ const data = {
231
+ nodes: [
232
+ { id: "a", class: "Group" },
233
+ { id: "b", name: "same", class: "Resource" },
234
+ { id: "c", name: "same", class: "Resource" },
235
+ ],
236
+ contains: [
237
+ { source: "a", target: "b" },
238
+ { source: "a", target: "c" },
239
+ ],
240
+ depends: [],
241
+ };
242
+ expect(prepareLineageTopology(data).nodes).toHaveLength(3);
243
+ expect(
244
+ prepareLineageTopology({
245
+ ...data,
246
+ contains: [...data.contains, { source: "b", target: "a" }],
247
+ }),
248
+ ).toBeUndefined();
249
+ expect(
250
+ prepareLineageTopology({
251
+ ...data,
252
+ depends: [{ source: "b", target: "missing" }],
253
+ }),
254
+ ).toBeUndefined();
255
+ });
256
+ });