"""Tests for scripts/modbus_cli.py. Acceptance criteria covered ---------------------------- 1. ``modbus_cli read`` (via mock) prints decoded engineering values for all profile metrics; columns are present for each metric key. 2. ``modbus_cli read`` exits non-zero on connection failure. 3. ``modbus_cli probe --fc 4`` prints raw register values in hex; optionally decodes float32 pairs. 4. ``modbus_cli probe`` exits non-zero on connection failure. 5. CLI has NO write-register sub-command (only ``read`` and ``probe`` exist). """ from __future__ import annotations import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Ensure project root is available when tests are run from the repo root. PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) import scripts.modbus_cli as cli # noqa: E402 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_ok_response(registers: list[int]) -> MagicMock: resp = MagicMock() resp.isError.return_value = False resp.registers = registers return resp def _make_error_response() -> MagicMock: resp = MagicMock() resp.isError.return_value = True resp.__str__ = lambda self: "ExceptionResponse(2)" return resp # --------------------------------------------------------------------------- # CLI: read sub-command # --------------------------------------------------------------------------- class TestReadCommand: """``python -m scripts.modbus_cli read ...`` with mocked driver.""" def _make_sdm120_registers(self) -> dict[int, int]: """Return a minimal register map covering all 8 sdm120 core metrics.""" import struct def pack(value: float) -> tuple[int, int]: raw = struct.pack(">f", value) hi = (raw[0] << 8) | raw[1] lo = (raw[2] << 8) | raw[3] return hi, lo pairs: dict[int, tuple[int, int]] = { 0x0000: pack(230.2), # voltage 0x0006: pack(1.3), # current 0x000C: pack(295.0), # active_power 0x001E: pack(0.98), # power_factor 0x0046: pack(50.0), # frequency 0x0048: pack(123.4), # import_energy 0x004A: pack(0.0), # export_energy 0x0156: pack(123.4), # total_energy } regs: dict[int, int] = {} for addr, (hi, lo) in pairs.items(): regs[addr] = hi regs[addr + 1] = lo return regs @patch("app.integrations.modbus.driver.ModbusTcpClient") def test_read_prints_decoded_values(self, mock_client_cls: MagicMock, capsys) -> None: """``read`` prints all profile metric keys with values.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client mock_client.connect.return_value = True regs = self._make_sdm120_registers() # sdm120 has 2 blocks; simulate both responses. # Block 1: 0x0000, count=0x0060 → we need 0x60=96 registers block1 = [regs.get(i, 0) for i in range(0x0000, 0x0060)] # Block 2: 0x0156, count=0x0004 → 4 registers block2 = [regs.get(i, 0) for i in range(0x0156, 0x015A)] mock_client.read_input_registers.side_effect = [ _make_ok_response(block1), _make_ok_response(block2), ] # Run the CLI. cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1", "--profile", "sdm120"]) captured = capsys.readouterr() output = captured.out # All eight core metric keys must appear in the output. for key in ("voltage", "current", "active_power", "power_factor", "frequency", "import_energy", "export_energy", "total_energy"): assert key in output, f"Missing metric key '{key}' in output:\n{output}" # The decoded voltage should be close to 230.2. assert "230." in output, f"Expected voltage ~230.2 in output:\n{output}" @patch("app.integrations.modbus.driver.ModbusTcpClient") def test_read_exits_nonzero_on_connection_failure( self, mock_client_cls: MagicMock ) -> None: """Connection failure causes ``sys.exit(1)``.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client mock_client.connect.return_value = False # Cannot connect. with pytest.raises(SystemExit) as exc_info: cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1", "--profile", "sdm120"]) assert exc_info.value.code != 0 def test_read_exits_nonzero_for_missing_profile(self, capsys) -> None: """Unknown profile name causes sys.exit(1).""" with pytest.raises(SystemExit) as exc_info: cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1", "--profile", "nonexistent_profile_xyz"]) assert exc_info.value.code != 0 # --------------------------------------------------------------------------- # CLI: probe sub-command # --------------------------------------------------------------------------- class TestProbeCommand: """``python -m scripts.modbus_cli probe ...`` with mocked pymodbus.""" @patch("scripts.modbus_cli.ModbusTcpClient" if False else "pymodbus.client.ModbusTcpClient") def _run_probe(self, mock_cls, args: list[str], regs: list[int]): """Helper: runs probe with a mocked client returning *regs*.""" @patch("app.integrations.modbus.driver.ModbusTcpClient") def test_probe_prints_raw_registers(self, mock_driver_cls, capsys) -> None: # probe uses its own client import, not the driver's — patch it there. pass # see next test for the direct approach def _setup_mock_client(self, regs: list[int]) -> MagicMock: """Return a mock ModbusTcpClient that returns *regs* for both FC03 and FC04.""" mock_cls = MagicMock() mock_client = MagicMock() mock_cls.return_value = mock_client mock_client.connect.return_value = True ok_response = _make_ok_response(regs) mock_client.read_input_registers.return_value = ok_response mock_client.read_holding_registers.return_value = ok_response return mock_cls def test_probe_fc4_prints_hex_values(self, capsys) -> None: """FC04 probe prints at least two hex addresses and values.""" mock_cls = self._setup_mock_client([0x4366, 0x3334]) with patch("pymodbus.client.ModbusTcpClient", mock_cls): cli.main([ "probe", "--host", "10.0.0.1", "--port", "502", "--unit", "1", "--fc", "4", "--address", "0x0000", "--count", "2", ]) captured = capsys.readouterr() output = captured.out # Expect hex addresses and values in the output. assert "0x0000" in output.lower() or "0000" in output assert "4366" in output.upper() assert "3334" in output.upper() def test_probe_fc4_with_float32_decode(self, capsys) -> None: """--decode float32 adds a float32 decode section to output.""" mock_cls = self._setup_mock_client([0x4366, 0x3334]) with patch("pymodbus.client.ModbusTcpClient", mock_cls): cli.main([ "probe", "--host", "10.0.0.1", "--port", "502", "--unit", "1", "--fc", "4", "--address", "0x0000", "--count", "2", "--decode", "float32", ]) captured = capsys.readouterr() output = captured.out # The float decode section should show ~230.2. assert "230." in output, f"Expected ~230.2 in float decode output:\n{output}" def test_probe_exits_nonzero_on_connection_failure(self, capsys) -> None: """Connection failure causes non-zero exit.""" mock_cls = MagicMock() mock_client = MagicMock() mock_cls.return_value = mock_client mock_client.connect.return_value = False with patch("pymodbus.client.ModbusTcpClient", mock_cls): with pytest.raises(SystemExit) as exc_info: cli.main([ "probe", "--host", "10.0.0.1", "--port", "502", "--unit", "1", "--fc", "4", "--address", "0x0000", "--count", "2", ]) assert exc_info.value.code != 0 def test_probe_fc3_also_works(self, capsys) -> None: """FC03 (holding registers) is supported by probe.""" mock_cls = self._setup_mock_client([0x0001, 0x0002]) with patch("pymodbus.client.ModbusTcpClient", mock_cls): cli.main([ "probe", "--host", "10.0.0.1", "--port", "502", "--unit", "1", "--fc", "3", "--address", "0x0014", "--count", "2", ]) # Verify holding-register read was called (not input-register read). mock_client = mock_cls.return_value mock_client.read_holding_registers.assert_called_once() mock_client.read_input_registers.assert_not_called() # --------------------------------------------------------------------------- # CLI: no write sub-commands # --------------------------------------------------------------------------- class TestNoWriteSubcommands: """The CLI must not expose any write-register sub-commands.""" def test_no_write_subcommand_in_parser(self) -> None: """The argument parser has no 'write', 'set', or 'force' sub-commands.""" parser = cli.build_parser() # Access the subparsers group. subparsers_actions = [ action for action in parser._actions if hasattr(action, "_name_parser_map") ] assert len(subparsers_actions) == 1 available_commands = set(subparsers_actions[0]._name_parser_map.keys()) # Only read-only commands must exist. assert "read" in available_commands assert "probe" in available_commands # No write-related commands. for name in available_commands: assert name not in {"write", "set", "force", "write-register", "set-register"}, ( f"Write sub-command '{name}' found in CLI — it must not exist" ) def test_only_read_and_probe_subcommands(self) -> None: """Exactly two sub-commands are available: read and probe.""" parser = cli.build_parser() subparsers_actions = [ action for action in parser._actions if hasattr(action, "_name_parser_map") ] available = set(subparsers_actions[0]._name_parser_map.keys()) assert available == {"read", "probe"}, ( f"Expected only {{read, probe}}, got {available}" )