🚧 WIP
This commit is contained in:
@@ -121,6 +121,186 @@ def test_read_error_tag_reports_error_not_crash(
|
||||
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(
|
||||
"<built-in method InvokeTypes of PyIDispatch object> "
|
||||
"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"),
|
||||
|
||||
Reference in New Issue
Block a user