🚧 WIP
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""CLI tests: env-var configuration and bad-argument handling.
|
||||
|
||||
The serve coroutine blocks on a shutdown signal (by design — it is a
|
||||
service), so serve-args tests capture the parsed args instead of
|
||||
running the real server loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wago_sim.cli import _env, _env_port, main
|
||||
|
||||
|
||||
class TestEnvHelpers:
|
||||
def test_port_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "1234")
|
||||
assert _env_port("WAGO_SIM_PORT", 502) == 1234
|
||||
monkeypatch.delenv("WAGO_SIM_PORT")
|
||||
assert _env_port("WAGO_SIM_PORT", 502) == 502
|
||||
|
||||
def test_host_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("WAGO_SIM_HOST", "10.0.0.5")
|
||||
assert _env("WAGO_SIM_HOST", "0.0.0.0") == "10.0.0.5"
|
||||
monkeypatch.delenv("WAGO_SIM_HOST")
|
||||
assert _env("WAGO_SIM_HOST", "0.0.0.0") == "0.0.0.0"
|
||||
|
||||
|
||||
class TestServeEnvConfig:
|
||||
"""WAGO_SIM_* env vars configure serve without CLI args (service mode)."""
|
||||
|
||||
@staticmethod
|
||||
def _capture_serve(monkeypatch):
|
||||
"""Replace the serve coroutine with an args recorder.
|
||||
|
||||
main() builds its parser on each call and resolves `_serve` from
|
||||
the module namespace, so monkeypatching before the call works.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_serve(args) -> int:
|
||||
captured["node"] = args.node
|
||||
captured["host"] = args.host
|
||||
captured["port"] = args.port
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("wago_sim.cli._serve", fake_serve)
|
||||
return captured
|
||||
|
||||
def test_node_and_port_from_env(self, monkeypatch):
|
||||
captured = self._capture_serve(monkeypatch)
|
||||
monkeypatch.setenv("WAGO_SIM_NODE", "config/pump_node.yaml")
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "15025")
|
||||
monkeypatch.setenv("WAGO_SIM_HOST", "127.0.0.1")
|
||||
assert main(["serve"]) == 0
|
||||
assert captured["node"] == "config/pump_node.yaml"
|
||||
assert captured["port"] == 15025
|
||||
assert captured["host"] == "127.0.0.1"
|
||||
|
||||
def test_cli_overrides_env(self, monkeypatch):
|
||||
captured = self._capture_serve(monkeypatch)
|
||||
monkeypatch.setenv("WAGO_SIM_NODE", "config/other.yaml")
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "1111")
|
||||
assert main(["serve", "--node", "config/pump_node.yaml",
|
||||
"--port", "15026"]) == 0
|
||||
assert captured["node"] == "config/pump_node.yaml"
|
||||
assert captured["port"] == 15026
|
||||
|
||||
def test_missing_node_is_an_error(self, monkeypatch):
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
# argparse parser.error exit code
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_node_env_only_no_cli_args(self, monkeypatch):
|
||||
"""The container CMD is plain `wago-sim serve` — everything env."""
|
||||
captured = self._capture_serve(monkeypatch)
|
||||
monkeypatch.setenv("WAGO_SIM_NODE", "config/pump_node.yaml")
|
||||
assert main(["serve"]) == 0
|
||||
assert captured["node"] == "config/pump_node.yaml"
|
||||
# host/port fall back to defaults
|
||||
assert captured["host"] == "0.0.0.0"
|
||||
assert captured["port"] == 502
|
||||
|
||||
|
||||
class TestBadArgumentHandling:
|
||||
"""Bad arguments produce clean errors, exit code 2, no tracebacks."""
|
||||
|
||||
def test_no_command_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main([])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_unknown_command_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["frobnicate"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_unknown_option_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--frobnicate"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_non_numeric_port_rejected_at_parse(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--node", "config/pump_node.yaml", "--port", "abc"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
@pytest.mark.parametrize("port", ["0", "-1", "65536", "99999"])
|
||||
def test_port_out_of_range_rejected(self, port):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--node", "config/pump_node.yaml", "--port", port])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_env_port_invalid_rejected(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "banana")
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
assert excinfo.value.code == 2
|
||||
assert "WAGO_SIM_PORT" in capsys.readouterr().err
|
||||
|
||||
def test_env_port_out_of_range_rejected(self, monkeypatch):
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "70000")
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_missing_node_file_is_clean_error(self, capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--node", "config/does_not_exist.yaml"])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "wago-sim: error:" in err
|
||||
assert "node config file not found" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_missing_scenario_file_is_clean_error(self, capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["validate", "--scenario", "scenarios/nope.yaml"])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "scenario file not found" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_invalid_yaml_is_clean_error(self, capsys, tmp_path):
|
||||
bad = tmp_path / "bad.yaml"
|
||||
bad.write_text("key: [unclosed", encoding="utf-8")
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["validate", "--scenario", str(bad)])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "not valid YAML" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_directory_as_yaml_is_clean_error(self, capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["validate", "--scenario", "scenarios"])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "not a file" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_run_missing_required_options_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["run"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
class TestReportPathHandling:
|
||||
def test_report_dirs_auto_created(self, tmp_path, capsys):
|
||||
"""Missing report parent directories are created before the run."""
|
||||
from wago_sim.cli import _prepare_report_path
|
||||
|
||||
target = tmp_path / "a" / "b" / "r.jsonl"
|
||||
result = _prepare_report_path(str(target), "--report-jsonl")
|
||||
assert result.parent.is_dir()
|
||||
|
||||
def test_unwritable_report_path_is_clean_error(self, tmp_path, capsys):
|
||||
from wago_sim.cli import _prepare_report_path
|
||||
|
||||
# a *file* used as parent directory -> cannot create
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("x", encoding="utf-8")
|
||||
target = blocker / "sub" / "r.jsonl"
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_prepare_report_path(str(target), "--report-jsonl")
|
||||
assert excinfo.value.code == 2
|
||||
assert "cannot create directory" in capsys.readouterr().err
|
||||
|
||||
def test_missing_node_is_an_error(self, monkeypatch):
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
assert excinfo.value.code == 2 # argparse error exit code
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Plain-text request/response tracing tests (verbose mode)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wago_sim import pdu as pdu_mod
|
||||
from wago_sim.mbap import MbapHeader
|
||||
from wago_sim.trace import (
|
||||
describe_address,
|
||||
describe_bit_address,
|
||||
format_raw,
|
||||
format_request,
|
||||
format_response,
|
||||
)
|
||||
|
||||
|
||||
def _header(txn: int = 1, unit: int = 1) -> MbapHeader:
|
||||
return MbapHeader(transaction_id=txn, protocol_id=0, length=6, unit_id=unit)
|
||||
|
||||
|
||||
class TestAddressAnnotations:
|
||||
def test_word_areas(self):
|
||||
assert "%IW0" in describe_address(0x0000)
|
||||
assert "%QW0" in describe_address(0x0200) # read-back mirror
|
||||
assert "%IW256" in describe_address(0x6000)
|
||||
assert "%QW256" in describe_address(0x7000)
|
||||
assert "config" in describe_address(0x1000).lower()
|
||||
|
||||
def test_write_mapping_differs_from_read(self):
|
||||
"""Table 120 (writes): 0x0000-0x00FF is the OUTPUT area %QW0-255;
|
||||
Table 119 (reads): the same range is the INPUT area %IW0-255."""
|
||||
assert "%QW0" in describe_address(0x0000, write=True)
|
||||
assert "%IW0" in describe_address(0x0000, write=False)
|
||||
assert "%QW5" in describe_address(0x0005, write=True)
|
||||
assert "mirror" in describe_address(0x0200, write=True)
|
||||
assert "read-back" in describe_address(0x0200, write=False)
|
||||
assert "%QW256" in describe_address(0x6000, write=True)
|
||||
|
||||
def test_illegal_word_areas(self):
|
||||
for addr in (0x0100, 0x0300, 0x3000, 0x62FC, 0x72FC):
|
||||
assert "illegal" in describe_address(addr)
|
||||
assert "illegal" in describe_address(addr, write=True)
|
||||
|
||||
def test_word_ranges(self):
|
||||
text = describe_address(0x0000, 4)
|
||||
assert "0x0000..0x0003" in text
|
||||
|
||||
def test_bit_areas(self):
|
||||
assert "channel" in describe_bit_address(0)
|
||||
assert "read-back" in describe_bit_address(0x0200)
|
||||
assert "area 2" in describe_bit_address(0x8000)
|
||||
assert "illegal" in describe_bit_address(0x0400)
|
||||
|
||||
|
||||
class TestRequestFormatting:
|
||||
def test_read_holding_registers(self):
|
||||
req = pdu_mod.ReadRequest(pdu_mod.FC_READ_HOLDING_REGISTERS, 0x2002, 1)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC03" in text
|
||||
assert "Read Holding Registers" in text
|
||||
assert "addr=0x2002" in text
|
||||
assert "count=1" in text
|
||||
assert "txn=0x0001" in text
|
||||
assert "unit=1" in text
|
||||
|
||||
def test_read_coils_bit_annotation(self):
|
||||
req = pdu_mod.ReadRequest(pdu_mod.FC_READ_COILS, 0x0200, 8)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC01" in text
|
||||
assert "read-back" in text
|
||||
|
||||
def test_write_single_coil(self):
|
||||
req = pdu_mod.WriteSingleCoilRequest(pdu_mod.FC_WRITE_SINGLE_COIL, 5, True)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC05" in text
|
||||
assert "value=ON" in text
|
||||
|
||||
def test_write_single_register(self):
|
||||
req = pdu_mod.WriteSingleRegisterRequest(
|
||||
pdu_mod.FC_WRITE_SINGLE_REGISTER, 0x0000, 0x1234
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC06" in text
|
||||
assert "value=0x1234" in text
|
||||
# write mapping: 0x0000 is %QW0 (output), not %IW0
|
||||
assert "%QW0" in text
|
||||
|
||||
def test_write_multiple_registers(self):
|
||||
req = pdu_mod.WriteMultipleRegistersRequest(
|
||||
pdu_mod.FC_WRITE_MULTIPLE_REGISTERS, 0x0000, (0x1234, 0x5678)
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC10" in text
|
||||
assert "0x1234" in text and "0x5678" in text
|
||||
|
||||
def test_write_multiple_coils_bitstring(self):
|
||||
req = pdu_mod.WriteMultipleCoilsRequest(
|
||||
pdu_mod.FC_WRITE_MULTIPLE_COILS, 0x0000, (True, False, True)
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "values=101" in text
|
||||
|
||||
def test_mask_write(self):
|
||||
req = pdu_mod.MaskWriteRegisterRequest(
|
||||
pdu_mod.FC_MASK_WRITE_REGISTER, 0x0000, 0x00F2, 0x0025
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC16" in text
|
||||
assert "and=0x00f2" in text
|
||||
|
||||
def test_read_write_multiple(self):
|
||||
req = pdu_mod.ReadWriteMultipleRegistersRequest(
|
||||
pdu_mod.FC_READ_WRITE_MULTIPLE_REGISTERS, 0x2002, 1, 0x0000, (0xBEEF,)
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC17" in text
|
||||
assert "read addr=0x2002" in text
|
||||
|
||||
def test_event_count(self):
|
||||
req = pdu_mod.GetCommEventCountRequest(pdu_mod.FC_GET_COMM_EVENT_COUNT)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC0B" in text
|
||||
assert "Get Comm Event Count" in text
|
||||
|
||||
|
||||
class TestResponseFormatting:
|
||||
def test_read_registers(self):
|
||||
resp = pdu_mod.ReadResponse(pdu_mod.FC_READ_HOLDING_REGISTERS)
|
||||
resp.registers = [0x1234]
|
||||
text = format_response(_header(), resp)
|
||||
assert "RSP" in text
|
||||
assert "1 regs" in text
|
||||
assert "0x1234" in text
|
||||
|
||||
def test_read_bits(self):
|
||||
resp = pdu_mod.ReadResponse(pdu_mod.FC_READ_COILS)
|
||||
resp.bits = [True, False, True]
|
||||
text = format_response(_header(), resp)
|
||||
assert "3 bits" in text
|
||||
assert "101" in text
|
||||
|
||||
def test_exception(self):
|
||||
resp = pdu_mod.ExceptionResponse(
|
||||
pdu_mod.FC_READ_HOLDING_REGISTERS,
|
||||
pdu_mod.EXC_ILLEGAL_DATA_ADDRESS,
|
||||
)
|
||||
text = format_response(_header(), resp)
|
||||
assert "EXC" in text
|
||||
assert "0x02" in text
|
||||
assert "Illegal Data Address" in text
|
||||
|
||||
def test_write_echo(self):
|
||||
resp = pdu_mod.WriteSingleRegisterResponse(0x0000, 0x1234)
|
||||
text = format_response(_header(), resp)
|
||||
assert "echo" in text
|
||||
|
||||
def test_event_count_response(self):
|
||||
resp = pdu_mod.GetCommEventCountResponse(0, 42)
|
||||
text = format_response(_header(), resp)
|
||||
assert "events=42" in text
|
||||
|
||||
|
||||
class TestRawFormatting:
|
||||
def test_raw_pdu(self):
|
||||
text = format_raw(_header(), b"\x03\x20\x02\x00\x01")
|
||||
assert "RAW" in text
|
||||
assert "03 20 02 00 01" in text
|
||||
|
||||
|
||||
class TestServerVerboseIntegration:
|
||||
"""The server funnels every transaction through the trace formatter."""
|
||||
|
||||
def test_trace_lines_in_logs(self, app, caplog):
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
app.server.host, app.server.port = "127.0.0.1", 15030
|
||||
|
||||
async def scenario():
|
||||
await app.server.start()
|
||||
await asyncio.sleep(0.05)
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", 15030)
|
||||
pdu = bytes([pdu_mod.FC_READ_HOLDING_REGISTERS]) + (0x2002).to_bytes(2, "big") + (1).to_bytes(2, "big")
|
||||
from wago_sim.mbap import build_frame
|
||||
writer.write(build_frame(1, 1, pdu))
|
||||
await writer.drain()
|
||||
header = await reader.readexactly(7)
|
||||
length = int.from_bytes(header[4:6], "big")
|
||||
body = await reader.readexactly(length - 1)
|
||||
writer.close()
|
||||
await app.server.stop()
|
||||
return body
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="wago_sim.server"):
|
||||
body = asyncio.run(scenario())
|
||||
assert int.from_bytes(body[2:4], "big") == 0x1234
|
||||
assert any("REQ" in rec.message for rec in caplog.records)
|
||||
assert any("RSP" in rec.message for rec in caplog.records)
|
||||
assert any("Read Holding Registers" in rec.message for rec in caplog.records)
|
||||
Reference in New Issue
Block a user