# 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.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
# This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
"""Tests for NetworkService."""
import os
import pytest
from unittest.mock import patch, MagicMock
import pandas as pd
from expert_backend.services.network_service import NetworkService
class TestNetworkServiceInit:
def test_initial_state(self):
service = NetworkService()
assert service.network is None
class TestLoadNetwork:
def test_load_network_file_not_found(self):
service = NetworkService()
with pytest.raises(FileNotFoundError, match="not found"):
service.load_network("/nonexistent/path")
@patch("expert_backend.services.network_service.pn")
def test_load_network_from_file(self, mock_pn, tmp_path):
# Create a fake xiidm file
xiidm = tmp_path / "test.xiidm"
xiidm.write_text("")
mock_network = MagicMock()
mock_network.id = "test_net"
mock_pn.load.return_value = mock_network
service = NetworkService()
result = service.load_network(str(xiidm))
mock_pn.load.assert_called_once_with(str(xiidm))
assert result["message"] == "Network loaded successfully"
assert result["id"] == "test_net"
assert service.network is mock_network
@patch("expert_backend.services.network_service.pn")
def test_load_network_keeps_multi_thread_variant_flag_off(self, mock_pn, tmp_path):
"""Regression guard for the `allow_variant_multi_thread_access` path.
pypowsybl exposes this flag to unlock concurrent variant ops, but
enabling it requires every thread that touches the Network to
explicitly set its working variant first — otherwise pypowsybl
raises "Variant index not set for current thread".
FastAPI serves each read-only endpoint (`/api/branches`, …) on an
arbitrary thread-pool worker without a variant-set guard, so the
flag MUST stay off. See docs/performance/history/concurrent-variants.md."""
xiidm = tmp_path / "grid.xiidm"
xiidm.write_text("")
mock_pn.load.return_value = MagicMock(id="x")
NetworkService().load_network(str(xiidm))
_, kwargs = mock_pn.load.call_args
assert kwargs.get("allow_variant_multi_thread_access") in (None, False), (
"Flag must remain OFF — see docs/performance/history/concurrent-variants.md."
)
class TestLoadNetworkZip:
"""Auto-decompression of zipped networks (e.g. a shipped network.xiidm.zip)."""
@staticmethod
def _make_zip(dir_path, zip_name="network.xiidm.zip", member="network.xiidm"):
import zipfile
zpath = os.path.join(str(dir_path), zip_name)
with zipfile.ZipFile(zpath, "w") as zf:
zf.writestr(member, "")
return zpath
@patch("expert_backend.services.network_service.pn")
def test_loads_from_explicit_zip(self, mock_pn, tmp_path):
zpath = self._make_zip(tmp_path)
mock_pn.load.return_value = MagicMock(id="z")
NetworkService().load_network(zpath)
loaded = mock_pn.load.call_args[0][0]
assert loaded.endswith("network.xiidm")
assert os.path.isfile(loaded)
@patch("expert_backend.services.network_service.pn")
def test_loads_xiidm_when_only_sibling_zip_exists(self, mock_pn, tmp_path):
self._make_zip(tmp_path)
requested = os.path.join(str(tmp_path), "network.xiidm") # absent on disk
assert not os.path.exists(requested)
mock_pn.load.return_value = MagicMock(id="z")
NetworkService().load_network(requested)
loaded = mock_pn.load.call_args[0][0]
assert os.path.isfile(loaded) and loaded.endswith("network.xiidm")
def test_resolve_is_cached(self, tmp_path):
self._make_zip(tmp_path)
requested = os.path.join(str(tmp_path), "network.xiidm")
svc = NetworkService()
first = svc._resolve_network_file(requested)
second = svc._resolve_network_file(requested)
assert first == second and os.path.isfile(first)
def test_zip_without_network_raises(self, tmp_path):
import zipfile
zpath = os.path.join(str(tmp_path), "bad.zip")
with zipfile.ZipFile(zpath, "w") as zf:
zf.writestr("readme.txt", "no network here")
with pytest.raises(FileNotFoundError):
NetworkService().load_network(zpath)
@patch("expert_backend.services.network_service.pn")
def test_load_network_from_directory(self, mock_pn, tmp_path):
# Create a directory with a xiidm file inside
xiidm = tmp_path / "grid.xiidm"
xiidm.write_text("")
mock_network = MagicMock()
mock_network.id = "dir_net"
mock_pn.load.return_value = mock_network
service = NetworkService()
result = service.load_network(str(tmp_path))
mock_pn.load.assert_called_once_with(str(xiidm))
assert result["id"] == "dir_net"
@patch("expert_backend.services.network_service.pn")
def test_load_network_from_directory_xml(self, mock_pn, tmp_path):
xml_file = tmp_path / "network.xml"
xml_file.write_text("")
mock_network = MagicMock()
mock_network.id = "xml_net"
mock_pn.load.return_value = mock_network
service = NetworkService()
result = service.load_network(str(tmp_path))
assert result["id"] == "xml_net"
def test_load_network_directory_no_xiidm(self, tmp_path):
# Empty directory
service = NetworkService()
with pytest.raises(FileNotFoundError, match="No .xiidm or .xml"):
service.load_network(str(tmp_path))
class TestGetDisconnectableElements:
def test_network_not_loaded(self):
service = NetworkService()
with pytest.raises(ValueError, match="Network not loaded"):
service.get_disconnectable_elements()
def test_returns_sorted_elements(self, mock_network_service):
elements = mock_network_service.get_disconnectable_elements()
# Should include all lines and transformers, sorted
assert elements == ["LINE_A", "LINE_B", "LINE_C", "TRAFO_1", "TRAFO_2"]
def test_empty_lines_and_transformers(self):
service = NetworkService()
service.network = MagicMock()
service.network.get_lines.return_value = pd.DataFrame()
service.network.get_2_windings_transformers.return_value = pd.DataFrame()
assert service.get_disconnectable_elements() == []
def test_only_lines(self):
service = NetworkService()
service.network = MagicMock()
service.network.get_lines.return_value = pd.DataFrame(
{"dummy": [1, 2]}, index=["B_LINE", "A_LINE"]
)
service.network.get_2_windings_transformers.return_value = pd.DataFrame()
assert service.get_disconnectable_elements() == ["A_LINE", "B_LINE"]
class TestGetVoltageLevels:
def test_network_not_loaded(self):
service = NetworkService()
with pytest.raises(ValueError, match="Network not loaded"):
service.get_voltage_levels()
def test_returns_sorted_voltage_levels(self, mock_network_service):
vls = mock_network_service.get_voltage_levels()
assert vls == ["VL1", "VL2", "VL3", "VL4", "VL5"]
def test_empty_voltage_levels(self):
service = NetworkService()
service.network = MagicMock()
service.network.get_voltage_levels.return_value = pd.DataFrame()
assert service.get_voltage_levels() == []
class TestGetNominalVoltages:
def test_network_not_loaded(self):
service = NetworkService()
with pytest.raises(ValueError, match="Network not loaded"):
service.get_nominal_voltages()
def test_returns_mapping(self, mock_network_service):
mapping = mock_network_service.get_nominal_voltages()
assert mapping == {
"VL1": 400.0,
"VL2": 225.0,
"VL3": 90.0,
"VL4": 63.0,
"VL5": 25.0,
}
def test_empty_voltage_levels(self):
service = NetworkService()
service.network = MagicMock()
service.network.get_voltage_levels.return_value = pd.DataFrame()
assert service.get_nominal_voltages() == {}
class TestGetElementVoltageLevels:
def test_network_not_loaded(self):
service = NetworkService()
with pytest.raises(ValueError, match="Network not loaded"):
service.get_element_voltage_levels("VL1")
def test_voltage_level_id(self, mock_network_service):
"""A voltage level ID should resolve to itself."""
result = mock_network_service.get_element_voltage_levels("VL1")
assert result == ["VL1"]
def test_line_resolves_to_two_vls(self, mock_network_service):
"""A line should resolve to its two endpoint voltage levels."""
result = mock_network_service.get_element_voltage_levels("LINE_A")
assert result == ["VL1", "VL2"]
def test_transformer_resolves_to_two_vls(self, mock_network_service):
result = mock_network_service.get_element_voltage_levels("TRAFO_1")
assert result == ["VL1", "VL4"]
def test_unknown_element_returns_empty(self, mock_network_service):
result = mock_network_service.get_element_voltage_levels("NONEXISTENT")
assert result == []