Skip to content

watlowlib.testing

Public testing surface — FakeTransport, FakeSlave, fixture records (StdBusRound, ModbusRound, Fixture), load_fixture, and controller_from_fixture. See Testing.

Public surface

watlowlib.testing

Public testing surface for downstream packages.

The test seam is a contract: anyone consuming :mod:watlowlib can import watlowlib.testing and write facade-level tests against captured PM3 round-trips without touching a serial port.

What's here:

  • :class:FakeTransport — re-export of the in-process scripted transport (fixture-replay grade: ordered queue + unmatched-write capture).
  • :class:FakeSlave — stub :class:anymodbus.Slave for the Modbus facade path (the equivalent shape for variants that emit a :class:ModbusOp rather than wire bytes).
  • :class:StdBusRound / :class:ModbusRound / :class:Fixture — typed records for one captured round-trip and a captured scenario.
  • :func:load_fixture — JSONL file → :class:Fixture.
  • :func:controller_from_fixture — JSONL file → opened :class:Controller ready for facade-level assertions.

Fixture file format is one JSON object per line. The first line may be a header recording the protocol and serial framing; everything else is a round.

Std Bus round::

{
    "protocol": "stdbus",
    "label": "read_pv",
    "request_hex": "55FF051000...",
    "response_hex": "55FF0600...",
}

Modbus round::

{
    "protocol": "modbus_rtu",
    "label": "read_pv",
    "method": "read_holding_registers",
    "address": 360,
    "count": 2,
    "response_words": [17299, 29054],
}

{
    "protocol": "modbus_rtu",
    "label": "set_setpoint",
    "method": "write_registers",
    "address": 2160,
    "values": [17348, 0],
}

Optional header ("kind": "header") sets address, baudrate, and parity for the whole capture::

{
    "kind": "header",
    "protocol": "stdbus",
    "address": 1,
    "baudrate": 38400,
    "parity": "none",
}

FakeSlave

FakeSlave(script=None)

Scripted :class:anymodbus.Slave stand-in for tests.

Mirrors the surface :class:watlowlib.protocol.modbus.client.ModbusProtocolClient actually calls (read_holding_registers, read_input_registers, write_register, write_registers) and records every call. Tests assert on :attr:reads and :attr:writes to verify the :class:ModbusOp lowered correctly.

Parameters:

Name Type Description Default
script Mapping[tuple[str, int], ScriptedSlaveEntry] | None

(method, address) → reply map. method is one of the four call names above. The reply is a tuple of register words, an anymodbus exception class (raised at call time, with the right constructor args), or None (treat the call as a no-op success). Missing entries surface a :class:KeyError so an unscripted call fails the test rather than returning empty results.

None
Source code in src/watlowlib/transport/fake.py
def __init__(self, script: Mapping[tuple[str, int], ScriptedSlaveEntry] | None = None) -> None:
    self._script: dict[tuple[str, int], ScriptedSlaveEntry] = dict(script or {})
    self.reads: list[tuple[str, int, int]] = []
    self.writes: list[tuple[str, int, tuple[int, ...]]] = []

add_script

add_script(method, address, reply)

Register or overwrite a scripted reply for (method, address).

Source code in src/watlowlib/transport/fake.py
def add_script(
    self,
    method: str,
    address: int,
    reply: ScriptedSlaveEntry,
) -> None:
    """Register or overwrite a scripted reply for ``(method, address)``."""
    self._script[(method, address)] = reply

FakeTransport

FakeTransport(
    script=None,
    *,
    queue=None,
    label="fake://test",
    latency_s=0.0,
)

Scripted :class:Transport for tests.

Parameters:

Name Type Description Default
script Mapping[bytes, ScriptedReply] | None

Mapping of write_bytes → reply. Every scripted write queues the corresponding reply into the read buffer. Unknown writes are recorded but produce no reply; the next read times out.

None
label str

Identifier used in errors.

'fake://test'
latency_s float

Per-operation artificial delay, useful for simulating a slow device.

0.0
Source code in src/watlowlib/transport/fake.py
def __init__(
    self,
    script: Mapping[bytes, ScriptedReply] | None = None,
    *,
    queue: Iterable[tuple[bytes, ScriptedReply]] | None = None,
    label: str = "fake://test",
    latency_s: float = 0.0,
) -> None:
    self._script: dict[bytes, ScriptedReply] = dict(script or {})
    self._queue: deque[tuple[bytes, ScriptedReply]] = deque(queue or [])
    self._writes: list[bytes] = []
    self._unmatched: list[bytes] = []
    self._read_buffer = bytearray()
    self._is_open = False
    self._label = label
    self._latency_s = latency_s
    self._force_read_timeout = False
    self._force_write_timeout = False

remaining_queue property

remaining_queue

Queue entries that have not been consumed yet.

unmatched_writes property

unmatched_writes

Writes that didn't match any scripted reply, in order.

A test can assert transport.unmatched_writes == () to catch accidentally-unscripted traffic — the corresponding read would have timed out, but a precise assertion fails faster and points at the right call.

writes property

writes

Every write payload recorded since construction, in order.

add_script

add_script(command, reply)

Register or overwrite a scripted reply for command.

Source code in src/watlowlib/transport/fake.py
def add_script(self, command: bytes, reply: ScriptedReply) -> None:
    """Register or overwrite a scripted reply for ``command``."""
    self._script[bytes(command)] = reply

extend_queue

extend_queue(rounds)

Append more ordered (write, reply) pairs to the FIFO queue.

Source code in src/watlowlib/transport/fake.py
def extend_queue(self, rounds: Iterable[tuple[bytes, ScriptedReply]]) -> None:
    """Append more ordered ``(write, reply)`` pairs to the FIFO queue."""
    self._queue.extend(rounds)

feed

feed(data)

Push unsolicited bytes into the read buffer.

Useful for simulating a device that left chatter on the line which the protocol client has to drain on recovery.

Source code in src/watlowlib/transport/fake.py
def feed(self, data: bytes) -> None:
    """Push unsolicited bytes into the read buffer.

    Useful for simulating a device that left chatter on the line
    which the protocol client has to drain on recovery.
    """
    self._read_buffer.extend(data)

force_read_timeout

force_read_timeout(enabled=True)

Force the next read to raise :class:WatlowTimeoutError.

Source code in src/watlowlib/transport/fake.py
def force_read_timeout(self, enabled: bool = True) -> None:
    """Force the next read to raise :class:`WatlowTimeoutError`."""
    self._force_read_timeout = enabled

force_write_timeout

force_write_timeout(enabled=True)

Force the next :meth:write to raise :class:WatlowTimeoutError.

Source code in src/watlowlib/transport/fake.py
def force_write_timeout(self, enabled: bool = True) -> None:
    """Force the next :meth:`write` to raise :class:`WatlowTimeoutError`."""
    self._force_write_timeout = enabled

Fixture dataclass

Fixture(
    protocol,
    address=1,
    serial_settings=(
        lambda: SerialSettings(port="fake://fixture")
    )(),
    stdbus_rounds=(),
    modbus_rounds=(),
)

A captured scenario — one wire protocol, many rounds.

fake_slave

fake_slave()

Build a :class:FakeSlave scripted with the Modbus rounds.

Read rounds populate the (method, address) → response_words script entry; write rounds need no entry — :class:FakeSlave treats unscripted writes as silent successes and records them on :attr:FakeSlave.writes so tests can assert on the lowered register words.

Source code in src/watlowlib/testing.py
def fake_slave(self) -> FakeSlave:
    """Build a :class:`FakeSlave` scripted with the Modbus rounds.

    Read rounds populate the ``(method, address) → response_words``
    script entry; write rounds need no entry — :class:`FakeSlave`
    treats unscripted writes as silent successes and records them
    on :attr:`FakeSlave.writes` so tests can assert on the lowered
    register words.
    """
    slave = FakeSlave()
    for round_ in self.modbus_rounds:
        if round_.response_words:
            slave.add_script(round_.method, round_.address, round_.response_words)
    return slave

fake_transport

fake_transport(*, label='fake://fixture')

Build a :class:FakeTransport scripted with the Std Bus rounds.

Each captured request maps to its captured response in the dict-based script, so a test that issues the same parameter read twice gets the same response both times — appropriate for facade smoke tests where ordering doesn't matter.

Source code in src/watlowlib/testing.py
def fake_transport(self, *, label: str = "fake://fixture") -> FakeTransport:
    """Build a :class:`FakeTransport` scripted with the Std Bus rounds.

    Each captured request maps to its captured response in the
    dict-based script, so a test that issues the same parameter
    read twice gets the same response both times — appropriate for
    facade smoke tests where ordering doesn't matter.
    """
    script = {round_.request: round_.response for round_ in self.stdbus_rounds}
    return FakeTransport(script, label=label)

ModbusRound dataclass

ModbusRound(
    label,
    method,
    address,
    count,
    response_words=(),
    values=(),
)

One captured Modbus call.

For reads response_words carries the register tuple the slave returned; values is empty. For writes values carries the register words the controller sent and response_words is empty (a successful write returns no payload).

StdBusRound dataclass

StdBusRound(label, request, response)

One captured Std Bus request/response pair.

controller_from_fixture async

controller_from_fixture(
    path, *, family=ControllerFamily.PM
)

Build an unopened :class:Controller scripted by path.

Returned in unopened form so the caller drives lifecycle through async with. Std Bus fixtures wire through :class:FakeTransport; Modbus fixtures wire through :class:FakeSlave (the :class:Transport shim only carries lifecycle, since the Modbus protocol client talks to its slave provider directly).

Source code in src/watlowlib/testing.py
async def controller_from_fixture(
    path: str | Path,
    *,
    family: ControllerFamily = ControllerFamily.PM,
) -> Controller:
    """Build an unopened :class:`Controller` scripted by ``path``.

    Returned in unopened form so the caller drives lifecycle through
    ``async with``. Std Bus fixtures wire through :class:`FakeTransport`;
    Modbus fixtures wire through :class:`FakeSlave` (the
    :class:`Transport` shim only carries lifecycle, since the Modbus
    protocol client talks to its slave provider directly).
    """
    fixture = load_fixture(path)
    if fixture.protocol is ProtocolKind.STDBUS:
        transport: Transport = fixture.fake_transport()
        return await open_controller(
            transport,
            protocol=ProtocolKind.STDBUS,
            address=fixture.address,
            serial_settings=fixture.serial_settings,
            family=family,
        )

    # Modbus path: build the protocol client over a FakeSlave directly,
    # and hand the controller a lifecycle-only :class:`FakeTransport`.
    slave = fixture.fake_slave()
    transport = FakeTransport(label="fake://fixture-modbus")
    # FakeSlave is structurally compatible with the
    # :class:`SlaveLike` Protocol the Modbus client expects.
    client = ModbusProtocolClient(
        slave_provider=lambda _addr: slave,
        port=transport.label,
    )
    session = Session(
        client,
        registry=PARAMETERS,
        family=family,
        address=fixture.address,
        port=transport.label,
    )
    return Controller(session, transport, serial_settings=fixture.serial_settings)

load_fixture

load_fixture(path)

Parse path as a JSONL fixture and return a :class:Fixture.

The first line may be a header ({"kind": "header", ...}) that sets address and the serial framing for the capture. Subsequent lines are rounds; their protocol field must agree with the header (or with the first round, if the header is omitted).

Raises:

Type Description
ValueError

malformed JSONL, missing fields, or a row whose protocol field disagrees with the rest of the file.

Source code in src/watlowlib/testing.py
def load_fixture(path: str | Path) -> Fixture:
    """Parse ``path`` as a JSONL fixture and return a :class:`Fixture`.

    The first line may be a header (``{"kind": "header", ...}``) that
    sets ``address`` and the serial framing for the capture. Subsequent
    lines are rounds; their ``protocol`` field must agree with the
    header (or with the first round, if the header is omitted).

    Raises:
        ValueError: malformed JSONL, missing fields, or a row whose
            ``protocol`` field disagrees with the rest of the file.
    """
    p = Path(path)
    rows: list[dict[str, Any]] = []
    for line_no, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        try:
            rows.append(json.loads(line))
        except json.JSONDecodeError as exc:
            raise ValueError(f"{p}:{line_no}: not valid JSON: {exc}") from exc
    if not rows:
        raise ValueError(f"{p}: fixture is empty")

    header: dict[str, Any] = {}
    body: list[dict[str, Any]] = list(rows)
    if rows[0].get("kind") == "header":
        header = rows[0]
        body = rows[1:]

    protocol = _resolve_protocol(header, body, source=p)

    address = int(header.get("address", 1) or 1)
    serial_settings = _settings_from_header(header)

    if protocol is ProtocolKind.STDBUS:
        stdbus_rounds = tuple(_parse_stdbus_row(row, p, idx) for idx, row in enumerate(body, 2))
        return Fixture(
            protocol=protocol,
            address=address,
            serial_settings=serial_settings,
            stdbus_rounds=stdbus_rounds,
        )

    modbus_rounds = tuple(_parse_modbus_row(row, p, idx) for idx, row in enumerate(body, 2))
    return Fixture(
        protocol=protocol,
        address=address,
        serial_settings=serial_settings,
        modbus_rounds=modbus_rounds,
    )