UNPKG

datajunction-ui

Version:
427 lines (388 loc) 13.5 kB
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(); }); }); });