UNPKG

datajunction-ui

Version:
1,691 lines (1,453 loc) 102 kB
import { render, screen, fireEvent, waitFor, act, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { QueryOverviewPanel, PreAggDetailsPanel, MetricDetailsPanel, getDimensionNodeName, normalizeGrain, getScheduleSummary, getStatusInfo, inferGranularity, formatBytes, getScanWarningLevel, formatScanEstimate, } from '../PreAggDetailsPanel'; import React from 'react'; // Mock the syntax highlighter to avoid issues with CSS imports vi.mock('react-syntax-highlighter', () => ({ Light: ({ children }) => ( <pre data-testid="syntax-highlighter">{children}</pre> ), })); vi.mock('react-syntax-highlighter/src/styles/hljs', () => ({ atomOneLight: {}, })); // Mock clipboard API Object.assign(navigator, { clipboard: { writeText: vi.fn(), }, }); 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', }, { parent_name: 'inventory.stock', aggregability: 'LIMITED', grain: ['warehouse_id'], components: [ { name: 'sum_quantity', expression: 'SUM(quantity)', aggregation: 'SUM', merge: 'SUM', }, ], }, ], 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'], }, ], }; const mockMetricsResult = { sql: 'SELECT date_id, SUM(revenue) as total_revenue FROM orders GROUP BY 1', }; const renderWithRouter = component => { return render(<MemoryRouter>{component}</MemoryRouter>); }; describe('QueryOverviewPanel', () => { const defaultProps = { measuresResult: mockMeasuresResult, metricsResult: mockMetricsResult, selectedMetrics: ['default.num_repair_orders', 'default.avg_repair_price'], selectedDimensions: [ 'default.date_dim.dateint', 'default.customer.country', ], }; describe('Empty States', () => { it('shows hint when no metrics selected', () => { renderWithRouter( <QueryOverviewPanel {...defaultProps} selectedMetrics={[]} selectedDimensions={[]} />, ); expect(screen.getByText('Query Planner')).toBeInTheDocument(); expect( screen.getByText(/Select metrics and dimensions/), ).toBeInTheDocument(); }); it('shows loading state when results are pending', () => { renderWithRouter( <QueryOverviewPanel {...defaultProps} measuresResult={null} metricsResult={null} />, ); expect(screen.getByText('Building query plan...')).toBeInTheDocument(); }); }); describe('Header', () => { it('renders the overview header', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('Query Plan')).toBeInTheDocument(); }); it('shows metric and dimension counts', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('2 metrics × 2 dimensions')).toBeInTheDocument(); }); }); describe('Pre-Aggregations Summary', () => { it('displays pre-aggregations section', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText(/Pre-Aggregations/)).toBeInTheDocument(); }); it('shows correct count of pre-aggregations', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('Pre-Aggregations (2)')).toBeInTheDocument(); }); it('displays pre-agg source names', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('repair_orders')).toBeInTheDocument(); expect(screen.getByText('stock')).toBeInTheDocument(); }); it('shows status badge for each pre-agg', () => { // The updated UI shows status badges instead of aggregability badges renderWithRouter(<QueryOverviewPanel {...defaultProps} />); // All pre-aggs without materialization config show "Not Set" status expect(screen.getAllByText('○ Not Set').length).toBe(2); }); it('displays grain columns', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('date_id, customer_id')).toBeInTheDocument(); expect(screen.getByText('warehouse_id')).toBeInTheDocument(); }); it('shows materialization status', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); // Status shows "Not Set" when no materialization is configured expect(screen.getAllByText('○ Not Set').length).toBe(2); }); }); describe('Metrics Summary', () => { it('displays metrics section', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText(/Metrics \(2\)/)).toBeInTheDocument(); }); it('shows metric short names', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('num_repair_orders')).toBeInTheDocument(); expect(screen.getByText('avg_repair_price')).toBeInTheDocument(); }); it('shows derived badge for derived metrics', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('Derived')).toBeInTheDocument(); }); it('renders metric links', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); const links = screen.getAllByRole('link'); expect( links.some( link => link.getAttribute('href') === '/nodes/default.num_repair_orders', ), ).toBe(true); }); }); describe('Dimensions Summary', () => { it('displays dimensions section', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText(/Dimensions \(2\)/)).toBeInTheDocument(); }); it('shows dimension short names', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('dateint')).toBeInTheDocument(); expect(screen.getByText('country')).toBeInTheDocument(); }); it('renders dimension links', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); const links = screen.getAllByRole('link'); expect( links.some( link => link.getAttribute('href') === '/nodes/default.date_dim', ), ).toBe(true); }); }); describe('SQL Section', () => { it('displays generated SQL section', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('Generated SQL')).toBeInTheDocument(); }); it('shows SQL view toggle with Optimized and Raw options', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByText('Optimized')).toBeInTheDocument(); expect(screen.getByText('Raw')).toBeInTheDocument(); }); it('renders SQL in syntax highlighter', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); expect(screen.getByTestId('syntax-highlighter')).toBeInTheDocument(); expect(screen.getByText(mockMetricsResult.sql)).toBeInTheDocument(); }); it('defaults to Optimized view', () => { renderWithRouter(<QueryOverviewPanel {...defaultProps} />); const optimizedBtn = screen.getByText('Optimized'); expect(optimizedBtn).toHaveClass('active'); }); it('fetches and displays raw SQL when Raw tab is clicked', async () => { const mockRawSql = 'SELECT * FROM raw_table WHERE date_id = 123 GROUP BY 1'; const onFetchRawSql = vi.fn().mockResolvedValue(mockRawSql); renderWithRouter( <QueryOverviewPanel {...defaultProps} onFetchRawSql={onFetchRawSql} />, ); // Click Raw tab const rawBtn = screen.getByText('Raw'); await act(async () => { fireEvent.click(rawBtn); }); await waitFor(() => { expect(onFetchRawSql).toHaveBeenCalled(); }); }); }); describe('Materialization CTA', () => { it('shows Configure button when not materialized', () => { renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} />, ); expect(screen.getByText('Configure')).toBeInTheDocument(); }); it('shows CTA content with Ready to materialize text', () => { renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} />, ); expect(screen.getByText('Ready to materialize?')).toBeInTheDocument(); }); it('opens configuration form when Configure button is clicked', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); // Should show configuration form await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); }); }); describe('Materialization Configuration Form', () => { const setupConfigForm = async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [ { name: 'date_id', type: 'int' }, { name: 'customer_id', type: 'int' }, ], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); const onPlanMaterialization = vi.fn().mockResolvedValue({}); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={onPlanMaterialization} onFetchNodePartitions={onFetchNodePartitions} />, ); const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); return { onPlanMaterialization, onFetchNodePartitions }; }; it('shows strategy options (Full and Incremental)', async () => { await setupConfigForm(); expect(screen.getByText('Strategy')).toBeInTheDocument(); expect(screen.getByText('Full')).toBeInTheDocument(); expect(screen.getByText('Incremental')).toBeInTheDocument(); }); it('allows switching strategy to Full', async () => { await setupConfigForm(); const fullRadio = screen.getByLabelText('Full'); fireEvent.click(fullRadio); expect(fullRadio).toBeChecked(); }); it('shows close button on configuration form', async () => { await setupConfigForm(); // Close button is the × in the header const closeButtons = screen.getAllByText('×'); expect(closeButtons.length).toBeGreaterThan(0); }); it('closes configuration form when close button is clicked', async () => { await setupConfigForm(); // Find and click the close button const closeButton = screen.getByRole('button', { name: /×/, }); fireEvent.click(closeButton); // Configuration form should be closed await waitFor(() => { expect( screen.queryByText('Configure Materialization'), ).not.toBeInTheDocument(); }); }); }); describe('Error Display', () => { it('shows materialization error when present', () => { renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} materializationError="Failed to plan materialization" onClearError={vi.fn()} />, ); expect( screen.getByText('Failed to plan materialization'), ).toBeInTheDocument(); }); it('calls onClearError when dismiss button is clicked', () => { const onClearError = vi.fn(); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} materializationError="Failed to plan materialization" onClearError={onClearError} />, ); // Find dismiss button (aria-label="Dismiss error") const dismissBtn = screen.getByLabelText('Dismiss error'); fireEvent.click(dismissBtn); expect(onClearError).toHaveBeenCalled(); }); }); describe('Workflow URLs Display', () => { it('displays workflow URLs when provided', () => { renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} workflowUrls={['http://workflow.example.com/job/123']} onClearWorkflowUrls={vi.fn()} />, ); // The workflow URL or a related indicator should be shown expect(screen.getByText(/workflow/i)).toBeInTheDocument(); }); }); describe('Planned Pre-aggregations', () => { it('shows status badges for planned pre-aggs', () => { const plannedPreaggs = { 'default.repair_orders|customer_id,date_id': { id: 1, node_name: 'default.repair_orders', grain_columns: ['date_id', 'customer_id'], workflow_status: 'active', workflow_urls: ['http://example.com'], }, }; renderWithRouter( <QueryOverviewPanel {...defaultProps} plannedPreaggs={plannedPreaggs} onPlanMaterialization={vi.fn()} />, ); // Should show some indicator of planned state // The exact text depends on the status logic expect( screen.queryByText('Not Set') || screen.queryByText('Workflow Active'), ).toBeTruthy(); }); }); describe('Loaded Cube Display', () => { it('shows cube name banner when loadedCubeName is provided', () => { renderWithRouter( <QueryOverviewPanel {...defaultProps} loadedCubeName="default.test_cube" onPlanMaterialization={vi.fn()} />, ); // Should show some indication of loaded cube // The exact text depends on the implementation expect(screen.getByText(/Query Plan/)).toBeInTheDocument(); }); }); describe('Partition Setup Form', () => { const setupPartitionTest = async (partitionResults = {}) => { const mockNodePartitions = { 'default.repair_orders': { columns: [ { name: 'date_id', type: 'int' }, { name: 'customer_id', type: 'int' }, { name: 'dateint', type: 'int' }, ], temporalPartitions: [], ...partitionResults, }, }; const onFetchNodePartitions = vi.fn().mockImplementation(nodeName => Promise.resolve( mockNodePartitions[nodeName] || { columns: [], temporalPartitions: [], }, ), ); const onSetPartition = vi.fn().mockResolvedValue({ status: 200 }); const onPlanMaterialization = vi.fn().mockResolvedValue({}); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={onPlanMaterialization} onFetchNodePartitions={onFetchNodePartitions} onSetPartition={onSetPartition} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); return { onFetchNodePartitions, onSetPartition, onPlanMaterialization }; }; it('shows partition setup form when incremental is selected and no temporal partitions', async () => { await setupPartitionTest(); // Select incremental strategy const incrementalRadio = screen.getByLabelText('Incremental'); await act(async () => { fireEvent.click(incrementalRadio); }); // Should show partition setup header await waitFor(() => { expect( screen.getByText('Set up temporal partitions for incremental builds'), ).toBeInTheDocument(); }); }); it('shows column dropdown with date-like columns prioritized', async () => { await setupPartitionTest(); // Select incremental strategy const incrementalRadio = screen.getByLabelText('Incremental'); await act(async () => { fireEvent.click(incrementalRadio); }); await waitFor(() => { // Multiple Column labels may exist (one per node) const columnLabels = screen.getAllByText('Column'); expect(columnLabels.length).toBeGreaterThan(0); }); // Should have date-like columns with star markers expect(screen.getByText(/dateint.*★/)).toBeInTheDocument(); expect(screen.getByText(/date_id.*★/)).toBeInTheDocument(); }); it('shows granularity dropdown with Day, Hour, Month options', async () => { await setupPartitionTest(); const incrementalRadio = screen.getByLabelText('Incremental'); await act(async () => { fireEvent.click(incrementalRadio); }); await waitFor(() => { const granularityLabels = screen.getAllByText('Granularity'); expect(granularityLabels.length).toBeGreaterThan(0); }); // Check granularity options exist - use getAllByRole since there may be multiple const granularitySelects = screen.getAllByRole('combobox'); // Find the one with Day selected const daySelect = granularitySelects.find( select => select.value === 'day', ); expect(daySelect).toBeInTheDocument(); }); it('shows format input field with placeholder', async () => { await setupPartitionTest(); const incrementalRadio = screen.getByLabelText('Incremental'); await act(async () => { fireEvent.click(incrementalRadio); }); await waitFor(() => { const formatLabels = screen.getAllByText('Format'); expect(formatLabels.length).toBeGreaterThan(0); }); // Format input should have placeholder - there may be multiple const formatInputs = screen.getAllByPlaceholderText('yyyyMMdd'); expect(formatInputs.length).toBeGreaterThan(0); }); it('disables Set button when no column is selected', async () => { await setupPartitionTest(); const incrementalRadio = screen.getByLabelText('Incremental'); await act(async () => { fireEvent.click(incrementalRadio); }); await waitFor(() => { // Wait for partition setup form to show expect( screen.getByText('Set up temporal partitions for incremental builds'), ).toBeInTheDocument(); }); // Set button should be disabled when no column selected (initially empty) const setBtns = screen.getAllByText('Set'); // At least one Set button should be disabled const hasDisabledBtn = setBtns.some(btn => btn.disabled); expect(hasDisabledBtn).toBe(true); }); it('enables Set button when column is selected', async () => { await setupPartitionTest(); const incrementalRadio = screen.getByLabelText('Incremental'); await act(async () => { fireEvent.click(incrementalRadio); }); await waitFor(() => { const columnLabels = screen.getAllByText('Column'); expect(columnLabels.length).toBeGreaterThan(0); }); // Select a column in the first dropdown const columnSelects = screen.getAllByRole('combobox'); await act(async () => { fireEvent.change(columnSelects[0], { target: { value: 'date_id' } }); }); // Set button should now be enabled const setBtns = screen.getAllByText('Set'); expect(setBtns[0]).not.toBeDisabled(); }); it('calls onSetPartition when Set button is clicked', async () => { const { onSetPartition } = await setupPartitionTest(); const incrementalRadio = screen.getByLabelText('Incremental'); await act(async () => { fireEvent.click(incrementalRadio); }); await waitFor(() => { const columnLabels = screen.getAllByText('Column'); expect(columnLabels.length).toBeGreaterThan(0); }); // Select a column const columnSelects = screen.getAllByRole('combobox'); await act(async () => { fireEvent.change(columnSelects[0], { target: { value: 'date_id' } }); }); // Click Set const setBtns = screen.getAllByText('Set'); await act(async () => { fireEvent.click(setBtns[0]); }); await waitFor(() => { expect(onSetPartition).toHaveBeenCalledWith( 'default.repair_orders', 'date_id', 'temporal', 'yyyyMMdd', 'day', ); }); }); it('shows success state when partition is already configured', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); // When partition already exists, the incremental strategy should default to enabled // and partition setup form should NOT be shown await waitFor(() => { // The incremental option should show the partition name badge // If partition setup prompt is NOT shown, it means partition is configured expect( screen.queryByText( 'Set up temporal partitions for incremental builds', ), ).not.toBeInTheDocument(); }); }); }); describe('Backfill Date Range in Config Form', () => { const setupBackfillTest = async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); const onPlanMaterialization = vi.fn().mockResolvedValue({}); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={onPlanMaterialization} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); return { onPlanMaterialization }; }; it('shows "Run initial backfill" checkbox for incremental strategy', async () => { await setupBackfillTest(); // Incremental should be the default when partition exists await waitFor(() => { expect(screen.getByText('Run initial backfill')).toBeInTheDocument(); }); }); it('shows backfill date range when checkbox is checked', async () => { await setupBackfillTest(); await waitFor(() => { expect(screen.getByText('Run initial backfill')).toBeInTheDocument(); }); // Checkbox should be checked by default const checkbox = screen.getByRole('checkbox', { name: /Run initial backfill/i, }); expect(checkbox).toBeChecked(); // Date range should be visible expect(screen.getByText('Backfill Date Range')).toBeInTheDocument(); expect(screen.getByText('From')).toBeInTheDocument(); expect(screen.getByText('To')).toBeInTheDocument(); }); it('hides backfill date range when checkbox is unchecked', async () => { await setupBackfillTest(); await waitFor(() => { expect(screen.getByText('Run initial backfill')).toBeInTheDocument(); }); // Uncheck the checkbox const checkbox = screen.getByRole('checkbox', { name: /Run initial backfill/i, }); await act(async () => { fireEvent.click(checkbox); }); // Date range should be hidden expect(screen.queryByText('Backfill Date Range')).not.toBeInTheDocument(); }); it('shows "Today" and "Specific date" options for end date', async () => { await setupBackfillTest(); await waitFor(() => { expect(screen.getByText('Backfill Date Range')).toBeInTheDocument(); }); // Find the select for backfill "To" field // There are multiple selects, we need the one with 'today' value const selects = screen.getAllByRole('combobox'); const toSelect = selects.find(s => s.value === 'today'); expect(toSelect).toBeInTheDocument(); // Change to specific date await act(async () => { fireEvent.change(toSelect, { target: { value: 'specific' } }); }); // Should show an additional date input await waitFor(() => { // There should be date inputs visible const dateInputs = document.querySelectorAll('input[type="date"]'); expect(dateInputs.length).toBeGreaterThanOrEqual(2); }); }); it('hides backfill options for full strategy', async () => { await setupBackfillTest(); // Switch to full strategy const fullRadio = screen.getByLabelText('Full'); await act(async () => { fireEvent.click(fullRadio); }); // Backfill checkbox should not be visible expect( screen.queryByText('Run initial backfill'), ).not.toBeInTheDocument(); }); }); describe('Schedule Configuration', () => { const setupScheduleTest = async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); }; it('shows schedule dropdown with recommended, hourly, and custom options', async () => { await setupScheduleTest(); expect(screen.getByText('Schedule')).toBeInTheDocument(); // The schedule select should have options - find one with 'auto' value const selects = screen.getAllByRole('combobox'); const scheduleSelect = selects.find(s => s.value === 'auto'); expect(scheduleSelect).toBeInTheDocument(); }); it('shows custom cron input when custom is selected', async () => { await setupScheduleTest(); // Find the schedule select (has 'auto' value initially) const selects = screen.getAllByRole('combobox'); const scheduleSelect = selects.find(s => s.value === 'auto'); await act(async () => { fireEvent.change(scheduleSelect, { target: { value: 'custom' } }); }); // Custom input should appear await waitFor(() => { expect(screen.getByPlaceholderText('0 6 * * *')).toBeInTheDocument(); }); }); }); describe('Lookback Window', () => { it('shows lookback window input for incremental strategy', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect(screen.getByText('Lookback Window')).toBeInTheDocument(); }); // Should have placeholder expect(screen.getByPlaceholderText('1 day')).toBeInTheDocument(); // Should have hint text expect(screen.getByText(/For late-arriving data/)).toBeInTheDocument(); }); it('hides lookback window for full strategy', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); // Switch to full strategy const fullRadio = screen.getByLabelText('Full'); await act(async () => { fireEvent.click(fullRadio); }); // Lookback window should be hidden expect(screen.queryByText('Lookback Window')).not.toBeInTheDocument(); }); }); describe('Druid Cube Configuration', () => { it('shows Druid cube checkbox', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Enable Druid cube materialization'), ).toBeInTheDocument(); }); }); it('shows cube name inputs when Druid is enabled and no cube loaded', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect(screen.getByText('Cube Name')).toBeInTheDocument(); }); // Should show namespace and name inputs expect(screen.getByPlaceholderText('users.myname')).toBeInTheDocument(); expect(screen.getByPlaceholderText('my_cube')).toBeInTheDocument(); }); it('shows preview of pre-aggregations to combine', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Pre-aggregations to combine:'), ).toBeInTheDocument(); }); // Should show the pre-agg source (may appear multiple times) const repairOrdersElements = screen.getAllByText('repair_orders'); expect(repairOrdersElements.length).toBeGreaterThan(0); }); }); describe('Form Submission', () => { it('shows correct button text for Druid cube materialization', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Create Pre-Agg Workflows & Schedule Cube'), ).toBeInTheDocument(); }); }); it('shows Cancel and submit buttons', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect(screen.getByText('Cancel')).toBeInTheDocument(); }); }); it('closes form when Cancel is clicked', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={vi.fn()} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Configure Materialization'), ).toBeInTheDocument(); }); // Click Cancel fireEvent.click(screen.getByText('Cancel')); // Form should be closed await waitFor(() => { expect( screen.queryByText('Configure Materialization'), ).not.toBeInTheDocument(); }); }); it('calls onPlanMaterialization with config when submitted', async () => { const onFetchNodePartitions = vi.fn().mockResolvedValue({ columns: [{ name: 'date_id', type: 'int' }], temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }], }); const onPlanMaterialization = vi.fn().mockResolvedValue({}); renderWithRouter( <QueryOverviewPanel {...defaultProps} onPlanMaterialization={onPlanMaterialization} onFetchNodePartitions={onFetchNodePartitions} />, ); // Open config form const configureBtn = screen.getByText('Configure'); await act(async () => { fireEvent.click(configureBtn); }); await waitFor(() => { expect( screen.getByText('Create Pre-Agg Workflows & Schedule Cube'), ).toBeInTheDocument(); }); // Submit the form const submitBtn = screen.getByText( 'Create Pre-Agg Workflows & Schedule Cube', ); await act(async () => { fireEvent.click(submitBtn); }); await waitFor(() => { expect(onPlanMaterialization).toHaveBeenCalled(); }); // Check the config structure const callArgs = onPlanMaterialization.mock.calls[0][1]; expect(callArgs).toHaveProperty('strategy'); expect(callArgs).toHaveProperty('schedule'); expect(callArgs).toHaveProperty('enableDruidCube', true); }); }); }); describe('PreAggDetailsPanel', () => { const mockPreAgg = { 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', }; const mockMetricFormulas = [ { name: 'default.total_revenue', short_name: 'total_revenue', combiner: 'SUM(sum_revenue)', is_derived: false, components: ['sum_revenue'], }, ]; const onClose = vi.fn(); beforeEach(() => { vi.clearAllMocks(); }); it('returns null when no preAgg provided', () => { const { container } = render( <PreAggDetailsPanel preAgg={null} onClose={onClose} />, ); expect(container.firstChild).toBeNull(); }); it('renders pre-aggregation badge', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('Pre-aggregation')).toBeInTheDocument(); }); it('displays source name', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('repair_orders')).toBeInTheDocument(); expect(screen.getByText('default.repair_orders')).toBeInTheDocument(); }); it('displays close button', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByTitle('Close panel')).toBeInTheDocument(); }); it('calls onClose when close button clicked', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); fireEvent.click(screen.getByTitle('Close panel')); expect(onClose).toHaveBeenCalled(); }); describe('Grain Section', () => { it('displays grain section', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('Grain (GROUP BY)')).toBeInTheDocument(); }); it('shows grain columns as pills', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('date_id')).toBeInTheDocument(); expect(screen.getByText('customer_id')).toBeInTheDocument(); }); it('shows empty message when no grain', () => { const noGrainPreAgg = { ...mockPreAgg, grain: [] }; render( <PreAggDetailsPanel preAgg={noGrainPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('No grain columns')).toBeInTheDocument(); }); }); describe('Related Metrics Section', () => { it('displays metrics using this section', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('Metrics Using This')).toBeInTheDocument(); }); it('shows related metrics', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('total_revenue')).toBeInTheDocument(); }); }); describe('Components Table', () => { it('displays components section', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('Components (2)')).toBeInTheDocument(); }); it('shows component names', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('sum_revenue')).toBeInTheDocument(); expect(screen.getByText('count_orders')).toBeInTheDocument(); }); it('shows component expressions', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('SUM(revenue)')).toBeInTheDocument(); expect(screen.getByText('COUNT(*)')).toBeInTheDocument(); }); it('shows aggregation functions', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getAllByText('SUM').length).toBeGreaterThan(0); expect(screen.getByText('COUNT')).toBeInTheDocument(); }); }); describe('SQL Section', () => { it('displays SQL section when sql is present', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('Pre-Aggregation SQL')).toBeInTheDocument(); }); it('shows copy button', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('Copy SQL')).toBeInTheDocument(); }); it('copies SQL when copy button is clicked', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); const copyBtn = screen.getByText('Copy SQL'); fireEvent.click(copyBtn); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( mockPreAgg.sql, ); }); it('hides SQL section when sql is not present', () => { const preAggWithoutSql = { ...mockPreAgg, sql: null }; render( <PreAggDetailsPanel preAgg={preAggWithoutSql} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.queryByText('Pre-Aggregation SQL')).not.toBeInTheDocument(); }); }); describe('Pre-aggregation Type Badge', () => { it('shows Pre-aggregation badge', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('Pre-aggregation')).toBeInTheDocument(); }); it('shows parent name in title', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); expect(screen.getByText('repair_orders')).toBeInTheDocument(); }); }); describe('Merge Function Display', () => { it('shows merge functions for components', () => { render( <PreAggDetailsPanel preAgg={mockPreAgg} metricFormulas={mockMetricFormulas} onClose={onClose} />, ); // Components have merge functions expect(screen.getAllByText('SUM').length).toBeGreaterThan(0); }); }); }); describe('MetricDetailsPanel', () => { const mockMetric = { 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'], }; const mockGrainGroups = [ { parent_name: 'default.repair_orders', components: [{ name: 'sum_revenue' }, { name: 'count_orders' }], }, ]; const onClose = vi.fn(); beforeEach(() => { vi.clearAllMocks(); }); it('returns null when no metric provided', () => { const { container } = render( <MetricDetailsPanel metric={null} onClose={onClose} />, ); expect(container.firstChild).toBeNull(); }); it('renders metric badge', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('Derived Metric')).toBeInTheDocument(); }); it('renders regular metric badge for non-derived', () => { const nonDerivedMetric = { ...mockMetric, is_derived: false }; render( <MetricDetailsPanel metric={nonDerivedMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('Metric')).toBeInTheDocument(); }); it('displays metric name', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('avg_repair_price')).toBeInTheDocument(); expect(screen.getByText('default.avg_repair_price')).toBeInTheDocument(); }); it('calls onClose when close button clicked', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); fireEvent.click(screen.getByTitle('Close panel')); expect(onClose).toHaveBeenCalled(); }); describe('Formula Section', () => { it('displays combiner formula section', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('Combiner Formula')).toBeInTheDocument(); }); it('shows the formula', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect( screen.getByText('SUM(sum_revenue) / SUM(count_orders)'), ).toBeInTheDocument(); }); }); describe('Components Section', () => { it('displays components used section', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('Components Used')).toBeInTheDocument(); }); it('shows component tags', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('sum_revenue')).toBeInTheDocument(); expect(screen.getByText('count_orders')).toBeInTheDocument(); }); }); describe('Source Pre-aggregations Section', () => { it('displays source pre-aggregations section', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('Source Pre-aggregations')).toBeInTheDocument(); }); it('shows related pre-agg sources', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={mockGrainGroups} onClose={onClose} />, ); expect(screen.getByText('repair_orders')).toBeInTheDocument(); }); it('shows empty message when no sources found', () => { render( <MetricDetailsPanel metric={mockMetric} grainGroups={[]} onClose={onClose} />, ); expect(screen.getByText('No source found')).toBeInTheDocument(); }); }); describ