UNPKG

datajunction-ui

Version:
1,726 lines (1,416 loc) 49.9 kB
import { render, screen, fireEvent, waitFor, act, } from '@testing-library/react'; import DJClientContext from '../../../providers/djclient'; import { QueryPlannerPage } from '../index'; import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; import React from 'react'; // Mock the MetricFlowGraph component to avoid dagre dependency issues vi.mock('../MetricFlowGraph', () => ({ __esModule: true, default: ({ grainGroups, metricFormulas, selectedNode, onNodeSelect }) => { if (!grainGroups?.length || !metricFormulas?.length) { return <div data-testid="graph-empty">Select metrics and dimensions</div>; } return ( <div data-testid="metric-flow-graph"> <span className="graph-stats"> {grainGroups.length} pre-aggregations → {metricFormulas.length}{' '} metrics </span> <button data-testid="select-preagg" onClick={() => onNodeSelect?.({ type: 'preagg', index: 0, data: grainGroups[0], }) } > Select Pre-agg </button> <button data-testid="select-metric" onClick={() => onNodeSelect?.({ type: 'metric', index: 0, data: metricFormulas[0], }) } > Select Metric </button> </div> ); }, })); const mockDjClient = { metrics: vi.fn(), commonDimensions: vi.fn(), commonMetrics: vi.fn(), measuresV3: vi.fn(), metricsV3: vi.fn(), listCubesForPreset: vi.fn(), cubeForPlanner: vi.fn(), planPreaggs: vi.fn(), updatePreaggConfig: vi.fn(), materializePreagg: vi.fn(), runPreaggBackfill: vi.fn(), deactivatePreaggWorkflow: vi.fn(), deactivateCubeWorkflow: vi.fn(), createCube: vi.fn(), materializeCubeV2: vi.fn(), refreshCubeWorkflow: vi.fn(), runCubeBackfill: vi.fn(), listPreaggs: vi.fn(), getNodeColumnsWithPartitions: vi.fn(), setPartition: vi.fn(), }; const mockMetrics = [ 'default.num_repair_orders', 'default.avg_repair_price', 'default.total_repair_cost', 'sales.revenue', 'sales.order_count', ]; const mockCommonDimensions = [ { name: 'default.date_dim.dateint', type: 'timestamp', node_name: 'default.date_dim', node_display_name: 'Date', properties: [], path: ['default.date_dim'], }, { name: 'default.date_dim.month', type: 'int', node_name: 'default.date_dim', node_display_name: 'Date', properties: [], path: ['default.date_dim'], }, { name: 'default.hard_hat.country', type: 'string', node_name: 'default.hard_hat', node_display_name: 'Hard Hat', properties: [], path: ['default.hard_hat'], }, ]; const mockMeasuresResult = { grain_groups: [ { parent_name: 'default.repair_orders', aggregability: 'FULL', grain: ['date_id', 'customer_id'], components: [ { name: 'sum_revenue', expression: 'SUM(revenue)', aggregation: 'SUM', merge: 'SUM', }, { name: 'count_orders', expression: 'COUNT(*)', aggregation: 'COUNT', merge: 'SUM', }, ], sql: 'SELECT date_id, customer_id, SUM(revenue) FROM orders GROUP BY 1, 2', }, ], metric_formulas: [ { 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'], }, ], requested_dimensions: ['default.date_dim.dateint'], }; const mockMetricsResult = { sql: 'SELECT date_id, SUM(revenue) as total_revenue FROM orders GROUP BY 1', }; const mockCubes = [ { name: 'default.test_cube', display_name: 'Test Cube' }, { name: 'sales.revenue_cube', display_name: 'Revenue Cube' }, ]; const mockCubeData = { cube_node_metrics: ['default.num_repair_orders', 'default.avg_repair_price'], cube_node_dimensions: ['default.date_dim.dateint'], cube_dimension_objects: [ { name: 'default.date_dim.dateint', type: 'timestamp', attribute: 'dateint', role: '', properties: [], }, ], cubeMaterialization: { schedule: '0 6 * * *', strategy: 'incremental_time', lookbackWindow: '1 DAY', workflowUrls: ['http://workflow.example.com/1'], }, }; const renderPage = (initialEntries = ['/planner']) => { return render( <MemoryRouter initialEntries={initialEntries}> <Routes> <Route path="/planner" element={ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}> <QueryPlannerPage /> </DJClientContext.Provider> } /> </Routes> </MemoryRouter>, ); }; describe('QueryPlannerPage', () => { beforeEach(() => { mockDjClient.metrics.mockResolvedValue(mockMetrics); mockDjClient.commonDimensions.mockResolvedValue(mockCommonDimensions); mockDjClient.commonMetrics.mockResolvedValue([]); mockDjClient.measuresV3.mockResolvedValue(mockMeasuresResult); mockDjClient.metricsV3.mockResolvedValue(mockMetricsResult); mockDjClient.listCubesForPreset.mockResolvedValue(mockCubes); mockDjClient.cubeForPlanner.mockResolvedValue(null); mockDjClient.listPreaggs.mockResolvedValue({ items: [] }); mockDjClient.getNodeColumnsWithPartitions.mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [], }); }); afterEach(() => { vi.clearAllMocks(); }); describe('Initial Render', () => { it('renders the page header', async () => { renderPage(); // Page has "Query Planner" text in multiple places (header and empty state) await waitFor(() => { expect(screen.getAllByText('Query Planner').length).toBeGreaterThan(0); }); }); it('renders the metrics section', async () => { renderPage(); await waitFor(() => { expect(screen.getByText('Metrics')).toBeInTheDocument(); }); }); it('renders the dimensions section', async () => { renderPage(); await waitFor(() => { expect(screen.getByText('Dimensions')).toBeInTheDocument(); }); }); it('fetches metrics on mount', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('fetches cube list on mount', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('shows empty state when no metrics/dimensions selected', async () => { renderPage(); await waitFor(() => { expect( screen.getByText('Select Metrics & Dimensions'), ).toBeInTheDocument(); }); }); }); describe('Metric Selection', () => { it('displays metrics grouped by namespace', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Check namespace headers are present expect(screen.getByText('default')).toBeInTheDocument(); expect(screen.getByText('sales')).toBeInTheDocument(); }); it('expands namespace when clicked', async () => { renderPage(); // Wait for the namespace label to actually render, not just for the // metrics() spy to be called — the spy fires before React commits. const defaultNamespace = await screen.findByText('default'); fireEvent.click(defaultNamespace); // Metrics should now be visible await waitFor(() => { expect(screen.getByText('num_repair_orders')).toBeInTheDocument(); }); }); it('fetches common dimensions when metrics are selected', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Expand and select a metric const defaultNamespace = screen.getByText('default'); fireEvent.click(defaultNamespace); await waitFor(() => { const checkbox = screen.getByRole('checkbox', { name: /num_repair_orders/i, }); fireEvent.click(checkbox); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); }); }); describe('Search Functionality', () => { it('filters metrics by search term', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); const searchInput = screen.getByPlaceholderText('Search metrics...'); fireEvent.change(searchInput, { target: { value: 'repair' } }); // Should auto-expand matching namespaces await waitFor(() => { expect(screen.getByText('num_repair_orders')).toBeInTheDocument(); }); }); }); describe('Cube Preset Loading', () => { it('displays cube dropdown button when cubes are available', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); expect(screen.getByText('Load from Cube')).toBeInTheDocument(); }); it('opens cube dropdown when clicked', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); const cubeButton = screen.getByText('Load from Cube'); fireEvent.click(cubeButton); expect( screen.getByPlaceholderText('Search cubes...'), ).toBeInTheDocument(); }); it('loads cube data when a cube is selected', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); // Open dropdown const cubeButton = screen.getByText('Load from Cube'); fireEvent.click(cubeButton); // Select a cube const cubeOption = screen.getByText('Test Cube'); fireEvent.click(cubeOption); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalledWith( 'default.test_cube', ); }); }); }); describe('URL Parameter Handling', () => { it('initializes from URL with metrics parameter', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); renderPage([ '/planner?metrics=default.num_repair_orders,default.avg_repair_price', ]); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); }); it('initializes from URL with cube parameter', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); renderPage(['/planner?cube=default.test_cube']); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalledWith( 'default.test_cube', ); }); }); it('selects cube dimensions immediately from the preset without waiting on common dimensions', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); // Simulate the (slow) common-dimensions intersection never resolving; // the cube's dimensions must still be shown and selected from the preset. mockDjClient.commonDimensions.mockReturnValue(new Promise(() => {})); renderPage(['/planner?cube=default.test_cube']); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalledWith( 'default.test_cube', ); }); // Dimensions section is populated and the cube's dimension is selected, // even though commonDimensions never resolved (no spinner, no empty list). await waitFor(() => { expect( screen.getByText(/1 selected \/ 1 available/), ).toBeInTheDocument(); }); expect( screen.queryByText('Loading dimensions...'), ).not.toBeInTheDocument(); }); }); describe('Graph Interactions', () => { it('displays graph when metrics and dimensions are selected', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Expand and select metric fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); // Wait for dimensions and select one await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); const dimCheckbox = screen.getByRole('checkbox', { name: /dateint/i }); fireEvent.click(dimCheckbox); // Wait for measures to be fetched await waitFor(() => { expect(mockDjClient.measuresV3).toHaveBeenCalled(); }); // Graph should now be displayed await waitFor(() => { expect(screen.getByTestId('metric-flow-graph')).toBeInTheDocument(); }); }); it('shows loading state while building data flow', async () => { // Delay the measures response mockDjClient.measuresV3.mockImplementation( () => new Promise(resolve => setTimeout(() => resolve(mockMeasuresResult), 100), ), ); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric and dimension quickly fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); // Should show loading state await waitFor(() => { expect(screen.getByText('Building data flow...')).toBeInTheDocument(); }); }); it('shows pre-agg details when preagg node is selected', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric and dimension fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); // Wait for graph to appear await waitFor(() => { expect(screen.getByTestId('metric-flow-graph')).toBeInTheDocument(); }); // Click on preagg node fireEvent.click(screen.getByTestId('select-preagg')); // Should show pre-agg details await waitFor(() => { expect(screen.getByText('Pre-aggregation')).toBeInTheDocument(); }); }); it('shows metric details when metric node is selected', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric and dimension fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); // Wait for graph to appear await waitFor(() => { expect(screen.getByTestId('metric-flow-graph')).toBeInTheDocument(); }); // Click on metric node fireEvent.click(screen.getByTestId('select-metric')); // Should show metric details (badge shows "Metric" for non-derived) await waitFor(() => { expect(screen.getByText('Metric')).toBeInTheDocument(); }); }); }); describe('Error Handling', () => { it('displays error when API call fails', async () => { mockDjClient.measuresV3.mockRejectedValue(new Error('API Error')); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric and dimension fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); // Should show error await waitFor(() => { expect(screen.getByText('API Error')).toBeInTheDocument(); }); }); it('handles commonDimensions API error gracefully', async () => { mockDjClient.commonDimensions.mockRejectedValue( new Error('Dimensions fetch failed'), ); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select a metric fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); // Should handle gracefully (empty dimensions) await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); }); }); describe('Dimension Selection', () => { it('clears invalid dimension selections when dimensions change', async () => { // First commonDimensions call returns 2 dimensions mockDjClient.commonDimensions.mockResolvedValueOnce(mockCommonDimensions); // Second call returns only 1 dimension mockDjClient.commonDimensions.mockResolvedValueOnce([ mockCommonDimensions[0], ]); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select first metric fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); // Wait for dimensions await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); // Select a dimension const dimCheckbox = screen.getByRole('checkbox', { name: /dateint/i }); fireEvent.click(dimCheckbox); // Select another metric (triggers new commonDimensions call) await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /avg_repair_price/i }), ); }); // Invalid dimensions should be cleared automatically await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalledTimes(2); }); }); }); describe('Clear Selection', () => { it('clears all selections when Clear is clicked', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); // Load a cube fireEvent.click(screen.getByText('Load from Cube')); fireEvent.click(screen.getByText('Test Cube')); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalled(); }); // Wait for common dimensions to load after cube selection await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); // Click the global Clear button (clear-all-btn class) const clearButton = document.querySelector('.clear-all-btn'); fireEvent.click(clearButton); // Should show "Load from Cube" again (cube unloaded) await waitFor(() => { expect(screen.getByText('Load from Cube')).toBeInTheDocument(); }); }); }); describe('Pre-aggregation Lookup', () => { it('fetches existing pre-aggregations when measures result changes', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric and dimension fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); // Should fetch existing pre-aggs await waitFor(() => { expect(mockDjClient.listPreaggs).toHaveBeenCalled(); }); }); }); describe('Filter Handling', () => { it('adds a filter when add filter button is clicked', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Find filter input and add a filter const filterInput = screen.getByPlaceholderText( /e\.g\. v3\.date\.date_id/i, ); fireEvent.change(filterInput, { target: { value: "date_id >= '2024-01-01'" }, }); const addButton = screen.getByText('Add'); fireEvent.click(addButton); // Filter should be added (check for chip) await waitFor(() => { expect(screen.getByText("date_id >= '2024-01-01'")).toBeInTheDocument(); }); }); it('removes a filter when remove button is clicked', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Add a filter first const filterInput = screen.getByPlaceholderText( /e\.g\. v3\.date\.date_id/i, ); fireEvent.change(filterInput, { target: { value: "status = 'active'" }, }); fireEvent.click(screen.getByText('Add')); await waitFor(() => { expect(screen.getByText("status = 'active'")).toBeInTheDocument(); }); // Remove the filter (button has generic title "Remove filter") const removeButton = screen.getByTitle('Remove filter'); fireEvent.click(removeButton); await waitFor(() => { expect(screen.queryByText("status = 'active'")).not.toBeInTheDocument(); }); }); it('clears existing filters when a new cube is loaded', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); // Set a filter in the current (ad-hoc) session. const filterInput = screen.getByPlaceholderText( /e\.g\. v3\.date\.date_id/i, ); fireEvent.change(filterInput, { target: { value: "status = 'active'" } }); fireEvent.click(screen.getByText('Add')); await waitFor(() => { expect(screen.getByText("status = 'active'")).toBeInTheDocument(); }); // Load a cube preset from the dropdown. fireEvent.click(screen.getByText('Load from Cube')); fireEvent.click(screen.getByText('Test Cube')); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalled(); }); // The stale filter from the previous selection is gone. await waitFor(() => { expect(screen.queryByText("status = 'active'")).not.toBeInTheDocument(); }); }); it('moves a filter back into the input when its chip is clicked (edit)', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); const filterInput = screen.getByPlaceholderText( /e\.g\. v3\.date\.date_id/i, ); fireEvent.change(filterInput, { target: { value: "status = 'active'" } }); fireEvent.click(screen.getByText('Add')); await waitFor(() => { expect(screen.getByText("status = 'active'")).toBeInTheDocument(); }); // Click the chip text to edit it. fireEvent.click(screen.getByText("status = 'active'")); // The expression returns to the input and the chip is gone. await waitFor(() => { expect(filterInput.value).toBe("status = 'active'"); }); expect(screen.queryByText("status = 'active'")).not.toBeInTheDocument(); }); it('disables add button when filter input is empty', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); const addButton = screen.getByText('Add'); expect(addButton).toBeDisabled(); }); it('prefills the filter input when a dimension is dragged onto the Filters section', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); renderPage(['/planner?cube=default.test_cube']); // Cube preset loads and selects its dimension (rendered as a draggable chip). await waitFor(() => { expect( screen.getByText(/1 selected \/ 1 available/), ).toBeInTheDocument(); }); // Minimal DataTransfer stand-in for jsdom. const dataTransfer = { store: {}, types: ['application/x-dj-dimension'], setData(type, val) { this.store[type] = val; }, getData(type) { return this.store[type] || ''; }, }; dataTransfer.setData( 'application/x-dj-dimension', 'default.date_dim.dateint', ); const filtersSection = document.querySelector('.filters-section'); fireEvent.dragOver(filtersSection, { dataTransfer }); fireEvent.drop(filtersSection, { dataTransfer }); const filterInput = screen.getByPlaceholderText( /e\.g\. v3\.date\.date_id/i, ); await waitFor(() => { expect(filterInput.value).toContain('default.date_dim.dateint'); }); }); it('expands a cube-based URL to metrics/dimensions/filters when a filter is added', async () => { mockDjClient.cubeForPlanner.mockResolvedValue(mockCubeData); const LocationDisplay = () => { const location = useLocation(); return <div data-testid="location-search">{location.search}</div>; }; render( <MemoryRouter initialEntries={['/planner?cube=default.test_cube']}> <Routes> <Route path="/planner" element={ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }} > <> <QueryPlannerPage /> <LocationDisplay /> </> </DJClientContext.Provider> } /> </Routes> </MemoryRouter>, ); // Cube preset loads from the URL... await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalledWith( 'default.test_cube', ); }); // ...and the URL stays compact (just the cube name) until the user edits. await waitFor(() => { expect(screen.getByTestId('location-search').textContent).toContain( 'cube=default.test_cube', ); }); // Cube dimensions are applied once common dimensions resolve. await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); // Apply a filter on the cube-based URL. const filterInput = screen.getByPlaceholderText( /e\.g\. v3\.date\.date_id/i, ); fireEvent.change(filterInput, { target: { value: 'default.date_dim.dateint = 20260101' }, }); fireEvent.click(screen.getByText('Add')); // The URL must expand to its fully-qualified form so the filter is // reflected and the link is shareable: the cube param is dropped and // metrics, dimensions, and filters are all encoded. await waitFor(() => { const search = screen.getByTestId('location-search').textContent; expect(search).not.toContain('cube='); expect(search).toContain('metrics='); expect(search).toContain('dimensions='); expect(search).toContain('filters='); }); }); }); describe('Run Query', () => { beforeEach(() => { mockDjClient.metricsV3.mockResolvedValue({ sql: 'SELECT * FROM metrics', dialect: 'SPARK', cube_name: null, }); }); it('shows Run Query button when metrics and dimensions selected', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); // Select dimension fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); // Run Query button should exist await waitFor(() => { expect(screen.getByText('Run Query')).toBeInTheDocument(); }); }); it('disables Run Query button when no metrics selected', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); const runButton = screen.getByText('Run Query'); expect(runButton).toBeDisabled(); }); }); describe('Materialization Handlers', () => { const mockMeasuresResult = { grainGroups: [ { node: 'default.repair_orders', grain_columns: ['default.date_dim.dateint'], measures: [ { name: 'sum_revenue', expression: 'SUM(revenue)' }, { name: 'count_orders', expression: 'COUNT(*)' }, ], }, ], metricFormulas: [ { metric: 'default.num_repair_orders', formula: 'default.repair_orders.count_orders', }, ], }; beforeEach(() => { mockDjClient.measuresV3.mockResolvedValue(mockMeasuresResult); mockDjClient.metricsV3.mockResolvedValue({ sql: 'SELECT * FROM metrics', dialect: 'SPARK', cube_name: null, }); mockDjClient.listPreaggs.mockResolvedValue({ items: [] }); mockDjClient.getNodeColumnsWithPartitions.mockResolvedValue({ columns: [], temporalPartitions: [], }); }); it('calls planPreaggs when materialization is planned', async () => { mockDjClient.planPreaggs.mockResolvedValue({ preaggs: [ { id: 1, node_name: 'default.repair_orders', grain_columns: ['default.date_dim.dateint'], }, ], }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric and dimension fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); await waitFor(() => { expect(mockDjClient.measuresV3).toHaveBeenCalled(); }); }); it('handles planPreaggs error gracefully', async () => { mockDjClient.planPreaggs.mockResolvedValue({ _error: true, message: 'Failed to plan', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('calls materializePreagg when workflow is created', async () => { mockDjClient.planPreaggs.mockResolvedValue({ preaggs: [ { id: 123, node_name: 'default.repair_orders', grain_columns: ['default.date_dim.dateint'], }, ], }); mockDjClient.materializePreagg.mockResolvedValue({ workflow_urls: ['http://workflow.example.com'], workflow_status: 'active', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('handles materializePreagg error', async () => { mockDjClient.materializePreagg.mockResolvedValue({ _error: true, message: 'Failed to create workflow', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('calls runPreaggBackfill when backfill is triggered', async () => { mockDjClient.runPreaggBackfill.mockResolvedValue({ job_url: 'http://job.example.com', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('handles runPreaggBackfill error', async () => { mockDjClient.runPreaggBackfill.mockResolvedValue({ _error: true, message: 'Backfill failed', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('calls updatePreaggConfig for config updates', async () => { mockDjClient.updatePreaggConfig.mockResolvedValue({ id: 123, strategy: 'incremental_time', schedule: '0 6 * * *', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('handles updatePreaggConfig error', async () => { mockDjClient.updatePreaggConfig.mockResolvedValue({ _error: true, message: 'Config update failed', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('calls deactivatePreaggWorkflow when workflow is deactivated', async () => { mockDjClient.deactivatePreaggWorkflow.mockResolvedValue({ success: true, }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('handles deactivatePreaggWorkflow error', async () => { mockDjClient.deactivatePreaggWorkflow.mockResolvedValue({ _error: true, message: 'Failed to deactivate', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); }); describe('Cube Materialization Flow', () => { beforeEach(() => { mockDjClient.listCubesForPreset.mockResolvedValue([ { name: 'default.test_cube', display_name: 'Test Cube' }, ]); mockDjClient.cubeForPlanner.mockResolvedValue({ name: 'default.test_cube', display_name: 'Test Cube', cube_node_metrics: ['default.num_repair_orders'], cube_node_dimensions: ['default.date_dim.dateint'], cubeMaterialization: { strategy: 'full', schedule: '0 6 * * *', lookbackWindow: null, druidDatasource: 'test_ds', preaggTables: [], workflowUrls: ['http://workflow.url'], }, availability: null, }); }); it('calls createCube when new cube is created', async () => { mockDjClient.createCube.mockResolvedValue({ status: 200, json: { name: 'new.cube' }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('handles createCube error - cube already exists', async () => { mockDjClient.createCube.mockResolvedValue({ status: 400, json: { message: 'Cube already exists' }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('handles createCube error - other error', async () => { mockDjClient.createCube.mockResolvedValue({ status: 500, json: { message: 'Server error' }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('calls materializeCubeV2 for cube materialization', async () => { mockDjClient.materializeCubeV2.mockResolvedValue({ status: 200, json: { workflow_urls: ['http://cube-workflow.example.com'], }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('handles materializeCubeV2 error', async () => { mockDjClient.materializeCubeV2.mockResolvedValue({ status: 500, json: { message: 'Cube materialization failed' }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('calls refreshCubeWorkflow for config updates', async () => { mockDjClient.refreshCubeWorkflow.mockResolvedValue({ status: 200, json: { workflow_urls: ['http://updated-workflow.url'] }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('handles refreshCubeWorkflow error', async () => { mockDjClient.refreshCubeWorkflow.mockResolvedValue({ status: 500, json: { message: 'Refresh failed' }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('calls deactivateCubeWorkflow when deactivating', async () => { mockDjClient.deactivateCubeWorkflow.mockResolvedValue({ status: 200, json: { success: true }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); // Load cube first fireEvent.click(screen.getByText('Load from Cube')); fireEvent.click(screen.getByText('Test Cube')); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalled(); }); }); it('handles deactivateCubeWorkflow error', async () => { mockDjClient.deactivateCubeWorkflow.mockResolvedValue({ status: 500, json: { message: 'Deactivation failed' }, }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('calls runCubeBackfill for backfill', async () => { mockDjClient.runCubeBackfill.mockResolvedValue({ job_url: 'http://backfill-job.example.com', }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); // Load cube fireEvent.click(screen.getByText('Load from Cube')); fireEvent.click(screen.getByText('Test Cube')); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalled(); }); }); it('handles runCubeBackfill error', async () => { mockDjClient.runCubeBackfill.mockResolvedValue({ _error: true, message: 'Backfill failed', }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); }); it('handles runCubeBackfill with missing cube name', async () => { // Don't load any cube - loadedCubeName will be null renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // No cube loaded, so runCubeBackfill shouldn't be callable }); }); describe('Workflow Status Updates', () => { const mockMeasuresWithPreagg = { grainGroups: [ { node: 'default.repair_orders', grain_columns: ['default.date_dim.dateint'], measures: [{ name: 'count_orders', expression: 'COUNT(*)' }], }, ], metricFormulas: [ { metric: 'default.num_repair_orders', formula: 'count_orders', }, ], }; beforeEach(() => { mockDjClient.measuresV3.mockResolvedValue(mockMeasuresWithPreagg); mockDjClient.metricsV3.mockResolvedValue({ sql: 'SELECT * FROM metrics', dialect: 'SPARK', cube_name: null, }); mockDjClient.listPreaggs.mockResolvedValue({ items: [] }); }); it('updates state when workflow is created successfully', async () => { mockDjClient.materializePreagg.mockResolvedValue({ workflow_urls: ['http://new-workflow.example.com'], workflow_status: 'active', }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('updates state when workflow is deactivated', async () => { mockDjClient.deactivatePreaggWorkflow.mockResolvedValue({ success: true, }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('handles network errors in workflow operations', async () => { mockDjClient.materializePreagg.mockRejectedValue( new Error('Network error'), ); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); }); describe('Raw SQL Fetching', () => { it('fetches raw SQL when needed', async () => { mockDjClient.metricsV3.mockResolvedValue({ sql: 'SELECT * FROM raw_tables', dialect: 'SPARK', cube_name: null, }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); // Select metric and dimension to trigger SQL fetch fireEvent.click(screen.getByText('default')); await waitFor(() => { fireEvent.click( screen.getByRole('checkbox', { name: /num_repair_orders/i }), ); }); await waitFor(() => { expect(mockDjClient.commonDimensions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('checkbox', { name: /dateint/i })); await waitFor(() => { expect(mockDjClient.metricsV3).toHaveBeenCalled(); }); }); }); describe('Cube Workflow Handlers', () => { beforeEach(() => { mockDjClient.listCubesForPreset.mockResolvedValue([ { name: 'default.test_cube', display_name: 'Test Cube' }, ]); }); it('handles cube deactivation', async () => { mockDjClient.cubeForPlanner.mockResolvedValue({ name: 'default.test_cube', display_name: 'Test Cube', cube_node_metrics: ['default.num_repair_orders'], cube_node_dimensions: ['default.date_dim.dateint'], cubeMaterialization: { strategy: 'full', schedule: '0 6 * * *', lookbackWindow: null, druidDatasource: 'test_ds', preaggTables: [], workflowUrls: ['http://workflow.url'], }, availability: null, }); mockDjClient.deactivateCubeWorkflow.mockResolvedValue({ success: true }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); // Load cube fireEvent.click(screen.getByText('Load from Cube')); fireEvent.click(screen.getByText('Test Cube')); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalled(); }); }); it('handles cube backfill', async () => { mockDjClient.cubeForPlanner.mockResolvedValue({ name: 'default.test_cube', display_name: 'Test Cube', cube_node_metrics: ['default.num_repair_orders'], cube_node_dimensions: ['default.date_dim.dateint'], cubeMaterialization: { strategy: 'full', schedule: '0 6 * * *', lookbackWindow: null, druidDatasource: 'test_ds', preaggTables: [], workflowUrls: [], }, availability: null, }); mockDjClient.runCubeBackfill.mockResolvedValue({ job_url: 'http://job' }); renderPage(); await waitFor(() => { expect(mockDjClient.listCubesForPreset).toHaveBeenCalled(); }); // Load cube fireEvent.click(screen.getByText('Load from Cube')); fireEvent.click(screen.getByText('Test Cube')); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalled(); }); }); }); describe('Partition Handling', () => { it('fetches node partitions when needed', async () => { mockDjClient.getNodeColumnsWithPartitions.mockResolvedValue({ columns: [{ name: 'date_col', type: 'date' }], temporalPartitions: [{ column: 'date_col' }], }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); it('handles setPartition call', async () => { mockDjClient.setPartition.mockResolvedValue({ success: true }); renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); }); describe('Results View Navigation', () => { it('shows results view hint when no selection', async () => { renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); expect( screen.getByText('Select at least one metric to run a query'), ).toBeInTheDocument(); }); }); describe('Pane Resize Handlers', () => { it('resizes selection panel when dragging left vertical-resizer', async () => { renderPage(); await waitFor(() => expect(mockDjClient.metrics).toHaveBeenCalled()); const resizers = document.querySelectorAll('.vertical-resizer'); // First resizer is between selection panel and main content const selectionResizer = resizers[0]; expect(selectionResizer).toBeInTheDocument(); // Mousedown begins drag fireEvent.mouseDown(selectionResizer, { clientX: 200 }); // Move right → wider selection panel fireEvent.mouseMove(document, { clientX: 280 }); const selectionPane = document.querySelector('.planner-selection'); // Inline width should be applied (clamped to min 200) expect(selectionPane.style.width).toBeTruthy(); // Mouseup ends drag fireEvent.mouseUp(document); // Further moves should not update const widthAfterUp = selectionPane.style.width; fireEvent.mouseMove(document, { clientX: 600 }); expect(selectionPane.style.width).toBe(widthAfterUp); }); it('resizes details panel when dragging right vertical-resizer', async () => { renderPage(); await waitFor(() => expect(mockDjClient.metrics).toHaveBeenCalled()); const resizers = document.querySelectorAll('.vertical-resizer'); // Second resizer is between graph and details panel (visible in non-results state) expect(resizers.length).toBeGreaterThanOrEqual(2); const detailsResizer = resizers[1]; // Mousedown begins drag fireEvent.mouseDown(detailsResizer, { clientX: 800 }); // Move left → wider details panel fireEvent.mouseMove(document, { clientX: 700 }); const detailsPane = document.querySelector('.planner-details'); expect(detailsPane.style.width).toBeTruthy(); // Mouseup ends drag fireEvent.mouseUp(document); const widthAfterUp = detailsPane.style.width; fireEvent.mouseMove(document, { clientX: 400 }); expect(detailsPane.style.width).toBe(widthAfterUp); }); }); describe('URL Init Edge Cases', () => { it('handles invalid cube data returned from cubeForPlanner (missing cube_node_metrics)', async () => { // cubeForPlanner returns data without cube_node_metrics array mockDjClient.cubeForPlanner.mockResolvedValue({ name: 'bad.cube' }); renderPage(['/planner?cube=bad.cube']); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalledWith('bad.cube'); }); // Page should still render without crashing (invalid cube data logged, not applied) expect(screen.getByText('Load from Cube')).toBeInTheDocument(); }); it('handles cubeForPlanner throwing during URL init', async () => { mockDjClient.cubeForPlanner.mockRejectedValue(new Error('Network error')); renderPage(['/planner?cube=bad.cube']); await waitFor(() => { expect(mockDjClient.cubeForPlanner).toHaveBeenCalled(); }); // Page should still render gracefully expect(screen.getByText('Load from Cube')).toBeInTheDocument(); }); it('handles metrics API failure on mount gracefully', async () => { mockDjClient.metrics.mockRejectedValue(new Error('API down')); // Should not throw renderPage(); await waitFor(() => { expect(mockDjClient.metrics).toHaveBeenCalled(); }); }); }); });