🎉 Initial commit
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
"""Browse tests (opc/browse.py + fake browser + CLI verbs).
|
||||
|
||||
Pure tests run on any OS against the fake client's address space; the COM
|
||||
path is Windows-only and covered by the live smoke tests (IMPLEMENTATION_PLAN
|
||||
§9: COM adapters are import-guarded).
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from opcda_diagnozer.main import main as cli_main
|
||||
from opcda_diagnozer.opc.base import Browser, DeviceInfo, OpcError, TagInfo
|
||||
from opcda_diagnozer.opc.browse import (
|
||||
MAX_DEPTH,
|
||||
list_devices,
|
||||
list_tags,
|
||||
split_device_path,
|
||||
tags_for_device,
|
||||
)
|
||||
from opcda_diagnozer.opc.fake import FAKE_SPACE, FakeOpcClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser() -> Browser:
|
||||
client = FakeOpcClient()
|
||||
client.connect()
|
||||
return client.create_browser()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# split_device_path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
(None, ()),
|
||||
((), ()),
|
||||
("", ()),
|
||||
(" ", ()),
|
||||
("Simulation Items.Bucket Brigade", ("Simulation Items", "Bucket Brigade")),
|
||||
("Simulation Items", ("Simulation Items",)),
|
||||
("a..b", ("a", "b")), # empty segments dropped
|
||||
(("Simulation Items", "Bucket Brigade"), ("Simulation Items", "Bucket Brigade")),
|
||||
(("Simulation Items", " Bucket Brigade "), ("Simulation Items", "Bucket Brigade")),
|
||||
(("Simulation Items", "", "Bucket Brigade"), ("Simulation Items", "Bucket Brigade")),
|
||||
# explicit segments keep embedded dots intact (no split)
|
||||
(("Bucket. Inc", "Device 1"), ("Bucket. Inc", "Device 1")),
|
||||
],
|
||||
)
|
||||
def test_split_device_path(raw, expected: tuple[str, ...]) -> None:
|
||||
assert split_device_path(raw) == expected
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# list_devices
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_list_devices_top_level(browser: Browser) -> None:
|
||||
space = list_devices(browser)
|
||||
assert space.organization == "hierarchical"
|
||||
assert space.root.sub_devices == ("Simulation Items", "Configured Aliases")
|
||||
names = [d.name for d in space.devices]
|
||||
assert names == ["Simulation Items", "Configured Aliases"]
|
||||
assert all(d.path == (d.name,) for d in space.devices)
|
||||
|
||||
|
||||
def test_list_devices_depth_two_includes_sub_devices(browser: Browser) -> None:
|
||||
space = list_devices(browser, depth=2)
|
||||
paths = [d.path for d in space.devices]
|
||||
assert ("Simulation Items", "Bucket Brigade") in paths
|
||||
assert ("Simulation Items", "Random") in paths
|
||||
# depth 2: the root's direct children plus their direct children
|
||||
assert len(paths) == 2 + 8 # 2 top-level + 8 under Simulation Items
|
||||
|
||||
|
||||
def test_list_devices_reports_tag_counts(browser: Browser) -> None:
|
||||
space = list_devices(browser)
|
||||
by_name = {d.name: d for d in space.devices}
|
||||
assert by_name["Simulation Items"].tag_names == () # no direct leaves
|
||||
assert by_name["Configured Aliases"].tag_names == ("Alias1", "Alias2")
|
||||
assert by_name["Simulation Items"].sub_devices[:4] == (
|
||||
"Bucket Brigade", "Random", "Read Error", "Saw-toothed Waves",
|
||||
)
|
||||
|
||||
|
||||
def test_list_devices_no_counts_skips_leaves(browser: Browser) -> None:
|
||||
space = list_devices(browser, include_tag_counts=False)
|
||||
assert space.root.tag_names == ()
|
||||
assert all(d.tag_names == () for d in space.devices)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth", [0, -1, MAX_DEPTH + 1])
|
||||
def test_list_devices_rejects_bad_depth(browser: Browser, depth: int) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
list_devices(browser, depth=depth)
|
||||
|
||||
|
||||
def test_list_devices_cycle_guard(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A pathological self-nesting branch must not hang the recursive walk."""
|
||||
|
||||
class _LoopBrowser:
|
||||
@property
|
||||
def organization(self) -> str:
|
||||
return "hierarchical"
|
||||
|
||||
def list_branches(self, path=()):
|
||||
return ["Loop"] # "Loop" always contains another "Loop" — cycle
|
||||
|
||||
def list_leaves(self, path=()):
|
||||
return []
|
||||
|
||||
def move_to(self, path):
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
with pytest.raises(OpcError, match="deeper than"):
|
||||
list_tags(_LoopBrowser(), recursive=True) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# list_tags
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_list_tags_root_direct(browser: Browser) -> None:
|
||||
tags = list_tags(browser)
|
||||
assert [t.name for t in tags] == ["#MonitorACLFile", "@ClientCount", "@Clients"]
|
||||
assert all(t.item_id == t.name for t in tags) # root leaves: bare ItemIDs
|
||||
assert all(t.path == () for t in tags)
|
||||
|
||||
|
||||
def test_list_tags_of_device_dotted_string(browser: Browser) -> None:
|
||||
tags = list_tags(browser, device="Simulation Items.Bucket Brigade")
|
||||
item_ids = [t.item_id for t in tags]
|
||||
assert "Bucket Brigade.Int1" in item_ids
|
||||
assert "Bucket Brigade.Real8" in item_ids
|
||||
assert len(tags) == 7
|
||||
|
||||
|
||||
def test_list_tags_of_device_segments(browser: Browser) -> None:
|
||||
tags = list_tags(browser, device=["Simulation Items", "Random"])
|
||||
assert {t.name for t in tags} == {"Int1", "Int4", "Money", "Time"}
|
||||
assert all(t.path == ("Simulation Items", "Random") for t in tags)
|
||||
|
||||
|
||||
def test_list_tags_recursive(browser: Browser) -> None:
|
||||
tags = list_tags(browser, device="Simulation Items", recursive=True)
|
||||
item_ids = {t.item_id for t in tags}
|
||||
# direct children of "Simulation Items" have no leaves; recursion gathers all
|
||||
assert "Bucket Brigade.Int1" in item_ids
|
||||
assert "Random.Money" in item_ids
|
||||
assert "Read Error.Int4" in item_ids
|
||||
assert "Saw-toothed Waves.Real8" in item_ids
|
||||
assert all(t.path for t in tags)
|
||||
|
||||
|
||||
|
||||
def test_tags_are_sorted_case_insensitively(browser: Browser) -> None:
|
||||
tags = list_tags(browser, device="Simulation Items.Bucket Brigade")
|
||||
assert [t.item_id for t in tags] == sorted(
|
||||
(t.item_id for t in tags), key=lambda s: s.lower()
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# fake browser & client protocol conformance
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_fake_browser_matches_protocol(browser: Browser) -> None:
|
||||
# runtime_checkable protocol: structural check
|
||||
assert isinstance(browser, Browser)
|
||||
|
||||
|
||||
def test_fake_client_browser_requires_connect() -> None:
|
||||
client = FakeOpcClient()
|
||||
with pytest.raises(OpcError, match="before connect"):
|
||||
client.create_browser()
|
||||
|
||||
|
||||
def test_fake_browser_unknown_branch_raises() -> None:
|
||||
client = FakeOpcClient()
|
||||
client.connect()
|
||||
browser = client.create_browser()
|
||||
with pytest.raises(OpcError, match="no such branch"):
|
||||
browser.list_branches(["Simulation Items", "Missing"])
|
||||
with pytest.raises(OpcError, match="no such branch"):
|
||||
browser.list_leaves(["Missing"])
|
||||
|
||||
|
||||
def test_fake_flat_space_reports_flat_organization() -> None:
|
||||
client = FakeOpcClient(space={(): ((), ("Tag1", "Tag2"))})
|
||||
client.connect()
|
||||
browser = client.create_browser()
|
||||
assert browser.organization == "flat"
|
||||
space = list_devices(browser)
|
||||
assert space.devices == ()
|
||||
assert space.root.tag_names == ("Tag1", "Tag2")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI verbs (against the fake backend through a config file)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path, args: list[str]) -> int:
|
||||
"""Run the CLI with the fake client injected in place of the COM adapter."""
|
||||
import opcda_diagnozer.main as main_mod
|
||||
from opcda_diagnozer.opc.fake import FakeOpcClient
|
||||
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"opc:\n server: Fake.Server.1\ntags:\n - name: Bucket Brigade.Int1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
client = FakeOpcClient()
|
||||
|
||||
def _fake_connect(config_path: str):
|
||||
client.connect()
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(main_mod, "_connect_from_config", _fake_connect)
|
||||
return cli_main(args)
|
||||
|
||||
|
||||
def test_cli_list_devices_table(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None:
|
||||
code = _run_cli(monkeypatch, tmp_path, ["list-devices", "-c", str(tmp_path / "config.yaml")])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert "Simulation Items" in out
|
||||
assert "Configured Aliases" in out
|
||||
assert "2 tags" in out
|
||||
|
||||
|
||||
def test_cli_list_devices_json(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None:
|
||||
code = _run_cli(monkeypatch, tmp_path, [
|
||||
"list-devices", "-c", str(tmp_path / "config.yaml"), "--json",
|
||||
])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
import json
|
||||
|
||||
lines = [json.loads(line) for line in out.splitlines() if line.strip()]
|
||||
assert {d["name"] for d in lines} == {"Simulation Items", "Configured Aliases"}
|
||||
assert lines[0]["qualified_name"] == "Simulation Items" # walk order = root order
|
||||
|
||||
|
||||
def test_cli_list_tags_dotted(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None:
|
||||
code = _run_cli(monkeypatch, tmp_path, [
|
||||
"list-tags", "-c", str(tmp_path / "config.yaml"),
|
||||
"Simulation Items.Bucket Brigade",
|
||||
])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert "Bucket Brigade.Int1" in out
|
||||
assert "Bucket Brigade.Real8" in out
|
||||
|
||||
|
||||
def test_cli_list_tags_segments_and_recursive(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path, capsys
|
||||
) -> None:
|
||||
code = _run_cli(monkeypatch, tmp_path, [
|
||||
"list-tags", "-c", str(tmp_path / "config.yaml"),
|
||||
"Simulation Items", "--recursive", "--json",
|
||||
])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
import json
|
||||
|
||||
item_ids = {json.loads(line)["item_id"] for line in out.splitlines() if line.strip()}
|
||||
assert "Bucket Brigade.Int1" in item_ids
|
||||
assert "Random.Money" in item_ids
|
||||
|
||||
|
||||
def test_cli_list_tags_missing_device_fails_cleanly(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path, capsys
|
||||
) -> None:
|
||||
code = _run_cli(monkeypatch, tmp_path, [
|
||||
"list-tags", "-c", str(tmp_path / "config.yaml"), "No.Such.Device",
|
||||
])
|
||||
captured = capsys.readouterr()
|
||||
assert code == 2
|
||||
assert "FAILED" in captured.err
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="COM browse needs Windows + pywin32")
|
||||
def test_live_automation_browse_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()
|
||||
browser = client.create_browser()
|
||||
devices = list_devices(browser)
|
||||
assert devices.organization == "hierarchical"
|
||||
assert any(d.name == "Simulation Items" for d in devices.devices)
|
||||
tags = list_tags(browser, device="Simulation Items.Bucket Brigade")
|
||||
assert any(t.item_id == "Bucket Brigade.Int1" for t in tags)
|
||||
except OpcError as exc:
|
||||
pytest.skip(f"simulation server not usable on this machine: {exc}")
|
||||
finally:
|
||||
client.close()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Config loading + validation tests (IMPLEMENTATION_PLAN §4.1)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from opcda_diagnozer.config import ConfigError, load_config
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[1] / "config.example.yaml"
|
||||
|
||||
|
||||
def _write(tmp_path: Path, text: str) -> Path:
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(text, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_example_config_loads() -> None:
|
||||
config = load_config(EXAMPLE)
|
||||
assert config.opc.server == "Matrikon.OPC.Simulation.1"
|
||||
assert config.opc.host == "CONTROL-HOST01"
|
||||
assert len(config.tags) == 2
|
||||
assert config.tags[0].name == "Channel1.Device1.Counter"
|
||||
assert config.tags[0].expect_change is True
|
||||
assert config.tags[1].expect_change is False
|
||||
assert config.poll.interval_s == 1.0
|
||||
assert config.poll.timeout_ms == 5000
|
||||
assert config.latency.warn_ms == 1000
|
||||
assert config.report.eventlog.min_severity == "WARN"
|
||||
assert config.report.jsonl.dir == "C:/ProgramData/OPCDA-Diagnozer/logs"
|
||||
|
||||
|
||||
def test_missing_file_is_actionable() -> None:
|
||||
with pytest.raises(ConfigError, match="not found"):
|
||||
load_config("no-such-config.yaml")
|
||||
|
||||
|
||||
def test_missing_opc_section_is_actionable(tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "tags:\n - name: t\n")
|
||||
with pytest.raises(ConfigError, match=r"opc: missing required key 'server'"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_unknown_key_rejected(tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "opc:\n server: X\n typo_key: 1\ntags:\n - name: t\n")
|
||||
with pytest.raises(ConfigError, match="typo_key"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_unknown_top_level_section_rejected(tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "opc:\n server: X\ntags:\n - name: t\nnonsense: {}\n")
|
||||
with pytest.raises(ConfigError, match="nonsense"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_empty_tags_rejected(tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "opc:\n server: X\ntags: []\n")
|
||||
with pytest.raises(ConfigError, match="at least one tag"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_duplicate_tags_rejected(tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "opc:\n server: X\ntags:\n - name: a\n - name: a\n")
|
||||
with pytest.raises(ConfigError, match="duplicate"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_latency_threshold_order_rejected(tmp_path: Path) -> None:
|
||||
p = _write(
|
||||
tmp_path,
|
||||
"opc:\n server: X\ntags:\n - name: t\n"
|
||||
"latency:\n warn_ms: 5000\n critical_ms: 1000\n",
|
||||
)
|
||||
with pytest.raises(ConfigError, match="warn_ms"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_hard_hang_must_exceed_timeout(tmp_path: Path) -> None:
|
||||
p = _write(
|
||||
tmp_path,
|
||||
"opc:\n server: X\ntags:\n - name: t\n"
|
||||
"poll:\n timeout_ms: 5000\n hard_hang_s: 4\n",
|
||||
)
|
||||
with pytest.raises(ConfigError, match="hard_hang_s"):
|
||||
load_config(p)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Detector tests (SPECIFICATION §6). Detector implementations land in Phase 1."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from opcda_diagnozer.monitor.detectors import PollOutcome
|
||||
from opcda_diagnozer.opc.base import ReadResult
|
||||
|
||||
|
||||
def test_poll_outcome_holds_one_cycle() -> None:
|
||||
result = ReadResult(
|
||||
tag="Channel1.Device1.Level", value=1.5, quality_raw=0xC0, device_time=None
|
||||
)
|
||||
outcome = PollOutcome(
|
||||
started_at=datetime.now(timezone.utc),
|
||||
rtt_ms=12.5,
|
||||
results=(result,),
|
||||
)
|
||||
assert outcome.rtt_ms == 12.5
|
||||
assert outcome.error is None
|
||||
assert outcome.results[0].tag == "Channel1.Device1.Level"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Discovery tests (opc/discovery.py).
|
||||
|
||||
Pure tests run on any OS; the registry/COM scans are Windows-only and skip
|
||||
elsewhere (IMPLEMENTATION_PLAN §9: COM adapters are import-guarded).
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from opcda_diagnozer.opc.base import OpcError
|
||||
from opcda_diagnozer.opc.discovery import (
|
||||
DA_CATEGORIES,
|
||||
ServerInfo,
|
||||
list_servers,
|
||||
normalize_string_array,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
(None, []),
|
||||
("", []),
|
||||
("One.Server.1", ["One.Server.1"]),
|
||||
(("A.Sim.1", "A.Sim.2"), ["A.Sim.1", "A.Sim.2"]),
|
||||
(("A.Sim.1", " A.Sim.1 ", "A.Sim.2"), ["A.Sim.1", "A.Sim.2"]), # dedupe + trim
|
||||
((1, None, "A.Sim.1"), ["A.Sim.1"]), # non-strings dropped
|
||||
([], []),
|
||||
],
|
||||
)
|
||||
def test_normalize_string_array(raw: object, expected: list[str]) -> None:
|
||||
assert normalize_string_array(raw) == expected
|
||||
|
||||
|
||||
def test_registry_source_rejects_remote_host() -> None:
|
||||
with pytest.raises(OpcError, match="local"):
|
||||
list_servers(host="REMOTE-HOST", source="registry")
|
||||
|
||||
|
||||
def test_unknown_source_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="unknown source"):
|
||||
list_servers(source="nonsense") # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="winreg scan is Windows-only")
|
||||
def test_registry_scan_local_structural() -> None:
|
||||
servers = list_servers(source="registry")
|
||||
assert isinstance(servers, list)
|
||||
for server in servers:
|
||||
assert server.source == "registry"
|
||||
assert server.prog_id # non-empty ProgID (or CLSID fallback)
|
||||
assert server.clsid is not None
|
||||
assert server.clsid.startswith("{") and server.clsid.endswith("}")
|
||||
assert set(server.da_versions) <= set(DA_CATEGORIES.values())
|
||||
assert [s.prog_id.lower() for s in servers] == sorted(s.prog_id.lower() for s in servers)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="COM enumeration needs Windows + pywin32")
|
||||
def test_automation_enumeration_smoke() -> None:
|
||||
"""Live smoke test — requires a registered Automation Wrapper (dev box)."""
|
||||
try:
|
||||
servers = list_servers(source="automation")
|
||||
except OpcError as exc:
|
||||
pytest.skip(f"Automation Wrapper not usable on this machine: {exc}")
|
||||
assert all(s.source == "automation" and s.prog_id for s in servers)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""End-to-end pipeline tests against opc.fake (IMPLEMENTATION_PLAN §9).
|
||||
|
||||
The fake client lets the event pipeline run without COM/hardware; Phase 1
|
||||
grows this into the full scenario suite.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from opcda_diagnozer.opc.base import OpcError
|
||||
from opcda_diagnozer.opc.fake import FakeOpcClient
|
||||
|
||||
|
||||
def test_fake_reads_good_quality() -> None:
|
||||
client = FakeOpcClient()
|
||||
client.connect()
|
||||
results = client.read(["Channel1.Device1.Counter", "Channel1.Device1.Level"])
|
||||
assert [r.tag for r in results] == ["Channel1.Device1.Counter", "Channel1.Device1.Level"]
|
||||
assert all(r.quality_raw == 0xC0 for r in results)
|
||||
assert all(r.device_time is not None for r in results)
|
||||
|
||||
|
||||
def test_fake_requires_connect_first() -> None:
|
||||
client = FakeOpcClient()
|
||||
with pytest.raises(OpcError):
|
||||
client.read(["Channel1.Device1.Counter"])
|
||||
|
||||
|
||||
def test_fake_close_disconnects() -> None:
|
||||
client = FakeOpcClient()
|
||||
client.connect()
|
||||
client.close()
|
||||
with pytest.raises(OpcError):
|
||||
client.read(["Channel1.Device1.Counter"])
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Quality decode tests (SPECIFICATION §7)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from opcda_diagnozer.quality import QualityClass, decode_quality
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "name", "quality_class"),
|
||||
[
|
||||
(0xC0, "GOOD", QualityClass.GOOD),
|
||||
(0xD8, "GOOD_LOCAL_OVERRIDE", QualityClass.GOOD),
|
||||
(0x44, "UNCERTAIN_LAST_USABLE", QualityClass.UNCERTAIN),
|
||||
(0x40, "UNCERTAIN", QualityClass.UNCERTAIN),
|
||||
(0x00, "BAD", QualityClass.BAD),
|
||||
(0x04, "BAD_CONFIG_ERROR", QualityClass.BAD),
|
||||
(0x08, "BAD_NOT_CONNECTED", QualityClass.BAD),
|
||||
(0x0C, "BAD_DEVICE_FAILURE", QualityClass.BAD),
|
||||
(0x10, "BAD_SENSOR_FAILURE", QualityClass.BAD),
|
||||
(0x14, "BAD_LAST_KNOWN_VALUE", QualityClass.BAD),
|
||||
(0x18, "BAD_COMM_FAILURE", QualityClass.BAD),
|
||||
(0x1C, "BAD_OUT_OF_SERVICE", QualityClass.BAD),
|
||||
],
|
||||
)
|
||||
def test_spec_table_decodes(raw: int, name: str, quality_class: QualityClass) -> None:
|
||||
info = decode_quality(raw)
|
||||
assert info.name == name
|
||||
assert info.quality_class is quality_class
|
||||
assert info.raw_hex == f"0x{raw:02X}"
|
||||
|
||||
|
||||
def test_unknown_code_falls_back_to_class() -> None:
|
||||
info = decode_quality(0x20)
|
||||
assert info.quality_class is QualityClass.BAD
|
||||
assert info.name.startswith("BAD_")
|
||||
|
||||
|
||||
def test_class_bits_come_from_low_byte() -> None:
|
||||
# 16-bit quality word: the high byte must not affect the class (§7)
|
||||
assert decode_quality(0x0118).quality_class is QualityClass.BAD
|
||||
assert decode_quality(0xFFC0).quality_class is QualityClass.GOOD
|
||||
@@ -0,0 +1,11 @@
|
||||
"""State machine tests (SPECIFICATION §10.2). Transitions land in Phase 1."""
|
||||
|
||||
from opcda_diagnozer.monitor.state import ConnectionState
|
||||
|
||||
|
||||
def test_connection_states_match_spec() -> None:
|
||||
assert {s.name for s in ConnectionState} == {
|
||||
"CONNECTING",
|
||||
"CONNECTED",
|
||||
"RECONNECT_WAIT",
|
||||
}
|
||||
Reference in New Issue
Block a user