datajunction-ui
Version:
DataJunction UI
427 lines (388 loc) • 13.5 kB
JSX
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import DJClientContext from '../../../providers/djclient';
import NodeDataFlowTab from '../NodeDataFlowTab';
// Sankey mock renders the node/link elements with fake props so SankeyNode/SankeyLink are covered
vi.mock('recharts', async () => {
const React = await vi.importActual('react');
return {
Sankey: ({ node: nodeEl, link: linkEl, data, children }) => {
const nodes = data?.nodes || [];
const makeNode = (payload, extra = {}) =>
React.isValidElement(nodeEl)
? React.cloneElement(nodeEl, {
x: 10,
y: 10,
width: 20,
height: 30,
payload,
hoveredNodeName: null,
onNodeHover: () => {},
onNavigate: () => {},
...extra,
})
: null;
const makeLink = (payload, extra = {}) =>
React.isValidElement(linkEl)
? React.cloneElement(linkEl, {
sourceX: 0,
targetX: 100,
sourceY: 50,
targetY: 50,
sourceControlX: 30,
targetControlX: 70,
linkWidth: 5,
index: 0,
payload,
...extra,
})
: null;
const firstNode = nodes[0];
const secondNode = nodes[1] || nodes[0];
return (
<div data-testid="sankey">
{/* SankeyNode: null payload → returns null */}
{makeNode(null)}
{/* SankeyNode: phantom type → returns <g/> */}
{firstNode && makeNode({ ...firstNode, type: 'phantom' })}
{/* SankeyNode: normal */}
{firstNode && makeNode(firstNode)}
{/* SankeyNode: hovered (isHovered=true) */}
{firstNode &&
makeNode(firstNode, { hoveredNodeName: firstNode.name })}
{/* SankeyNode: dimmed (hoveredNodeName !== this node) */}
{firstNode && makeNode(firstNode, { hoveredNodeName: 'other.node' })}
{/* SankeyNode: no name → cursor:default */}
{makeNode({ type: 'metric', display_name: 'No Name Node' })}
{/* SankeyLink: phantom target → returns <g/> */}
{firstNode &&
secondNode &&
makeLink({
source: firstNode,
target: { ...secondNode, type: 'phantom' },
})}
{/* SankeyLink: no hover */}
{firstNode &&
secondNode &&
makeLink({ source: firstNode, target: secondNode })}
{/* SankeyLink: hovered matching source → isConnected=true, opacity=0.8 */}
{firstNode &&
secondNode &&
makeLink(
{ source: firstNode, target: secondNode },
{ hoveredNodeName: firstNode.name },
)}
{/* SankeyLink: hovered not matching → isConnected=false, opacity=0.15 */}
{firstNode &&
secondNode &&
makeLink(
{ source: firstNode, target: secondNode },
{ hoveredNodeName: 'something.else' },
)}
{children}
</div>
);
},
// Tooltip mock invokes the content function with various args to cover lines 413-421
Tooltip: ({ content }) => {
if (typeof content !== 'function') return null;
return (
<div data-testid="tooltip">
{/* active=false → line 413 returns null */}
{content({
active: false,
payload: [{ payload: { name: 'test', type: 'metric' } }],
})}
{/* payload empty → line 413 returns null */}
{content({ active: true, payload: [] })}
{/* item=null → line 415 returns null */}
{content({ active: true, payload: [{}] })}
{/* item.name exists + item.type → lines 416-421 */}
{content({
active: true,
payload: [{ payload: { name: 'a.b.metric', type: 'metric' } }],
})}
{/* item.name falsy → uses source→target fallback (line 418) */}
{content({
active: true,
payload: [
{
payload: {
source: { display_name: 'Src Node', name: 'src' },
target: { name: 'tgt' },
},
},
],
})}
{/* item.name falsy, source has no display_name → uses source.name */}
{content({
active: true,
payload: [
{
payload: {
source: { name: 'src.node' },
target: { display_name: 'Target Display' },
},
},
],
})}
</div>
);
},
};
});
// ResizeObserver fires callback immediately so containerWidth gets set to 800
beforeAll(() => {
global.ResizeObserver = function (callback) {
return {
observe: function (el) {
callback([{ contentRect: { width: 800 } }]);
},
disconnect: function () {},
};
};
HTMLCanvasElement.prototype.getContext = () => ({
font: '',
measureText: () => ({ width: 50 }),
});
});
describe('<NodeDataFlowTab />', () => {
const mockDjClient = {
upstreamsGQL: vi.fn(),
downstreamsGQL: vi.fn(),
findCubesWithMetrics: vi.fn(),
};
const renderWithContext = djNode =>
render(
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<MemoryRouter>
<NodeDataFlowTab djNode={djNode} />
</MemoryRouter>
</DJClientContext.Provider>,
);
beforeEach(() => {
vi.clearAllMocks();
mockDjClient.findCubesWithMetrics.mockResolvedValue([]);
});
it('stays in loading state when djNode has no name (line 186 early return)', async () => {
renderWithContext({});
// Effect returns early, loading stays true — no API calls
expect(mockDjClient.upstreamsGQL).not.toHaveBeenCalled();
expect(
screen.queryByText('No data flow relationships found for this node.'),
).not.toBeInTheDocument();
});
it('shows "No data flow relationships" when dag and downstreams return empty arrays', async () => {
// Use a source node — metrics get phantom links which make links.length > 0
const djNode = { name: 'default.source1', type: 'source', parents: [] };
mockDjClient.upstreamsGQL.mockResolvedValue([]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
await waitFor(() => {
expect(
screen.getByText('No data flow relationships found for this node.'),
).toBeInTheDocument();
});
});
it('renders Sankey when links exist between nodes', async () => {
const source = {
name: 'default.source1',
type: 'source',
current: { parents: [] },
};
const djNode = {
name: 'default.metric1',
type: 'metric',
parents: [{ name: 'default.source1' }],
};
mockDjClient.upstreamsGQL.mockResolvedValue([source]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
});
it('handles error from Promise.all gracefully (lines 268-269)', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const djNode = { name: 'default.metric1', type: 'metric', parents: [] };
mockDjClient.upstreamsGQL.mockRejectedValue(new Error('Network error'));
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(expect.any(Error));
});
expect(
screen.getByText('No data flow relationships found for this node.'),
).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('filters CUBE type from downstream nodes and calls findCubesWithMetrics (lines 195-196)', async () => {
const djNode = {
name: 'default.metric1',
type: 'metric',
parents: [],
};
mockDjClient.upstreamsGQL.mockResolvedValue([]);
mockDjClient.downstreamsGQL.mockResolvedValue([
{
name: 'default.cube1',
type: 'CUBE',
current: { parents: [{ name: 'default.metric1' }] },
},
{
name: 'default.transform1',
type: 'TRANSFORM',
current: { parents: [{ name: 'default.metric1' }] },
},
]);
// cube has metric as parent → creates a link
mockDjClient.findCubesWithMetrics.mockResolvedValue([
{
name: 'default.cube1',
type: 'cube',
parents: [{ name: 'default.metric1' }],
},
]);
renderWithContext(djNode);
await waitFor(() => {
expect(mockDjClient.findCubesWithMetrics).toHaveBeenCalledWith([
'default.cube1',
]);
});
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
});
it('covers sort return 0 branch for same-type non-seed nodes (line 220)', async () => {
// Two source nodes, neither is the seed (metric1) — sort returns 0
const source1 = {
name: 'default.source1',
type: 'source',
current: { parents: [] },
};
const source2 = {
name: 'default.source2',
type: 'source',
current: { parents: [] },
};
const djNode = {
name: 'default.metric1',
type: 'metric',
parents: [{ name: 'default.source1' }, { name: 'default.source2' }],
};
mockDjClient.upstreamsGQL.mockResolvedValue([source1, source2]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
});
it('covers SankeyLink hover (linkHovered=true) via mouseEnter on path', async () => {
const source = {
name: 'default.source1',
type: 'source',
current: { parents: [] },
};
const djNode = {
name: 'default.metric1',
type: 'metric',
parents: [{ name: 'default.source1' }],
};
mockDjClient.upstreamsGQL.mockResolvedValue([source]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
const { container } = renderWithContext(djNode);
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
// Trigger linkHovered=true to cover the 0.85 opacity branch (line 137)
const path = container.querySelector('path');
if (path) {
fireEvent.mouseEnter(path);
fireEvent.mouseLeave(path);
}
});
it('covers ResizeObserver callback setting containerWidth (line 179)', async () => {
const djNode = {
name: 'default.metric1',
type: 'metric',
parents: [{ name: 'default.source1' }],
};
const source = {
name: 'default.source1',
type: 'source',
current: { parents: [] },
};
mockDjClient.upstreamsGQL.mockResolvedValue([source]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
// ResizeObserver mock fires immediately with width=800 → containerWidth>0 → Sankey renders
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
});
it('renders SankeyNode with rightmostType=metric when no cubes', async () => {
const source = {
name: 'default.source1',
type: 'source',
current: { parents: [] },
};
const transform = {
name: 'default.transform1',
type: 'transform',
current: { parents: [{ name: 'default.source1' }] },
};
const djNode = {
name: 'default.metric1',
type: 'metric',
parents: [{ name: 'default.transform1' }],
};
mockDjClient.upstreamsGQL.mockResolvedValue([source, transform]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
});
it('includes dimension nodes in the flow graph (they have data lineage like transforms)', async () => {
const dimNode = {
name: 'default.dim1',
type: 'dimension',
current: { parents: [{ name: 'default.source1' }] },
};
const djNode = {
name: 'default.metric1',
type: 'metric',
parents: [{ name: 'default.source1' }],
};
const source = {
name: 'default.source1',
type: 'source',
current: { parents: [] },
};
mockDjClient.upstreamsGQL.mockResolvedValue([dimNode, source]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
});
it('shows data flow for a dimension node viewed directly', async () => {
const djNode = {
name: 'default.dispatcher',
type: 'dimension',
parents: [{ name: 'default.dispatchers_source' }],
};
const source = {
name: 'default.dispatchers_source',
type: 'source',
current: { parents: [] },
};
mockDjClient.upstreamsGQL.mockResolvedValue([source]);
mockDjClient.downstreamsGQL.mockResolvedValue([]);
renderWithContext(djNode);
await waitFor(() => {
expect(screen.getByTestId('sankey')).toBeInTheDocument();
});
});
});