Coverage for tests/test_dashboard.py: 99%
178 statements
« prev ^ index » next coverage.py v7.4.4, created at 2026-03-26 09:43 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2026-03-26 09:43 +0000
1"""
2Tests for the Dashboard class.
4Run with: pytest test_dashboard.py -v
5"""
6import pytest
7from unittest.mock import MagicMock, patch
8import pandas as pd
9import numpy as np
10from dash import Dash
11from agentlib_flexquant.utils.interactive import Dashboard, CustomBound
14class TestCustomBound:
15 """Tests for the CustomBound class."""
17 def test_init_with_all_params(self):
18 """Test CustomBound initialization with all parameters."""
19 bound = CustomBound("T", "T_lower", "T_upper")
20 assert bound.for_variable == "T"
21 assert bound.lower_bound == "T_lower"
22 assert bound.upper_bound == "T_upper"
24 def test_init_with_defaults(self):
25 """Test CustomBound initialization with default values."""
26 bound = CustomBound("T")
27 assert bound.for_variable == "T"
28 assert bound.lower_bound is None
29 assert bound.upper_bound is None
31 def test_init_with_only_lower_bound(self):
32 """Test CustomBound initialization with only lower bound."""
33 bound = CustomBound("T", lb_name="T_min")
34 assert bound.for_variable == "T"
35 assert bound.lower_bound == "T_min"
36 assert bound.upper_bound is None
38 def test_init_with_only_upper_bound(self):
39 """Test CustomBound initialization with only upper bound."""
40 bound = CustomBound("T", ub_name="T_max")
41 assert bound.for_variable == "T"
42 assert bound.lower_bound is None
43 assert bound.upper_bound == "T_max"
46@pytest.fixture
47def mock_dashboard():
48 """Create a mock Dashboard instance for testing."""
49 with patch.object(Dashboard, '__init__', lambda self, **kwargs: None):
50 dashboard = Dashboard.__new__(Dashboard)
52 # Setup minimal required state
53 dashboard.current_timescale_of_data = "hours"
54 dashboard.current_timescale_input = "hours"
55 dashboard.port = None
56 dashboard.custom_bounds = []
58 # Mock agent configs
59 dashboard.baseline_agent_config = MagicMock(id="baseline")
60 dashboard.pos_flex_agent_config = MagicMock(id="pos_flex")
61 dashboard.neg_flex_agent_config = MagicMock(id="neg_flex")
62 dashboard.simulator_agent_config = MagicMock(id="simulator")
64 # Mock module configs
65 dashboard.baseline_module_config = MagicMock(
66 module_id="baseline_mpc",
67 controls=[],
68 time_step=3600,
69 prediction_horizon=24,
70 optimization_backend={"discretization_options": {"method": "collocation"}},
71 )
72 dashboard.pos_flex_module_config = MagicMock(module_id="pos_mpc")
73 dashboard.neg_flex_module_config = MagicMock(module_id="neg_mpc")
74 dashboard.simulator_module_config = MagicMock(module_id="sim")
76 # Create mock dataframes with proper structure
77 time_index = pd.MultiIndex.from_product(
78 [[0, 1, 2], [0, 1, 2, 3]], names=["time", "step"]
79 )
81 dashboard.df_baseline = pd.DataFrame(
82 np.random.rand(12, 2),
83 index=time_index,
84 columns=pd.MultiIndex.from_tuples(
85 [("variable", "T"), ("variable", "power")]
86 ),
87 )
88 dashboard.df_pos_flex = dashboard.df_baseline.copy()
89 dashboard.df_neg_flex = dashboard.df_baseline.copy()
91 dashboard.df_baseline_stats = pd.DataFrame(
92 {"iter_count": [5, 6, 7]}, index=[0, 1, 2]
93 )
94 dashboard.df_pos_flex_stats = dashboard.df_baseline_stats.copy()
95 dashboard.df_neg_flex_stats = dashboard.df_baseline_stats.copy()
97 dashboard.df_simulation = pd.DataFrame(
98 {"T": [20, 21, 22, 23], "power": [100, 110, 105, 115]}, index=[0, 1, 2, 3]
99 )
101 dashboard.df_indicator = pd.DataFrame(
102 np.random.rand(12, 2),
103 index=time_index,
104 columns=["energy_flex_positive", "energy_flex_negative"],
105 )
107 market_index = pd.MultiIndex.from_product(
108 [[0, 1], [0]], names=["time", "step"]
109 )
110 dashboard.df_market = pd.DataFrame(
111 {"status": ["pending", "pending"], "pos_price": [10, 12], "neg_price": [8, 9]},
112 index=market_index,
113 )
115 dashboard.intersection_mpcs_sim = {
116 "T": {
117 "baseline_mpc": "T",
118 "pos_mpc": "T",
119 "neg_mpc": "T",
120 "sim": "T",
121 }
122 }
124 dashboard.plotting_variables = ["iter_count", "T"]
125 dashboard.kpi_names_pos = {"energy_flex": "energy_flex_positive"}
126 dashboard.kpi_names_neg = {"energy_flex": "energy_flex_negative"}
128 dashboard.LINE_PROPERTIES = {
129 "baseline": {"color": "black"},
130 "pos_flex": {"color": "blue"},
131 "neg_flex": {"color": "red"},
132 "simulator": {"color": "black"},
133 "bounds": {"color": "grey"},
134 "characteristic_times_current": {"color": "grey", "dash": "dash"},
135 "characteristic_times_accepted": {"color": "yellow"},
136 }
137 dashboard.bounds_key = "bounds"
138 dashboard.characteristic_times_current_key = "characteristic_times_current"
139 dashboard.characteristic_times_accepted_key = "characteristic_times_accepted"
140 dashboard.label_positive = "positive"
141 dashboard.label_negative = "negative"
142 dashboard.MPC_ITERATIONS = "iter_count"
144 # Mock methods that may be called
145 dashboard.convert_timescale_of_dataframe_index = MagicMock()
146 dashboard.get_intersection_mpcs_sim = MagicMock(
147 return_value=dashboard.intersection_mpcs_sim
148 )
150 return dashboard
153class TestDashboardCreateApp:
154 """This test module verifies the Dashboard functionality without running the actual
155 Dash server. It uses pytest fixtures and mocking to:
157 1. Create a mock Dashboard instance with simulated data (dataframes, configs, etc.)
158 that bypasses the actual initialization which requires real data files.
160 2. Test the separation of app creation from app running:
161 - `create_app()` returns a configured Dash app without blocking
162 - `show()` calls `create_app()` internally and then runs the server
164 3. Verify component behavior:
165 - CustomBound initialization with various parameter combinations
166 - Layout creation with all required UI components (sliders, checkboxes, dropdowns)
167 - Callback registration for interactivity
168 - Plotting helper methods add correct traces/shapes to figures
170 4. Test edge cases:
171 - Single vs. list of CustomBounds
172 - None vs. provided port numbers
173 - Multiple app creation calls
175 The mocking approach allows testing the Dashboard logic without dependencies on
176 external data files, FMU models, or a running web server. Tests validate that
177 the refactored structure (separating `create_app` from `show`) works correctly
178 and enables testability.
179 """
181 def test_create_app_returns_dash_instance(self, mock_dashboard):
182 """Test that create_app returns a Dash app instance."""
183 app = mock_dashboard.create_app()
184 assert isinstance(app, Dash)
186 def test_create_app_sets_empty_custom_bounds_when_none(self, mock_dashboard):
187 """Test that custom_bounds is set to empty list when None is passed."""
188 mock_dashboard.create_app(custom_bounds=None)
189 assert mock_dashboard.custom_bounds == []
191 def test_create_app_wraps_single_custom_bound_in_list(self, mock_dashboard):
192 """Test that a single CustomBound is wrapped in a list."""
193 bound = CustomBound("T", "T_lower", "T_upper")
194 mock_dashboard.create_app(custom_bounds=bound)
195 assert len(mock_dashboard.custom_bounds) == 1
196 assert mock_dashboard.custom_bounds[0] is bound
198 def test_create_app_accepts_list_of_custom_bounds(self, mock_dashboard):
199 """Test that a list of CustomBounds is accepted."""
200 bounds = [
201 CustomBound("T", "T_lower", "T_upper"),
202 CustomBound("P", "P_lower", "P_upper"),
203 ]
204 mock_dashboard.create_app(custom_bounds=bounds)
205 assert mock_dashboard.custom_bounds == bounds
206 assert len(mock_dashboard.custom_bounds) == 2
208 def test_create_app_has_layout(self, mock_dashboard):
209 """Test that the created app has a layout."""
210 app = mock_dashboard.create_app()
211 assert app.layout is not None
213 def test_create_app_layout_contains_required_components(self, mock_dashboard):
214 """Test that the layout contains required component IDs."""
215 app = mock_dashboard.create_app()
217 # Convert layout to string to check for component IDs
218 layout_str = str(app.layout)
220 assert "time_slider" in layout_str
221 assert "time_typing" in layout_str
222 assert "time_unit" in layout_str
223 assert "graphs_container_variables" in layout_str
224 assert "accepted_characteristic_times" in layout_str
225 assert "current_characteristic_times" in layout_str
226 assert "zoom_to_offer_window" in layout_str
227 assert "zoom_to_prediction_interval" in layout_str
230class TestDashboardShow:
231 """Tests for the Dashboard.show() method."""
233 def test_show_does_not_block_when_mocked(self, mock_dashboard):
234 """Test that show() can complete when app.run() is mocked."""
235 with patch("webbrowser.open_new_tab") as mock_browser, patch.object(
236 Dash, "run"
237 ) as mock_run:
238 mock_dashboard.port = 8050
239 mock_dashboard.show()
241 mock_browser.assert_called_once_with("http://localhost:8050")
242 mock_run.assert_called_once_with(debug=False, port=8050)
244 def test_show_uses_provided_port(self, mock_dashboard):
245 """Test that show() uses the provided port."""
246 mock_dashboard.port = 9999
248 with patch("webbrowser.open_new_tab") as mock_browser, patch.object(
249 Dash, "run"
250 ) as mock_run:
251 mock_dashboard.show()
253 mock_browser.assert_called_once_with("http://localhost:9999")
254 mock_run.assert_called_once_with(debug=False, port=9999)
256 def test_show_gets_port_when_not_provided(self, mock_dashboard):
257 """Test that show() gets a port when none is provided."""
258 mock_dashboard.port = None
260 with patch("webbrowser.open_new_tab"), patch.object(
261 Dash, "run"
262 ) as mock_run, patch(
263 "agentlib_flexquant.utils.interactive.get_port", return_value=8888
264 ):
265 mock_dashboard.show()
267 mock_run.assert_called_once_with(debug=False, port=8888)
269 def test_show_with_custom_bounds(self, mock_dashboard):
270 """Test show() with custom bounds."""
271 with patch("webbrowser.open_new_tab"), patch.object(Dash, "run"):
272 mock_dashboard.port = 8050
273 bound = CustomBound("T", "T_lower", "T_upper")
274 mock_dashboard.show(custom_bounds=bound)
276 assert len(mock_dashboard.custom_bounds) == 1
277 assert mock_dashboard.custom_bounds[0].for_variable == "T"
279 def test_show_calls_create_app(self, mock_dashboard):
280 """Test that show() calls create_app() internally."""
281 with patch("webbrowser.open_new_tab"), patch.object(Dash, "run"), patch.object(
282 mock_dashboard, "create_app", wraps=mock_dashboard.create_app
283 ) as mock_create:
284 mock_dashboard.port = 8050
285 mock_dashboard.show()
287 mock_create.assert_called_once()
290class TestDashboardLayout:
291 """Tests for the Dashboard layout creation."""
293 def test_create_layout_returns_list(self, mock_dashboard):
294 """Test that _create_layout returns a list."""
295 layout = mock_dashboard._create_layout()
296 assert isinstance(layout, list)
298 def test_create_layout_has_results_header(self, mock_dashboard):
299 """Test that the layout has a Results header."""
300 layout = mock_dashboard._create_layout()
302 # First element should be H1 with "Results"
303 from dash import html
305 assert isinstance(layout[0], html.H1)
306 assert layout[0].children == "Results"
309class TestDashboardCallbacks:
310 """Tests for Dashboard callbacks registration."""
312 def test_callbacks_are_registered(self, mock_dashboard):
313 """Test that callbacks are registered on the app."""
314 app = mock_dashboard.create_app()
316 # Check that callbacks were registered by checking the callback_map
317 assert len(app.callback_map) > 0
319 def test_time_slider_callback_registered(self, mock_dashboard):
320 """Test that the time slider callback is registered."""
321 app = mock_dashboard.create_app()
323 # Look for the time_slider output in callback map
324 callback_outputs = [str(key) for key in app.callback_map.keys()]
325 time_slider_registered = any("time_slider" in output for output in callback_outputs)
326 assert time_slider_registered
328 def test_graphs_container_callback_registered(self, mock_dashboard):
329 """Test that the graphs container callback is registered."""
330 app = mock_dashboard.create_app()
332 callback_outputs = [str(key) for key in app.callback_map.keys()]
333 graphs_registered = any(
334 "graphs_container_variables" in output for output in callback_outputs
335 )
336 assert graphs_registered
339class TestDashboardPlottingMethods:
340 """Tests for Dashboard plotting helper methods."""
342 def test_mark_time_adds_vline(self, mock_dashboard):
343 """Test that _mark_time adds a vertical line to the figure."""
344 from plotly import graph_objects as go
346 fig = go.Figure()
347 mock_dashboard._mark_time(fig, at_time_step=1.0, line_prop={"color": "green"})
349 # Check that a shape (vline) was added
350 assert len(fig.layout.shapes) == 1
352 def test_plot_mpc_stats_adds_traces(self, mock_dashboard):
353 """Test that _plot_mpc_stats adds traces to the figure."""
354 from plotly import graph_objects as go
356 fig = go.Figure()
357 mock_dashboard._plot_mpc_stats(fig, variable="iter_count")
359 # Should add 3 traces (baseline, pos_flex, neg_flex)
360 assert len(fig.data) == 3
363class TestDashboardIntegration:
364 """Integration tests for the Dashboard."""
366 def test_full_app_creation_workflow(self, mock_dashboard):
367 """Test the complete app creation workflow."""
368 # Create app with custom bounds
369 bounds = [
370 CustomBound("T", "T_lower", "T_upper"),
371 ]
372 app = mock_dashboard.create_app(custom_bounds=bounds)
374 # Verify app is properly configured
375 assert isinstance(app, Dash)
376 assert app.layout is not None
377 assert len(app.callback_map) > 0
378 assert mock_dashboard.custom_bounds == bounds
380 def test_app_can_be_created_multiple_times(self, mock_dashboard):
381 """Test that create_app can be called multiple times."""
382 app1 = mock_dashboard.create_app()
383 app2 = mock_dashboard.create_app(custom_bounds=CustomBound("T"))
385 assert isinstance(app1, Dash)
386 assert isinstance(app2, Dash)
389if __name__ == "__main__":
390 pytest.main([__file__, "-v"])