Skip to content

alicatlib.testing

Test helpers — FakeTransport, FakeTransportFromFixture, and the fixture-file parser. See Testing for usage patterns and fixture grammar.

alicatlib.testing

Testing helpers — re-exports + fixture-file loader.

Importing from this module keeps downstream test code one import deep:

.. code-block:: python

from alicatlib.testing import FakeTransport, FakeTransportFromFixture

The fixture format is plaintext, intentionally skimmable by humans so captured hardware sessions round-trip through code review::

# scenario: identify-flow-controller (10v05, MC-100SCCM-D)
> A??M*
< A M01 Alicat Scientific
< A M02 www.example.com
< A M03 +1 555-0000
< A M04 MC-100SCCM-D
...

Parsing rules are deliberately narrow (design §6.2):

  • Lines starting with # are comments; ignored.
  • Blank lines are ignored.
  • > introduces a send — the carriage-return terminator is appended automatically so the fixture stays readable.
  • < introduces one reply line (\\r-terminated).
  • Multiple < lines after a single > concatenate into one :class:FakeTransport scripted reply — the right shape for multiline commands like ??M*.
  • Duplicate > entries are a file-format error rather than a silent overwrite.

Not shipped yet: record_session(device, scenario, path), which captures live hardware traffic into this format. Planned alongside the hardware integration suite.

FakeTransport

FakeTransport(
    script=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 known write queues the corresponding reply into the read buffer. Unknown writes are recorded but produce no reply — subsequent reads will then hit idle_timeout / timeout, which is the intended failure mode (tests see a real timeout if they forgot to script a command).

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/alicatlib/transport/fake.py
def __init__(
    self,
    script: Mapping[bytes, ScriptedReply] | None = None,
    *,
    label: str = "fake://test",
    latency_s: float = 0.0,
) -> None:
    self._script: dict[bytes, ScriptedReply] = dict(script or {})
    self._writes: 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
    self._force_disconnected = False
    # Track calls to ``reopen`` so baud-change tests can assert the
    # transport observed the reconfiguration. ``None`` until
    # :meth:`reopen` is called; then holds the last requested
    # baudrate. ``reopen_count`` lets tests distinguish "never
    # called" from "called with the default baud".
    self._last_reopen_baud: int | None = None
    self._reopen_count: int = 0
    self._force_reopen_error: bool = False

last_reopen_baud property

last_reopen_baud

Baud rate requested by the most recent :meth:reopen, or None.

reopen_count property

reopen_count

Number of :meth:reopen calls since construction.

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/alicatlib/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

feed

feed(data)

Push unsolicited bytes into the read buffer.

Useful for simulating a device that was left streaming, or garbage on the line that the session must drain on recovery.

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

    Useful for simulating a device that was left streaming, or garbage on
    the line that the session must drain on recovery.
    """
    self._read_buffer.extend(data)

force_read_timeout

force_read_timeout(enabled=True)

Force the next :meth:read_until to raise AlicatTimeoutError.

Source code in src/alicatlib/transport/fake.py
def force_read_timeout(self, enabled: bool = True) -> None:
    """Force the next :meth:`read_until` to raise ``AlicatTimeoutError``."""
    self._force_read_timeout = enabled

force_reopen_error

force_reopen_error(enabled=True)

Force the next :meth:reopen to raise :class:AlicatConnectionError.

Used by :class:Session.change_baud_rate tests to exercise the BROKEN-state transition without a real serial adapter.

Source code in src/alicatlib/transport/fake.py
def force_reopen_error(self, enabled: bool = True) -> None:
    """Force the next :meth:`reopen` to raise :class:`AlicatConnectionError`.

    Used by :class:`Session.change_baud_rate` tests to exercise
    the BROKEN-state transition without a real serial adapter.
    """
    self._force_reopen_error = enabled

force_write_timeout

force_write_timeout(enabled=True)

Force the next :meth:write to raise AlicatTimeoutError.

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

reopen async

reopen(*, baudrate)

Simulate a baud-rate change — close, record, reopen.

If force_reopen_error() has been called the reopen raises :class:AlicatConnectionError after the close, leaving the transport closed. That's the "reopen wedged" path tested at the session layer for :attr:SessionState.BROKEN transitions.

Source code in src/alicatlib/transport/fake.py
async def reopen(self, *, baudrate: int) -> None:
    """Simulate a baud-rate change — close, record, reopen.

    If ``force_reopen_error()`` has been called the reopen raises
    :class:`AlicatConnectionError` after the close, leaving the
    transport closed. That's the "reopen wedged" path tested at
    the session layer for :attr:`SessionState.BROKEN` transitions.
    """
    await self.close()
    self._reopen_count += 1
    self._last_reopen_baud = baudrate
    if self._force_reopen_error:
        raise AlicatConnectionError(
            f"forced reopen error on {self._label}",
            context=ErrorContext(port=self._label),
        )
    await self.open()

FakeTransportFromFixture

FakeTransportFromFixture(path, *, label=None)

Load a fixture file into a new, already-built :class:FakeTransport.

The convenience for test code is that one line replaces the boilerplate "parse fixture, construct transport". The returned transport is not open — the caller awaits .open() as usual — because :class:FakeTransport's construction is synchronous but opening isn't.

Parameters:

Name Type Description Default
path str | Path

Path to the .txt fixture.

required
label str | None

Optional override for :attr:FakeTransport.label; defaults to "fixture://<basename>" so :class:ErrorContext.port entries point at the actual fixture file during failures.

None
Source code in src/alicatlib/testing.py
def FakeTransportFromFixture(  # noqa: N802 — public factory, title-case matches the class it returns
    path: str | Path,
    *,
    label: str | None = None,
) -> FakeTransport:
    """Load a fixture file into a new, already-built :class:`FakeTransport`.

    The convenience for test code is that one line replaces the
    boilerplate "parse fixture, construct transport". The returned
    transport is *not* open — the caller awaits ``.open()`` as usual —
    because :class:`FakeTransport`'s construction is synchronous but
    opening isn't.

    Args:
        path: Path to the ``.txt`` fixture.
        label: Optional override for :attr:`FakeTransport.label`; defaults
            to ``"fixture://<basename>"`` so :class:`ErrorContext.port`
            entries point at the actual fixture file during failures.
    """
    script = parse_fixture(path)
    resolved_label = label if label is not None else f"fixture://{Path(path).name}"
    return FakeTransport(script, label=resolved_label)

parse_fixture

parse_fixture(path)

Parse a fixture file into a :class:FakeTransport script map.

The returned dict maps send_bytes → reply_bytes. Both payloads are ASCII-encoded with a trailing \r on every logical line — the exact shape :class:FakeTransport expects.

Raises:

Type Description
ValueError

On malformed lines, a < before any >, or a duplicate > entry. Every error message names the offending line number in the source file.

FileNotFoundError

Via the underlying :meth:Path.read_text.

Source code in src/alicatlib/testing.py
def parse_fixture(path: str | Path) -> dict[bytes, bytes]:
    r"""Parse a fixture file into a :class:`FakeTransport` script map.

    The returned dict maps ``send_bytes → reply_bytes``. Both payloads
    are ASCII-encoded with a trailing ``\r`` on every logical line — the
    exact shape :class:`FakeTransport` expects.

    Raises:
        ValueError: On malformed lines, a ``<`` before any ``>``, or a
            duplicate ``>`` entry. Every error message names the
            offending line number in the source file.
        FileNotFoundError: Via the underlying :meth:`Path.read_text`.
    """
    fixture_path = Path(path)
    script: dict[bytes, bytes] = {}
    current_send: bytes | None = None
    current_reply_chunks: list[bytes] = []

    def _flush() -> None:
        nonlocal current_send, current_reply_chunks
        if current_send is None:
            return
        if current_send in script:
            raise ValueError(
                f"{fixture_path}: duplicate send entry {current_send!r}",
            )
        script[current_send] = b"".join(current_reply_chunks)
        current_send = None
        current_reply_chunks = []

    # File is read as UTF-8 so comments can include non-ASCII characters.
    # Individual ``>`` / ``<`` payloads are encoded back to ASCII at the
    # byte-emit step below — any non-ASCII payload surfaces as UnicodeEncodeError
    # at that point, which is the right behavior (Alicat wire is ASCII-only).
    text = fixture_path.read_text(encoding="utf-8")
    for line_number, line in _iter_semantic_lines(text.splitlines()):
        lean = line.lstrip()
        if lean.startswith(">"):
            _flush()
            payload = _content_after_marker(line, ">")
            current_send = payload.encode("ascii") + b"\r"
        elif lean.startswith("<"):
            if current_send is None:
                raise ValueError(
                    f"{fixture_path}:{line_number}: '<' line without preceding '>'",
                )
            payload = _content_after_marker(line, "<")
            current_reply_chunks.append(payload.encode("ascii") + b"\r")
        else:
            raise ValueError(
                f"{fixture_path}:{line_number}: unrecognized line {line!r}; "
                f"lines must start with '>', '<', or '#'",
            )
    _flush()
    return script