"""Tests for the read/probe CLI verbs and the --server/--host overrides. All fake-client driven — no COM needed (IMPLEMENTATION_PLAN §9). The read path itself (OPCItem.Read) is verified live on a dev box with the Matrikon simulation server. """ import json import sys from datetime import datetime, timezone from pathlib import Path import pytest from opcda_diagnozer.main import main as cli_main from opcda_diagnozer.opc.base import OPCDataSource, OpcError, ReadResult from opcda_diagnozer.opc.fake import FakeOpcClient CONFIG = "opc:\n server: Fake.Server.1\ntags:\n - name: Bucket Brigade.Int1\n - name: Bucket Brigade.Real8\n" def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, args: list[str], client: FakeOpcClient | None = None) -> tuple[int, FakeOpcClient]: """Run the CLI with the fake client injected in place of the COM adapter.""" import opcda_diagnozer.main as main_mod config = tmp_path / "config.yaml" config.write_text(CONFIG, encoding="utf-8") fake = client if client is not None else FakeOpcClient() def _fake_connect(config_path: str, server=None, host=None): assert isinstance(server, str | None) fake.connect() return fake monkeypatch.setattr(main_mod, "_connect_from_config", _fake_connect) return cli_main(args), fake def test_read_explicit_tags(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None: code, fake = _run_cli(monkeypatch, tmp_path, [ "read", "-c", str(tmp_path / "config.yaml"), "Bucket Brigade.Int1", "Bucket Brigade.Real8", ]) out = capsys.readouterr().out assert code == 0 assert "Bucket Brigade.Int1" in out assert "0xC0" in out # GOOD quality shown decoded # request order preserved, source = DEVICE by default assert fake.reads == [ (("Bucket Brigade.Int1", "Bucket Brigade.Real8"), OPCDataSource.DEVICE), ] def test_read_from_config_tags(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None: code, fake = _run_cli(monkeypatch, tmp_path, [ "read", "-c", str(tmp_path / "config.yaml"), ]) out = capsys.readouterr().out assert code == 0 assert "Bucket Brigade.Int1" in out and "Bucket Brigade.Real8" in out assert fake.reads[0][0] == ("Bucket Brigade.Int1", "Bucket Brigade.Real8") def test_read_cache_source_flag(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _, fake = _run_cli(monkeypatch, tmp_path, [ "read", "-c", str(tmp_path / "config.yaml"), "--source", "cache", "Bucket Brigade.Int1", ]) assert fake.reads[0][1] == OPCDataSource.CACHE def test_read_json_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None: code, _ = _run_cli(monkeypatch, tmp_path, [ "read", "-c", str(tmp_path / "config.yaml"), "--json", "Bucket Brigade.Int1", ]) out = capsys.readouterr().out assert code == 0 payload = json.loads(out.strip().splitlines()[0]) assert payload["tag"] == "Bucket Brigade.Int1" assert payload["quality"] == "GOOD" assert payload["quality_class"] == "GOOD" assert payload["device_time_utc"] def test_read_bad_quality_tag_fails_with_nonzero_exit( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys ) -> None: bad = ReadResult( tag="Read Error.Int1", value=None, quality_raw=0x1C, # BAD_OUT_OF_SERVICE (0x18 would be COMM_FAILURE) device_time=datetime(2026, 10, 7, 9, 0, 0, tzinfo=timezone.utc), ) code, _ = _run_cli( monkeypatch, tmp_path, ["read", "-c", str(tmp_path / "config.yaml"), "Read Error.Int1"], client=FakeOpcClient(read_results=[bad]), ) captured = capsys.readouterr() assert code == 1 # non-GOOD quality -> exit 1 assert "BAD_OUT_OF_SERVICE" in captured.out assert "did not read GOOD" in captured.err def test_read_error_tag_reports_error_not_crash( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys ) -> None: missing = ReadResult( tag="No.Such.Tag", value=None, quality_raw=0, device_time=None, error="cannot add tag 'No.Such.Tag' (HRESULT 0xC0040008: ...)", ) code, _ = _run_cli( monkeypatch, tmp_path, ["read", "-c", str(tmp_path / "config.yaml"), "No.Such.Tag"], client=FakeOpcClient(read_results=[missing]), ) captured = capsys.readouterr() assert code == 1 assert "ERROR" in captured.out assert "0xC0040008" in captured.out # --------------------------------------------------------------------------- # # per-tag error rendering (_per_tag_error) — no COM needed # --------------------------------------------------------------------------- # def _fake_com_error(hresult: int) -> Exception: """A stand-in with the pywin32 com_error shape for the hresult check.""" err = Exception("server rejected the call") err.hresult = hresult # type: ignore[attr-defined] return err def test_per_tag_error_keeps_hresult_for_com_errors() -> None: from opcda_diagnozer.opc.automation import _per_tag_error rendered = _per_tag_error("cannot add tag 'X'", _fake_com_error(0x80020009)) assert "cannot add tag 'X'" in rendered assert "HRESULT 0x80020009" in rendered # decoded COM suffix preserved def test_per_tag_error_names_plain_exceptions() -> None: """A plain ValueError must show its type+text, not a bare 'failed'.""" from opcda_diagnozer.opc.automation import _per_tag_error rendered = _per_tag_error("read of tag 'X' failed", ValueError("tz math broke")) assert "read of tag 'X' failed" in rendered assert "ValueError" in rendered assert "tz math broke" in rendered assert "HRESULT" not in rendered # never fake a COM suffix for non-COM errors def test_timestamp_failure_never_fails_a_read() -> None: """A degenerate device timestamp is metadata: value/quality stay readable. Reproduces the alias-tag failure shape end-to-end through the adapter's read() with a stubbed COM item: a TimeStamp whose astimezone() explodes (zero FILETIME 1601 / DST math on Matrikon aliases) must yield a GOOD result with device_time=None — never a per-tag error. """ from datetime import timezone as _tz from opcda_diagnozer.opc import automation from opcda_diagnozer.opc.base import OPCDataSource class _PoisonedItem: """A COM item stand-in: reads succeed, TimeStamp cannot be normalized.""" ItemID = "P5002.Connected" def __init__(self) -> None: self.read_calls: list[int] = [] def Read(self, source: int) -> None: self.read_calls.append(source) @property def Value(self): return 1 @property def Quality(self): return 0xC0 @property def TimeStamp(self): class _Bad: def astimezone(self, tz): raise ValueError("astimezone() cannot be applied to a naive datetime") return _Bad() class _Items: def AddItem(self, item_id: str, handle: int): assert item_id == "P5002.Connected" return _PoisonedItem() class _Group: OPCItems = _Items() class _Parent: OPCGroups = None # set below Parent = _Parent() class _Groups: def Add(self, name: str): return _Group() class _Opc: # minimal stand-in for the wrapper's dispatch object OPCGroups = _Groups() class _ClientStub(automation.AutomationOpcClient): def __init__(self) -> None: super().__init__(server="stub") self._opc = _Opc() # read() requires a connected-looking client def _release_and_uninit(self) -> None: # no COM to release on the stub self._opc = None client = _ClientStub() results = client.read(["P5002.Connected"], source=OPCDataSource.DEVICE) assert len(results) == 1 result = results[0] assert result.error is None # the poison never surfaced assert result.value == 1 assert result.quality_raw == 0xC0 assert result.device_time is None # metadata dropped, not the read def test_timestamp_property_explosion_never_fails_a_read() -> None: """The frozen-exe failure shape: the TimeStamp PROPERTY itself explodes. Missing win32timezone made pywin32 raise SystemError('InvokeTypes ... exception set') when *accessing* item.TimeStamp (not in astimezone) — the guard must therefore wrap the property fetch too, and the read must still return the value/quality. """ from opcda_diagnozer.opc import automation from opcda_diagnozer.opc.base import OPCDataSource class _ExplodingTsItem: ItemID = "Bucket Brigade.Int1" def Read(self, source: int) -> None: return None @property def Value(self): return 42 @property def Quality(self): return 0xC0 @property def TimeStamp(self): # the exact frozen-exe signature: a non-COM SystemError raised by # pywin32's conversion inside the property access raise SystemError( " " "returned a result with an exception set" ) class _Items: def AddItem(self, item_id: str, handle: int): return _ExplodingTsItem() class _Group: OPCItems = _Items() class _Parent: OPCGroups = None Parent = _Parent() class _Groups: def Add(self, name: str): return _Group() class _Opc: OPCGroups = _Groups() class _ClientStub(automation.AutomationOpcClient): def __init__(self) -> None: super().__init__(server="stub") self._opc = _Opc() def _release_and_uninit(self) -> None: self._opc = None client = _ClientStub() results = client.read(["Bucket Brigade.Int1"], source=OPCDataSource.DEVICE) assert len(results) == 1 result = results[0] assert result.error is None # the timestamp explosion never surfaced assert result.value == 42 assert result.quality_raw == 0xC0 assert result.device_time is None def test_probe_reads_config_tags(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None: code, fake = _run_cli(monkeypatch, tmp_path, [ "probe", "-c", str(tmp_path / "config.yaml"), ]) out = capsys.readouterr().out assert code == 0 assert fake.reads[0][0] == ("Bucket Brigade.Int1", "Bucket Brigade.Real8") assert "Bucket Brigade.Int1" in out def test_probe_missing_config_is_actionable( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys ) -> None: # no monkeypatching: the real _connect_from_config must produce the hint code = cli_main(["probe", "-c", str(tmp_path / "no-such-config.yaml")]) captured = capsys.readouterr() assert code == 2 assert "FAILED" in captured.err assert "--server" in captured.err # the hint points at the override def test_server_override_flows_to_client( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys ) -> None: """--server/--host must reach the client even when a config exists.""" import opcda_diagnozer.main as main_mod config = tmp_path / "config.yaml" config.write_text(CONFIG, encoding="utf-8") seen: dict[str, object] = {} def _fake_connect(config_path: str, server=None, host=None): seen["server"], seen["host"] = server, host client = FakeOpcClient() client.connect() return client monkeypatch.setattr(main_mod, "_connect_from_config", _fake_connect) code = cli_main([ "read", "-c", str(config), "--server", "Other.Server.1", "--host", "OTHER-HOST", "Some.Tag", ]) assert code == 0 assert seen == {"server": "Other.Server.1", "host": "OTHER-HOST"} def test_read_without_config_and_without_server_is_actionable( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys ) -> None: # no monkeypatching: the real connect path must fail with the hint code = cli_main([ "read", "-c", str(tmp_path / "no-such.yaml"), "Some.Tag", ]) captured = capsys.readouterr() assert code == 2 assert "--server" in captured.err @pytest.mark.skipif(sys.platform != "win32", reason="live read needs Windows + pywin32") def test_live_automation_read_smoke() -> None: """Live smoke test — requires the Matrikon simulation server on this dev box.""" pytest.importorskip("pythoncom") from opcda_diagnozer.opc.automation import AutomationOpcClient client = AutomationOpcClient(server="Matrikon.OPC.Simulation.1") try: client.connect() results = client.read( ["Bucket Brigade.Int1", "Read Error.Int1", "No.Such.Tag"], source=OPCDataSource.DEVICE, ) by_tag = {r.tag: r for r in results} good = by_tag["Bucket Brigade.Int1"] assert good.quality_raw == 0xC0 assert good.device_time is not None # unknown tag -> per-tag error, never an exception assert by_tag["No.Such.Tag"].error is not None # "Read Error.Int1" from DEVICE returns the error code as the value # with GOOD quality (verified live 2026-10-07); the BAD quality only # appears on the CACHE path — the diagnozer's quality monitoring # therefore reads CACHE for that tag in the poll loop (Phase 1). assert by_tag["Read Error.Int1"].value is not None except OpcError as exc: pytest.skip(f"simulation server not usable on this machine: {exc}") finally: client.close()