diff --git a/run_tests.py b/run_tests.py index b59a728..04b920c 100644 --- a/run_tests.py +++ b/run_tests.py @@ -961,31 +961,31 @@ def run_fallback_report() -> TestResult: "file_upload", ["tests/test_file_upload_comprehensive.py"], "Running comprehensive file upload testing suite", - "file_upload" + "file_upload", ) NEW_SUITES.add_test( - "error_boundary", + "error_boundary", ["tests/test_error_boundary_comprehensive.py"], "Running comprehensive error boundary testing suite", - "error_boundary" + "error_boundary", ) NEW_SUITES.add_test( "visualization", - ["tests/test_visualization_pipeline_comprehensive.py"], + ["tests/test_visualization_pipeline_comprehensive.py"], "Running comprehensive visualization pipeline testing suite", - "visualization" + "visualization", ) NEW_SUITES.add_test( "all_new_suites", [ "tests/test_file_upload_comprehensive.py", - "tests/test_error_boundary_comprehensive.py", - "tests/test_visualization_pipeline_comprehensive.py" + "tests/test_error_boundary_comprehensive.py", + "tests/test_visualization_pipeline_comprehensive.py", ], - "Running all new comprehensive testing suites" + "Running all new comprehensive testing suites", ) # Update the TEST_CATEGORIES to include process mining, UI tests, and new suites @@ -1286,7 +1286,9 @@ def main(): "--process-mining-all", action="store_true", help="Run all process mining tests" ) parser.add_argument("--ui-all", action="store_true", help="Run all UI tests") - parser.add_argument("--new-suites-all", action="store_true", help="Run all new comprehensive test suites") + parser.add_argument( + "--new-suites-all", action="store_true", help="Run all new comprehensive test suites" + ) # Category specific test options parser.add_argument( diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 96c97df..95e6d20 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -37,9 +37,26 @@ def load_sample_data(self) -> None: This helper abstracts the data loading flow, making tests immune to changes in the data loading implementation. """ + import time + try: # Click the Load Sample Data button using its stable key with increased timeout - self.at.button(key="load_sample_data_button").click().run(timeout=10) + self.at.button(key="load_sample_data_button").click().run(timeout=15) + + # Wait for data loading to complete by checking session state + max_retries = 5 + for attempt in range(max_retries): + if ( + hasattr(self.at.session_state, "events_data") + and self.at.session_state.events_data is not None + and len(self.at.session_state.events_data) > 0 + ): + # Data loaded successfully, now wait for event_statistics + break + time.sleep(0.5) # Wait 500ms between retries + if attempt < max_retries - 1: # Don't run on last attempt + self.at.run(timeout=10) # Re-run to refresh state + except Exception as e: # If button interaction fails, manually load sample data for testing from datetime import datetime, timedelta @@ -67,10 +84,52 @@ def load_sample_data(self) -> None: self.at.session_state.events_data = pd.DataFrame(data) + # CRITICAL FIX: Calculate event_statistics manually when using fallback + # Import the function from app.py + import os + import sys + + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + from app import get_event_statistics + + self.at.session_state.event_statistics = get_event_statistics( + self.at.session_state.events_data + ) + # Verify data was loaded by checking session state assert self.at.session_state.events_data is not None, "Sample data should be loaded" assert len(self.at.session_state.events_data) > 0, "Sample data should not be empty" + # Wait for event_statistics to be calculated (give it more time) + max_retries = 10 + for attempt in range(max_retries): + if ( + hasattr(self.at.session_state, "event_statistics") + and len(self.at.session_state.event_statistics) > 0 + ): + break + time.sleep(0.3) # Wait 300ms between retries + if attempt < max_retries - 1: # Don't run on last attempt + self.at.run( + timeout=10 + ) # Re-run to refresh state and trigger statistics calculation + + # Final verification that event_statistics was calculated + if ( + not hasattr(self.at.session_state, "event_statistics") + or len(self.at.session_state.event_statistics) == 0 + ): + # Last resort: manually calculate if still not present + import os + import sys + + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + from app import get_event_statistics + + self.at.session_state.event_statistics = get_event_statistics( + self.at.session_state.events_data + ) + def build_funnel(self, steps: List[str]) -> None: """ Build a funnel by selecting the specified steps. diff --git a/tests/test_app_ui_phase1_golden.py b/tests/test_app_ui_phase1_golden.py index 9c52fd1..5420b0f 100644 --- a/tests/test_app_ui_phase1_golden.py +++ b/tests/test_app_ui_phase1_golden.py @@ -12,9 +12,38 @@ 4. Cover the complete "happy path" and key edge cases """ +import time + from streamlit.testing.v1 import AppTest APP_FILE = "app.py" +DEFAULT_TIMEOUT = 20 # Increased from 10 to handle slower module loading in Python 3.12 + + +def wait_for_condition(at, condition_func, max_retries=10, sleep_time=0.5, timeout=15): + """ + Robust waiting mechanism for test conditions. + + Args: + at: AppTest instance + condition_func: Function that returns True when condition is met + max_retries: Maximum number of retries + sleep_time: Time to sleep between retries + timeout: Timeout for each at.run() call + """ + for attempt in range(max_retries): + try: + if condition_func(): + return True + except (AttributeError, KeyError, TypeError): + # Condition not ready yet, continue waiting + pass + + if attempt < max_retries - 1: + time.sleep(sleep_time) + at.run(timeout=timeout) + + return False class TestPhase1GoldenStandard: @@ -30,10 +59,23 @@ def test_full_successful_analysis_flow(self): This test validates the full workflow from data loading to analysis results. It must pass before, during, and after Phase 1 refactoring. """ - at = AppTest.from_file(APP_FILE, default_timeout=10).run() - - # Step 1: Load sample data - at.button(key="load_sample_data_button").click().run() + at = AppTest.from_file(APP_FILE, default_timeout=DEFAULT_TIMEOUT).run() + + # Step 1: Load sample data with robust waiting + at.button(key="load_sample_data_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for data to be fully loaded and processed + data_loaded = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "events_data") + and at.session_state.events_data is not None + and len(at.session_state.events_data) > 0 + ), + max_retries=15, + timeout=DEFAULT_TIMEOUT, + ) + assert data_loaded, "Events data should be loaded within timeout" # Verify data was loaded (check session state) assert at.session_state.events_data is not None, "Events data should be loaded" @@ -48,7 +90,13 @@ def test_full_successful_analysis_flow(self): for event in selected_events: checkbox_key = f"event_cb_{event.replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key).check().run() + at.checkbox(key=checkbox_key).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for funnel steps to be updated + funnel_built = wait_for_condition( + at, lambda: len(at.session_state.funnel_steps) == 3, timeout=DEFAULT_TIMEOUT + ) + assert funnel_built, "Funnel should be built within timeout" # Verify funnel steps were added to session state assert len(at.session_state.funnel_steps) == 3, "Should have 3 funnel steps" @@ -56,8 +104,20 @@ def test_full_successful_analysis_flow(self): "Steps should match selected events" ) - # Step 3: Run analysis - at.button(key="analyze_funnel_button").click().run() + # Step 3: Run analysis with robust waiting + at.button(key="analyze_funnel_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for analysis to complete + analysis_complete = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "analysis_results") + and at.session_state.analysis_results is not None + ), + max_retries=20, # Analysis can take longer + timeout=DEFAULT_TIMEOUT, + ) + assert analysis_complete, "Analysis should complete within timeout" # Verify analysis results were generated assert at.session_state.analysis_results is not None, ( @@ -76,23 +136,47 @@ def test_state_reset_on_clear_all(self): This test ensures that the Clear All button properly resets the funnel state and that the UI reflects this change correctly. """ - at = AppTest.from_file(APP_FILE, default_timeout=10).run() - - # Load data and build funnel - at.button(key="load_sample_data_button").click().run() + at = AppTest.from_file(APP_FILE, default_timeout=DEFAULT_TIMEOUT).run() + + # Load data and build funnel with robust waiting + at.button(key="load_sample_data_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for data loading + data_loaded = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "events_data") + and at.session_state.events_data is not None + and len(at.session_state.events_data) > 0 + ), + timeout=DEFAULT_TIMEOUT, + ) + assert data_loaded, "Data should be loaded" available_events = sorted(at.session_state.events_data["event_name"].unique()) first_event = available_events[0] checkbox_key = f"event_cb_{first_event.replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key).check().run() + at.checkbox(key=checkbox_key).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for step to be added + step_added = wait_for_condition( + at, lambda: len(at.session_state.funnel_steps) == 1, timeout=DEFAULT_TIMEOUT + ) + assert step_added, "Step should be added" # Verify step was added assert len(at.session_state.funnel_steps) == 1, "Should have 1 funnel step" assert first_event in at.session_state.funnel_steps, "Event should be in funnel steps" # Clear all steps - at.button(key="clear_all_button").click().run() + at.button(key="clear_all_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for state to be cleared + state_cleared = wait_for_condition( + at, lambda: len(at.session_state.funnel_steps) == 0, timeout=DEFAULT_TIMEOUT + ) + assert state_cleared, "State should be cleared" # Verify state was cleared assert len(at.session_state.funnel_steps) == 0, "Funnel steps should be empty after clear" @@ -108,24 +192,48 @@ def test_event_selection_state_management(self): This test verifies that event selection/deselection properly updates the session state and that the UI reflects these changes. """ - at = AppTest.from_file(APP_FILE, default_timeout=10).run() - - # Load data - at.button(key="load_sample_data_button").click().run() + at = AppTest.from_file(APP_FILE, default_timeout=DEFAULT_TIMEOUT).run() + + # Load data with robust waiting + at.button(key="load_sample_data_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for data loading + data_loaded = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "events_data") + and at.session_state.events_data is not None + and len(at.session_state.events_data) > 0 + ), + timeout=DEFAULT_TIMEOUT, + ) + assert data_loaded, "Data should be loaded" available_events = sorted(at.session_state.events_data["event_name"].unique()) test_events = available_events[:2] # Select first event checkbox_key_1 = f"event_cb_{test_events[0].replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key_1).check().run() + at.checkbox(key=checkbox_key_1).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for first event selection + first_selected = wait_for_condition( + at, lambda: len(at.session_state.funnel_steps) == 1, timeout=DEFAULT_TIMEOUT + ) + assert first_selected, "First event should be selected" assert len(at.session_state.funnel_steps) == 1, "Should have 1 step after first selection" assert test_events[0] in at.session_state.funnel_steps, "First event should be selected" # Select second event checkbox_key_2 = f"event_cb_{test_events[1].replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key_2).check().run() + at.checkbox(key=checkbox_key_2).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for second event selection + second_selected = wait_for_condition( + at, lambda: len(at.session_state.funnel_steps) == 2, timeout=DEFAULT_TIMEOUT + ) + assert second_selected, "Second event should be selected" assert len(at.session_state.funnel_steps) == 2, ( "Should have 2 steps after second selection" @@ -133,7 +241,18 @@ def test_event_selection_state_management(self): assert test_events[1] in at.session_state.funnel_steps, "Second event should be selected" # Deselect first event - at.checkbox(key=checkbox_key_1).uncheck().run() + at.checkbox(key=checkbox_key_1).uncheck().run(timeout=DEFAULT_TIMEOUT) + + # Wait for first event deselection + first_deselected = wait_for_condition( + at, + lambda: ( + len(at.session_state.funnel_steps) == 1 + and test_events[0] not in at.session_state.funnel_steps + ), + timeout=DEFAULT_TIMEOUT, + ) + assert first_deselected, "First event should be deselected" assert len(at.session_state.funnel_steps) == 1, "Should have 1 step after deselection" assert test_events[0] not in at.session_state.funnel_steps, ( @@ -153,14 +272,27 @@ def test_data_loading_initializes_session_state(self): This test ensures that loading data sets up all the necessary session state variables that other components depend on. """ - at = AppTest.from_file(APP_FILE, default_timeout=10).run() + at = AppTest.from_file(APP_FILE, default_timeout=DEFAULT_TIMEOUT).run() # Verify initial state assert at.session_state.events_data is None, "Events data should be None initially" assert len(at.session_state.funnel_steps) == 0, "Funnel steps should be empty initially" - # Load data - at.button(key="load_sample_data_button").click().run() + # Load data with robust waiting + at.button(key="load_sample_data_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for data loading to complete + data_loaded = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "events_data") + and at.session_state.events_data is not None + and len(at.session_state.events_data) > 0 + ), + max_retries=15, + timeout=DEFAULT_TIMEOUT, + ) + assert data_loaded, "Events data should be loaded within timeout" # Verify data loading initialized required state assert at.session_state.events_data is not None, "Events data should be loaded" @@ -171,6 +303,18 @@ def test_data_loading_initializes_session_state(self): for col in required_columns: assert col in at.session_state.events_data.columns, f"Column {col} should exist" + # Wait for event statistics to be generated + stats_generated = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "event_statistics") + and len(at.session_state.event_statistics) > 0 + ), + max_retries=10, + timeout=DEFAULT_TIMEOUT, + ) + assert stats_generated, "Event statistics should be generated within timeout" + # Verify event statistics were generated assert hasattr(at.session_state, "event_statistics"), ( "Event statistics should be generated" @@ -189,10 +333,22 @@ def test_analysis_requires_minimum_steps(self): This test ensures that the analysis function properly validates that at least 2 steps are required for funnel analysis. """ - at = AppTest.from_file(APP_FILE, default_timeout=10).run() - - # Load data - at.button(key="load_sample_data_button").click().run() + at = AppTest.from_file(APP_FILE, default_timeout=DEFAULT_TIMEOUT).run() + + # Load data with robust waiting + at.button(key="load_sample_data_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for data loading + data_loaded = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "events_data") + and at.session_state.events_data is not None + and len(at.session_state.events_data) > 0 + ), + timeout=DEFAULT_TIMEOUT, + ) + assert data_loaded, "Data should be loaded" # Note: analyze_funnel_button only appears when there are funnel steps # So we can't test clicking it with 0 steps - the button won't exist @@ -201,10 +357,20 @@ def test_analysis_requires_minimum_steps(self): available_events = sorted(at.session_state.events_data["event_name"].unique()) first_event = available_events[0] checkbox_key = f"event_cb_{first_event.replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key).check().run() + at.checkbox(key=checkbox_key).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for step to be added + step_added = wait_for_condition( + at, lambda: len(at.session_state.funnel_steps) == 1, timeout=DEFAULT_TIMEOUT + ) + assert step_added, "Step should be added" # Now the analyze button should appear, try to analyze with 1 step - at.button(key="analyze_funnel_button").click().run() + at.button(key="analyze_funnel_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait a moment for any potential analysis attempt + time.sleep(1) + at.run(timeout=DEFAULT_TIMEOUT) # Should not generate analysis results with insufficient steps (< 2) assert at.session_state.analysis_results is None, "Should not generate results with 1 step" @@ -212,10 +378,25 @@ def test_analysis_requires_minimum_steps(self): # Add second step second_event = available_events[1] checkbox_key_2 = f"event_cb_{second_event.replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key_2).check().run() + at.checkbox(key=checkbox_key_2).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for second step + second_step_added = wait_for_condition( + at, lambda: len(at.session_state.funnel_steps) == 2, timeout=DEFAULT_TIMEOUT + ) + assert second_step_added, "Second step should be added" # Now analysis should work with 2+ steps - at.button(key="analyze_funnel_button").click().run() + at.button(key="analyze_funnel_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for analysis to complete + analysis_complete = wait_for_condition( + at, + lambda: at.session_state.analysis_results is not None, + max_retries=20, + timeout=DEFAULT_TIMEOUT, + ) + assert analysis_complete, "Analysis should complete with 2+ steps" # Should generate analysis results with 2+ steps assert at.session_state.analysis_results is not None, ( @@ -232,33 +413,75 @@ def test_session_state_persistence_across_interactions(self): This test ensures that session state maintains consistency across multiple user interactions and UI updates. """ - at = AppTest.from_file(APP_FILE, default_timeout=10).run() + at = AppTest.from_file(APP_FILE, default_timeout=DEFAULT_TIMEOUT).run() + + # Load data with robust waiting + at.button(key="load_sample_data_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for data loading + data_loaded = wait_for_condition( + at, + lambda: ( + hasattr(at.session_state, "events_data") + and at.session_state.events_data is not None + and len(at.session_state.events_data) > 0 + ), + timeout=DEFAULT_TIMEOUT, + ) + assert data_loaded, "Data should be loaded" - # Load data - at.button(key="load_sample_data_button").click().run() original_data_length = len(at.session_state.events_data) - # Build funnel + # Build funnel with robust waiting available_events = sorted(at.session_state.events_data["event_name"].unique()) selected_events = available_events[:3] - for event in selected_events: + for i, event in enumerate(selected_events): checkbox_key = f"event_cb_{event.replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key).check().run() + at.checkbox(key=checkbox_key).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for each step to be added + step_count = i + 1 + step_added = wait_for_condition( + at, + lambda: len(at.session_state.funnel_steps) == step_count, + timeout=DEFAULT_TIMEOUT, + ) + assert step_added, f"Step {step_count} should be added" - # Run analysis - at.button(key="analyze_funnel_button").click().run() + # Run analysis with robust waiting + at.button(key="analyze_funnel_button").click().run(timeout=DEFAULT_TIMEOUT) + + # Wait for analysis to complete + analysis_complete = wait_for_condition( + at, + lambda: at.session_state.analysis_results is not None, + max_retries=20, + timeout=DEFAULT_TIMEOUT, + ) + assert analysis_complete, "Analysis should complete" # Verify all state is still consistent assert len(at.session_state.events_data) == original_data_length, "Data should persist" assert len(at.session_state.funnel_steps) == 3, "Funnel steps should persist" assert at.session_state.analysis_results is not None, "Analysis results should persist" - # Add another step + # Add another step if available if len(available_events) > 3: fourth_event = available_events[3] checkbox_key_4 = f"event_cb_{fourth_event.replace(' ', '_').replace('-', '_')}" - at.checkbox(key=checkbox_key_4).check().run() + at.checkbox(key=checkbox_key_4).check().run(timeout=DEFAULT_TIMEOUT) + + # Wait for fourth step and results clearing + fourth_step_added = wait_for_condition( + at, + lambda: ( + len(at.session_state.funnel_steps) == 4 + and at.session_state.analysis_results is None + ), + timeout=DEFAULT_TIMEOUT, + ) + assert fourth_step_added, "Fourth step should be added and results cleared" # Verify analysis results were cleared (as expected when funnel changes) assert at.session_state.analysis_results is None, ( diff --git a/tests/test_error_boundary_comprehensive.py b/tests/test_error_boundary_comprehensive.py index dcfdd6d..dbec1cc 100644 --- a/tests/test_error_boundary_comprehensive.py +++ b/tests/test_error_boundary_comprehensive.py @@ -8,17 +8,14 @@ - Graceful degradation patterns """ -import json -import logging from datetime import datetime, timedelta -from io import StringIO from unittest.mock import Mock, patch import pandas as pd import pytest -from core import DataSourceManager, FunnelCalculator, FunnelConfigManager -from models import CountingMethod, FunnelConfig, FunnelOrder, ReentryMode +from core import DataSourceManager, FunnelCalculator +from models import FunnelConfig @pytest.mark.error_boundary @@ -52,8 +49,12 @@ def test_file_loading_exception_handling(self, data_manager): result_df = data_manager.load_from_file(mock_file) # Should return empty DataFrame - assert isinstance(result_df, pd.DataFrame), f"Should return DataFrame for {type(exception).__name__}" - assert len(result_df) == 0, f"Should return empty DataFrame for {type(exception).__name__}" + assert isinstance(result_df, pd.DataFrame), ( + f"Should return DataFrame for {type(exception).__name__}" + ) + assert len(result_df) == 0, ( + f"Should return empty DataFrame for {type(exception).__name__}" + ) print("✅ File loading exception handling test passed") @@ -66,7 +67,6 @@ def test_data_validation_error_messages(self, data_manager): pd.DataFrame({"wrong_column": [1, 2, 3]}), ["missing", "required", "columns"], ), - # Empty DataFrame scenario ( pd.DataFrame(), @@ -83,7 +83,9 @@ def test_data_validation_error_messages(self, data_manager): # Should contain user-friendly keywords message_lower = message.lower() for keyword in expected_keywords: - assert keyword in message_lower, f"Error message should contain '{keyword}': {message}" + assert keyword in message_lower, ( + f"Error message should contain '{keyword}': {message}" + ) print("✅ Data validation error messages test passed") @@ -107,10 +109,10 @@ def test_empty_data_handling(self, calculator): for empty_df in empty_scenarios: steps = ["Step1", "Step2"] - + # Should handle empty data gracefully results = calculator.calculate_funnel_metrics(empty_df, steps) - + # Should return valid structure with zero counts assert results.steps == [], "Should return empty steps for empty data" assert results.users_count == [], "Should return empty users_count for empty data" @@ -120,13 +122,15 @@ def test_empty_data_handling(self, calculator): def test_invalid_funnel_steps_handling(self, calculator): """Test handling of invalid funnel step configurations.""" # Create valid data - valid_data = pd.DataFrame({ - "user_id": ["user1", "user2", "user3"], - "event_name": ["EventA", "EventB", "EventC"], - "timestamp": [datetime.now() + timedelta(minutes=i) for i in range(3)], - "event_properties": ["{}"] * 3, - "user_properties": ["{}"] * 3, - }) + valid_data = pd.DataFrame( + { + "user_id": ["user1", "user2", "user3"], + "event_name": ["EventA", "EventB", "EventC"], + "timestamp": [datetime.now() + timedelta(minutes=i) for i in range(3)], + "event_properties": ["{}"] * 3, + "user_properties": ["{}"] * 3, + } + ) invalid_step_scenarios = [ [], # Empty steps @@ -137,10 +141,12 @@ def test_invalid_funnel_steps_handling(self, calculator): for steps in invalid_step_scenarios: # Should handle invalid steps gracefully results = calculator.calculate_funnel_metrics(valid_data, steps) - + # Should return appropriate structure if not steps: - assert results.steps == [], f"Should return empty steps for invalid configuration: {steps}" + assert results.steps == [], ( + f"Should return empty steps for invalid configuration: {steps}" + ) else: # Should filter out non-existent events assert isinstance(results.steps, list), f"Should return list for steps: {steps}" @@ -155,18 +161,20 @@ class TestRecoveryMechanisms: def test_partial_data_recovery(self): """Test recovery from partial data corruption.""" # Create partially corrupted data - partial_data = pd.DataFrame({ - "user_id": ["user1", None, "user3", ""], # Some missing user IDs - "event_name": ["Event1", "Event2", None, "Event4"], # Some missing events - "timestamp": [ - datetime.now(), - datetime.now() + timedelta(minutes=1), - None, # Missing timestamp - datetime.now() + timedelta(minutes=3), - ], - "event_properties": ["{}"] * 4, - "user_properties": ["{}"] * 4, - }) + partial_data = pd.DataFrame( + { + "user_id": ["user1", None, "user3", ""], # Some missing user IDs + "event_name": ["Event1", "Event2", None, "Event4"], # Some missing events + "timestamp": [ + datetime.now(), + datetime.now() + timedelta(minutes=1), + None, # Missing timestamp + datetime.now() + timedelta(minutes=3), + ], + "event_properties": ["{}"] * 4, + "user_properties": ["{}"] * 4, + } + ) config = FunnelConfig() calculator = FunnelCalculator(config) @@ -174,7 +182,7 @@ def test_partial_data_recovery(self): # Should recover and process valid data results = calculator.calculate_funnel_metrics(partial_data, steps) - + # Should have some results from valid data assert isinstance(results.steps, list), "Should return valid results from partial data" @@ -188,7 +196,7 @@ class TestUserFriendlyErrorMessages: def test_error_message_clarity(self): """Test that error messages are clear and actionable.""" data_manager = DataSourceManager() - + # Test scenarios with expected user-friendly messages error_scenarios = [ ( @@ -200,17 +208,19 @@ def test_error_message_clarity(self): for test_data, should_contain, should_not_contain in error_scenarios: is_valid, message = data_manager.validate_event_data(test_data) - + assert not is_valid, "Should be invalid" - + message_lower = message.lower() - + # Should contain helpful keywords for keyword in should_contain: assert keyword in message_lower, f"Message should contain '{keyword}': {message}" - + # Should not contain technical jargon for jargon in should_not_contain: - assert jargon not in message_lower, f"Message should not contain '{jargon}': {message}" + assert jargon not in message_lower, ( + f"Message should not contain '{jargon}': {message}" + ) - print("✅ Error message clarity test passed") \ No newline at end of file + print("✅ Error message clarity test passed") diff --git a/tests/test_file_upload_comprehensive.py b/tests/test_file_upload_comprehensive.py index 76ac495..e8ab2f5 100644 --- a/tests/test_file_upload_comprehensive.py +++ b/tests/test_file_upload_comprehensive.py @@ -14,12 +14,9 @@ - @pytest.mark.security - Security validation tests """ -import io import json -import tempfile import time from datetime import datetime, timedelta -from pathlib import Path from unittest.mock import Mock, patch import pandas as pd @@ -48,33 +45,41 @@ def valid_csv_content(self): @pytest.fixture def valid_parquet_data(self): """Valid DataFrame for Parquet testing.""" - return pd.DataFrame({ - "user_id": ["user_001", "user_002", "user_003"], - "event_name": ["Sign Up", "Login", "Purchase"], - "timestamp": [ - datetime(2024, 1, 1, 10, 0, 0), - datetime(2024, 1, 1, 11, 0, 0), - datetime(2024, 1, 1, 12, 0, 0), - ], - "event_properties": ["{}", "{}", "{}"], - "user_properties": ["{}", "{}", "{}"], - }) + return pd.DataFrame( + { + "user_id": ["user_001", "user_002", "user_003"], + "event_name": ["Sign Up", "Login", "Purchase"], + "timestamp": [ + datetime(2024, 1, 1, 10, 0, 0), + datetime(2024, 1, 1, 11, 0, 0), + datetime(2024, 1, 1, 12, 0, 0), + ], + "event_properties": ["{}", "{}", "{}"], + "user_properties": ["{}", "{}", "{}"], + } + ) def test_csv_format_validation_success(self, data_manager): """Test successful CSV file validation and loading.""" # Create mock uploaded file mock_file = Mock() mock_file.name = "test_events.csv" - + with patch("pandas.read_csv") as mock_read_csv: # Setup pandas to return valid DataFrame - expected_df = pd.DataFrame({ - "user_id": ["user_001", "user_002", "user_003"], - "event_name": ["Sign Up", "Login", "Purchase"], - "timestamp": ["2024-01-01 10:00:00", "2024-01-01 11:00:00", "2024-01-01 12:00:00"], - "event_properties": ["{}", "{}", "{}"], - "user_properties": ["{}", "{}", "{}"], - }) + expected_df = pd.DataFrame( + { + "user_id": ["user_001", "user_002", "user_003"], + "event_name": ["Sign Up", "Login", "Purchase"], + "timestamp": [ + "2024-01-01 10:00:00", + "2024-01-01 11:00:00", + "2024-01-01 12:00:00", + ], + "event_properties": ["{}", "{}", "{}"], + "user_properties": ["{}", "{}", "{}"], + } + ) mock_read_csv.return_value = expected_df # Load file @@ -94,7 +99,7 @@ def test_parquet_format_validation_success(self, data_manager, valid_parquet_dat # Create mock uploaded file mock_file = Mock() mock_file.name = "test_events.parquet" - + with patch("pandas.read_parquet") as mock_read_parquet: mock_read_parquet.return_value = valid_parquet_data @@ -127,7 +132,7 @@ def test_unsupported_file_format_rejection(self, data_manager): def test_missing_required_columns_validation(self, data_manager): """Test validation of missing required columns.""" required_columns = ["user_id", "event_name", "timestamp"] - + for missing_col in required_columns: # Create data missing one required column test_data = { @@ -138,14 +143,16 @@ def test_missing_required_columns_validation(self, data_manager): "user_properties": ["{}", "{}"], } del test_data[missing_col] - + invalid_df = pd.DataFrame(test_data) - + # Test validation is_valid, message = data_manager.validate_event_data(invalid_df) - + assert not is_valid, f"Should be invalid when missing {missing_col}" - assert missing_col.lower() in message.lower(), f"Error message should mention {missing_col}" + assert missing_col.lower() in message.lower(), ( + f"Error message should mention {missing_col}" + ) print("✅ Missing required columns validation test passed") @@ -161,19 +168,23 @@ def test_invalid_timestamp_format_handling(self, data_manager): ] for timestamps in invalid_timestamp_scenarios: - df = pd.DataFrame({ - "user_id": ["user1", "user2"], - "event_name": ["Event1", "Event2"], - "timestamp": timestamps, - "event_properties": ["{}", "{}"], - "user_properties": ["{}", "{}"], - }) + df = pd.DataFrame( + { + "user_id": ["user1", "user2"], + "event_name": ["Event1", "Event2"], + "timestamp": timestamps, + "event_properties": ["{}", "{}"], + "user_properties": ["{}", "{}"], + } + ) is_valid, message = data_manager.validate_event_data(df) # Should either be invalid or successfully convert timestamps if not is_valid: - assert "timestamp" in message.lower(), f"Should mention timestamp issues for {timestamps}" + assert "timestamp" in message.lower(), ( + f"Should mention timestamp issues for {timestamps}" + ) print("✅ Invalid timestamp format handling test passed") @@ -224,7 +235,9 @@ def test_corrupted_parquet_file_handling(self, data_manager): result_df = data_manager.load_from_file(mock_file) # Should handle gracefully - assert isinstance(result_df, pd.DataFrame), f"Should return DataFrame for error: {error}" + assert isinstance(result_df, pd.DataFrame), ( + f"Should return DataFrame for error: {error}" + ) assert len(result_df) == 0, f"Should return empty DataFrame for error: {error}" print("✅ Corrupted Parquet file handling test passed") @@ -233,22 +246,24 @@ def test_malformed_json_properties_handling(self, data_manager): """Test handling of malformed JSON in event/user properties.""" malformed_json_scenarios = [ '{"valid": "json"}', # Valid JSON - "{invalid json}", # Invalid JSON syntax - "not json at all", # Not JSON - '{"incomplete": }', # Incomplete JSON - "", # Empty string - None, # Null value + "{invalid json}", # Invalid JSON syntax + "not json at all", # Not JSON + '{"incomplete": }', # Incomplete JSON + "", # Empty string + None, # Null value '{"nested": {"deep": {"very": "deep"}}}', # Very nested JSON ] # Create DataFrame with malformed JSON - df = pd.DataFrame({ - "user_id": [f"user_{i}" for i in range(len(malformed_json_scenarios))], - "event_name": ["Event"] * len(malformed_json_scenarios), - "timestamp": [datetime.now()] * len(malformed_json_scenarios), - "event_properties": malformed_json_scenarios, - "user_properties": malformed_json_scenarios, - }) + df = pd.DataFrame( + { + "user_id": [f"user_{i}" for i in range(len(malformed_json_scenarios))], + "event_name": ["Event"] * len(malformed_json_scenarios), + "timestamp": [datetime.now()] * len(malformed_json_scenarios), + "event_properties": malformed_json_scenarios, + "user_properties": malformed_json_scenarios, + } + ) # Should validate successfully (malformed JSON doesn't fail validation) is_valid, message = data_manager.validate_event_data(df) @@ -268,7 +283,7 @@ def test_encoding_issues_handling(self, data_manager): with patch("pandas.read_csv") as mock_read_csv: # Simulate encoding errors encoding_errors = [ - UnicodeDecodeError('utf-8', b'', 0, 1, 'invalid start byte'), + UnicodeDecodeError("utf-8", b"", 0, 1, "invalid start byte"), UnicodeError("Unicode error"), Exception("Encoding detection failed"), ] @@ -279,8 +294,12 @@ def test_encoding_issues_handling(self, data_manager): result_df = data_manager.load_from_file(mock_file) # Should handle encoding errors gracefully - assert isinstance(result_df, pd.DataFrame), f"Should return DataFrame for encoding error: {error}" - assert len(result_df) == 0, f"Should return empty DataFrame for encoding error: {error}" + assert isinstance(result_df, pd.DataFrame), ( + f"Should return DataFrame for encoding error: {error}" + ) + assert len(result_df) == 0, ( + f"Should return empty DataFrame for encoding error: {error}" + ) print("✅ Encoding issues handling test passed") @@ -303,13 +322,17 @@ def test_large_csv_file_performance(self, data_manager): with patch("pandas.read_csv") as mock_read_csv: # Create large DataFrame - large_df = pd.DataFrame({ - "user_id": [f"user_{i:06d}" for i in range(n_rows)], - "event_name": [f"Event_{i % 10}" for i in range(n_rows)], - "timestamp": [datetime(2024, 1, 1) + timedelta(minutes=i) for i in range(n_rows)], - "event_properties": ["{}"] * n_rows, - "user_properties": ["{}"] * n_rows, - }) + large_df = pd.DataFrame( + { + "user_id": [f"user_{i:06d}" for i in range(n_rows)], + "event_name": [f"Event_{i % 10}" for i in range(n_rows)], + "timestamp": [ + datetime(2024, 1, 1) + timedelta(minutes=i) for i in range(n_rows) + ], + "event_properties": ["{}"] * n_rows, + "user_properties": ["{}"] * n_rows, + } + ) mock_read_csv.return_value = large_df # Measure loading time @@ -320,46 +343,62 @@ def test_large_csv_file_performance(self, data_manager): execution_time = end_time - start_time # Should load in reasonable time (less than 10 seconds) - assert execution_time < 10.0, f"Large file loading took too long: {execution_time:.2f}s" - + assert execution_time < 10.0, ( + f"Large file loading took too long: {execution_time:.2f}s" + ) + # Should load successfully assert isinstance(result_df, pd.DataFrame), "Should return DataFrame" assert len(result_df) == n_rows, f"Should load all {n_rows} rows" - print(f"✅ Large CSV file performance test passed ({execution_time:.2f}s for {n_rows} rows)") + print( + f"✅ Large CSV file performance test passed ({execution_time:.2f}s for {n_rows} rows)" + ) def test_memory_usage_monitoring(self, data_manager): """Test memory usage with large datasets.""" import tracemalloc - + # Start memory tracking tracemalloc.start() - + n_rows = 100000 mock_file = Mock() mock_file.name = "memory_test.csv" with patch("pandas.read_csv") as mock_read_csv: # Create memory-intensive DataFrame - large_df = pd.DataFrame({ - "user_id": [f"user_{i:06d}" for i in range(n_rows)], - "event_name": [f"Event_{i % 20}" for i in range(n_rows)], - "timestamp": [datetime(2024, 1, 1) + timedelta(minutes=i) for i in range(n_rows)], - "event_properties": [ - json.dumps({"category": f"cat_{i % 10}", "value": i, "metadata": {"nested": True}}) - for i in range(n_rows) - ], - "user_properties": [ - json.dumps({"segment": f"seg_{i % 5}", "score": i % 100, "profile": {"active": True}}) - for i in range(n_rows) - ], - }) + large_df = pd.DataFrame( + { + "user_id": [f"user_{i:06d}" for i in range(n_rows)], + "event_name": [f"Event_{i % 20}" for i in range(n_rows)], + "timestamp": [ + datetime(2024, 1, 1) + timedelta(minutes=i) for i in range(n_rows) + ], + "event_properties": [ + json.dumps( + {"category": f"cat_{i % 10}", "value": i, "metadata": {"nested": True}} + ) + for i in range(n_rows) + ], + "user_properties": [ + json.dumps( + { + "segment": f"seg_{i % 5}", + "score": i % 100, + "profile": {"active": True}, + } + ) + for i in range(n_rows) + ], + } + ) mock_read_csv.return_value = large_df # Load and validate data result_df = data_manager.load_from_file(mock_file) is_valid, _ = data_manager.validate_event_data(result_df) - + # Get memory usage current, peak = tracemalloc.get_traced_memory() tracemalloc.stop() @@ -380,7 +419,7 @@ def test_chunked_processing_simulation(self, data_manager): chunk_size = 10000 total_rows = 50000 chunks_processed = 0 - + mock_file = Mock() mock_file.name = "chunked_test.csv" @@ -388,31 +427,39 @@ def test_chunked_processing_simulation(self, data_manager): for chunk_start in range(0, total_rows, chunk_size): chunk_end = min(chunk_start + chunk_size, total_rows) chunk_rows = chunk_end - chunk_start - + with patch("pandas.read_csv") as mock_read_csv: # Create chunk DataFrame - chunk_df = pd.DataFrame({ - "user_id": [f"user_{i:06d}" for i in range(chunk_start, chunk_end)], - "event_name": [f"Event_{i % 5}" for i in range(chunk_rows)], - "timestamp": [datetime(2024, 1, 1) + timedelta(minutes=i) for i in range(chunk_rows)], - "event_properties": ["{}"] * chunk_rows, - "user_properties": ["{}"] * chunk_rows, - }) + chunk_df = pd.DataFrame( + { + "user_id": [f"user_{i:06d}" for i in range(chunk_start, chunk_end)], + "event_name": [f"Event_{i % 5}" for i in range(chunk_rows)], + "timestamp": [ + datetime(2024, 1, 1) + timedelta(minutes=i) for i in range(chunk_rows) + ], + "event_properties": ["{}"] * chunk_rows, + "user_properties": ["{}"] * chunk_rows, + } + ) mock_read_csv.return_value = chunk_df # Process chunk result_df = data_manager.load_from_file(mock_file) is_valid, _ = data_manager.validate_event_data(result_df) - + assert is_valid, f"Chunk {chunks_processed} should be valid" - assert len(result_df) == chunk_rows, f"Chunk {chunks_processed} should have {chunk_rows} rows" - + assert len(result_df) == chunk_rows, ( + f"Chunk {chunks_processed} should have {chunk_rows} rows" + ) + chunks_processed += 1 expected_chunks = (total_rows + chunk_size - 1) // chunk_size assert chunks_processed == expected_chunks, f"Should process {expected_chunks} chunks" - print(f"✅ Chunked processing simulation test passed ({chunks_processed} chunks of {chunk_size} rows)") + print( + f"✅ Chunked processing simulation test passed ({chunks_processed} chunks of {chunk_size} rows)" + ) def test_concurrent_file_upload_simulation(self, data_manager): """Test sequential simulation of concurrent file uploads (avoiding threading issues in tests).""" @@ -428,28 +475,36 @@ def test_concurrent_file_upload_simulation(self, data_manager): with patch("pandas.read_csv") as mock_read_csv: # Create test DataFrame - test_df = pd.DataFrame({ - "user_id": [f"worker_{worker_id}_user_{i}" for i in range(500)], # Smaller size - "event_name": [f"Event_{i % 3}" for i in range(500)], - "timestamp": [datetime.now() + timedelta(minutes=i) for i in range(500)], - "event_properties": ["{}"] * 500, - "user_properties": ["{}"] * 500, - }) + test_df = pd.DataFrame( + { + "user_id": [ + f"worker_{worker_id}_user_{i}" for i in range(500) + ], # Smaller size + "event_name": [f"Event_{i % 3}" for i in range(500)], + "timestamp": [datetime.now() + timedelta(minutes=i) for i in range(500)], + "event_properties": ["{}"] * 500, + "user_properties": ["{}"] * 500, + } + ) mock_read_csv.return_value = test_df start_time = time.time() result_df = data_manager.load_from_file(mock_file) end_time = time.time() - results.append({ - "worker_id": worker_id, - "duration": end_time - start_time, - "rows_loaded": len(result_df), - "success": len(result_df) == 500, - }) + results.append( + { + "worker_id": worker_id, + "duration": end_time - start_time, + "rows_loaded": len(result_df), + "success": len(result_df) == 500, + } + ) # Validate results - assert len(results) == num_simulated_uploads, f"All {num_simulated_uploads} simulated uploads should complete" + assert len(results) == num_simulated_uploads, ( + f"All {num_simulated_uploads} simulated uploads should complete" + ) # Check performance avg_duration = sum(r["duration"] for r in results) / len(results) @@ -457,9 +512,13 @@ def test_concurrent_file_upload_simulation(self, data_manager): # Check success successful_uploads = sum(1 for r in results if r["success"]) - assert successful_uploads == num_simulated_uploads, f"All simulated uploads should succeed, got {successful_uploads}/{num_simulated_uploads}" + assert successful_uploads == num_simulated_uploads, ( + f"All simulated uploads should succeed, got {successful_uploads}/{num_simulated_uploads}" + ) - print(f"✅ Concurrent file upload simulation test passed ({num_simulated_uploads} simulated uploads, avg {avg_duration:.2f}s)") + print( + f"✅ Concurrent file upload simulation test passed ({num_simulated_uploads} simulated uploads, avg {avg_duration:.2f}s)" + ) @pytest.mark.file_upload @@ -495,7 +554,9 @@ def test_filename_validation_security(self, data_manager): result_df = data_manager.load_from_file(mock_file) # Should return empty DataFrame for security (since we don't actually read the file) - assert isinstance(result_df, pd.DataFrame), f"Should return DataFrame for filename: {filename}" + assert isinstance(result_df, pd.DataFrame), ( + f"Should return DataFrame for filename: {filename}" + ) # Note: Actual file reading is mocked, so we get empty DataFrame print("✅ Filename validation security test passed") @@ -512,7 +573,9 @@ def test_file_size_limits_simulation(self, data_manager): result_df = data_manager.load_from_file(mock_file) # Should handle memory errors gracefully - assert isinstance(result_df, pd.DataFrame), "Should return DataFrame even for memory errors" + assert isinstance(result_df, pd.DataFrame), ( + "Should return DataFrame even for memory errors" + ) assert len(result_df) == 0, "Should return empty DataFrame for oversized files" print("✅ File size limits simulation test passed") @@ -522,38 +585,44 @@ def test_content_validation_security(self, data_manager): # Test with potentially malicious content malicious_content_scenarios = [ # SQL injection attempts in data - pd.DataFrame({ - "user_id": ["'; DROP TABLE users; --", "user2"], - "event_name": ["SELECT * FROM events", "Event2"], - "timestamp": [datetime.now(), datetime.now()], - "event_properties": ["{}"] * 2, - "user_properties": ["{}"] * 2, - }), - + pd.DataFrame( + { + "user_id": ["'; DROP TABLE users; --", "user2"], + "event_name": ["SELECT * FROM events", "Event2"], + "timestamp": [datetime.now(), datetime.now()], + "event_properties": ["{}"] * 2, + "user_properties": ["{}"] * 2, + } + ), # XSS attempts in data - pd.DataFrame({ - "user_id": ["", "user2"], - "event_name": ["", "Event2"], - "timestamp": [datetime.now(), datetime.now()], - "event_properties": ["{}"] * 2, - "user_properties": ["{}"] * 2, - }), - + pd.DataFrame( + { + "user_id": ["", "user2"], + "event_name": ["", "Event2"], + "timestamp": [datetime.now(), datetime.now()], + "event_properties": ["{}"] * 2, + "user_properties": ["{}"] * 2, + } + ), # Extremely long strings (potential buffer overflow) - pd.DataFrame({ - "user_id": ["A" * 10000, "user2"], - "event_name": ["B" * 10000, "Event2"], - "timestamp": [datetime.now(), datetime.now()], - "event_properties": ["{}"] * 2, - "user_properties": ["{}"] * 2, - }), + pd.DataFrame( + { + "user_id": ["A" * 10000, "user2"], + "event_name": ["B" * 10000, "Event2"], + "timestamp": [datetime.now(), datetime.now()], + "event_properties": ["{}"] * 2, + "user_properties": ["{}"] * 2, + } + ), ] for i, malicious_df in enumerate(malicious_content_scenarios): # Should validate without crashing is_valid, message = data_manager.validate_event_data(malicious_df) - + # Should be valid structurally (content filtering is not part of validation) - assert is_valid or "timestamp" in message.lower(), f"Scenario {i} should validate or have timestamp issue" + assert is_valid or "timestamp" in message.lower(), ( + f"Scenario {i} should validate or have timestamp issue" + ) - print("✅ Content validation security test passed") \ No newline at end of file + print("✅ Content validation security test passed") diff --git a/tests/test_visualization_pipeline_comprehensive.py b/tests/test_visualization_pipeline_comprehensive.py index 1eb8ca5..8d7aeb1 100644 --- a/tests/test_visualization_pipeline_comprehensive.py +++ b/tests/test_visualization_pipeline_comprehensive.py @@ -10,19 +10,12 @@ """ import json -import time -from datetime import datetime, timedelta -from unittest.mock import Mock, patch -import pandas as pd import plotly.graph_objects as go import pytest from models import ( - CohortData, FunnelResults, - PathAnalysisData, - TimeToConvertStats, ) from ui.visualization import FunnelVisualizer from ui.visualization.layout import LayoutConfig @@ -59,21 +52,23 @@ def test_funnel_chart_rendering_pipeline(self, visualizer, sample_funnel_results """Test complete funnel chart rendering pipeline.""" # Test basic funnel chart chart = visualizer.create_enhanced_funnel_chart(sample_funnel_results) - + # Validate chart object assert isinstance(chart, go.Figure), "Should return Plotly Figure object" assert chart.data is not None, "Chart should have data" assert chart.layout is not None, "Chart should have layout" - + # Validate data traces assert len(chart.data) > 0, "Chart should have at least one trace" - + # Validate layout properties assert chart.layout.title is not None, "Chart should have title" assert chart.layout.height is not None, "Chart should have height" - + # Test with segments - segmented_chart = visualizer.create_enhanced_funnel_chart(sample_funnel_results, show_segments=True) + segmented_chart = visualizer.create_enhanced_funnel_chart( + sample_funnel_results, show_segments=True + ) assert isinstance(segmented_chart, go.Figure), "Segmented chart should be valid Figure" print("✅ Funnel chart rendering pipeline test passed") @@ -81,20 +76,20 @@ def test_funnel_chart_rendering_pipeline(self, visualizer, sample_funnel_results def test_sankey_diagram_rendering_pipeline(self, visualizer, sample_funnel_results): """Test Sankey diagram rendering pipeline.""" chart = visualizer.create_enhanced_conversion_flow_sankey(sample_funnel_results) - + # Validate Sankey structure assert isinstance(chart, go.Figure), "Should return Plotly Figure object" - + # Find Sankey trace sankey_trace = None for trace in chart.data: - if hasattr(trace, 'type') and trace.type == 'sankey': + if hasattr(trace, "type") and trace.type == "sankey": sankey_trace = trace break - + assert sankey_trace is not None, "Should contain Sankey trace" - assert hasattr(sankey_trace, 'node'), "Sankey should have nodes" - assert hasattr(sankey_trace, 'link'), "Sankey should have links" + assert hasattr(sankey_trace, "node"), "Sankey should have nodes" + assert hasattr(sankey_trace, "link"), "Sankey should have links" print("✅ Sankey diagram rendering pipeline test passed") @@ -113,16 +108,18 @@ def test_responsive_height_calculation(self, visualizer): """Test responsive height calculation algorithm.""" # Test LayoutConfig responsive height calculation test_scenarios = [ - (400, 3, 400), # Small funnel, minimum height - (400, 10, 580), # Medium funnel, scaled height - (400, 50, 800), # Large funnel, capped at maximum + (400, 3, 400), # Small funnel, minimum height + (400, 10, 580), # Medium funnel, scaled height + (400, 50, 800), # Large funnel, capped at maximum ] - + for base_height, data_items, expected_range in test_scenarios: calculated_height = LayoutConfig.get_responsive_height(base_height, data_items) - + # Should be within universal height standards - assert 350 <= calculated_height <= 800, f"Height {calculated_height} outside standards for {data_items} items" + assert 350 <= calculated_height <= 800, ( + f"Height {calculated_height} outside standards for {data_items} items" + ) print("✅ Responsive height calculation test passed") @@ -136,12 +133,12 @@ def test_mobile_compatibility(self, visualizer): drop_offs=[0, 20, 20], drop_off_rates=[0.0, 20.0, 25.0], ) - + chart = visualizer.create_enhanced_funnel_chart(mobile_results) - + # Validate mobile-friendly properties assert chart.layout.height <= 600, "Mobile charts should have reasonable height" - + # Check margin configuration for mobile margins = chart.layout.margin assert margins.l <= 80, "Left margin should be mobile-friendly" @@ -174,15 +171,15 @@ def test_colorblind_friendly_palettes(self, visualizer): "Segment A": [600, 500, 400], "Segment B": [400, 300, 200], } - + chart = visualizer.create_enhanced_funnel_chart(segmented_results, show_segments=True) - + # Validate colorblind-friendly colors are used colors_used = [] for trace in chart.data: - if hasattr(trace, 'marker') and trace.marker and hasattr(trace.marker, 'color'): + if hasattr(trace, "marker") and trace.marker and hasattr(trace.marker, "color"): colors_used.append(trace.marker.color) - + # Should use distinct, accessible colors assert len(set(colors_used)) >= 2, "Should use multiple distinct colors" @@ -200,45 +197,49 @@ def visualizer(self): def test_plotly_figure_structure(self, visualizer): """Test Plotly Figure structure compliance.""" - chart = visualizer.create_enhanced_funnel_chart(FunnelResults( - steps=["Step 1", "Step 2"], - users_count=[100, 80], - conversion_rates=[100.0, 80.0], - drop_offs=[0, 20], - drop_off_rates=[0.0, 20.0], - )) - + chart = visualizer.create_enhanced_funnel_chart( + FunnelResults( + steps=["Step 1", "Step 2"], + users_count=[100, 80], + conversion_rates=[100.0, 80.0], + drop_offs=[0, 20], + drop_off_rates=[0.0, 20.0], + ) + ) + # Validate Figure structure assert isinstance(chart, go.Figure), "Should be Plotly Figure object" - assert hasattr(chart, 'data'), "Should have data attribute" - assert hasattr(chart, 'layout'), "Should have layout attribute" - + assert hasattr(chart, "data"), "Should have data attribute" + assert hasattr(chart, "layout"), "Should have layout attribute" + # Validate data traces assert isinstance(chart.data, tuple), "Data should be tuple of traces" for trace in chart.data: - assert hasattr(trace, 'type'), "Each trace should have type" + assert hasattr(trace, "type"), "Each trace should have type" print("✅ Plotly Figure structure test passed") def test_json_serialization_compatibility(self, visualizer): """Test JSON serialization compatibility.""" - chart = visualizer.create_enhanced_funnel_chart(FunnelResults( - steps=["Step 1", "Step 2"], - users_count=[100, 80], - conversion_rates=[100.0, 80.0], - drop_offs=[0, 20], - drop_off_rates=[0.0, 20.0], - )) - + chart = visualizer.create_enhanced_funnel_chart( + FunnelResults( + steps=["Step 1", "Step 2"], + users_count=[100, 80], + conversion_rates=[100.0, 80.0], + drop_offs=[0, 20], + drop_off_rates=[0.0, 20.0], + ) + ) + # Should be JSON serializable try: chart_json = chart.to_json() assert isinstance(chart_json, str), "Should serialize to JSON string" - + # Should be valid JSON parsed_json = json.loads(chart_json) assert isinstance(parsed_json, dict), "Should parse to dictionary" - + except Exception as e: assert False, f"Chart should be JSON serializable: {str(e)}" @@ -263,10 +264,10 @@ def test_empty_data_visualization(self, visualizer): drop_offs=[], drop_off_rates=[], ) - + # Should handle empty data gracefully chart = visualizer.create_enhanced_funnel_chart(empty_results) - + assert isinstance(chart, go.Figure), "Should return valid Figure for empty data" print("✅ Empty data visualization test passed") @@ -283,4 +284,4 @@ def test_invalid_data_visualization(self, visualizer): # Expected behavior - should raise clear exception assert str(e), "Exception should have descriptive message" - print("✅ Invalid data visualization test passed") \ No newline at end of file + print("✅ Invalid data visualization test passed")