Skip to content

sartoriuslib.testing

FakeTransport, canned_frames, fixture parsers, and script builders. See Testing for usage patterns and Design §8.2 for the fixture format.

sartoriuslib.testing

First-class public testing support — :mod:sartoriuslib.testing.

Re-exports the test doubles and fixture helpers used to drive :mod:sartoriuslib without real hardware. See design doc §8.2.

Exposed now:

  • :class:FakeTransport — scripted in-process :class:Transport.
  • :class:ScriptedReply — the type alias for scripted-reply values.
  • :class:CannedFrames — reference xBPI wire frames from real balances, available as canned_frames.
  • :func:build_identify_script — assemble a scripted {tx: rx} dict for :func:open_device identify sequences (model / manufacturer / software / factory / SBN).
  • :func:parse_xbpi_fixture — turn a text fixture file (> send / < reply lines, # comments) into a scripted mapping ready for :class:FakeTransport.
  • :func:parse_sbi_fixture — the SBI counterpart, accepting readable tokens like ESC P.

canned_frames module-attribute

canned_frames = CannedFrames()

Module-level singleton of :class:CannedFrames for ergonomic access (from sartoriuslib.testing import canned_frames).

CannedFrames

Reference xBPI wire frames from real balances.

Attribute values are TX/RX :class:bytes ready to drop into a :class:FakeTransport script. RX frames use the correct checksum (not the typo'd 0x55 from docs/protocol.md §3.3 — see CHANGELOG.md).

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 scripted write queues the corresponding reply into the read buffer. Unknown writes are recorded but produce no reply — subsequent reads will then hit a 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/sartoriuslib/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
    # Track calls to ``reopen`` so protocol-flip tests can assert the
    # transport observed the reconfiguration. ``None`` until
    # :meth:`reopen` is called; then holds the last requested value
    # for each setting. ``reopen_count`` lets tests distinguish
    # "never called" from "called with the same settings".
    self._last_reopen_baud: int | None = None
    self._last_reopen_parity: Parity | None = None
    self._last_reopen_stopbits: StopBits | 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.

last_reopen_parity property

last_reopen_parity

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

last_reopen_stopbits property

last_reopen_stopbits

Stop bits 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/sartoriuslib/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 in SBI autoprint mode, or garbage on the line that the session must drain on recovery.

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

    Useful for simulating a device that was left in SBI autoprint
    mode, 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 read to raise SartoriusTimeoutError.

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

force_reopen_error

force_reopen_error(enabled=True)

Force the next :meth:reopen to raise SartoriusConnectionError.

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

force_write_timeout

force_write_timeout(enabled=True)

Force the next :meth:write to raise SartoriusTimeoutError.

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

reopen async

reopen(*, baudrate=None, parity=None, stopbits=None)

Simulate a serial-setting change — close, record, reopen.

If force_reopen_error() has been called the reopen raises :class:SartoriusConnectionError after the close, leaving the transport closed. That is the "reopen wedged" path used by :meth:Balance.configure_protocol tests for the BROKEN-state transition.

Source code in src/sartoriuslib/transport/fake.py
async def reopen(
    self,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
) -> None:
    """Simulate a serial-setting change — close, record, reopen.

    If ``force_reopen_error()`` has been called the reopen raises
    :class:`SartoriusConnectionError` after the close, leaving the
    transport closed. That is the "reopen wedged" path used by
    :meth:`Balance.configure_protocol` tests for the BROKEN-state
    transition.
    """
    await self.close()
    self._reopen_count += 1
    if baudrate is not None:
        self._last_reopen_baud = baudrate
    if parity is not None:
        self._last_reopen_parity = parity
    if stopbits is not None:
        self._last_reopen_stopbits = stopbits
    if self._force_reopen_error:
        raise SartoriusConnectionError(
            f"forced reopen error on {self._label}",
            context=ErrorContext(port=self._label),
        )
    await self.open()

build_identify_script

build_identify_script(
    *,
    model="MSE1203S-100-DR",
    manufacturer="Sartorius",
    software=None,
    factory_number=None,
    sbn=0,
)

Build a scripted transport mapping for :meth:Balance.identify.

The returned dict covers every TX frame the identify sequence sends (model, manufacturer, software, factory number, SBN) so a :class:FakeTransport constructed with it can drive :func:open_device from end to end without hardware.

Source code in src/sartoriuslib/testing.py
def build_identify_script(
    *,
    model: str = "MSE1203S-100-DR",
    manufacturer: str = "Sartorius",
    software: bytes | None = None,
    factory_number: bytes | None = None,
    sbn: int = 0x00,
) -> dict[bytes, bytes]:
    """Build a scripted transport mapping for :meth:`Balance.identify`.

    The returned dict covers every TX frame the identify sequence
    sends (model, manufacturer, software, factory number, SBN) so a
    :class:`FakeTransport` constructed with it can drive
    :func:`open_device` from end to end without hardware.
    """
    if software is None:
        software = bytes([0x00, 0x39, 0x21, 0x00, 0x39, 0x01, 0x39, 0x01, 0x00, 0x01])
    if factory_number is None:
        factory_number = bytes([0x00, 0x31, 0x80, 0x11, 0x65])

    return {
        canned_frames.TX_READ_MODEL: _ascii_blob(0x54, 20, model),
        canned_frames.TX_READ_MANUFACTURER: _ascii_blob(0x50, 16, manufacturer),
        canned_frames.TX_READ_SW_VERSION: _rx(0x4A, software),
        canned_frames.TX_READ_FACTORY_NUMBER: _rx(0x45, factory_number),
        canned_frames.TX_READ_SBN: _short_data_u8(sbn),
    }

build_metrology_script

build_metrology_script(
    *,
    capacity_g=1200.0,
    increment_g=0.001,
    config_counter=1,
    area=0,
)

Build a {tx: rx} script covering capacity / increment / counter.

Pair with :func:build_identify_script to script a full open sequence (identity + metrology probe). Defaults match the MSE1203S unit in docs/protocol.md.

Pass config_counter=None to omit the 0xBA reply entirely — use that when simulating a balance that genuinely lacks :attr:Capability.CONFIG_COUNTER (e.g. WZA). The new probe-driven capability detection in :meth:Balance._probe_dispatch_capabilities relies on a missing reply (not a falsy value) to decide the cap is absent, so unfaithful "always reply" fixtures would silently over-report the capability.

Source code in src/sartoriuslib/testing.py
def build_metrology_script(
    *,
    capacity_g: float = 1200.0,
    increment_g: float = 0.001,
    config_counter: int | None = 1,
    area: int = 0,
) -> dict[bytes, bytes]:
    """Build a ``{tx: rx}`` script covering capacity / increment / counter.

    Pair with :func:`build_identify_script` to script a full
    open sequence (identity + metrology probe). Defaults match the
    MSE1203S unit in ``docs/protocol.md``.

    Pass ``config_counter=None`` to omit the ``0xBA`` reply entirely —
    use that when simulating a balance that genuinely lacks
    :attr:`Capability.CONFIG_COUNTER` (e.g. WZA). The new probe-driven
    capability detection in :meth:`Balance._probe_dispatch_capabilities`
    relies on a missing reply (not a falsy value) to decide the cap is
    absent, so unfaithful "always reply" fixtures would silently
    over-report the capability.
    """
    tx_capacity = build_command(0x0C, bytes([0x21, area]))
    tx_increment = build_command(0x0D, bytes([0x21, area]))
    script: dict[bytes, bytes] = {
        tx_capacity: _typed_float_rx(capacity_g),
        tx_increment: _typed_float_rx(increment_g),
    }
    if config_counter is not None:
        tx_counter = build_command(0xBA)
        script[tx_counter] = _short_data_u8(config_counter)
    return script

build_parameter_read_script

build_parameter_read_script(index, current, max_value)

Build a one-entry script for a read_parameter(index) call.

Source code in src/sartoriuslib/testing.py
def build_parameter_read_script(
    index: int,
    current: int,
    max_value: int,
) -> dict[bytes, bytes]:
    """Build a one-entry script for a ``read_parameter(index)`` call."""
    tx = build_command(0x55, bytes([0x21, index]))
    return {tx: _parameter_rx(current, max_value)}

build_parameter_write_script

build_parameter_write_script(index, value)

Build a one-entry script for a write_parameter(index, value) call.

Source code in src/sartoriuslib/testing.py
def build_parameter_write_script(index: int, value: int) -> dict[bytes, bytes]:
    """Build a one-entry script for a ``write_parameter(index, value)`` call."""
    tx = build_command(0x56, bytes([0x21, index, 0x21, value]))
    return {tx: canned_frames.RX_ACK}

build_sbi_identify_script

build_sbi_identify_script(
    *, model="WZA8202-N", serial="12345678", software="1.0"
)

Build a scripted SBI identity mapping for open_device(..., SBI).

Source code in src/sartoriuslib/testing.py
def build_sbi_identify_script(
    *,
    model: str = "WZA8202-N",
    serial: str = "12345678",
    software: str = "1.0",
) -> dict[bytes, bytes]:
    """Build a scripted SBI identity mapping for ``open_device(..., SBI)``."""
    return {
        TOKEN_TYPE: _sbi_line(model),
        TOKEN_SERIAL: _sbi_line(serial),
        TOKEN_SOFTWARE: _sbi_line(software),
    }

build_temperature_script

build_temperature_script(
    *, sensor_celsius, out_of_range_after=None
)

Script the per-sensor replies for temperature(N) calls.

sensor_celsius maps sensor index → temperature in °C, or None to script the 7f ff ff ff "reserved slot" sentinel for that index. out_of_range_after adds an xBPI 0x04 (unknown opcode) reply for index N (and the helper does NOT script higher indices, mirroring the wire reality where the device stops replying past the end). Useful for testing :meth:Balance.discover_temperature_sensors against a sparse layout (e.g. MSE: {0: 25.5, 1: 25.6, 2: None, 3: 36.7} + out_of_range_after=4).

Source code in src/sartoriuslib/testing.py
def build_temperature_script(
    *,
    sensor_celsius: dict[int, float | None],
    out_of_range_after: int | None = None,
) -> dict[bytes, bytes]:
    """Script the per-sensor replies for ``temperature(N)`` calls.

    ``sensor_celsius`` maps sensor index → temperature in °C, or ``None``
    to script the ``7f ff ff ff`` "reserved slot" sentinel for that
    index. ``out_of_range_after`` adds an xBPI ``0x04`` (unknown opcode)
    reply for index ``N`` (and the helper does NOT script higher
    indices, mirroring the wire reality where the device stops
    replying past the end). Useful for testing
    :meth:`Balance.discover_temperature_sensors` against a sparse
    layout (e.g. MSE: ``{0: 25.5, 1: 25.6, 2: None, 3: 36.7}`` +
    ``out_of_range_after=4``).
    """
    sentinel_body = b"\x7f\xff\xff\xff\xff"
    script: dict[bytes, bytes] = {}
    for sensor, celsius in sensor_celsius.items():
        tx = build_command(0x76, bytes([0x21, sensor]))
        if celsius is None:
            script[tx] = _rx(0x35, sentinel_body)
        else:
            script[tx] = _typed_float_rx(celsius)
    if out_of_range_after is not None:
        tx = build_command(0x76, bytes([0x21, out_of_range_after]))
        # xBPI 0x01 0x04 = unknown/unsupported opcode.
        script[tx] = _rx(0x01, b"\x04")
    return script

parse_sbi_fixture

parse_sbi_fixture(text)

Parse an SBI text fixture into a {tx_bytes: rx_bytes} mapping.

Format::

# SBI fixture: print
> ESC P
< +     0.00 g

Reply lines get \r\n appended when the fixture omits a terminator. Multiple < lines after one > concatenate into a multi-line reply.

Source code in src/sartoriuslib/testing.py
def parse_sbi_fixture(text: str) -> dict[bytes, bytes]:
    r"""Parse an SBI text fixture into a ``{tx_bytes: rx_bytes}`` mapping.

    Format::

        # SBI fixture: print
        > ESC P
        < +     0.00 g

    Reply lines get ``\r\n`` appended when the fixture omits a terminator.
    Multiple ``<`` lines after one ``>`` concatenate into a multi-line reply.
    """
    mapping: dict[bytes, bytes] = {}
    current_tx: bytes | None = None
    for lineno, raw in enumerate(text.splitlines(), start=1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        marker, _, rest = line.partition(" ")
        payload = rest.strip()
        if marker == ">":
            if not payload:
                raise SartoriusValidationError(
                    f"fixture line {lineno}: empty SBI TX payload",
                    context=ErrorContext(extra={"line": lineno}),
                )
            try:
                current_tx = normalize_token(payload)
            except Exception as exc:
                raise SartoriusValidationError(
                    f"fixture line {lineno}: invalid SBI token {payload!r}",
                    context=ErrorContext(extra={"line": lineno, "payload": payload}),
                ) from exc
            mapping.setdefault(current_tx, b"")
        elif marker == "<":
            if current_tx is None:
                raise SartoriusValidationError(
                    f"fixture line {lineno}: '<' reply before any '>' request",
                    context=ErrorContext(extra={"line": lineno}),
                )
            mapping[current_tx] = mapping[current_tx] + _sbi_line(payload)
        else:
            raise SartoriusValidationError(
                f"fixture line {lineno}: unrecognised marker {marker!r} (expected '>' or '<')",
                context=ErrorContext(extra={"line": lineno, "marker": marker}),
            )
    return mapping

parse_xbpi_fixture

parse_xbpi_fixture(text)

Parse an xBPI text fixture into a {tx_bytes: rx_bytes} mapping.

Format (design §8.2)::

# xBPI fixture: read net weight
> 04 01 09 1e 2c
< 0b 41 48 bb a3 d7 0a 3d 30 82 45 07
  • Blank lines and #-comment lines are ignored.
  • > lines carry TX bytes (host→balance).
  • < lines carry RX bytes (balance→host); they attach to the most recent > line.
  • Hex digits may be separated by whitespace in any form.
  • Multiple < lines following one > concatenate into one reply blob (useful for synthesising multi-frame responses).

Raises :class:SartoriusValidationError for malformed input (an < line before any >, odd-length hex tokens, etc.).

Source code in src/sartoriuslib/testing.py
def parse_xbpi_fixture(text: str) -> dict[bytes, bytes]:
    r"""Parse an xBPI text fixture into a ``{tx_bytes: rx_bytes}`` mapping.

    Format (design §8.2)::

        # xBPI fixture: read net weight
        > 04 01 09 1e 2c
        < 0b 41 48 bb a3 d7 0a 3d 30 82 45 07

    - Blank lines and ``#``-comment lines are ignored.
    - ``>`` lines carry TX bytes (host→balance).
    - ``<`` lines carry RX bytes (balance→host); they attach to the most
      recent ``>`` line.
    - Hex digits may be separated by whitespace in any form.
    - Multiple ``<`` lines following one ``>`` concatenate into one
      reply blob (useful for synthesising multi-frame responses).

    Raises :class:`SartoriusValidationError` for malformed input (an
    ``<`` line before any ``>``, odd-length hex tokens, etc.).
    """
    mapping: dict[bytes, bytes] = {}
    current_tx: bytes | None = None
    for lineno, raw in enumerate(text.splitlines(), start=1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        marker, _, rest = line.partition(" ")
        payload = rest.strip()
        if marker == ">":
            if not payload:
                raise SartoriusValidationError(
                    f"fixture line {lineno}: empty TX payload",
                    context=ErrorContext(extra={"line": lineno}),
                )
            current_tx = _decode_hex_tokens(payload, lineno)
            mapping.setdefault(current_tx, b"")
        elif marker == "<":
            if current_tx is None:
                raise SartoriusValidationError(
                    f"fixture line {lineno}: '<' reply before any '>' request",
                    context=ErrorContext(extra={"line": lineno}),
                )
            rx = _decode_hex_tokens(payload, lineno) if payload else b""
            mapping[current_tx] = mapping[current_tx] + rx
        else:
            raise SartoriusValidationError(
                f"fixture line {lineno}: unrecognised marker {marker!r} (expected '>' or '<')",
                context=ErrorContext(extra={"line": lineno, "marker": marker}),
            )
    return mapping