# Copyright (c) 2025-2026, RTE (https://www.rte-france.com) # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. # SPDX-License-Identifier: MPL-2.0 """Tests for the explicit RecommenderService ⇄ recommender-registry composition. Successor of the orphaned root ``tests/test_service_integration.py`` (2026-07 D1 revision). The integration is no longer an import-time monkey-patch: :class:`RecommenderService` inherits :class:`ModelSelectionMixin` directly, ``update_config`` / ``reset`` call ``_apply_model_settings`` / ``_reset_model_settings`` themselves, and the single, model-aware ``run_analysis_step2`` lives on :class:`AnalysisMixin`. These tests pin that wiring — and would fail loudly if anyone reintroduced a shadowing wrapper. Needs the real ``expert_op4grid_recommender`` (``run_analysis_step2`` builds recommenders from the registry, whose package ``__init__`` imports the concrete model classes), so it is skipped under the conftest mock layer. """ from __future__ import annotations from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest pytest.importorskip("expert_op4grid_recommender.models.base") from expert_backend.services.model_selection_mixin import ModelSelectionMixin # noqa: E402 from expert_backend.services.recommender_service import ( # noqa: E402 RecommenderService, recommender_service, ) # --------------------------------------------------------------------- # Composition wiring # --------------------------------------------------------------------- def test_service_inherits_model_selection_mixin(): assert ModelSelectionMixin in RecommenderService.__mro__ def test_service_class_has_model_selection_helpers(): for attr in ( "get_active_model_name", "get_compute_overflow_graph", "_reset_model_settings", "_apply_model_settings", ): assert hasattr(RecommenderService, attr), f"missing {attr!r}" def test_no_import_time_wrappers_remain(): """The de-ghosting contract: production methods are the ones defined in their home modules — no ``_with_model`` wrapper shadows them.""" assert RecommenderService.update_config.__name__ == "update_config" assert RecommenderService.reset.__name__ == "reset" assert RecommenderService.run_analysis_step2.__name__ == "run_analysis_step2" assert ( RecommenderService.run_analysis_step2.__module__ == "expert_backend.services.analysis_mixin" ) def test_singleton_has_default_model_state(): # __init__ initialises the model-selection state, so the module-level # singleton exposes the defaults before /api/config is ever called. assert recommender_service.get_active_model_name() == "expert" assert recommender_service.get_compute_overflow_graph() is True def test_fresh_instance_has_default_model_state(): svc = RecommenderService() assert svc.get_active_model_name() == "expert" assert svc.get_compute_overflow_graph() is True def test_reset_restores_model_defaults(): svc = RecommenderService() svc._recommender_model_name = "random" svc._compute_overflow_graph = False svc.reset() assert svc.get_active_model_name() == "expert" assert svc.get_compute_overflow_graph() is True def test_update_config_captures_model_selection(tmp_path): """``update_config`` applies the two model-selection fields itself (formerly done by an import-time wrapper).""" svc = RecommenderService() settings = SimpleNamespace( network_path=str(tmp_path / "net.xiidm"), action_file_path=str(tmp_path / "actions.json"), min_line_reconnections=2.0, min_close_coupling=3.0, min_open_coupling=2.0, min_line_disconnections=3.0, n_prioritized_actions=10, model="random_overflow", compute_overflow_graph=False, ) with patch.object(RecommenderService, "prefetch_base_nad_async"), \ patch( "expert_backend.services.recommender_service.load_actions", return_value={"disco_X": {"description": "d"}}, ), \ patch( "expert_backend.services.recommender_service.enrich_actions_lazy", side_effect=lambda raw, net: raw, ): svc.update_config(settings) assert svc.get_active_model_name() == "random_overflow" assert svc.get_compute_overflow_graph() is False # --------------------------------------------------------------------- # Model-aware run_analysis_step2 behaviour # --------------------------------------------------------------------- def test_run_analysis_step2_requires_context(): """Without step-1 having populated the context, step-2 must error out.""" svc = RecommenderService() svc._analysis_context = None gen = svc.run_analysis_step2(selected_overloads=[]) with pytest.raises(ValueError, match="Analysis context not found"): next(gen) def test_run_analysis_step2_emits_error_for_unknown_model(): """Unknown model -> single error event then closes the stream.""" svc = RecommenderService() # Fake a non-empty context so we reach the model build step. svc._analysis_context = { "lines_overloaded_names": [], "lines_overloaded_ids": [], "lines_overloaded_ids_kept": [], "lines_we_care_about": None, } svc._recommender_model_name = "__not_a_model__" svc._compute_overflow_graph = False events = list(svc.run_analysis_step2( selected_overloads=[], all_overloads=[], monitor_deselected=False, additional_lines_to_cut=[], )) assert len(events) == 1 assert events[0]["type"] == "error" assert "__not_a_model__" in events[0]["message"] # --------------------------------------------------------------------- # Step-2 overflow-graph cache on the model-aware path. # # The overflow graph is model-INDEPENDENT — only action discovery # consumes the recommender — so a re-run with the same contingency + # Step-2 inputs but a different model must REUSE the cached graph and # skip `run_analysis_step2_graph`. # --------------------------------------------------------------------- def _seed_step2_state(svc, tmp_path): """Put `svc` into the post-step1 state and stub the per-instance helpers so `run_analysis_step2` runs end to end without the heavy pipeline. Returns the fake produced-HTML path.""" svc._reset_model_settings() svc._last_disconnected_elements = ["LINE_C"] svc._analysis_context = { "lines_overloaded_names": ["L1"], "lines_overloaded_ids": [0], "lines_overloaded_ids_kept": [0], "lines_we_care_about": None, } svc._last_step2_context = None svc._last_step2_signature = None svc._overflow_layout_cache = {} pdf = tmp_path / "overflow.html" pdf.write_text("") svc._narrow_context_to_selected_overloads = MagicMock(side_effect=lambda ctx, *a, **k: ctx) svc._get_latest_pdf_path = MagicMock(return_value=str(pdf)) svc._enrich_actions = MagicMock(return_value={}) svc._augment_combined_actions_with_target_max_rho = MagicMock() svc._compute_mw_start_for_scores = MagicMock(return_value={}) return str(pdf) def _graph_required_recommender(name="expert"): rec = MagicMock() rec.requires_overflow_graph = True rec.name = name return rec _DISCOVERY_RESULT = { "prioritized_actions": {}, "action_scores": {}, "lines_overloaded_names": ["L1"], } def test_unchanged_signature_reuses_overflow_graph(tmp_path): """Re-running with an identical signature (only the model swapped) skips `run_analysis_step2_graph` and reuses the cached graph — discovery still re-runs because it's the model-dependent step.""" svc = RecommenderService() expected_pdf = _seed_step2_state(svc, tmp_path) with patch( "expert_backend.recommenders.registry.build_recommender", return_value=_graph_required_recommender(), ), patch( "expert_backend.services.analysis_mixin.run_analysis_step2_graph", side_effect=lambda ctx: ctx, ) as mock_graph, patch( "expert_backend.services.analysis_mixin.run_analysis_step2_discovery", return_value=dict(_DISCOVERY_RESULT), ) as mock_discovery: kwargs = dict( selected_overloads=["L1"], all_overloads=["L1"], monitor_deselected=False, additional_lines_to_cut=["EXTRA"], ) # First run — builds the graph and seeds the cache. events1 = list(svc.run_analysis_step2(**kwargs)) assert mock_graph.call_count == 1 assert svc._last_step2_signature is not None pdf_event1 = next(e for e in events1 if e.get("type") == "pdf") assert pdf_event1["pdf_path"] == expected_pdf # Second run, identical signature — graph rebuild is skipped, # discovery re-runs (a model swap only affects discovery). events2 = list(svc.run_analysis_step2(**kwargs)) assert mock_graph.call_count == 1 # NOT rebuilt assert mock_discovery.call_count == 2 # discovery re-ran pdf_event2 = next(e for e in events2 if e.get("type") == "pdf") assert pdf_event2["pdf_path"] == expected_pdf assert pdf_event2.get("cached") is True def test_changed_additional_lines_rebuilds_overflow_graph(tmp_path): """Changing the `additional_lines_to_cut` hypothesis changes the signature, so the overflow graph MUST be rebuilt.""" svc = RecommenderService() _seed_step2_state(svc, tmp_path) with patch( "expert_backend.recommenders.registry.build_recommender", return_value=_graph_required_recommender(), ), patch( "expert_backend.services.analysis_mixin.run_analysis_step2_graph", side_effect=lambda ctx: ctx, ) as mock_graph, patch( "expert_backend.services.analysis_mixin.run_analysis_step2_discovery", return_value=dict(_DISCOVERY_RESULT), ): list(svc.run_analysis_step2( selected_overloads=["L1"], all_overloads=["L1"], monitor_deselected=False, additional_lines_to_cut=["EXTRA"], )) assert mock_graph.call_count == 1 list(svc.run_analysis_step2( selected_overloads=["L1"], all_overloads=["L1"], monitor_deselected=False, additional_lines_to_cut=["OTHER"], )) assert mock_graph.call_count == 2 # rebuilt for the new signature def test_graph_skipping_model_does_not_reuse_or_seed_cache(tmp_path): """A model that doesn't need the overflow graph never builds OR reuses it — and clears the signature so a later graph-requiring run can't false-hit on it.""" svc = RecommenderService() _seed_step2_state(svc, tmp_path) # Pre-seed a stale cache to prove the no-graph path clears it. svc._last_step2_signature = ("stale",) svc._last_step2_context = {"stale": True} no_graph_rec = MagicMock() no_graph_rec.requires_overflow_graph = False no_graph_rec.name = "random" with patch( "expert_backend.recommenders.registry.build_recommender", return_value=no_graph_rec, ), patch( "expert_backend.services.analysis_mixin.run_analysis_step2_graph", ) as mock_graph, patch( "expert_backend.services.analysis_mixin.run_analysis_step2_discovery", return_value=dict(_DISCOVERY_RESULT), ): # The operator did not opt into the (expensive) graph build, and # the model doesn't require it → the graph step is skipped. svc._compute_overflow_graph = False events = list(svc.run_analysis_step2( selected_overloads=["L1"], all_overloads=["L1"], monitor_deselected=False, additional_lines_to_cut=[], )) mock_graph.assert_not_called() pdf_event = next(e for e in events if e.get("type") == "pdf") assert pdf_event["pdf_path"] is None assert svc._last_step2_signature is None assert svc._last_step2_context is None def test_result_event_restores_antenna_meta_from_discovery(): """Regression guard for the antenna_meta mirror-drift bug: the model-aware generator must forward ``antenna_meta`` from the discovery results to the result event (the frontend's AntennaNotice reads it). The pre-D1 production wrapper silently dropped it.""" svc = RecommenderService() svc._reset_model_settings() svc._compute_overflow_graph = False svc._last_disconnected_elements = ["LINE_C"] svc._analysis_context = { "lines_overloaded_names": ["L1"], "lines_overloaded_ids": [0], "lines_overloaded_ids_kept": [0], "lines_we_care_about": None, } svc._narrow_context_to_selected_overloads = MagicMock(side_effect=lambda ctx, *a, **k: ctx) svc._enrich_actions = MagicMock(return_value={}) svc._augment_combined_actions_with_target_max_rho = MagicMock() svc._compute_mw_start_for_scores = MagicMock(return_value={}) no_graph_rec = MagicMock() no_graph_rec.requires_overflow_graph = False no_graph_rec.name = "random" antenna = {"pocket_subs": ["SUB_A"], "direction": "import"} with patch( "expert_backend.recommenders.registry.build_recommender", return_value=no_graph_rec, ), patch( "expert_backend.services.analysis_mixin.run_analysis_step2_discovery", return_value={**_DISCOVERY_RESULT, "antenna_meta": antenna}, ): events = list(svc.run_analysis_step2(selected_overloads=["L1"])) result_event = next(e for e in events if e.get("type") == "result") assert result_event["antenna_meta"] == antenna