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

1""" 

2Tests for the Dashboard class. 

3 

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 

12 

13 

14class TestCustomBound: 

15 """Tests for the CustomBound class.""" 

16 

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" 

23 

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 

30 

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 

37 

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" 

44 

45 

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) 

51 

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 = [] 

57 

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") 

63 

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") 

75 

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 ) 

80 

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() 

90 

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() 

96 

97 dashboard.df_simulation = pd.DataFrame( 

98 {"T": [20, 21, 22, 23], "power": [100, 110, 105, 115]}, index=[0, 1, 2, 3] 

99 ) 

100 

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 ) 

106 

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 ) 

114 

115 dashboard.intersection_mpcs_sim = { 

116 "T": { 

117 "baseline_mpc": "T", 

118 "pos_mpc": "T", 

119 "neg_mpc": "T", 

120 "sim": "T", 

121 } 

122 } 

123 

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"} 

127 

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" 

143 

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 ) 

149 

150 return dashboard 

151 

152 

153class TestDashboardCreateApp: 

154 """This test module verifies the Dashboard functionality without running the actual 

155 Dash server. It uses pytest fixtures and mocking to: 

156 

157 1. Create a mock Dashboard instance with simulated data (dataframes, configs, etc.) 

158 that bypasses the actual initialization which requires real data files. 

159 

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 

163 

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 

169 

170 4. Test edge cases: 

171 - Single vs. list of CustomBounds 

172 - None vs. provided port numbers 

173 - Multiple app creation calls 

174 

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 """ 

180 

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) 

185 

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 == [] 

190 

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 

197 

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 

207 

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 

212 

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() 

216 

217 # Convert layout to string to check for component IDs 

218 layout_str = str(app.layout) 

219 

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 

228 

229 

230class TestDashboardShow: 

231 """Tests for the Dashboard.show() method.""" 

232 

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() 

240 

241 mock_browser.assert_called_once_with("http://localhost:8050") 

242 mock_run.assert_called_once_with(debug=False, port=8050) 

243 

244 def test_show_uses_provided_port(self, mock_dashboard): 

245 """Test that show() uses the provided port.""" 

246 mock_dashboard.port = 9999 

247 

248 with patch("webbrowser.open_new_tab") as mock_browser, patch.object( 

249 Dash, "run" 

250 ) as mock_run: 

251 mock_dashboard.show() 

252 

253 mock_browser.assert_called_once_with("http://localhost:9999") 

254 mock_run.assert_called_once_with(debug=False, port=9999) 

255 

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 

259 

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() 

266 

267 mock_run.assert_called_once_with(debug=False, port=8888) 

268 

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) 

275 

276 assert len(mock_dashboard.custom_bounds) == 1 

277 assert mock_dashboard.custom_bounds[0].for_variable == "T" 

278 

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() 

286 

287 mock_create.assert_called_once() 

288 

289 

290class TestDashboardLayout: 

291 """Tests for the Dashboard layout creation.""" 

292 

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) 

297 

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() 

301 

302 # First element should be H1 with "Results" 

303 from dash import html 

304 

305 assert isinstance(layout[0], html.H1) 

306 assert layout[0].children == "Results" 

307 

308 

309class TestDashboardCallbacks: 

310 """Tests for Dashboard callbacks registration.""" 

311 

312 def test_callbacks_are_registered(self, mock_dashboard): 

313 """Test that callbacks are registered on the app.""" 

314 app = mock_dashboard.create_app() 

315 

316 # Check that callbacks were registered by checking the callback_map 

317 assert len(app.callback_map) > 0 

318 

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() 

322 

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 

327 

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() 

331 

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 

337 

338 

339class TestDashboardPlottingMethods: 

340 """Tests for Dashboard plotting helper methods.""" 

341 

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 

345 

346 fig = go.Figure() 

347 mock_dashboard._mark_time(fig, at_time_step=1.0, line_prop={"color": "green"}) 

348 

349 # Check that a shape (vline) was added 

350 assert len(fig.layout.shapes) == 1 

351 

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 

355 

356 fig = go.Figure() 

357 mock_dashboard._plot_mpc_stats(fig, variable="iter_count") 

358 

359 # Should add 3 traces (baseline, pos_flex, neg_flex) 

360 assert len(fig.data) == 3 

361 

362 

363class TestDashboardIntegration: 

364 """Integration tests for the Dashboard.""" 

365 

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) 

373 

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 

379 

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")) 

384 

385 assert isinstance(app1, Dash) 

386 assert isinstance(app2, Dash) 

387 

388 

389if __name__ == "__main__": 

390 pytest.main([__file__, "-v"])