@truedat/dd 8.11.6 → 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,660 @@
1
+ /* eslint-disable react/prop-types -- Minimal render doubles exercise the real graph interactions. */
2
+ import { act, fireEvent, screen } from "@testing-library/react";
3
+ import { render } from "@truedat/test/render";
4
+ import { LineageGraph } from "../LineageGraph";
5
+ import * as lineageLayout from "../../utils/lineageLayout";
6
+
7
+ const mockGraphRender = jest.fn();
8
+
9
+ jest.mock("@truedat/core/components", () => ({
10
+ Graph: (props) => {
11
+ const { useContext } = require("react");
12
+ const {
13
+ EdgeHoverContext,
14
+ } = require("@truedat/core/components/graph/ColoredEdge");
15
+ const hover = useContext(EdgeHoverContext);
16
+ mockGraphRender({ ...props, ...props.graphOptions });
17
+
18
+ return (
19
+ <div
20
+ data-orientation={props.graphOptions.controlsOrientation}
21
+ data-testid="mock-lineage-graph"
22
+ >
23
+ {props.topControls}
24
+ {props.nodes.map((node) => (
25
+ <button
26
+ aria-label={`select-${node.id}`}
27
+ className="react-flow__node"
28
+ data-id={node.id}
29
+ key={node.id}
30
+ onMouseEnter={() => hover.setHoveredNodeId(node.id)}
31
+ onMouseLeave={() => hover.setHoveredNodeId(null)}
32
+ onClick={(event) => props.onNodeClick(event, node)}
33
+ onDoubleClick={(event) => props.onNodeDoubleClick(event, node)}
34
+ type="button"
35
+ />
36
+ ))}
37
+ {props.edges.map((edge) =>
38
+ edge.data?.onClick ? (
39
+ <button
40
+ aria-label={`select-edge-${edge.id}`}
41
+ className="td-graph-edge-hit-area"
42
+ key={edge.id}
43
+ onClick={edge.data.onClick}
44
+ type="button"
45
+ />
46
+ ) : null,
47
+ )}
48
+ <button
49
+ aria-label="clear-canvas-selection"
50
+ onClick={props.onPaneClick}
51
+ type="button"
52
+ />
53
+ </div>
54
+ );
55
+ },
56
+ }));
57
+
58
+ const resources = [
59
+ {
60
+ external_id: "warehouse.sales.orders",
61
+ fields: [],
62
+ id: "orders",
63
+ name: "Orders",
64
+ schema: "sales",
65
+ system: "Warehouse",
66
+ system_group: "Oracle",
67
+ },
68
+ {
69
+ external_id: "warehouse.sales.customers",
70
+ fields: [],
71
+ id: "customers",
72
+ name: "Customers",
73
+ schema: "sales",
74
+ system: "Warehouse",
75
+ system_group: "Oracle",
76
+ },
77
+ ];
78
+
79
+ const renderGraph = () =>
80
+ render(
81
+ <LineageGraph
82
+ cancelPoll={jest.fn()}
83
+ edges={[]}
84
+ groups={[]}
85
+ resources={resources}
86
+ />,
87
+ { state: { graph: { id: "graph-1" } } },
88
+ );
89
+
90
+ describe("<LineageGraph /> toolbar interactions", () => {
91
+ it("keeps the query origin independent from selection and clears selection", () => {
92
+ render(
93
+ <LineageGraph
94
+ cancelPoll={jest.fn()}
95
+ edges={[]}
96
+ groups={[]}
97
+ resources={resources}
98
+ requestedNodeIds={["orders"]}
99
+ />,
100
+ { state: { graph: { id: "graph-1" } } },
101
+ );
102
+ const nodes = () => mockGraphRender.mock.calls.at(-1)[0].nodes;
103
+ expect(
104
+ nodes().find(({ id }) => id === "warehouse.sales.orders").className,
105
+ ).toContain("lineage-node--origin");
106
+ expect(nodes().some((node) => node.data.focusSelected)).toBe(false);
107
+ expect(nodes().some((node) => node.data.focusDimmed)).toBe(false);
108
+ fireEvent.click(
109
+ screen.getByRole("button", { name: "select-warehouse.sales.customers" }),
110
+ );
111
+ expect(
112
+ nodes().find(({ id }) => id === "warehouse.sales.orders").className,
113
+ ).toContain("lineage-node--origin");
114
+ expect(
115
+ nodes().find(({ id }) => id === "warehouse.sales.customers").className,
116
+ ).not.toContain("lineage-node--origin");
117
+ expect(
118
+ nodes().find(({ id }) => id === "warehouse.sales.customers").className,
119
+ ).toContain("lineage-node--selected");
120
+ fireEvent.click(
121
+ screen.getByRole("button", { name: "clear-canvas-selection" }),
122
+ );
123
+ expect(
124
+ nodes().some((node) => node.className.includes("lineage-node--selected")),
125
+ ).toBe(false);
126
+ expect(
127
+ nodes().find(({ id }) => id === "warehouse.sales.orders").className,
128
+ ).toContain("lineage-node--origin");
129
+ });
130
+
131
+ it.each(["Enter", " "])("opens the focused node inspector with %s", (key) => {
132
+ renderGraph();
133
+ const node = screen.getByRole("button", {
134
+ name: "select-warehouse.sales.orders",
135
+ });
136
+ fireEvent.keyDown(node, { key });
137
+ expect(screen.getByLabelText("Lineage inspector")).toHaveTextContent(
138
+ "Orders",
139
+ );
140
+ expect(
141
+ mockGraphRender.mock.calls
142
+ .at(-1)[0]
143
+ .nodes.find(({ id }) => id === "warehouse.sales.orders").ariaLabel,
144
+ ).toBe("Orders");
145
+ fireEvent.click(
146
+ screen.getByRole("button", { name: "Clear selection for Orders" }),
147
+ );
148
+ fireEvent.keyDown(screen.getByRole("button", { name: "Search nodes" }), {
149
+ key,
150
+ });
151
+ expect(
152
+ screen.queryByLabelText("Lineage inspector"),
153
+ ).not.toBeInTheDocument();
154
+ });
155
+
156
+ beforeEach(() => {
157
+ jest.clearAllMocks();
158
+ window.sessionStorage.clear();
159
+ });
160
+
161
+ it("ignores legacy stored depth and starts at the graph maximum below four", () => {
162
+ window.sessionStorage.setItem("td.lineage.expansionDepth", "1");
163
+
164
+ render(
165
+ <LineageGraph
166
+ cancelPoll={jest.fn()}
167
+ edges={[]}
168
+ graphId="depth-graph"
169
+ groups={[
170
+ {
171
+ h: 300,
172
+ id: "level-1",
173
+ name: "Level 1",
174
+ w: 300,
175
+ x: 0,
176
+ y: 0,
177
+ z: 1,
178
+ },
179
+ {
180
+ h: 220,
181
+ id: "level-2",
182
+ name: "Level 2",
183
+ w: 220,
184
+ x: 20,
185
+ y: 50,
186
+ z: 2,
187
+ },
188
+ ]}
189
+ resources={[
190
+ {
191
+ h: 60,
192
+ id: "leaf",
193
+ name: "Leaf",
194
+ w: 120,
195
+ x: 40,
196
+ y: 90,
197
+ z: 3,
198
+ },
199
+ ]}
200
+ />,
201
+ { state: { graph: { id: "depth-graph" } } },
202
+ );
203
+
204
+ expect(
205
+ screen.getByRole("button", { name: "Visible levels" }),
206
+ ).toHaveTextContent("2");
207
+ });
208
+
209
+ it("opens the inspector on selection and closes it through clear selection", () => {
210
+ renderGraph();
211
+
212
+ expect(screen.getByTestId("mock-lineage-graph")).toHaveAttribute(
213
+ "data-orientation",
214
+ "vertical",
215
+ );
216
+ expect(
217
+ screen.getByRole("button", { name: "Lineage legend" }),
218
+ ).toBeInTheDocument();
219
+
220
+ fireEvent.click(
221
+ screen.getByRole("button", {
222
+ name: "select-warehouse.sales.orders",
223
+ }),
224
+ );
225
+
226
+ expect(screen.getByLabelText("Lineage inspector")).toBeInTheDocument();
227
+
228
+ fireEvent.click(
229
+ screen.getByRole("button", { name: "Clear selection for Orders" }),
230
+ );
231
+ expect(
232
+ screen.queryByLabelText("Lineage inspector"),
233
+ ).not.toBeInTheDocument();
234
+
235
+ // Sin botón de reabrir en la barra: solo queda el clear.
236
+ expect(
237
+ screen.queryByRole("button", { name: "View information for Orders" }),
238
+ ).not.toBeInTheDocument();
239
+
240
+ fireEvent.click(
241
+ screen.getByRole("button", { name: "clear-canvas-selection" }),
242
+ );
243
+ expect(
244
+ screen.getByRole("button", { name: "Lineage legend" }),
245
+ ).toBeInTheDocument();
246
+
247
+ // Seleccionar otro nodo vuelve a abrir el inspector maximizado.
248
+ fireEvent.click(
249
+ screen.getByRole("button", {
250
+ name: "select-warehouse.sales.customers",
251
+ }),
252
+ );
253
+ expect(screen.getByLabelText("Lineage inspector")).toBeInTheDocument();
254
+ expect(
255
+ document.querySelector(".lineage-graph__dock-context-label"),
256
+ ).toHaveTextContent("Customers");
257
+
258
+ fireEvent.keyDown(document, { key: "Escape" });
259
+ act(() => jest.runOnlyPendingTimers());
260
+ expect(
261
+ screen.queryByLabelText("Lineage inspector"),
262
+ ).not.toBeInTheDocument();
263
+ expect(
264
+ screen.queryByRole("button", { name: "Clear selection for Customers" }),
265
+ ).not.toBeInTheDocument();
266
+ expect(
267
+ screen.getByRole("button", { name: "Lineage legend" }),
268
+ ).toBeInTheDocument();
269
+ expect(
270
+ mockGraphRender.mock.calls
271
+ .at(-1)[0]
272
+ .nodes.some((node) =>
273
+ node.className.includes("lineage-node--selected"),
274
+ ),
275
+ ).toBe(false);
276
+
277
+ fireEvent.click(
278
+ screen.getByRole("button", { name: "clear-canvas-selection" }),
279
+ );
280
+ expect(
281
+ screen.getByRole("button", { name: "Lineage legend" }),
282
+ ).toBeInTheDocument();
283
+ });
284
+
285
+ it("debounces search and emits a repeatable focus request", () => {
286
+ renderGraph();
287
+ fireEvent.click(screen.getByRole("button", { name: "Search nodes" }));
288
+ fireEvent.change(
289
+ screen.getByRole("textbox", { name: "Search graph nodes" }),
290
+ { target: { value: "warehouse.sales.orders" } },
291
+ );
292
+
293
+ expect(
294
+ screen.queryByRole("option", { name: /Orders/ }),
295
+ ).not.toBeInTheDocument();
296
+
297
+ act(() => jest.advanceTimersByTime(299));
298
+ expect(
299
+ screen.queryByRole("option", { name: /Orders/ }),
300
+ ).not.toBeInTheDocument();
301
+
302
+ act(() => jest.advanceTimersByTime(1));
303
+ fireEvent.click(screen.getByRole("option", { name: /Orders/ }));
304
+
305
+ const firstFocusedProps = mockGraphRender.mock.calls.at(-1)[0];
306
+
307
+ expect(firstFocusedProps.focusNodeId).toBe("warehouse.sales.orders");
308
+ expect(screen.getByLabelText("Lineage inspector")).toBeInTheDocument();
309
+
310
+ fireEvent.click(screen.getByRole("button", { name: "Search nodes" }));
311
+ fireEvent.click(screen.getByRole("option", { name: /Orders/ }));
312
+
313
+ const repeatedFocusProps = mockGraphRender.mock.calls.at(-1)[0];
314
+
315
+ expect(repeatedFocusProps.focusNodeId).toBe("warehouse.sales.orders");
316
+ expect(repeatedFocusProps.focusNodeRequestKey).toBeGreaterThan(
317
+ firstFocusedProps.focusNodeRequestKey,
318
+ );
319
+ });
320
+
321
+ it("re-centers the graph when the visible depth level changes", () => {
322
+ render(
323
+ <LineageGraph
324
+ cancelPoll={jest.fn()}
325
+ edges={[]}
326
+ graphId="depth-graph"
327
+ groups={[
328
+ { h: 300, id: "level-1", name: "Level 1", w: 300, x: 0, y: 0, z: 1 },
329
+ {
330
+ h: 220,
331
+ id: "level-2",
332
+ name: "Level 2",
333
+ w: 220,
334
+ x: 20,
335
+ y: 50,
336
+ z: 2,
337
+ },
338
+ ]}
339
+ resources={[
340
+ { h: 60, id: "leaf", name: "Leaf", w: 120, x: 40, y: 90, z: 3 },
341
+ ]}
342
+ />,
343
+ { state: { graph: { id: "depth-graph" } } },
344
+ );
345
+
346
+ const before = mockGraphRender.mock.calls.at(-1)[0];
347
+
348
+ expect(before.fitViewRequestKey).toBeUndefined();
349
+
350
+ fireEvent.click(screen.getByRole("button", { name: "Visible levels" }));
351
+ fireEvent.change(screen.getByRole("slider", { name: "Visible levels" }), {
352
+ target: { value: "0" },
353
+ });
354
+
355
+ const after = mockGraphRender.mock.calls.at(-1)[0];
356
+
357
+ expect(after.fitViewRequestKey).toBeGreaterThan(
358
+ before.fitViewRequestKey || 0,
359
+ );
360
+ });
361
+
362
+ it("keeps the viewport and the acted node fixed when fields expand", () => {
363
+ render(
364
+ <LineageGraph
365
+ cancelPoll={jest.fn()}
366
+ edges={[]}
367
+ groups={[]}
368
+ resources={[
369
+ {
370
+ external_id: "warehouse.sales.expanded_orders",
371
+ fields: Array.from({ length: 12 }, (_, index) => ({
372
+ name: `field-${index}`,
373
+ })),
374
+ h: 82,
375
+ id: "expanded-orders",
376
+ name: "Expanded orders",
377
+ w: 288,
378
+ x: 420,
379
+ y: 260,
380
+ },
381
+ ]}
382
+ />,
383
+ { state: { graph: { id: "graph-1" } } },
384
+ );
385
+
386
+ const initialProps = mockGraphRender.mock.calls.at(-1)[0];
387
+ const initialNode = initialProps.nodes.find(
388
+ ({ id }) => id === "warehouse.sales.expanded_orders",
389
+ );
390
+
391
+ expect(initialProps.fitViewOnLayoutChange).toBe(true);
392
+ expect(initialProps.preserveUserViewportOnLayoutChange).toBe(true);
393
+ expect(initialProps.fitViewRequestKey).toBeUndefined();
394
+ expect(initialNode.data.onToggleFields).toEqual(expect.any(Function));
395
+
396
+ act(() => initialNode.data.onToggleFields());
397
+
398
+ const expandedProps = mockGraphRender.mock.calls.at(-1)[0];
399
+ const expandedNode = expandedProps.nodes.find(
400
+ ({ id }) => id === "warehouse.sales.expanded_orders",
401
+ );
402
+
403
+ expect(expandedNode.data.absoluteGeometry).toMatchObject({
404
+ x: initialNode.data.absoluteGeometry.x,
405
+ y: initialNode.data.absoluteGeometry.y,
406
+ });
407
+ expect(expandedNode.data.absoluteGeometry.h).toBeGreaterThan(
408
+ initialNode.data.absoluteGeometry.h,
409
+ );
410
+ expect(expandedProps.fitViewOnLayoutChange).toBe(true);
411
+ expect(expandedProps.preserveUserViewportOnLayoutChange).toBe(true);
412
+ expect(expandedProps.fitViewRequestKey).toBeUndefined();
413
+ expect(expandedProps.focusNodeId).toBeNull();
414
+ });
415
+
416
+ it("opens the inspector from another panel when a node is double-clicked", () => {
417
+ renderGraph();
418
+
419
+ fireEvent.click(
420
+ screen.getByRole("button", {
421
+ name: "select-warehouse.sales.orders",
422
+ }),
423
+ );
424
+ fireEvent.click(screen.getByRole("button", { name: "Search nodes" }));
425
+ fireEvent.doubleClick(
426
+ screen.getByRole("button", {
427
+ name: "select-warehouse.sales.customers",
428
+ }),
429
+ );
430
+
431
+ expect(screen.getByLabelText("Lineage inspector")).toHaveTextContent(
432
+ "Customers",
433
+ );
434
+ });
435
+
436
+ it("keeps the inspector open and replaces its context on selection", () => {
437
+ renderGraph();
438
+
439
+ fireEvent.click(
440
+ screen.getByRole("button", {
441
+ name: "select-warehouse.sales.orders",
442
+ }),
443
+ );
444
+ const customersNode = screen.getByRole("button", {
445
+ name: "select-warehouse.sales.customers",
446
+ });
447
+
448
+ fireEvent.mouseDown(customersNode);
449
+ fireEvent.click(customersNode);
450
+
451
+ expect(screen.getByLabelText("Lineage inspector")).toBeInTheDocument();
452
+ expect(
453
+ document.querySelector(".lineage-graph__dock-context-label"),
454
+ ).toHaveTextContent("Customers");
455
+ expect(
456
+ document.querySelector(".lineage-graph__dock-context-type"),
457
+ ).toHaveTextContent("Table");
458
+ expect(
459
+ screen.getByRole("button", { name: "Clear selection for Customers" }),
460
+ ).toBeInTheDocument();
461
+ });
462
+
463
+ it("opens the inspector when a connection with metadata is selected", () => {
464
+ render(
465
+ <LineageGraph
466
+ cancelPoll={jest.fn()}
467
+ edges={[
468
+ {
469
+ id: "orders-customers",
470
+ metadata: { rule: "customer_id = id" },
471
+ source: "warehouse.sales.orders",
472
+ target: "warehouse.sales.customers",
473
+ },
474
+ ]}
475
+ groups={[]}
476
+ resources={resources}
477
+ />,
478
+ { state: { graph: { id: "graph-1" } } },
479
+ );
480
+
481
+ fireEvent.click(
482
+ screen.getByRole("button", {
483
+ name: "select-edge-orders-customers",
484
+ }),
485
+ );
486
+
487
+ expect(screen.getByLabelText("Lineage inspector")).toBeInTheDocument();
488
+ expect(
489
+ document.querySelector(".lineage-graph__dock-context-label"),
490
+ ).toHaveTextContent("Orders → Customers");
491
+ expect(
492
+ document.querySelector(".lineage-graph__dock-context-type"),
493
+ ).toHaveTextContent("Connection metadata");
494
+ expect(
495
+ screen.getByRole("button", {
496
+ name: "Clear selection for Orders → Customers",
497
+ }),
498
+ ).toBeInTheDocument();
499
+ });
500
+
501
+ it.skip("locks selection and panel controls while focus is pinned", () => {
502
+ render(
503
+ <LineageGraph
504
+ cancelPoll={jest.fn()}
505
+ edges={[
506
+ {
507
+ id: "orders-customers",
508
+ metadata: { rule: "customer_id = id" },
509
+ source: "warehouse.sales.orders",
510
+ target: "warehouse.sales.customers",
511
+ },
512
+ ]}
513
+ groups={[]}
514
+ resources={resources}
515
+ />,
516
+ { state: { graph: { id: "graph-1" } } },
517
+ );
518
+
519
+ fireEvent.click(
520
+ screen.getByRole("button", { name: "select-warehouse.sales.orders" }),
521
+ );
522
+ fireEvent.click(screen.getByRole("button", { name: "Pin focus" }));
523
+ fireEvent.click(
524
+ screen.getByRole("button", {
525
+ name: "select-warehouse.sales.customers",
526
+ }),
527
+ );
528
+ fireEvent.click(
529
+ screen.getByRole("button", { name: "select-edge-orders-customers" }),
530
+ );
531
+ fireEvent.click(
532
+ screen.getByRole("button", { name: "clear-canvas-selection" }),
533
+ );
534
+ fireEvent.mouseDown(document.body);
535
+ fireEvent.keyDown(document, { key: "Escape" });
536
+
537
+ expect(screen.getByLabelText("Lineage inspector")).toHaveTextContent(
538
+ "Orders",
539
+ );
540
+ expect(screen.getByRole("button", { name: "Search nodes" })).toBeDisabled();
541
+ expect(
542
+ mockGraphRender.mock.calls
543
+ .at(-1)[0]
544
+ .nodes.find(({ id }) => id === "warehouse.sales.orders").data
545
+ .focusSelected,
546
+ ).toBe(true);
547
+
548
+ expect(
549
+ screen
550
+ .getAllByRole("button", { name: "Unpin focus" })
551
+ .every((button) => !button.disabled),
552
+ ).toBe(true);
553
+ expect(
554
+ screen
555
+ .getAllByRole("button", { name: "Unpin focus" })[1]
556
+ .closest(".lineage-graph__dock-context"),
557
+ ).toHaveClass("lineage-graph__dock-context--pinned");
558
+
559
+ fireEvent.click(screen.getAllByRole("button", { name: "Unpin focus" })[0]);
560
+ expect(screen.getByLabelText("Lineage inspector")).toBeInTheDocument();
561
+ expect(
562
+ screen.queryByRole("button", { name: "View information for Orders" }),
563
+ ).not.toBeInTheDocument();
564
+ fireEvent.click(
565
+ screen.getByRole("button", {
566
+ name: "select-warehouse.sales.customers",
567
+ }),
568
+ );
569
+ expect(screen.getByLabelText("Lineage inspector")).toHaveTextContent(
570
+ "Customers",
571
+ );
572
+
573
+ fireEvent.click(screen.getByRole("button", { name: "Pin focus" }));
574
+ fireEvent.click(
575
+ screen.getByRole("button", {
576
+ name: "Clear selection for Customers",
577
+ }),
578
+ );
579
+ expect(
580
+ screen.getByRole("button", { name: "Lineage legend" }),
581
+ ).toBeInTheDocument();
582
+ });
583
+ });
584
+
585
+ it("projects multiple hidden query origins to their nearest visible container", () => {
586
+ render(
587
+ <LineageGraph
588
+ cancelPoll={jest.fn()}
589
+ edges={[]}
590
+ graphId="origin-hierarchy"
591
+ groups={[
592
+ { h: 400, id: "system", name: "System", w: 400, x: 0, y: 0, z: 1 },
593
+ { h: 300, id: "table", name: "Table", w: 300, x: 20, y: 50, z: 2 },
594
+ ]}
595
+ resources={[
596
+ { h: 32, id: "field-a", name: "Field A", w: 200, x: 40, y: 90, z: 3 },
597
+ { h: 32, id: "field-b", name: "Field B", w: 200, x: 40, y: 130, z: 3 },
598
+ ]}
599
+ requestedNodeIds={["field-a", "field-b"]}
600
+ />,
601
+ { state: { graph: { id: "origin-hierarchy" } } },
602
+ );
603
+ const nodes = () => mockGraphRender.mock.calls.at(-1)[0].nodes;
604
+ expect(
605
+ nodes().filter(({ data }) => data.queryOrigin === "direct"),
606
+ ).toHaveLength(2);
607
+ const table = nodes().find(({ id }) => id === "table");
608
+ act(() => table.data.onToggleCollapse());
609
+ expect(nodes().find(({ id }) => id === "table").data.queryOrigin).toBe(
610
+ "contained",
611
+ );
612
+ expect(
613
+ nodes().find(({ id }) => id === "system").data.queryOrigin,
614
+ ).toBeUndefined();
615
+ expect(nodes().some(({ id }) => id === "field-a")).toBe(false);
616
+ act(() =>
617
+ nodes()
618
+ .find(({ id }) => id === "system")
619
+ .data.onToggleCollapse(),
620
+ );
621
+ expect(nodes().find(({ id }) => id === "system").data.queryOrigin).toBe(
622
+ "contained",
623
+ );
624
+ act(() =>
625
+ nodes()
626
+ .find(({ id }) => id === "system")
627
+ .data.onToggleCollapse(),
628
+ );
629
+ act(() =>
630
+ nodes()
631
+ .find(({ id }) => id === "table")
632
+ .data.onToggleCollapse(),
633
+ );
634
+ expect(
635
+ nodes().filter(({ data }) => data.queryOrigin === "direct"),
636
+ ).toHaveLength(2);
637
+ });
638
+
639
+ it("does not recompute topology or geometry on selection or hover", () => {
640
+ const layout = jest.spyOn(lineageLayout, "layoutVisibleLineageGraph");
641
+ renderGraph();
642
+ const callCount = layout.mock.calls.length;
643
+ const before = mockGraphRender.mock.calls
644
+ .at(-1)[0]
645
+ .nodes.map(({ position }) => position);
646
+ fireEvent.click(
647
+ screen.getByRole("button", { name: "select-warehouse.sales.orders" }),
648
+ );
649
+ fireEvent.mouseEnter(
650
+ screen.getByRole("button", { name: "select-warehouse.sales.customers" }),
651
+ );
652
+ fireEvent.mouseLeave(
653
+ screen.getByRole("button", { name: "select-warehouse.sales.customers" }),
654
+ );
655
+ expect(layout).toHaveBeenCalledTimes(callCount);
656
+ expect(
657
+ mockGraphRender.mock.calls.at(-1)[0].nodes.map(({ position }) => position),
658
+ ).toEqual(before);
659
+ layout.mockRestore();
660
+ });