""" Test Plotly interactivity features in the visualization module. Verifies that Plotly charts have the expected interactive capabilities: 1. Hover templates are properly configured 2. Icicle chart settings allow click-to-drill-down navigation 3. Layout settings support proper display of interactive features Phase 4.7.2: Verify Plotly interactivity (zoom, pan, hover) """ import pytest import pandas as pd import numpy as np from datetime import datetime import plotly.graph_objects as go # Import the visualization module try: from visualization.plotly_generator import create_icicle_figure, save_figure_html HAS_VISUALIZATION = True except ImportError: HAS_VISUALIZATION = False @pytest.fixture def sample_chart_data(): """ Create sample chart data (ice_df) for testing visualization. This mimics the output of prepare_chart_data() from analysis/pathway_analyzer.py """ # Sample hierarchy data: Root -> Trust -> Directory -> Drug data = { 'parents': [ '', # Root (N&WICS) 'N&WICS', # Trust 1 'N&WICS', # Trust 2 'Trust1', # Directory in Trust1 'Trust1', # Another Directory 'Trust2', # Directory in Trust2 'Trust1/Rheum', # Drug 'Trust1/Derm', # Drug 'Trust2/Rheum', # Drug ], 'ids': [ 'N&WICS', 'Trust1', 'Trust2', 'Trust1/Rheum', 'Trust1/Derm', 'Trust2/Rheum', 'Trust1/Rheum/Adalimumab', 'Trust1/Derm/Adalimumab', 'Trust2/Rheum/Etanercept', ], 'labels': [ 'Norfolk & Waveney ICS', 'Manchester University Trust', 'Barts Health Trust', 'Rheumatology', 'Dermatology', 'Rheumatology', 'Adalimumab', 'Adalimumab', 'Etanercept', ], 'value': [50, 30, 20, 20, 10, 20, 20, 10, 20], 'colour': [1.0, 0.6, 0.4, 0.4, 0.2, 0.4, 0.4, 0.2, 0.4], 'cost': [50000, 30000, 20000, 20000, 10000, 20000, 20000, 10000, 20000], 'costpp': [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000], 'cost_pp_pa': [2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000], 'First seen': [ pd.Timestamp('2023-01-01')] * 9, 'Last seen': [ pd.Timestamp('2023-12-31')] * 9, 'First seen (Parent)': [ pd.Timestamp('2023-01-01')] * 9, 'Last seen (Parent)': [ pd.Timestamp('2023-12-31')] * 9, 'average_spacing': ['14 days'] * 9, 'avg_days': [pd.Timedelta('180 days')] * 9, } return pd.DataFrame(data) @pytest.mark.skipif(not HAS_VISUALIZATION, reason="Visualization module not available") class TestPlotlyFigureConfiguration: """Test that Plotly figures have correct interactive configuration.""" def test_figure_has_hovertemplate(self, sample_chart_data): """Verify the icicle chart has a hover template configured.""" fig = create_icicle_figure(sample_chart_data, "Test Title") # Get the icicle trace assert len(fig.data) > 0, "Figure should have at least one trace" icicle_trace = fig.data[0] assert icicle_trace.type == 'icicle', "First trace should be an icicle chart" # Verify hovertemplate is set and contains expected placeholders assert icicle_trace.hovertemplate is not None, "Hover template should be configured" assert '%{label}' in icicle_trace.hovertemplate, "Hover should include label" assert '%{customdata' in icicle_trace.hovertemplate, "Hover should include custom data" def test_figure_has_texttemplate(self, sample_chart_data): """Verify the icicle chart has a text template for in-chart text.""" fig = create_icicle_figure(sample_chart_data, "Test Title") icicle_trace = fig.data[0] # Verify texttemplate is set assert icicle_trace.texttemplate is not None, "Text template should be configured" assert '%{label}' in icicle_trace.texttemplate, "Text should include label" def test_figure_has_correct_branchvalues(self, sample_chart_data): """Verify branchvalues is set to 'total' for proper hierarchy summing.""" fig = create_icicle_figure(sample_chart_data, "Test Title") icicle_trace = fig.data[0] # branchvalues should be 'total' for proper hierarchy display assert icicle_trace.branchvalues == 'total', \ "branchvalues should be 'total' for hierarchy summation" def test_figure_has_maxdepth_for_drilldown(self, sample_chart_data): """Verify maxdepth is set to allow drill-down navigation.""" fig = create_icicle_figure(sample_chart_data, "Test Title") icicle_trace = fig.data[0] # maxdepth should be set to limit initial view depth # Users can then click to drill into deeper levels assert icicle_trace.maxdepth is not None, "maxdepth should be configured for drill-down" assert icicle_trace.maxdepth >= 2, "maxdepth should be at least 2 to show hierarchy" def test_figure_layout_has_hoverlabel(self, sample_chart_data): """Verify layout has hoverlabel configuration for readable tooltips.""" fig = create_icicle_figure(sample_chart_data, "Test Title") # Check hoverlabel configuration assert 'hoverlabel' in fig.layout, "Layout should have hoverlabel configuration" # Plotly uses 'font' as a dict with 'size' attribute assert fig.layout.hoverlabel.font is not None, "Hover label font should be configured" assert fig.layout.hoverlabel.font.size is not None, "Hover label font size should be set" assert fig.layout.hoverlabel.font.size >= 12, "Hover label should be readable (>=12px)" def test_figure_has_proper_margins(self, sample_chart_data): """Verify layout has margins configured for proper display.""" fig = create_icicle_figure(sample_chart_data, "Test Title") # Check margin configuration assert fig.layout.margin is not None, "Margins should be configured" assert fig.layout.margin.t >= 50, "Top margin should have room for title" def test_figure_has_title(self, sample_chart_data): """Verify the figure has a title configured.""" fig = create_icicle_figure(sample_chart_data, "Test Analysis") assert fig.layout.title is not None, "Figure should have a title" assert "Test Analysis" in fig.layout.title.text, "Title should include custom text" def test_figure_has_colorscale(self, sample_chart_data): """Verify the icicle chart has a colorscale for visual differentiation.""" fig = create_icicle_figure(sample_chart_data, "Test Title") icicle_trace = fig.data[0] # Check marker has colorscale assert icicle_trace.marker is not None, "Marker should be configured" assert icicle_trace.marker.colorscale is not None, "Colorscale should be set" @pytest.mark.skipif(not HAS_VISUALIZATION, reason="Visualization module not available") class TestPlotlyInteractiveFeatures: """Test that Plotly figures support expected interactive features.""" def test_figure_is_interactive_type(self, sample_chart_data): """Verify the figure is a go.Figure which supports interactivity.""" fig = create_icicle_figure(sample_chart_data, "Test Title") assert isinstance(fig, go.Figure), "Should return a Plotly Figure object" def test_figure_can_be_converted_to_html(self, sample_chart_data, tmp_path): """Verify the figure can be saved as interactive HTML.""" fig = create_icicle_figure(sample_chart_data, "Test Title") # Save to temporary file html_path = save_figure_html(fig, str(tmp_path), "test_chart", open_browser=False) assert html_path.endswith('.html'), "Should save as HTML file" # Verify the HTML file exists and contains Plotly data with open(html_path, 'r', encoding='utf-8') as f: html_content = f.read() assert 'plotly' in html_content.lower(), "HTML should contain Plotly" # Interactive HTML should include the plotly.js library assert 'cdn.plot.ly' in html_content or 'plotly-' in html_content, \ "HTML should include Plotly.js for interactivity" def test_figure_data_includes_ids_for_drilldown(self, sample_chart_data): """Verify figure data includes ids necessary for click-to-drill navigation.""" fig = create_icicle_figure(sample_chart_data, "Test Title") icicle_trace = fig.data[0] # ids are required for proper drill-down behavior in icicle charts assert icicle_trace.ids is not None, "ids should be provided for drill-down" assert len(icicle_trace.ids) > 0, "ids should not be empty" def test_figure_data_includes_parents_for_hierarchy(self, sample_chart_data): """Verify figure data includes parents for hierarchy navigation.""" fig = create_icicle_figure(sample_chart_data, "Test Title") icicle_trace = fig.data[0] # parents are required for hierarchy structure assert icicle_trace.parents is not None, "parents should be provided" assert len(icicle_trace.parents) > 0, "parents should not be empty" def test_figure_customdata_enables_rich_hover(self, sample_chart_data): """Verify customdata is provided for rich hover information.""" fig = create_icicle_figure(sample_chart_data, "Test Title") icicle_trace = fig.data[0] # customdata enables rich hover templates with additional info assert icicle_trace.customdata is not None, "customdata should be provided" # customdata should be a 2D array with multiple columns of data assert len(icicle_trace.customdata) > 0, "customdata should have rows" # Each row should have multiple data points for hover display if hasattr(icicle_trace.customdata[0], '__len__'): assert len(icicle_trace.customdata[0]) >= 5, \ "customdata should have multiple columns for rich hover" @pytest.mark.skipif(not HAS_VISUALIZATION, reason="Visualization module not available") class TestReflexCompatibility: """Test that figures are compatible with Reflex's rx.plotly() component.""" def test_figure_to_json_serializable(self, sample_chart_data): """Verify figure can be serialized to JSON (required for Reflex).""" fig = create_icicle_figure(sample_chart_data, "Test Title") # Reflex needs to serialize the figure to JSON for the frontend try: json_data = fig.to_json() assert json_data is not None assert len(json_data) > 0 except Exception as e: pytest.fail(f"Figure should be JSON serializable: {e}") def test_figure_to_dict(self, sample_chart_data): """Verify figure can be converted to dict (used by Reflex internally).""" fig = create_icicle_figure(sample_chart_data, "Test Title") # Reflex may use to_dict internally fig_dict = fig.to_dict() assert 'data' in fig_dict, "Figure dict should have data" assert 'layout' in fig_dict, "Figure dict should have layout" assert len(fig_dict['data']) > 0, "Data should not be empty" if __name__ == '__main__': pytest.main([__file__, '-v'])