();
@@ -42,7 +47,11 @@ vi.mock("../nodes", async (importOriginal) => {
selectMode?: string;
}) => {
mockLineageNode(props);
- return {props.runsAggregatedTag}
;
+ return (
+
+ {props.runsAggregatedTag}
+
+ );
},
};
});
@@ -55,10 +64,13 @@ import { getSemanticColorTheme } from "../../../theme";
import { GraphNode, type GraphNodeProps } from "../GraphNodeOss";
function createViewContext(
- overrides: Partial = {},
+ overrides: Partial & {
+ openNodeDetails?: (nodeId: string, view: "analysis") => void;
+ } = {},
): LineageViewContextType {
return {
interactive: true,
+ openNodeDetails: vi.fn(),
selectNode: vi.fn(),
selectMode: "normal",
focusedNode: undefined,
@@ -80,14 +92,15 @@ function createViewContext(
} as LineageViewContextType;
}
-function createNodeProps(): GraphNodeProps {
+function createNodeProps(id = "model.test.orders"): GraphNodeProps {
+ const name = id.split(".").at(-1) ?? id;
const node: LineageGraphNode = {
- id: "model.test.orders",
+ id,
type: "lineageGraphNode",
position: { x: 0, y: 0 },
data: {
- id: "model.test.orders",
- name: "orders",
+ id,
+ name,
resourceType: "model",
changeStatus: "modified",
change: { category: "non_breaking" }, // wire-enum-ok
@@ -110,6 +123,24 @@ function createNodeProps(): GraphNodeProps {
};
}
+type ValidationTypes = NonNullable<
+ NonNullable<
+ import("../../../api").NodeRunsAggregated["validation_summary"]
+ >["types"]
+>;
+
+function validationSummary(
+ resultCount: number,
+ differenceCount: number,
+ types: ValidationTypes,
+) {
+ return {
+ result_count: resultCount,
+ difference_count: differenceCount,
+ types,
+ };
+}
+
describe("GraphNode", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -198,6 +229,439 @@ describe("GraphNode", () => {
expect(selectNode).toHaveBeenCalledWith("model.test.orders");
});
+ it("does not mount the aggregate display when the node has no visible schema, row-count, or validation data", () => {
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ row_count: { run_id: "row-count", result: { curr: 42 } },
+ },
+ },
+ };
+
+ render();
+
+ expect(mockLineageNode).toHaveBeenLastCalledWith(
+ expect.objectContaining({ runsAggregatedTag: undefined }),
+ );
+ expect(
+ screen.queryByTestId("node-runs-aggregated-display"),
+ ).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["zero", 0],
+ ["negative", -1],
+ ["NaN", Number.NaN],
+ ["infinite", Number.POSITIVE_INFINITY],
+ ])(
+ "does not mount an otherwise-empty aggregate display for a %s validation result count",
+ (_label, resultCount) => {
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ validation_summary: validationSummary(resultCount, 0, {}),
+ },
+ },
+ };
+
+ render();
+
+ expect(mockLineageNode).toHaveBeenLastCalledWith(
+ expect.objectContaining({ runsAggregatedTag: undefined }),
+ );
+ expect(
+ screen.queryByTestId("node-runs-aggregated-display"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId("validation-summary-chip")).toBeNull();
+ },
+ );
+
+ it("preserves schema and row-count evidence while hiding a zero-result validation summary", () => {
+ const props = createNodeProps();
+ props.data.change = {
+ category: "non_breaking", // wire-enum-ok
+ columns: { id: "modified" },
+ };
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ row_count_diff: {
+ run_id: "row-count-1",
+ result: { base: 100, curr: 150 },
+ },
+ validation_summary: validationSummary(0, 0, {}),
+ },
+ },
+ };
+
+ render();
+
+ expect(
+ screen.getByTestId("node-runs-aggregated-display"),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Schema (changed)")).toBeInTheDocument();
+ expect(screen.getByText("↑ +50.0% Rows")).toBeInTheDocument();
+ expect(screen.queryByTestId("validation-summary-chip")).toBeNull();
+ });
+
+ it.each([
+ {
+ name: "value diff",
+ summary: validationSummary(1, 1, {
+ value_diff: {
+ result_available: true,
+ latest_run_id: "value-1",
+ difference_count: 1,
+ },
+ }),
+ chip: "1 result · 1 diff",
+ detail: "1 result · 1 diff",
+ },
+ {
+ name: "profile diff",
+ summary: validationSummary(1, 91, {
+ profile_diff: {
+ result_available: true,
+ latest_run_id: "profile-1",
+ result_count: 1,
+ },
+ }),
+ chip: "1 result",
+ detail: "1 result",
+ },
+ {
+ name: "top-k diff",
+ summary: validationSummary(2, 91, {
+ top_k_diff: {
+ result_available: true,
+ latest_run_ids_by_column: { country: "top-k-1", status: "top-k-2" },
+ column_count: 2,
+ },
+ }),
+ chip: "2 results",
+ detail: "2 columns",
+ },
+ {
+ name: "histogram diff",
+ summary: validationSummary(3, 91, {
+ histogram_diff: {
+ result_available: true,
+ latest_run_ids_by_column: {
+ amount: "histogram-1",
+ quantity: "histogram-2",
+ tax: "histogram-3",
+ },
+ column_count: 3,
+ },
+ }),
+ chip: "3 results",
+ detail: "3 columns",
+ },
+ ])(
+ "renders neutral, type-correct counts for a $name summary",
+ ({ name, summary, chip, detail }) => {
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": { validation_summary: summary },
+ },
+ };
+
+ render();
+
+ const trigger = screen.getByRole("button", {
+ name: new RegExp(`orders.*${chip.replace(" · ", ".*")}`, "i"),
+ });
+ expect(trigger).toHaveTextContent(chip);
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
+ expect(trigger).toHaveStyle({ minHeight: "24px" });
+ expect(trigger.querySelectorAll(".MuiChip-root")).toHaveLength(1);
+ expect(trigger.querySelector(".MuiChip-root")).toHaveClass(
+ "MuiChip-colorDefault",
+ "MuiChip-outlined",
+ );
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+
+ fireEvent.mouseEnter(trigger);
+
+ const dialog = screen.getByRole("dialog", {
+ name: "orders validation details",
+ });
+ expect(dialog).toBeInTheDocument();
+ expect(
+ within(dialog).getByText(new RegExp(name, "i")),
+ ).toBeInTheDocument();
+ expect(
+ within(dialog).getByText(detail, { exact: true }),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it("renders one mixed-summary chip and readable per-type details", () => {
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ validation_summary: validationSummary(7, 2, {
+ value_diff: {
+ result_available: true,
+ latest_run_id: "value-1",
+ difference_count: 2,
+ },
+ profile_diff: {
+ result_available: true,
+ latest_run_id: "profile-1",
+ result_count: 1,
+ },
+ top_k_diff: {
+ result_available: true,
+ latest_run_ids_by_column: {
+ country: "top-k-1",
+ status: "top-k-2",
+ },
+ column_count: 2,
+ },
+ histogram_diff: {
+ result_available: true,
+ latest_run_ids_by_column: {
+ amount: "histogram-1",
+ quantity: "histogram-2",
+ tax: "histogram-3",
+ },
+ column_count: 3,
+ },
+ }),
+ },
+ },
+ };
+
+ render();
+
+ const trigger = screen.getByRole("button", {
+ name: /orders.*7 results.*2 diffs/i,
+ });
+ expect(trigger).toHaveTextContent("7 results · 2 diffs");
+ expect(screen.getAllByTestId("validation-summary-chip")).toHaveLength(1);
+
+ fireEvent.mouseEnter(trigger);
+
+ expect(screen.getByText("Value diff")).toBeInTheDocument();
+ expect(screen.getByText("Profile diff")).toBeInTheDocument();
+ expect(screen.getByText("Top-k diff")).toBeInTheDocument();
+ expect(screen.getByText("Histogram diff")).toBeInTheDocument();
+ expect(screen.getByText("1 result · 2 diffs")).toBeInTheDocument();
+ expect(screen.getByText("1 result", { exact: true })).toBeInTheDocument();
+ expect(screen.getAllByText("2 columns")).toHaveLength(1);
+ expect(screen.getAllByText("3 columns")).toHaveLength(1);
+ });
+
+ it("opens Analysis repeatably from keyboard without toggling batch selection or the node surface", async () => {
+ const user = userEvent.setup();
+ const openNodeDetails = vi.fn();
+ const selectNode = vi.fn();
+ mockViewContext.current = createViewContext({
+ openNodeDetails,
+ selectNode,
+ });
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ validation_summary: validationSummary(1, 0, {
+ value_diff: {
+ result_available: true,
+ latest_run_id: "value-1",
+ difference_count: 0,
+ },
+ }),
+ },
+ },
+ };
+
+ render();
+
+ const trigger = screen.getByRole("button", {
+ name: /orders.*1 result.*0 diffs/i,
+ });
+ await user.tab();
+ expect(trigger).toHaveFocus();
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+
+ await user.keyboard("{Escape}");
+ expect(trigger).toHaveFocus();
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+
+ await user.keyboard("{Enter} ");
+
+ expect(openNodeDetails).toHaveBeenNthCalledWith(
+ 1,
+ "model.test.orders",
+ "analysis",
+ );
+ expect(openNodeDetails).toHaveBeenNthCalledWith(
+ 2,
+ "model.test.orders",
+ "analysis",
+ );
+ expect(selectNode).not.toHaveBeenCalled();
+ expect(mockNodeSurfaceClick).not.toHaveBeenCalled();
+ });
+
+ it("keeps lazy details open while the summary button retains focus", () => {
+ vi.useFakeTimers();
+ try {
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ validation_summary: validationSummary(1, 0, {
+ profile_diff: {
+ result_available: true,
+ latest_run_id: "profile-1",
+ result_count: 1,
+ },
+ }),
+ },
+ },
+ };
+ render();
+ const trigger = screen.getByRole("button", { name: /orders.*1 result/i });
+
+ act(() => trigger.focus());
+ fireEvent.mouseLeave(trigger);
+ act(() => vi.advanceTimersByTime(101));
+
+ expect(trigger).toHaveFocus();
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("adds and removes the display as aggregate data changes", () => {
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = { runsAggregated: {} };
+ const initialProps = createNodeProps();
+ const { rerender } = render();
+ expect(
+ screen.queryByTestId("node-runs-aggregated-display"),
+ ).not.toBeInTheDocument();
+
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ validation_summary: validationSummary(1, 0, {
+ profile_diff: {
+ result_available: true,
+ latest_run_id: "profile-1",
+ result_count: 1,
+ },
+ }),
+ },
+ },
+ };
+ rerender();
+ expect(screen.getByText("1 result")).toBeInTheDocument();
+
+ mockGraphContext.current = { runsAggregated: {} };
+ rerender();
+ expect(
+ screen.queryByTestId("node-runs-aggregated-display"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("coexists with schema, row-count, and new-CLL presentation inside the fixed node slot", () => {
+ const props = createNodeProps();
+ props.data.change = {
+ category: "partial_breaking", // wire-enum-ok
+ columns: {},
+ };
+ mockViewContext.current = createViewContext({ newCllExperience: true });
+ mockGraphContext.current = {
+ runsAggregated: {
+ "model.test.orders": {
+ row_count_diff: {
+ run_id: "row-count-1",
+ result: { base: 100, curr: 150 },
+ },
+ validation_summary: validationSummary(1234567, 765432, {
+ value_diff: {
+ result_available: true,
+ latest_run_id: "value-1",
+ difference_count: 765432,
+ },
+ }),
+ },
+ },
+ };
+
+ render();
+
+ expect(screen.getByText("↑ +50.0% Rows")).toBeInTheDocument();
+ expect(
+ screen.getByText("1,234,567 results · 765,432 diffs"),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Schema (no change)")).toBeInTheDocument();
+ expect(mockLineageNode).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ changeCategory: undefined,
+ showChangeAnalysis: false,
+ }),
+ );
+ });
+
+ it("mounts aggregate displays and popovers in proportion to summarized nodes in a large sparse graph", () => {
+ const summarizedNodeIds = [
+ "model.test.node_7",
+ "model.test.node_503",
+ "model.test.node_997",
+ ];
+ mockViewContext.current = createViewContext();
+ mockGraphContext.current = {
+ runsAggregated: Object.fromEntries(
+ summarizedNodeIds.map((id, index) => [
+ id,
+ {
+ validation_summary: validationSummary(index + 1, 0, {
+ profile_diff: {
+ result_available: true,
+ latest_run_id: `profile-${index}`,
+ result_count: 1,
+ },
+ }),
+ },
+ ]),
+ ),
+ };
+
+ render(
+ <>
+ {Array.from({ length: 1000 }, (_, index) => {
+ const id = `model.test.node_${index}`;
+ return ;
+ })}
+ >,
+ );
+
+ expect(screen.getAllByTestId("node-runs-aggregated-display")).toHaveLength(
+ summarizedNodeIds.length,
+ );
+ expect(screen.getAllByTestId("validation-summary-chip")).toHaveLength(
+ summarizedNodeIds.length,
+ );
+ expect(screen.queryAllByRole("dialog")).toHaveLength(0);
+
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /node_503.*2 results/i }),
+ );
+ expect(screen.getAllByRole("dialog")).toHaveLength(1);
+ });
+
it.each([
[100, 150, "↑ +50.0% Rows", "increase"],
[100, 80, "↓ -20.0% Rows", "decrease"],
diff --git a/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx b/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx
index a008781b1..27f211b09 100644
--- a/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx
+++ b/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx
@@ -584,6 +584,32 @@ describe("LineageNode", () => {
expect(screen.getByTestId("runs-tag")).toBeInTheDocument();
});
+ it("keeps the change label and aggregate evidence in the same fixed 300x60 card", () => {
+ const props = createMockNodeProps(
+ {
+ showChangeAnalysis: true,
+ changeCategory: "non_breaking", // wire-enum-ok
+ runsAggregatedTag: (
+
+ 1,234,567 results · 765,432 diffs
+
+ ),
+ },
+ { label: "coexistence" },
+ );
+
+ render();
+
+ expect(screen.getByText("Additive Change")).toBeInTheDocument();
+ expect(screen.getByTestId("runs-tag")).toBeInTheDocument();
+ expect(screen.getByTestId("lineage-node-card")).toHaveStyle({
+ height: "60px",
+ });
+ expect(screen.getByTestId("lineage-node-card").parentElement).toHaveStyle(
+ { width: "300px" },
+ );
+ });
+
it("does not render runsAggregatedTag in action_result mode", () => {
const props = createMockNodeProps(
{
@@ -597,6 +623,23 @@ describe("LineageNode", () => {
expect(screen.queryByTestId("runs-tag")).not.toBeInTheDocument();
});
+
+ it("keeps the change label in action_result mode when the node has no action tag", () => {
+ const props = createMockNodeProps(
+ {
+ selectMode: "action_result",
+ showChangeAnalysis: true,
+ changeCategory: "non_breaking", // wire-enum-ok
+ runsAggregatedTag: +10%,
+ },
+ { label: "test" },
+ );
+
+ render();
+
+ expect(screen.getByText("Additive Change")).toBeInTheDocument();
+ expect(screen.queryByTestId("runs-tag")).not.toBeInTheDocument();
+ });
});
// ==========================================================================
diff --git a/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts b/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts
index 0dffcd4ad..3cc581f7d 100644
--- a/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts
+++ b/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
-import { nextFocusedNodeId } from "../LineageViewOss";
+import {
+ createNodeDetailsOpenRequest,
+ nextFocusedNodeId,
+} from "../LineageViewOss";
describe("nextFocusedNodeId", () => {
it("focuses a newly clicked node", () => {
@@ -20,3 +23,22 @@ describe("nextFocusedNodeId", () => {
);
});
});
+
+describe("createNodeDetailsOpenRequest", () => {
+ it("issues a new token for repeated requests to the same node and view", () => {
+ expect(
+ createNodeDetailsOpenRequest(40, "model.test.orders", "analysis"),
+ ).toEqual({
+ nodeId: "model.test.orders",
+ view: "analysis",
+ requestToken: 41,
+ });
+ expect(
+ createNodeDetailsOpenRequest(41, "model.test.orders", "analysis"),
+ ).toEqual({
+ nodeId: "model.test.orders",
+ view: "analysis",
+ requestToken: 42,
+ });
+ });
+});
diff --git a/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx b/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx
index 2976d070c..498750107 100644
--- a/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx
+++ b/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx
@@ -9,8 +9,10 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { useState } from "react";
import { afterEach, beforeEach, vi } from "vitest";
import type { NodeColumnData } from "../../../api";
+import type { NodeDetailsOpenRequest } from "../../../contexts";
import type { NodeViewNodeData } from "../NodeView";
import { NodeView } from "../NodeView";
@@ -259,6 +261,146 @@ describe("NodeView", () => {
);
});
+ test("honors repeated explicit Analysis requests for the same focused node", () => {
+ const node = createNode("model", testColumns);
+ const sharedProps = {
+ node,
+ modelDetail: createModelDetail(testColumns),
+ onCloseNode: vi.fn(),
+ isSingleEnv: false,
+ SchemaView: MockSchemaView,
+ };
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ fireEvent.click(screen.getByRole("tab", { name: "Columns" }));
+ expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ test("consumes an Analysis request so remounting cannot replay it while later activations still work", () => {
+ const node = createNode("model", testColumns);
+
+ function RequestHarness() {
+ const [mounted, setMounted] = useState(true);
+ const [nextToken, setNextToken] = useState(1);
+ const [request, setRequest] = useState<
+ NodeDetailsOpenRequest | undefined
+ >({ nodeId: node.id, view: "analysis", requestToken: 1 });
+
+ return (
+ <>
+
+
+ {mounted && (
+
+ setRequest((current) =>
+ current?.requestToken === requestToken
+ ? undefined
+ : current,
+ )
+ }
+ />
+ )}
+ >
+ );
+ }
+
+ render();
+ expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ fireEvent.click(screen.getByRole("tab", { name: "Columns" }));
+ fireEvent.click(screen.getByRole("button", { name: "Unmount details" }));
+ fireEvent.click(screen.getByRole("button", { name: "Remount details" }));
+
+ expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Request Analysis" }));
+ expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ fireEvent.click(screen.getByRole("tab", { name: "Columns" }));
+ fireEvent.click(screen.getByRole("button", { name: "Request Analysis" }));
+ expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ test("ignores an Analysis request addressed to a different node", () => {
+ const node = createNode("model", testColumns);
+ renderNodeView(node, testColumns, {
+ openRequest: {
+ nodeId: "model.test.somewhere_else",
+ view: "analysis",
+ requestToken: 1,
+ },
+ });
+
+ expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
test.each(["Analysis", "Lineage"])(
"resets to Columns when focus moves from the %s tab to a source node",
(selectedTab) => {
diff --git a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx
index a5203bb1d..9800a427c 100644
--- a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx
+++ b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx
@@ -668,27 +668,43 @@ function LineageNodeComponent({
visibility: showContent ? "inherit" : "hidden",
}}
>
-
+
{actionTag ? (
<>
{actionTag}
>
- ) : changeCategoryLabel ? (
-
- {changeCategoryLabel}
-
- ) : selectMode !== "action_result" && runsAggregatedTag ? (
- runsAggregatedTag
- ) : null}
+ ) : (
+ <>
+ {changeCategoryLabel && (
+
+ {changeCategoryLabel}
+
+ )}
+ {selectMode !== "action_result" && runsAggregatedTag && (
+
+ {runsAggregatedTag}
+
+ )}
+ >
+ )}