This commit is contained in:
Jonas Bengtsson
2026-10-07 17:57:30 +02:00
parent 2aa040e757
commit 76b1cc5ccf
9 changed files with 538 additions and 478 deletions
+211 -124
View File
@@ -1,24 +1,28 @@
"""Browse tests (opc/browse.py + fake browser + CLI verbs).
"""Browse tests (opc/browse.py walk_tree + the list-items CLI verb).
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
path is Windows-only and covered by the live smoke test (IMPLEMENTATION_PLAN
§9: COM adapters are import-guarded).
"""
import json
import sys
from pathlib import Path
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.base import Browser, OpcError, SpaceNode
from opcda_diagnozer.opc.browse import (
MAX_DEPTH,
list_devices,
list_tags,
split_device_path,
tags_for_device,
DEFAULT_MAX_DEPTH,
WHOLE_SUBTREE,
flatten_branches,
flatten_leaves,
require_branch,
split_branch_path,
walk_tree,
)
from opcda_diagnozer.opc.fake import FAKE_SPACE, FakeOpcClient
from opcda_diagnozer.opc.fake import FakeOpcClient
@pytest.fixture
@@ -29,7 +33,7 @@ def browser() -> Browser:
# --------------------------------------------------------------------------- #
# split_device_path
# split_branch_path
# --------------------------------------------------------------------------- #
@@ -50,57 +54,99 @@ def browser() -> Browser:
(("Bucket. Inc", "Device 1"), ("Bucket. Inc", "Device 1")),
],
)
def test_split_device_path(raw, expected: tuple[str, ...]) -> None:
assert split_device_path(raw) == expected
def test_split_branch_path(raw, expected: tuple[str, ...]) -> None:
assert split_branch_path(raw) == expected
# --------------------------------------------------------------------------- #
# list_devices
# walk_tree
# --------------------------------------------------------------------------- #
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_walk_tree_depth_one(browser: Browser) -> None:
"""Default depth: direct leaves + branches, branches have no children."""
nodes = walk_tree(browser)
assert [(n.kind, n.name) for n in nodes] == [
("leaf", "#MonitorACLFile"),
("leaf", "@ClientCount"),
("leaf", "@Clients"),
("branch", "Simulation Items"),
("branch", "Configured Aliases"),
]
# depth 1: the walk stops at the branches themselves
assert all(n.children == () for n in nodes if n.kind == "branch")
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_walk_tree_depth_one_includes_root_leaves(browser: Browser) -> None:
"""Root-level leaves (belonging to no branch) are ordinary tree items."""
nodes = walk_tree(browser)
root_leaves = [n for n in nodes if n.kind == "leaf"]
assert {n.name for n in root_leaves} == {"@Clients", "@ClientCount", "#MonitorACLFile"}
assert all(n.path == () for n in root_leaves) # they hang directly at the root
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] == (
def test_walk_tree_depth_two(browser: Browser) -> None:
nodes = walk_tree(browser, depth=2)
sim = next(n for n in nodes if n.name == "Simulation Items")
child_names = [c.name for c in sim.children]
assert child_names == [
"Bucket Brigade", "Random", "Read Error", "Saw-toothed Waves",
)
"Square Waves", "Triangle Waves", "Write Error", "Write Only",
]
# depth 2 stops inside those sub-branches too
bucket = next(c for c in sim.children if c.name == "Bucket Brigade")
assert bucket.kind == "branch" and bucket.children == ()
assert len(flatten_leaves(nodes)) == 5 # root leaves only; nothing deeper read
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)
def test_walk_tree_whole_subtree(browser: Browser) -> None:
"""depth=0: everything defined in the tree, in structure."""
nodes = walk_tree(browser, depth=WHOLE_SUBTREE)
tags = flatten_leaves(nodes)
item_ids = {t.item_id for t in tags}
assert "Bucket Brigade.Int1" in item_ids # device tags
assert "Configured Aliases.Alias1" in item_ids # alias tags
assert "@ClientCount" in item_ids # root-level, no branch
# the structure is preserved: Bucket Brigade is nested under Simulation Items
sim = next(n for n in nodes if n.name == "Simulation Items")
bucket = next(c for c in sim.children if c.name == "Bucket Brigade")
assert bucket.path == ("Simulation Items", "Bucket Brigade")
assert any(c.name == "Int1" and c.item_id == "Bucket Brigade.Int1" for c in bucket.children)
@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_walk_tree_from_subtree(browser: Browser) -> None:
"""--branch limits the walk to that subtree."""
nodes = walk_tree(browser, branch="Simulation Items.Bucket Brigade", depth=WHOLE_SUBTREE)
# only the subtree: no root leaves, no other branches
kinds = [(n.kind, n.name) for n in nodes]
assert all(n.kind == "leaf" for n in nodes)
assert {n.name for n in nodes} == {
"Int1", "Int2", "Int4", "Real4", "Real8", "String", "Boolean",
}
def test_list_devices_cycle_guard(monkeypatch: pytest.MonkeyPatch) -> None:
"""A pathological self-nesting branch must not hang the recursive walk."""
def test_walk_tree_missing_branch_is_actionable(browser: Browser) -> None:
with pytest.raises(OpcError, match="list-items"):
walk_tree(browser, branch="Simulation Items.No Such Device")
with pytest.raises(OpcError, match="not found under"):
walk_tree(browser, branch="Nope")
@pytest.mark.parametrize("depth", [-1, -5])
def test_walk_tree_rejects_negative_depth(browser: Browser, depth: int) -> None:
with pytest.raises(ValueError, match=">= 0"):
walk_tree(browser, depth=depth)
def test_walk_tree_rejects_bad_max_depth(browser: Browser) -> None:
with pytest.raises(ValueError, match="max_depth"):
walk_tree(browser, max_depth=0)
with pytest.raises(ValueError, match="max_depth"):
walk_tree(browser, depth=33, max_depth=32)
def test_walk_tree_configurable_max_depth_guards_cycles() -> None:
"""A pathological self-nesting branch must hit the max_depth guard, not hang."""
class _LoopBrowser:
@property
@@ -119,53 +165,38 @@ def test_list_devices_cycle_guard(monkeypatch: pytest.MonkeyPatch) -> None:
def close(self) -> None:
return None
with pytest.raises(OpcError, match="deeper than"):
list_tags(_LoopBrowser(), recursive=True) # type: ignore[arg-type]
with pytest.raises(OpcError, match="deeper than 2"):
walk_tree(_LoopBrowser(), depth=WHOLE_SUBTREE, max_depth=2) # type: ignore[arg-type]
def test_walk_tree_default_max_depth_constant() -> None:
"""The default guard matches the documented config default (32)."""
assert DEFAULT_MAX_DEPTH == 32
# --------------------------------------------------------------------------- #
# list_tags
# flatten helpers
# --------------------------------------------------------------------------- #
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_flatten_leaves_sorted_case_insensitively(browser: Browser) -> None:
tags = flatten_leaves(walk_tree(browser, depth=WHOLE_SUBTREE))
ids = [t.item_id for t in tags]
assert ids == sorted(ids, key=str.lower)
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_flatten_branches_depth_first(browser: Browser) -> None:
branches = flatten_branches(walk_tree(browser, depth=WHOLE_SUBTREE))
paths = [".".join(b.path) for b in branches]
# depth-first: parents before their children
assert paths.index("Simulation Items") < paths.index("Simulation Items.Bucket Brigade")
assert paths.index("Simulation Items") < paths.index("Configured Aliases")
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()
)
def test_spacenode_is_frozen() -> None:
node = SpaceNode(kind="leaf", name="X", path=(), item_id="X")
with pytest.raises(AttributeError):
node.name = "Y" # type: ignore[misc]
# --------------------------------------------------------------------------- #
@@ -174,7 +205,6 @@ def test_tags_are_sorted_case_insensitively(browser: Browser) -> None:
def test_fake_browser_matches_protocol(browser: Browser) -> None:
# runtime_checkable protocol: structural check
assert isinstance(browser, Browser)
@@ -199,17 +229,24 @@ def test_fake_flat_space_reports_flat_organization() -> None:
client.connect()
browser = client.create_browser()
assert browser.organization == "flat"
space = list_devices(browser)
assert space.devices == ()
assert space.root.tag_names == ("Tag1", "Tag2")
nodes = walk_tree(browser)
assert all(n.kind == "leaf" for n in nodes)
assert {n.name for n in nodes} == {"Tag1", "Tag2"}
def test_require_branch_root_ok_and_missing(browser: Browser) -> None:
assert require_branch(browser, None, what="list-items") == ()
assert require_branch(browser, "Simulation Items", what="list-items") == ("Simulation Items",)
with pytest.raises(OpcError, match="list-items"):
require_branch(browser, "Missing", what="list-items")
# --------------------------------------------------------------------------- #
# CLI verbs (against the fake backend through a config file)
# CLI verb (against the fake backend through a config file)
# --------------------------------------------------------------------------- #
def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path, args: list[str]) -> int:
def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: 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
@@ -229,69 +266,117 @@ def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path, args: list[str]) -> int:
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:
def test_cli_list_items_all_tree(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-devices", "-c", str(tmp_path / "config.yaml"), "--json",
"list-items", "all", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
])
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
# tree structure: root leaves visible, branches with trailing /
assert "@ClientCount [ItemID: @ClientCount]" in out
assert "Simulation Items/" in out
assert " Bucket Brigade/" in out # nested one level
assert " Int1 [ItemID: Bucket Brigade.Int1]" in out # nested two levels
def test_cli_list_tags_dotted(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None:
def test_cli_list_items_default_is_all_depth1(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
code = _run_cli(monkeypatch, tmp_path, ["list-items", "-c", str(tmp_path / "config.yaml")])
out = capsys.readouterr().out
assert code == 0
assert "Simulation Items/" in out
assert "Bucket Brigade" not in out # depth 1 stops at the top branches
def test_cli_list_items_branches(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-tags", "-c", str(tmp_path / "config.yaml"),
"Simulation Items.Bucket Brigade",
"list-items", "branches", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
])
out = capsys.readouterr().out
assert code == 0
assert "Simulation Items" in out
assert "Simulation Items.Bucket Brigade" in out
assert "8 sub-branches" in out
assert "[ItemID" not in out # no tags in the branches view
def test_cli_list_items_tags_flat(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-items", "tags", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
])
out = capsys.readouterr().out
assert code == 0
assert "Bucket Brigade.Int1" in out
assert "Bucket Brigade.Real8" in out
assert "Configured Aliases.Alias1" in out
assert "@ClientCount" in out # root-level tags are tags too
def test_cli_list_tags_segments_and_recursive(
monkeypatch: pytest.MonkeyPatch, tmp_path, capsys
def test_cli_list_items_branch_subtree(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-tags", "-c", str(tmp_path / "config.yaml"),
"Simulation Items", "--recursive", "--json",
"list-items", "tags", "--branch", "Simulation Items.Bucket Brigade",
"-c", str(tmp_path / "config.yaml"),
])
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
assert "Bucket Brigade.Int1" in out
assert "Random.Money" not in out # only the selected subtree
assert "Configured Aliases" not in out
def test_cli_list_tags_missing_device_fails_cleanly(
monkeypatch: pytest.MonkeyPatch, tmp_path, capsys
def test_cli_list_items_branch_repeated_segments(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
"""Repeating --branch gives explicit segments (names may contain dots)."""
code = _run_cli(monkeypatch, tmp_path, [
"list-items", "tags", "--branch", "Simulation Items", "--branch", "Bucket Brigade",
"-c", str(tmp_path / "config.yaml"),
])
out = capsys.readouterr().out
assert code == 0
assert "Bucket Brigade.Int1" in out
def test_cli_list_items_json_tree(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-items", "--json", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
])
out = capsys.readouterr().out
assert code == 0
lines = [json.loads(line) for line in out.splitlines() if line.strip()]
assert lines[0]["kind"] == "leaf"
sim = next(l for l in lines if l["name"] == "Simulation Items")
assert sim["kind"] == "branch"
assert any(c["name"] == "Bucket Brigade" for c in sim["children"])
def test_cli_list_items_missing_branch_fails_cleanly(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-tags", "-c", str(tmp_path / "config.yaml"), "No.Such.Device",
"list-items", "--branch", "No.Such.Branch", "-c", str(tmp_path / "config.yaml"),
])
captured = capsys.readouterr()
assert code == 2
assert "FAILED" in captured.err
def test_cli_old_verbs_are_gone(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
"""list-devices / list-tags were replaced by list-items."""
for gone in ("list-devices", "list-tags"):
with pytest.raises(SystemExit) as exc_info:
_run_cli(monkeypatch, tmp_path, [gone, "-c", str(tmp_path / "config.yaml")])
assert exc_info.value.code == 2 # argparse: unknown verb
assert "invalid choice" in capsys.readouterr().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."""
"""Live smoke test — requires a registered Automation Wrapper (dev box)."""
pytest.importorskip("pythoncom")
from opcda_diagnozer.opc.automation import AutomationOpcClient
@@ -299,11 +384,13 @@ def test_live_automation_browse_smoke() -> None:
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")
nodes = walk_tree(browser, depth=WHOLE_SUBTREE)
tags = flatten_leaves(nodes)
assert any(t.item_id == "Bucket Brigade.Int1" for t in tags)
assert any(t.name == "@ClientCount" for t in tags) # root-level leaf
sim = next(n for n in nodes if n.name == "Simulation Items")
assert sim.kind == "branch"
assert any(c.name == "Bucket Brigade" for c in sim.children)
except OpcError as exc:
pytest.skip(f"simulation server not usable on this machine: {exc}")
finally: