datajunction-ui
Version:
DataJunction UI
627 lines (554 loc) • 16.9 kB
JSX
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
// Mock dagre module with inline class definition
vi.mock('dagre', () => {
// Define mock graph class inside the mock factory
function MockGraph() {
this.setDefaultEdgeLabel = vi.fn().mockReturnValue(this);
this.setGraph = vi.fn().mockReturnValue(this);
this.setNode = vi.fn().mockReturnValue(this);
this.setEdge = vi.fn().mockReturnValue(this);
this.node = vi.fn().mockReturnValue({ x: 100, y: 100 });
}
const dagreModule = {
graphlib: {
Graph: MockGraph,
},
layout: vi.fn(),
};
return { ...dagreModule, default: dagreModule };
});
// Mock ReactFlow and related components
vi.mock('reactflow', () => {
return {
__esModule: true,
default: ({ children, nodes, edges, onNodeClick, onPaneClick }) => (
<div data-testid="react-flow" onClick={onPaneClick}>
{nodes?.map(node => (
<div
key={node.id}
data-testid={`node-${node.id}`}
className={`flow-node ${node.type}`}
onClick={e => {
e.stopPropagation();
onNodeClick?.(e, node);
}}
>
{/* For metrics, use shortName; for preaggs, use name */}
{node.data.shortName || node.data.name}
</div>
))}
{children}
</div>
),
Background: () => <div data-testid="background" />,
Controls: () => <div data-testid="controls" />,
MarkerType: { ArrowClosed: 'arrowclosed' },
useNodesState: nodes => [nodes, vi.fn(), vi.fn()],
useEdgesState: edges => [edges, vi.fn(), vi.fn()],
Handle: ({ type, position }) => (
<div data-testid={`handle-${type}-${position}`} />
),
Position: { Left: 'left', Right: 'right' },
};
});
// Import the component after mocks are set up
import { MetricFlowGraph } from '../MetricFlowGraph';
const mockGrainGroups = [
{
parent_name: 'default.repair_orders',
grain: ['date_id', 'customer_id'],
components: [
{ name: 'sum_revenue', expression: 'SUM(revenue)' },
{ name: 'count_orders', expression: 'COUNT(*)' },
],
},
{
parent_name: 'inventory.stock',
grain: ['warehouse_id'],
components: [{ name: 'sum_quantity', expression: 'SUM(quantity)' }],
},
];
const mockMetricFormulas = [
{
name: 'default.num_repair_orders',
short_name: 'num_repair_orders',
combiner: 'SUM(count_orders)',
is_derived: false,
components: ['count_orders'],
},
{
name: 'default.avg_repair_price',
short_name: 'avg_repair_price',
combiner: 'SUM(sum_revenue) / SUM(count_orders)',
is_derived: true,
components: ['sum_revenue', 'count_orders'],
},
{
name: 'inventory.total_stock',
short_name: 'total_stock',
combiner: 'SUM(sum_quantity)',
is_derived: false,
components: ['sum_quantity'],
},
];
describe('MetricFlowGraph', () => {
describe('Empty State', () => {
it('shows empty state when no grain groups provided', () => {
render(
<MetricFlowGraph
grainGroups={[]}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
expect(
screen.getByText(
/Select metrics and dimensions above to visualize the data flow/i,
),
).toBeInTheDocument();
});
it('shows empty state when no metric formulas provided', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={[]}
onNodeSelect={vi.fn()}
/>,
);
expect(
screen.getByText(
/Select metrics and dimensions above to visualize the data flow/i,
),
).toBeInTheDocument();
});
it('shows empty state when both are null', () => {
render(
<MetricFlowGraph
grainGroups={null}
metricFormulas={null}
onNodeSelect={vi.fn()}
/>,
);
expect(
screen.getByText(
/Select metrics and dimensions above to visualize the data flow/i,
),
).toBeInTheDocument();
});
it('shows empty icon in empty state', () => {
render(
<MetricFlowGraph
grainGroups={[]}
metricFormulas={[]}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByText('◎')).toBeInTheDocument();
});
});
describe('Graph Rendering', () => {
it('renders ReactFlow when data is provided', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
it('renders background and controls', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByTestId('background')).toBeInTheDocument();
expect(screen.getByTestId('controls')).toBeInTheDocument();
});
it('renders pre-aggregation nodes', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByText('repair_orders')).toBeInTheDocument();
expect(screen.getByText('stock')).toBeInTheDocument();
});
it('renders metric nodes', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByText('num_repair_orders')).toBeInTheDocument();
expect(screen.getByText('avg_repair_price')).toBeInTheDocument();
expect(screen.getByText('total_stock')).toBeInTheDocument();
});
});
describe('Node Selection', () => {
it('calls onNodeSelect with preagg data when preagg node is clicked', () => {
const onNodeSelect = vi.fn();
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={onNodeSelect}
/>,
);
const preaggNode = screen.getByText('repair_orders');
fireEvent.click(preaggNode);
expect(onNodeSelect).toHaveBeenCalledWith(
expect.objectContaining({
type: 'preagg',
index: 0,
data: mockGrainGroups[0],
}),
);
});
it('calls onNodeSelect with metric data when metric node is clicked', () => {
const onNodeSelect = vi.fn();
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={onNodeSelect}
/>,
);
const metricNode = screen.getByText('num_repair_orders');
fireEvent.click(metricNode);
expect(onNodeSelect).toHaveBeenCalledWith(
expect.objectContaining({
type: 'metric',
index: 0,
data: mockMetricFormulas[0],
}),
);
});
it('calls onNodeSelect with null when pane is clicked', () => {
const onNodeSelect = vi.fn();
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={onNodeSelect}
/>,
);
const flowPane = screen.getByTestId('react-flow');
fireEvent.click(flowPane);
expect(onNodeSelect).toHaveBeenCalledWith(null);
});
});
describe('Legend', () => {
it('renders legend with Pre-agg, Metric, and Derived labels', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByText('Pre-agg')).toBeInTheDocument();
expect(screen.getByText('Metric')).toBeInTheDocument();
expect(screen.getByText('Derived')).toBeInTheDocument();
});
});
describe('Node Types', () => {
it('creates preagg nodes for each grain group', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
// 2 grain groups = 2 preagg nodes
const preaggNodes = document.querySelectorAll('.flow-node.preagg');
expect(preaggNodes.length).toBe(2);
});
it('creates metric nodes for each metric formula', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
// 3 metric formulas = 3 metric nodes
const metricNodes = document.querySelectorAll('.flow-node.metric');
expect(metricNodes.length).toBe(3);
});
});
describe('Node Selection by Index', () => {
it('selects correct preagg when second preagg is clicked', () => {
const onNodeSelect = vi.fn();
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={onNodeSelect}
/>,
);
const stockNode = screen.getByText('stock');
fireEvent.click(stockNode);
expect(onNodeSelect).toHaveBeenCalledWith(
expect.objectContaining({
type: 'preagg',
index: 1,
data: mockGrainGroups[1],
}),
);
});
it('selects correct metric when derived metric is clicked', () => {
const onNodeSelect = vi.fn();
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={onNodeSelect}
/>,
);
const derivedMetric = screen.getByText('avg_repair_price');
fireEvent.click(derivedMetric);
expect(onNodeSelect).toHaveBeenCalledWith(
expect.objectContaining({
type: 'metric',
index: 1,
data: expect.objectContaining({
is_derived: true,
short_name: 'avg_repair_price',
}),
}),
);
});
});
describe('Graph Layout', () => {
it('uses dagre for layout computation', async () => {
const dagre = await import('dagre');
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
// dagre.layout should have been called
expect(dagre.layout).toHaveBeenCalled();
});
});
});
describe('MetricFlowGraph Node Display', () => {
it('shows parent name without namespace prefix', () => {
render(
<MetricFlowGraph
grainGroups={[
{
parent_name: 'default.namespace.my_table',
grain: ['id'],
components: [{ name: 'comp1' }],
},
]}
metricFormulas={[
{
name: 'default.metric',
short_name: 'metric',
components: ['comp1'],
},
]}
onNodeSelect={vi.fn()}
/>,
);
// Should show just 'my_table', not the full path
expect(screen.getByText('my_table')).toBeInTheDocument();
});
it('shows short name for metric', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
/>,
);
// Should show short_name
expect(screen.getByText('num_repair_orders')).toBeInTheDocument();
expect(screen.getByText('avg_repair_price')).toBeInTheDocument();
});
describe('Branch Coverage - Edge Cases', () => {
it('handles grain group with empty grain array', () => {
const grainGroupsEmptyGrain = [
{
parent_name: 'default.orders',
grain: [], // Empty grain array
components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
},
];
render(
<MetricFlowGraph
grainGroups={grainGroupsEmptyGrain}
metricFormulas={[
{
name: 'default.metric',
short_name: 'metric',
components: ['count_orders'],
is_derived: false,
},
]}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
it('handles grain group with no components', () => {
const grainGroupsNoComponents = [
{
parent_name: 'default.orders',
grain: ['date_id'],
components: [], // Empty components
},
];
render(
<MetricFlowGraph
grainGroups={grainGroupsNoComponents}
metricFormulas={[
{
name: 'default.metric',
short_name: 'metric',
components: [],
is_derived: false,
},
]}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
it('handles grain group with undefined components', () => {
const grainGroupsUndefinedComponents = [
{
parent_name: 'default.orders',
grain: ['date_id'],
// components is undefined
},
];
render(
<MetricFlowGraph
grainGroups={grainGroupsUndefinedComponents}
metricFormulas={[
{
name: 'default.metric',
short_name: 'metric',
components: [],
is_derived: false,
},
]}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
it('handles metric with is_derived false', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={[
{
name: 'default.simple_metric',
short_name: 'simple_metric',
combiner: 'SUM(count)',
is_derived: false,
components: ['count_orders'],
},
]}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByText('simple_metric')).toBeInTheDocument();
});
it('handles metric with is_derived true', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={[
{
name: 'default.derived_metric',
short_name: 'derived_metric',
combiner: 'SUM(a) / SUM(b)',
is_derived: true,
components: ['sum_revenue', 'count_orders'],
},
]}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByText('derived_metric')).toBeInTheDocument();
});
it('handles selectedNode prop for preagg', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
selectedNode={{ type: 'preagg', index: 0, data: mockGrainGroups[0] }}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
it('handles selectedNode prop for metric', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
selectedNode={{
type: 'metric',
index: 0,
data: mockMetricFormulas[0],
}}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
it('handles no selectedNode', () => {
render(
<MetricFlowGraph
grainGroups={mockGrainGroups}
metricFormulas={mockMetricFormulas}
onNodeSelect={vi.fn()}
selectedNode={null}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
it('handles grain group with undefined grain', () => {
const grainGroupsUndefinedGrain = [
{
parent_name: 'default.orders',
// grain is undefined
components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
},
];
render(
<MetricFlowGraph
grainGroups={grainGroupsUndefinedGrain}
metricFormulas={[
{
name: 'default.metric',
short_name: 'metric',
components: ['count_orders'],
is_derived: false,
},
]}
onNodeSelect={vi.fn()}
/>,
);
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
});
});
});