Skip to content

fujilib.testing

Everything needed to develop and test without hardware (design §10): the family's arrow-format frame fixtures, builders for synthetic readings and frames, a simulated ZP analyzer on a real serial port pair, and the sanitized bench register bank.

The builders are for code that consumes fujilib's models rather than the line: an application's adapter, a sink, a simulator of its own. A built frame becomes the sample and the row a recording produces:

from fujilib import ChannelId, Gas, ReadingState, Sample, sample_to_row
from fujilib.testing import frame, reading, status

held_o2 = reading(
    ChannelId.CH3, Gas.O2, 2095, 2, channel_status=status(hold=True), state=ReadingState.HOLD
)
sample = Sample.from_frame(frame((held_o2,)), device="zpa", address=1)
row = sample_to_row(sample)  # row["ch3_value"] == 20.95, row["ch3_state"] == "hold"

fujilib.testing.arrow

Test helpers: the family's arrow-format frame fixtures (design §2.10, §10).

An arrow fixture is plain text, one frame per line, as hex bytes::

> 01 03 00 04 00 02 85 CA          # a request the host sends
< 01 03 04 00 00 03 E8 FA 8D       # the analyzer's reply
  • > starts an exchange with a request; < adds reply bytes to it (several < lines are concatenated). A request with no < has no reply.
  • # starts a comment anywhere on a line; blank lines are ignored.
  • Hex may be separated by spaces, colons or commas.

Exchange dataclass

Exchange(request, response, line, comment)

One request and the reply that followed it, from an arrow fixture.

comment instance-attribute

comment

The request line's comment, without the #.

line instance-attribute

line

The 1-based line of the request.

hex_to_bytes

hex_to_bytes(text)

Parse hex bytes separated by spaces, colons or commas.

Raises:

Type Description
FujiValidationError

a token is not one or two hex digits, or text is empty.

Source code in src/fujilib/testing/arrow.py
def hex_to_bytes(text: str) -> bytes:
    """Parse hex bytes separated by spaces, colons or commas.

    Raises:
        FujiValidationError: a token is not one or two hex digits, or ``text`` is empty.
    """
    tokens = text.replace(":", " ").replace(",", " ").split()
    if not tokens:
        msg = "no hex bytes"
        raise FujiValidationError(msg)
    out = bytearray()
    for token in tokens:
        if len(token) > 2:  # noqa: PLR2004
            if len(token) % 2:
                msg = f"odd-length hex {token!r}"
                raise FujiValidationError(msg)
            pairs = [token[i : i + 2] for i in range(0, len(token), 2)]
        else:
            pairs = [token]
        for pair in pairs:
            try:
                out.append(int(pair, 16))
            except ValueError:
                msg = f"{pair!r} is not a hex byte"
                raise FujiValidationError(msg) from None
    return bytes(out)

parse_arrow_fixture

parse_arrow_fixture(source, *, name=None)

Parse an arrow fixture from a path or from its text.

Parameters:

Name Type Description Default
source str | Path

A :class:~pathlib.Path to read, or the fixture text itself.

required
name str | None

The label for error messages; defaults to the path, or <string>.

None

Raises:

Type Description
FujiValidationError

a malformed line; the message starts with name:line.

Source code in src/fujilib/testing/arrow.py
def parse_arrow_fixture(source: str | Path, *, name: str | None = None) -> tuple[Exchange, ...]:
    """Parse an arrow fixture from a path or from its text.

    Args:
        source: A :class:`~pathlib.Path` to read, or the fixture text itself.
        name: The label for error messages; defaults to the path, or ``<string>``.

    Raises:
        FujiValidationError: a malformed line; the message starts with ``name:line``.
    """
    if isinstance(source, Path):
        label = name or str(source)
        text = source.read_text(encoding="utf-8")
    else:
        label = name or "<string>"
        text = source
    exchanges: list[Exchange] = []
    request: tuple[bytes, int, str] | None = None
    response: bytearray | None = None

    def flush() -> None:
        if request is not None:
            data, line, comment = request
            reply = bytes(response) if response is not None else None
            exchanges.append(Exchange(data, reply, line, comment))

    for number, raw in enumerate(text.splitlines(), start=1):
        content, _, comment = raw.partition("#")
        content = content.strip()
        if not content:
            continue
        marker, _, payload = content.partition(" ")
        try:
            data = hex_to_bytes(payload)
        except FujiValidationError as exc:
            msg = f"{label}:{number}: {exc}"
            raise FujiValidationError(msg, context=ErrorContext(extra={"line": number})) from None
        if marker == ">":
            flush()
            request, response = (data, number, comment.strip()), None
        elif marker == "<":
            if request is None:
                msg = f"{label}:{number}: a reply before any request"
                raise FujiValidationError(msg, context=ErrorContext(extra={"line": number}))
            response = (response or bytearray()) + data
        else:
            msg = f"{label}:{number}: a line must start with '>' or '<', not {marker!r}"
            raise FujiValidationError(msg, context=ErrorContext(extra={"line": number}))
    flush()
    return tuple(exchanges)

replay_script

replay_script(exchanges)

Map each request to its reply, for replaying a fixture to a client.

Requests without a reply are left out.

Raises:

Type Description
FujiValidationError

the same request appears with two different replies.

Source code in src/fujilib/testing/arrow.py
def replay_script(exchanges: tuple[Exchange, ...]) -> Mapping[bytes, bytes]:
    """Map each request to its reply, for replaying a fixture to a client.

    Requests without a reply are left out.

    Raises:
        FujiValidationError: the same request appears with two different replies.
    """
    script: dict[bytes, bytes] = {}
    for exchange in exchanges:
        if exchange.response is None:
            continue
        known = script.get(exchange.request)
        if known is not None and known != exchange.response:
            msg = f"line {exchange.line}: this request already has a different reply"
            raise FujiValidationError(msg, context=ErrorContext(extra={"line": exchange.line}))
        script[exchange.request] = exchange.response
    return MappingProxyType(script)

fujilib.testing.frames

Builders for synthetic readings, statuses and frames (design §10).

For code that consumes fujilib's models without an analyzer or a simulated line: a downstream adapter's tests, a sink's tests, or an application's own simulator. Each builder returns the real frozen model, so :meth:Sample.from_frame() <fujilib.streaming.sample.Sample.from_frame> and :func:~fujilib.sinks.base.sample_to_row turn a built :class:Frame into exactly the sample and row a recording of an analyzer produces.

  • :func:reading and :func:status build one channel; :func:analyzer the analyzer-level status; :func:frame a whole poll.
  • :func:timing builds a transaction's timing from an offset. Its defaults, :data:T0 and :data:MONO0, are fixed, so a test's timestamps are too; a simulator passes its own clock readings as origin and mono_origin_ns.
  • :func:bench_readings is CO2, CO and O2 as the bench ZPA reported them.

Nothing here checks that the parts agree: a state is taken as given, not derived from the status. The decoders do that (:mod:fujilib.devices.decode); use :class:~fujilib.testing.mock.MockAnalyzer when the decoding itself is under test.

analyzer

analyzer(
    *,
    instrument_error=False,
    errors=(),
    alarms=(AlarmState.NONE,) * 6,
    auto_calibration=False,
)

The analyzer-level status, on the measurement screen with nothing active.

Parameters:

Name Type Description Default
instrument_error bool

The instrument-error flag.

False
errors tuple[ErrorCode, ...]

The analyzer's active errors: 1, 2, 3 or 10.

()
alarms tuple[AlarmState | int, ...]

The states of alarms 1-6, in order.

(NONE,) * 6
auto_calibration bool

An auto calibration is running.

False
Source code in src/fujilib/testing/frames.py
def analyzer(
    *,
    instrument_error: bool = False,
    errors: tuple[ErrorCode, ...] = (),
    alarms: tuple[AlarmState | int, ...] = (AlarmState.NONE,) * 6,
    auto_calibration: bool = False,
) -> AnalyzerStatus:
    """The analyzer-level status, on the measurement screen with nothing active.

    Args:
        instrument_error: The instrument-error flag.
        errors: The analyzer's active errors: 1, 2, 3 or 10.
        alarms: The states of alarms 1-6, in order.
        auto_calibration: An auto calibration is running.
    """
    return AnalyzerStatus(
        instrument_error=instrument_error,
        calibration_error=False,
        errors=frozenset(errors),
        alarms=alarms,
        peak_count=0,
        peak_alarm=False,
        auto_calibration_running=auto_calibration,
        display=DisplayState(
            screen=DisplayScreen.MEASUREMENT,
            calibration_step=ManualCalibrationStep.NONE,
            top_channel=ChannelId.CH1,
            cursor_channel=None,
        ),
    )

bench_readings

bench_readings()

CO2, CO and O2 as the bench ZPA reported them at capture.

Source code in src/fujilib/testing/frames.py
def bench_readings() -> tuple[Reading, ...]:
    """CO2, CO and O2 as the bench ZPA reported them at capture."""
    return (
        reading(ChannelId.CH1, Gas.CO2, -11, 2),
        reading(ChannelId.CH2, Gas.CO, -9, 3),
        reading(ChannelId.CH3, Gas.O2, 2029, 2),
    )

frame

frame(
    readings=None,
    status_block=None,
    *,
    detail=True,
    readings_timing=None,
    status_timing=None,
)

A poll's frame.

Parameters:

Name Type Description Default
readings tuple[Reading, ...] | None

The channels; :func:bench_readings if omitted.

None
status_block AnalyzerStatus | None

The analyzer status; :func:analyzer's default if omitted.

None
detail bool

False builds the frame of a poll that read only the concentration block: no analyzer status and no status timing.

True
readings_timing TransferTiming | None

The concentration block's timing, which times the sample; timing(0.0) if omitted.

None
status_timing TransferTiming | None

The status block's timing; timing(25.0) if omitted. Ignored without detail.

None
Source code in src/fujilib/testing/frames.py
def frame(
    readings: tuple[Reading, ...] | None = None,
    status_block: AnalyzerStatus | None = None,
    *,
    detail: bool = True,
    readings_timing: TransferTiming | None = None,
    status_timing: TransferTiming | None = None,
) -> Frame:
    """A poll's frame.

    Args:
        readings: The channels; :func:`bench_readings` if omitted.
        status_block: The analyzer status; :func:`analyzer`'s default if omitted.
        detail: ``False`` builds the frame of a poll that read only the
            concentration block: no analyzer status and no status timing.
        readings_timing: The concentration block's timing, which times the
            sample; ``timing(0.0)`` if omitted.
        status_timing: The status block's timing; ``timing(25.0)`` if omitted.
            Ignored without ``detail``.
    """
    if status_timing is None:
        status_timing = timing(25.0)
    return Frame(
        readings=readings if readings is not None else bench_readings(),
        analyzer=(status_block or analyzer()) if detail else None,
        protocol=ProtocolKind.MODBUS_RTU,
        readings_timing=readings_timing if readings_timing is not None else timing(0.0),
        status_timing=status_timing if detail else None,
        raw=b"\x00" * _POLL_BYTES,
    )

reading

reading(
    channel,
    gas,
    raw,
    decimals,
    *,
    unit=Unit.VOL_PERCENT,
    state=ReadingState.OK,
    channel_status=None,
    label_source=LabelSource.ASSERTED,
    role=ChannelRole.INSTANTANEOUS,
)

One channel's reading, its value raw / 10**decimals.

Parameters:

Name Type Description Default
channel ChannelId

The channel.

required
gas Gas

The gas it carries, also given as the suggested gas.

required
raw int

The signed integer of the concentration register.

required
decimals int

The decimal-point register, 0-3.

required
unit Unit

The unit of the channel's current range.

VOL_PERCENT
state ReadingState

The validity state; it is not derived from channel_status.

OK
channel_status ChannelStatus | None

The channel's status; :func:status's default if omitted.

None
label_source LabelSource

Where the gas label comes from.

ASSERTED
role ChannelRole

The channel's role.

INSTANTANEOUS
Source code in src/fujilib/testing/frames.py
def reading(
    channel: ChannelId,
    gas: Gas,
    raw: int,
    decimals: int,
    *,
    unit: Unit = Unit.VOL_PERCENT,
    state: ReadingState = ReadingState.OK,
    channel_status: ChannelStatus | None = None,
    label_source: LabelSource = LabelSource.ASSERTED,
    role: ChannelRole = ChannelRole.INSTANTANEOUS,
) -> Reading:
    """One channel's reading, its value ``raw / 10**decimals``.

    Args:
        channel: The channel.
        gas: The gas it carries, also given as the suggested gas.
        raw: The signed integer of the concentration register.
        decimals: The decimal-point register, 0-3.
        unit: The unit of the channel's current range.
        state: The validity state; it is not derived from ``channel_status``.
        channel_status: The channel's status; :func:`status`'s default if omitted.
        label_source: Where the gas label comes from.
        role: The channel's role.
    """
    return Reading(
        channel=channel,
        gas=gas,
        suggested_gas=gas,
        label_source=label_source,
        role=role,
        value=raw / 10**decimals,
        unit=unit,
        raw_value=raw,
        decimals=decimals,
        status=channel_status if channel_status is not None else status(),
        state=state,
        protocol=ProtocolKind.MODBUS_RTU,
    )

status

status(
    *, rng=1, hold=False, zero=False, span=False, errors=()
)

A measured channel's status: live on range 1 unless told otherwise.

Parameters:

Name Type Description Default
rng int

The current range, 1 or 2.

1
hold bool

The channel's output is held.

False
zero bool

A manual zero calibration of the channel is under way.

False
span bool

A manual span calibration of the channel is under way.

False
errors tuple[ErrorCode, ...]

The channel's active errors, 4-9.

()
Source code in src/fujilib/testing/frames.py
def status(
    *,
    rng: int = 1,
    hold: bool = False,
    zero: bool = False,
    span: bool = False,
    errors: tuple[ErrorCode, ...] = (),
) -> ChannelStatus:
    """A measured channel's status: live on range 1 unless told otherwise.

    Args:
        rng: The current range, 1 or 2.
        hold: The channel's output is held.
        zero: A manual zero calibration of the channel is under way.
        span: A manual span calibration of the channel is under way.
        errors: The channel's active errors, 4-9.
    """
    return ChannelStatus(
        range=rng,
        zero_calibrating=zero,
        span_calibrating=span,
        auto_zero_running=False,
        auto_span_running=False,
        hold=hold,
        errors=frozenset(errors),
    )

timing

timing(
    offset_ms=0.0,
    latency_ms=20.0,
    *,
    origin=T0,
    mono_origin_ns=MONO0,
)

The timing of a transaction sent offset_ms after the origin.

Parameters:

Name Type Description Default
offset_ms float

When the request was sent, in milliseconds after the origin.

0.0
latency_ms float

The round trip, request to reply.

20.0
origin datetime

The wall-clock origin, tz-aware.

T0
mono_origin_ns int

The monotonic origin, the same instant as origin.

MONO0
Source code in src/fujilib/testing/frames.py
def timing(
    offset_ms: float = 0.0,
    latency_ms: float = 20.0,
    *,
    origin: datetime = T0,
    mono_origin_ns: int = MONO0,
) -> TransferTiming:
    """The timing of a transaction sent ``offset_ms`` after the origin.

    Args:
        offset_ms: When the request was sent, in milliseconds after the origin.
        latency_ms: The round trip, request to reply.
        origin: The wall-clock origin, tz-aware.
        mono_origin_ns: The monotonic origin, the same instant as ``origin``.
    """
    start = origin + timedelta(milliseconds=offset_ms)
    mono = mono_origin_ns + int(offset_ms * 1e6)
    return TransferTiming(
        requested_at=start,
        received_at=start + timedelta(milliseconds=latency_ms),
        t_request_mono_ns=mono,
        t_reply_mono_ns=mono + int(latency_ms * 1e6),
    )

fujilib.testing.mock

A simulated ZP-series analyzer and the RS-485 line it sits on (design §10).

Two pieces, kept apart so several stations can share one line:

  • :class:MockAnalyzer is one station: its register banks, the region map it answers, the exception replies of one :class:ExceptionProfile, and the fault plan for its replies. It does no I/O.
  • :class:MockLine is the line: anymodbus's MockServer on the analyzer end of a serial port pair. The server reads each request frame once and drops one with a bad CRC or for an absent station (the analyzer stays silent then). The line records the request and hands it to its station, whose reply goes out with the station's faults applied.

Exception profiles. Both follow the manual (TN5A1190a p.13): a read or write that starts at an address the function cannot use answers 02, and one whose count runs past the registers that exist, or asks for more than 64 words, answers 03. They differ where the bench unit (firmware 1.02) differs from the manual: FC01 and FC02 answer 02 on the bench, 01 in the manual (protocol findings §5). Function codes never sent to the bench answer as the manual says in both.

Writes. The simulator accepts only the writes the manual documents minus the ones fujilib must never make, written out here independently of :data:fujilib.registry.write_policy.WRITE_ENVELOPE. At the key-simulation register 07D0h that is the six calibration keys, never MODE, SIDE or two keys at once. A write outside that list raises :class:MockWriteViolation, which fails the test. A write is stored as sent, as the bench analyzer stores a value outside a setting's range, neither refusing nor clamping it (protocol findings §13.6). A test that wants a refusal injects an exception reply, and one that wants a write acknowledged but not stored injects :attr:FaultKind.IGNORE.

Ranges. A channel's selected range (40106-40110), written while its range method is manual, becomes its current range (30038-30042) :attr:MockAnalyzerConfig.range_lag_s later, since the bench analyzer switches some tens of milliseconds after the setting reads back (protocol findings §13.2). Under the auto or remote method the current range stays.

Operation commands are recorded and act as the manuals describe (ZPA manual p.31, p.46-47, p.53-60), on the AnyIO clock, with every flow time shortened by :attr:MockAnalyzerConfig.time_scale:

  • return to measurement shows the measurement screen. On a manual calibration's wait step it leaves the channels' flags set, as the bench analyzer did (protocol findings §18.4);
  • auto calibration zeroes the channels enabled for it (40021-40025) together for flow time 1, then spans them one at a time from Ch1 for flow times 2-6, then holds for flow time 7 if output hold is on;
  • auto zero calibration zeroes the same channels for its flow time, and holds as long again if output hold is on.

While either runs, input 30049 and the channels' auto-zero or auto-span and hold flags are set, and each enabled channel measures on its auto-calibration range, back to its own range at the end. Errors set in :attr:MockAnalyzer.calibration_errors appear at the end, as a failed calibration's would. A command that arrives while one runs changes nothing, and so does blowback, which has no status register. None of this is verified on hardware: the bench analyzer has no calibration valves to drive.

The front panel. Keys reach it two ways: :meth:MockAnalyzer.press, an operator pressing keys at the panel, and a key code written to 42001. Its manual calibration follows what the bench analyzer showed (protocol findings §14, §18):

  • ZERO or SPAN opens channel selection (step 4 or 7) with the cursor where it was, or on the first position after a return to measurement (42002) or, with :attr:MockAnalyzerConfig.cursor_reset_idle_s, after a long pause.
  • UP and DOWN move the cursor, wrapping round at both ends. For a zero, the channels set to "at once" share one position, which reads as its first channel when reached going down and its last going up.
  • ENT selects the channel: the wait step (5 or 8), the channels' zero or span flags, and 30186 at 0.
  • ENT again runs it (6 or 9, 30186 at 4) for :attr:MockAnalyzerConfig.manual_calibration_s, the last :attr:MockAnalyzerConfig.storing_silence_s of it without answering. It then sets each channel's reading to its calibration gas on its current range, and ends on the measurement step with 30186 at 6.
  • ESC from selection or wait returns to measurement; from the wait step the flags clear :attr:MockAnalyzerConfig.flag_lag_s later. Return to measurement (42002) on the wait step leaves them set.
  • 30190 shows each key pressed at the panel for :attr:MockAnalyzerConfig.key_hold_s; a key written to 42001 never shows there.
  • With key lock on, a key written to 42001 is acknowledged and does nothing, and the analyzer then answers nothing for :attr:MockAnalyzerConfig.key_lock_silence_s.

:meth:MockAnalyzer.flow changes the gas at a channel's inlet: the reading approaches the new value with a time constant, on the AnyIO clock.

What the bench has not shown is written from the manuals (ZPA manual p.64-67, p.75-77, p.89) and is unverified:

  • A channel in :attr:MockAnalyzer.calibration_errors ends on the error display (step 10), with its error set and 30186 left at 4. ESC clears the display. ENT forces the calibration on error 5 or 7, and clears the display otherwise.
  • Output hold sets the channels' hold flags from the wait step to the end; their readings are not held.
  • MODE opens the menu screen and ESC closes it; no menu is modelled beyond that.
  • Key lock does not stop keys pressed at the panel here; the bench has not shown what it does to them.

The simulator validates library integration. It does not validate USB timing, UART behaviour on real hardware, or analyzer semantics it was programmed to assume.

ExceptionProfile

Bases: StrEnum

Which exception replies the simulator gives.

BENCH_1_02 class-attribute instance-attribute

BENCH_1_02 = 'bench_1_02'

As the bench unit (firmware 1.02) answered: FC01 and FC02 answer exception 02.

DOCUMENTED class-attribute instance-attribute

DOCUMENTED = 'documented'

As the MODBUS manual describes: FC01 and FC02 answer exception 01.

Fault dataclass

Fault(
    kind,
    times=1,
    when=None,
    delay_s=0.0,
    exception_code=4,
    function_code=None,
)

A fault for the replies to matching requests.

function_code class-attribute instance-attribute

function_code = None

For :attr:FaultKind.WRONG_FUNCTION: the code to reply with.

times class-attribute instance-attribute

times = 1

How many matching requests it affects; None for every one.

when class-attribute instance-attribute

when = None

Which requests it affects; None for any.

FaultKind

Bases: StrEnum

What goes wrong with a reply.

CORRUPT_CRC class-attribute instance-attribute

CORRUPT_CRC = 'corrupt_crc'

The reply's CRC is wrong.

DELAY class-attribute instance-attribute

DELAY = 'delay'

The reply is sent :attr:Fault.delay_s late (a late reply when longer than the timeout).

The line is held up meanwhile, as a slow analyzer on a half-duplex line holds it up: the next request is read only after the delayed reply has gone out.

DROP class-attribute instance-attribute

DROP = 'drop'

No reply.

EXCEPTION class-attribute instance-attribute

EXCEPTION = 'exception'

An exception reply with :attr:Fault.exception_code.

GARBAGE class-attribute instance-attribute

GARBAGE = 'garbage'

Bytes that are not a reply: the station, then function code 0.

IGNORE class-attribute instance-attribute

IGNORE = 'ignore'

A normal reply to a write or command that changes nothing, as a write the analyzer acknowledges and then does not store.

WRONG_COUNT class-attribute instance-attribute

WRONG_COUNT = 'wrong_count'

A well-formed read reply with one word too few (one too many for a one-word read).

WRONG_FUNCTION class-attribute instance-attribute

WRONG_FUNCTION = 'wrong_function'

A reply that echoes another function code.

By default a well-formed reply of the same length: 03 and 04 swap, as do 06 and 10. :attr:Fault.function_code sets the code instead, e.g. 07, which anymodbus cannot frame at all, as a damaged byte on the line.

MockAnalyzer

MockAnalyzer(config=None)

One simulated station: registers, regions, exception profile and faults.

Start from config, or an empty analyzer at station 1.

Source code in src/fujilib/testing/mock.py
def __init__(self, config: MockAnalyzerConfig | None = None) -> None:
    """Start from ``config``, or an empty analyzer at station 1."""
    self.config = config if config is not None else MockAnalyzerConfig()
    self.station = self.config.station
    self.profile = self.config.profile
    self.regions = self.config.regions
    self.input: dict[int, int] = dict(self.config.input)
    """Input-register words; change them freely."""
    self.holding: dict[int, int] = dict(self.config.holding)
    """Holding-register words; change them freely."""
    self.faults: list[Fault] = []
    """Pending faults, consulted in order."""
    self.exchanges: list[MockExchange] = []
    """Every request to this station, with its reply."""
    self.commands: list[tuple[int, int]] = []
    """Operation commands carried out, as ``(address, value)``; refused or ignored ones
    are not."""
    self.violations: list[MockRequest] = []
    """Writes refused with :class:`MockWriteViolation`."""
    self.on_request: Callable[[MockRequest], None] | None = None
    """Called with each request before it is answered, to change state mid-sequence."""
    self.calibration: MockCalibration | None = None
    """The auto calibration or auto zero calibration running, if any."""
    self.calibration_errors: dict[int, int] = {}
    """Errors 4-8 by channel, to raise when the next calibration ends."""
    self.time_scale = self.config.time_scale
    self.range_lag_s = self.config.range_lag_s
    self.range_changes: dict[int, tuple[int, float]] = {}
    """Range switches still to come, by channel: the current-range word and when."""
    self.keys: list[tuple[int, float]] = []
    """Keys pressed at the panel, as ``(key code, AnyIO time)``."""
    self.remote_keys: list[tuple[int, float]] = []
    """Keys written to 42001 that acted, as ``(key code, AnyIO time)``."""
    self.swallowed_keys: list[tuple[int, float]] = []
    """Keys written to 42001 that key lock swallowed, as ``(key code, AnyIO time)``."""
    self._key_until: float | None = None
    self._last_key_at: float | None = None
    self._cursor_reset = False
    self._manual: _ManualCalibration | None = None
    self._silences: list[tuple[float, float]] = []
    self._flag_clears: list[tuple[tuple[int, ...], bool, float]] = []
    self._flows: dict[int, _Flow] = {}

calibration instance-attribute

calibration = None

The auto calibration or auto zero calibration running, if any.

calibration_errors instance-attribute

calibration_errors = {}

Errors 4-8 by channel, to raise when the next calibration ends.

commands instance-attribute

commands = []

Operation commands carried out, as (address, value); refused or ignored ones are not.

exchanges instance-attribute

exchanges = []

Every request to this station, with its reply.

faults instance-attribute

faults = []

Pending faults, consulted in order.

holding instance-attribute

holding = dict(self.config.holding)

Holding-register words; change them freely.

input instance-attribute

input = dict(self.config.input)

Input-register words; change them freely.

keys instance-attribute

keys = []

Keys pressed at the panel, as (key code, AnyIO time).

on_request instance-attribute

on_request = None

Called with each request before it is answered, to change state mid-sequence.

range_changes instance-attribute

range_changes = {}

Range switches still to come, by channel: the current-range word and when.

remote_keys instance-attribute

remote_keys = []

Keys written to 42001 that acted, as (key code, AnyIO time).

swallowed_keys instance-attribute

swallowed_keys = []

Keys written to 42001 that key lock swallowed, as (key code, AnyIO time).

violations instance-attribute

violations = []

Writes refused with :class:MockWriteViolation.

answer

answer(exchange)

Take in exchange's request; return the response PDU and the fault for its reply.

Called by :class:MockLine. A request the fault refuses with an exception reply changes nothing; a reply lost or damaged on its way back does not undo what the analyzer already did.

Source code in src/fujilib/testing/mock.py
def answer(self, exchange: MockExchange) -> tuple[bytes, Fault | None]:
    """Take in ``exchange``'s request; return the response PDU and the fault for its reply.

    Called by :class:`MockLine`. A request the fault refuses with an
    exception reply changes nothing; a reply lost or damaged on its way
    back does not undo what the analyzer already did.
    """
    self.exchanges.append(exchange)
    request = exchange.request
    now = request.arrived_at
    self._switch_ranges(now)
    self._advance(now)
    self._advance_panel(now)
    self._advance_flows(now)
    if self.on_request is not None:
        self.on_request(request)
    if self.silent(now):
        return b"", Fault(FaultKind.DROP)
    fault = self.take_fault(request)
    ignored = fault is not None and fault.kind in {FaultKind.EXCEPTION, FaultKind.IGNORE}
    return self.handle(request, apply=not ignored), fault

clear

clear()

Forget the exchanges recorded so far.

Source code in src/fujilib/testing/mock.py
def clear(self) -> None:
    """Forget the exchanges recorded so far."""
    self.exchanges.clear()

finish_calibration

finish_calibration()

End the running calibration now, as if its time had passed.

Source code in src/fujilib/testing/mock.py
def finish_calibration(self) -> None:
    """End the running calibration now, as if its time had passed."""
    if self.calibration is not None:
        self._end_calibration(self.calibration)

flow

flow(channel, value, *, tau_s=0.0, at=None)

Put a gas of value at channel's inlet, now or at AnyIO time at.

The reading approaches value exponentially with time constant tau_s, in the channel's own unit and decimals; with tau_s 0 it takes the value at once.

Source code in src/fujilib/testing/mock.py
def flow(
    self, channel: ChannelId | str, value: float, *, tau_s: float = 0.0, at: float | None = None
) -> None:
    """Put a gas of ``value`` at ``channel``'s inlet, now or at AnyIO time ``at``.

    The reading approaches ``value`` exponentially with time constant
    ``tau_s``, in the channel's own unit and decimals; with ``tau_s`` 0 it
    takes the value at once.
    """
    n = coerce_channel(channel).number
    now = anyio.current_time() if at is None else at
    decimals = self.register(f"reading.ch{n}.decimals")[0]
    raw = decode_int(self.register(f"reading.ch{n}.value")[0], signed=True)
    self._flows[n] = _Flow(start=raw / 10**decimals, target=value, since=now, tau_s=tau_s)
    self._advance_flows(now)

handle

handle(request, *, apply=True)

The response PDU to request (an exception PDU included).

Parameters:

Name Type Description Default
request MockRequest

The request.

required
apply bool

Carry out a write or command. False for a request the analyzer will refuse (an injected exception reply): it is still checked, but changes nothing.

True

Raises:

Type Description
MockWriteViolation

request writes where fujilib must never write.

Source code in src/fujilib/testing/mock.py
def handle(self, request: MockRequest, *, apply: bool = True) -> bytes:
    """The response PDU to ``request`` (an exception PDU included).

    Args:
        request: The request.
        apply: Carry out a write or command. ``False`` for a request the
            analyzer will refuse (an injected exception reply): it is still
            checked, but changes nothing.

    Raises:
        MockWriteViolation: ``request`` writes where fujilib must never write.
    """
    fc = request.function
    if fc in {FC_READ_HOLDING, FC_READ_INPUT}:
        return self._read(request)
    if fc in {FC_WRITE_SINGLE, FC_WRITE_MULTIPLE}:
        return self._write(request, apply=apply)
    if (
        fc in {_FC_READ_COILS, _FC_READ_DISCRETE}
        and self.profile is ExceptionProfile.BENCH_1_02
    ):
        return _exception(fc, _EXC_ILLEGAL_ADDRESS)
    return _exception(fc, _EXC_ILLEGAL_FUNCTION)

inject

inject(
    kind,
    *,
    times=1,
    when=None,
    delay_s=0.0,
    exception_code=4,
    function_code=None,
)

Add a fault for the replies to the next matching requests.

Source code in src/fujilib/testing/mock.py
def inject(
    self,
    kind: FaultKind,
    *,
    times: int | None = 1,
    when: Callable[[MockRequest], bool] | None = None,
    delay_s: float = 0.0,
    exception_code: int = 0x04,
    function_code: int | None = None,
) -> Fault:
    """Add a fault for the replies to the next matching requests."""
    fault = Fault(kind, times, when, delay_s, exception_code, function_code)
    self.faults.append(fault)
    return fault

press

press(key, *, at=None)

Press key (a 42001 key code) at the front panel, now or at AnyIO time at.

This is the operator: 30190 shows the key, and key lock does not stop it.

Source code in src/fujilib/testing/mock.py
def press(self, key: int, *, at: float | None = None) -> None:
    """Press ``key`` (a 42001 key code) at the front panel, now or at AnyIO time ``at``.

    This is the operator: 30190 shows the key, and key lock does not stop it.
    """
    now = anyio.current_time() if at is None else at
    self._advance_panel(now)
    self.keys.append((key, now))
    self.set_register("display.key", key)
    self._key_until = now + self.config.key_hold_s
    self._act(key, now)

register

register(name)

The words of the register called name.

Source code in src/fujilib/testing/mock.py
def register(self, name: str) -> tuple[int, ...]:
    """The words of the register called ``name``."""
    spec = REGISTRY.resolve(name)
    bank = self.input if spec.table is RegisterTable.INPUT else self.holding
    return tuple(bank.get(a, 0) for a in range(spec.address, spec.last_address + 1))

reply_frame

reply_frame(request, pdu, fault)

The reply frame for response pdu, with fault applied.

Source code in src/fujilib/testing/mock.py
def reply_frame(self, request: MockRequest, pdu: bytes, fault: Fault | None) -> bytes:
    """The reply frame for response ``pdu``, with ``fault`` applied."""
    kind = fault.kind if fault is not None else None
    if kind is FaultKind.WRONG_COUNT and pdu[0] in {FC_READ_HOLDING, FC_READ_INPUT}:
        words = [int.from_bytes(pdu[i : i + 2], "big") for i in range(2, len(pdu), 2)]
        words = words[:-1] if len(words) > 1 else [*words, 0]
        pdu = _read_pdu(pdu[0], words)
    elif fault is not None and fault.kind is FaultKind.WRONG_FUNCTION:
        base = pdu[0] & 0x7F
        other = fault.function_code or _OTHER_FUNCTION.get(base, base)
        pdu = bytes((other | (pdu[0] & 0x80),)) + pdu[1:]
    elif fault is not None and fault.kind is FaultKind.EXCEPTION:
        pdu = _exception(request.function, fault.exception_code)
    elif kind is FaultKind.GARBAGE:
        return bytes((self.station, 0x00, 0x00, 0x00, 0x00))
    head = bytes((self.station,)) + pdu
    crc = crc16_modbus_bytes(head)
    if kind is FaultKind.CORRUPT_CRC:
        crc = bytes((crc[0] ^ 0x01, crc[1]))
    return head + crc

send_reply async

send_reply(stream, exchange, pdu, fault)

Send the reply to exchange with fault applied. Called by :class:MockLine.

Source code in src/fujilib/testing/mock.py
async def send_reply(
    self,
    stream: anyio.abc.ByteStream,
    exchange: MockExchange,
    pdu: bytes,
    fault: Fault | None,
) -> None:
    """Send the reply to ``exchange`` with ``fault`` applied. Called by :class:`MockLine`."""
    if fault is not None and fault.kind is FaultKind.DROP:
        return
    if fault is not None and fault.kind is FaultKind.DELAY:
        await anyio.sleep(fault.delay_s)
    reply = self.reply_frame(exchange.request, pdu, fault)
    await stream.send(reply)
    exchange.reply = reply
    exchange.replied_at = anyio.current_time()

set_reading

set_reading(channel, raw, decimals, unit=Unit.VOL_PERCENT)

Set a channel's concentration, decimal point and unit.

Source code in src/fujilib/testing/mock.py
def set_reading(
    self,
    channel: ChannelId | str,
    raw: int,
    decimals: int,
    unit: Unit = Unit.VOL_PERCENT,
) -> None:
    """Set a channel's concentration, decimal point and unit."""
    n = coerce_channel(channel).number
    self.set_register(f"reading.ch{n}.value", encode_int(raw, signed=True))
    self.set_register(f"reading.ch{n}.decimals", decimals)
    self.set_register(f"reading.ch{n}.unit", unit_code(unit))

set_register

set_register(name, value)

Set the register called name in the registry; a negative value is two's complement.

Raises:

Type Description
FujiConfigurationError

no register has that name.

ValueError

the number of words does not match the register.

Source code in src/fujilib/testing/mock.py
def set_register(self, name: str, value: int | Sequence[int]) -> None:
    """Set the register called ``name`` in the registry; a negative value is two's complement.

    Raises:
        FujiConfigurationError: no register has that name.
        ValueError: the number of words does not match the register.
    """
    spec = REGISTRY.resolve(name)
    words = (value,) if isinstance(value, int) else tuple(value)
    if len(words) != spec.count:
        msg = f"{name} is {spec.count} word(s), got {len(words)}"
        raise ValueError(msg)
    self.set_words(spec.table, spec.address, (w & _WORD_MAX for w in words))

set_words

set_words(table, address, words)

Set consecutive words of table from address.

Raises:

Type Description
ValueError

a word is outside 0-0xFFFF.

Source code in src/fujilib/testing/mock.py
def set_words(self, table: RegisterTable, address: int, words: Iterable[int]) -> None:
    """Set consecutive words of ``table`` from ``address``.

    Raises:
        ValueError: a word is outside 0-0xFFFF.
    """
    bank = self.input if table is RegisterTable.INPUT else self.holding
    for offset, word in enumerate(words):
        if not 0 <= word <= _WORD_MAX:
            msg = f"register words are 0-0xFFFF, got {word}"
            raise ValueError(msg)
        bank[address + offset] = word

silent

silent(now)

Whether the analyzer answers nothing at AnyIO time now.

Source code in src/fujilib/testing/mock.py
def silent(self, now: float) -> bool:
    """Whether the analyzer answers nothing at AnyIO time ``now``."""
    self._silences = [(start, end) for start, end in self._silences if end > now]
    return any(start <= now for start, _ in self._silences)

take_fault

take_fault(request)

The first pending fault that matches request, used up by one.

Source code in src/fujilib/testing/mock.py
def take_fault(self, request: MockRequest) -> Fault | None:
    """The first pending fault that matches ``request``, used up by one."""
    for fault in self.faults:
        if fault.when is not None and not fault.when(request):
            continue
        if fault.times is not None:
            fault.times -= 1
            if fault.times <= 0:
                self.faults.remove(fault)
        return fault
    return None

transactions

transactions()

(function, address, count) of every request, in order.

Source code in src/fujilib/testing/mock.py
def transactions(self) -> list[tuple[int, int | None, int | None]]:
    """``(function, address, count)`` of every request, in order."""
    return [e.request.key for e in self.exchanges]

MockAnalyzerConfig dataclass

MockAnalyzerConfig(
    station=1,
    profile=ExceptionProfile.BENCH_1_02,
    input=(lambda: MappingProxyType({}))(),
    holding=(lambda: MappingProxyType({}))(),
    regions=zp_readable_regions(),
    description="",
    time_scale=0.001,
    range_lag_s=0.0,
    manual_calibration_s=0.0,
    key_hold_s=0.3,
    panel_channels=(1, 2, 3, 4, 5),
    key_lock_silence_s=1.6,
    storing_silence_s=0.0,
    flag_lag_s=0.0,
    cursor_reset_idle_s=None,
)

What a :class:MockAnalyzer starts from.

cursor_reset_idle_s class-attribute instance-attribute

cursor_reset_idle_s = None

Seconds without a key after which ZERO or SPAN opens on the first position; None for never.

flag_lag_s class-attribute instance-attribute

flag_lag_s = 0.0

Seconds a channel's flag stays set after ESC leaves the wait step (a read, on the bench).

holding class-attribute instance-attribute

holding = field(
    default_factory=lambda: MappingProxyType({})
)

Holding-register words by address; unset addresses in a region read 0.

input class-attribute instance-attribute

input = field(default_factory=lambda: MappingProxyType({}))

Input-register words by address; unset addresses in a region read 0.

key_hold_s class-attribute instance-attribute

key_hold_s = 0.3

Seconds 30190 shows a key pressed at the panel.

key_lock_silence_s class-attribute instance-attribute

key_lock_silence_s = 1.6

Seconds the analyzer answers nothing after key lock swallows a key written to 42001.

manual_calibration_s class-attribute instance-attribute

manual_calibration_s = 0.0

Seconds a manual calibration runs after the ENT that starts it (1.6-2.4 s on the bench).

panel_channels class-attribute instance-attribute

panel_channels = (1, 2, 3, 4, 5)

The measured channels the panel's calibration cursor offers.

range_lag_s class-attribute instance-attribute

range_lag_s = 0.0

Seconds after a range write before the channel measures on the range selected.

storing_silence_s class-attribute instance-attribute

storing_silence_s = 0.0

Seconds at the end of a manual calibration's run without an answer (about 1 on the bench, for a zero).

time_scale class-attribute instance-attribute

time_scale = 0.001

Simulated seconds per second of a calibration's flow times.

MockCalibration dataclass

MockCalibration(
    kind, started_at, channels, phases, hold, restore_ranges
)

An auto calibration or auto zero calibration the simulator is running.

channels instance-attribute

channels

The channels enabled for it, 1-5.

duration_s property

duration_s

Simulated seconds from start to end.

kind instance-attribute

kind

"auto_calibration" or "auto_zero".

phases instance-attribute

phases

(phase, span channel or None, simulated seconds), in order.

restore_ranges instance-attribute

restore_ranges

(channel, current-range word) to put back at the end.

started_at instance-attribute

started_at

On the AnyIO clock.

phase_at

phase_at(elapsed)

The phase elapsed seconds after the start, or None once finished.

Source code in src/fujilib/testing/mock.py
def phase_at(self, elapsed: float) -> tuple[str, int | None] | None:
    """The phase ``elapsed`` seconds after the start, or ``None`` once finished."""
    for phase, channel, duration in self.phases:
        if elapsed < duration:
            return phase, channel
        elapsed -= duration
    return None

MockExchange dataclass

MockExchange(request, reply=None, replied_at=None)

A request and what the line sent back.

replied_at class-attribute instance-attribute

replied_at = None

When the reply was sent, on the AnyIO clock.

reply class-attribute instance-attribute

reply = None

The reply frame, or None when nothing was sent.

MockLine

MockLine(*analyzers)

A simulated RS-485 line: anymodbus's MockServer, requests routed by station.

Put analyzers on the line.

Raises:

Type Description
ValueError

two analyzers share a station number.

Source code in src/fujilib/testing/mock.py
def __init__(self, *analyzers: MockAnalyzer) -> None:
    """Put ``analyzers`` on the line.

    Raises:
        ValueError: two analyzers share a station number.
    """
    self._stations: dict[int, _Station] = {}
    self._server = MockServer(on_request=self._record)
    self.exchanges: list[MockExchange] = []
    """Every request received with a valid CRC, for any station, with its reply."""
    for analyzer in analyzers:
        self.add(analyzer)

exchanges instance-attribute

exchanges = []

Every request received with a valid CRC, for any station, with its reply.

stations property

stations

The analyzers on the line, by station number.

add

add(analyzer)

Put another analyzer on the line.

Raises:

Type Description
ValueError

its station number is taken.

Source code in src/fujilib/testing/mock.py
def add(self, analyzer: MockAnalyzer) -> None:
    """Put another analyzer on the line.

    Raises:
        ValueError: its station number is taken.
    """
    if analyzer.station in self._stations:
        msg = f"station {analyzer.station} is already on the line"
        raise ValueError(msg)
    station = _Station(analyzer)
    self._stations[analyzer.station] = station
    self._server.add(station)

serve async

serve(stream)

Answer requests on stream until it closes or the task is cancelled.

The line ends quietly when either end closes.

Raises:

Type Description
MockWriteViolation

a client wrote where fujilib must never write.

Source code in src/fujilib/testing/mock.py
async def serve(self, stream: anyio.abc.ByteStream) -> None:
    """Answer requests on ``stream`` until it closes or the task is cancelled.

    The line ends quietly when either end closes.

    Raises:
        MockWriteViolation: a client wrote where fujilib must never write.
    """
    await self._server.serve(stream)

MockRegion dataclass

MockRegion(function, first, last)

Addresses first..last that function code function can read.

contains

contains(address)

Whether address lies in the region.

Source code in src/fujilib/testing/mock.py
def contains(self, address: int) -> bool:
    """Whether ``address`` lies in the region."""
    return self.first <= address <= self.last

MockRequest dataclass

MockRequest(
    station,
    function,
    address,
    count,
    values,
    pdu,
    arrived_at,
)

One request as the line received it; a frame with a bad CRC never gets here.

address instance-attribute

address

The start address, or None for a function code without one.

arrived_at instance-attribute

arrived_at

When the request had arrived, on the AnyIO clock.

count instance-attribute

count

Words read or written, or None for a function code without a count.

key property

key

(function, address, count), as a transaction list records it.

pdu instance-attribute

pdu

The request PDU: function code and body.

values instance-attribute

values

The words written, for FC06 and FC10.

MockWriteViolation

Bases: AssertionError

A client sent a write fujilib must never send. Fails the test.

zp_readable_regions

zp_readable_regions(*, observed=True, firmware_2_24=False)

The readable map of a ZP analyzer.

Parameters:

Name Type Description Default
observed bool

Use the bench unit's wider FC04 block 03E8h-0479h (clock, A/D values and the fixed settings) instead of the documented 0425h-0469h (protocol findings §4).

True
firmware_2_24 bool

Add type-code digits 27-29 (047Ah-047Ch) and the calibration log (1000h-1707h).

False
Source code in src/fujilib/testing/mock.py
def zp_readable_regions(
    *, observed: bool = True, firmware_2_24: bool = False
) -> tuple[MockRegion, ...]:
    """The readable map of a ZP analyzer.

    Args:
        observed: Use the bench unit's wider FC04 block 03E8h-0479h (clock,
            A/D values and the fixed settings) instead of the documented
            0425h-0469h (protocol findings §4).
        firmware_2_24: Add type-code digits 27-29 (047Ah-047Ch) and the
            calibration log (1000h-1707h).
    """
    regions = [
        MockRegion(FC_READ_HOLDING, 0x0000, 0x00AB),
        MockRegion(FC_READ_INPUT, 0x0000, 0x00C1),
        MockRegion(FC_READ_INPUT, 0x03E8, 0x0479)
        if observed
        else MockRegion(FC_READ_INPUT, 0x0425, 0x0469),
    ]
    if firmware_2_24:
        regions += [
            MockRegion(FC_READ_INPUT, 0x047A, 0x047C),
            MockRegion(FC_READ_INPUT, 0x1000, 0x1707),
        ]
    return tuple(regions)

fujilib.testing.pair

Wiring a client to the simulator, and loading register banks (design §10).

  • :func:mock_transport puts :class:~fujilib.testing.mock.MockAnalyzer stations on one end of anyserial.testing.serial_port_pair() and returns a :class:~fujilib.transport.serial.SerialTransport over the other end. Both ends are real SerialPort objects, so anymodbus drains and clears input exactly as it does on hardware.
  • :func:mock_port adds the :class:~fujilib.protocol.modbus.port.ModbusPort; :func:mock_analyzer_pair goes one step further, to one station's client.
  • :func:fake_transport replays an arrow fixture byte for byte.
  • :data:DEFAULT_ZPA_BANK is the sanitized bench bank: the documented register blocks of the bench ZPA plus its observed clock and A/D block, with the factory calibration blocks left out (design §13.1 #11).

The pairs use the library's timing defaults, except that the one-shot startup settle is 0: it exists for RS-485 adapters, and there is none here.

fake_transport

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

A :class:FakeTransport that replays the arrow fixture source (a path or its text).

Raises:

Type Description
FujiValidationError

the fixture is malformed, or gives one request two replies.

Source code in src/fujilib/testing/pair.py
def fake_transport(source: str | Path, *, label: str = "fake://fixture") -> FakeTransport:
    """A :class:`FakeTransport` that replays the arrow fixture ``source`` (a path or its text).

    Raises:
        FujiValidationError: the fixture is malformed, or gives one request two replies.
    """
    return FakeTransport(replay_script(parse_arrow_fixture(source)), label=label)

load_bank

load_bank(
    source=BENCH_BANK_PATH,
    *,
    station=None,
    profile=ExceptionProfile.BENCH_1_02,
    regions=None,
)

A simulator configuration from a register dump.

Parameters:

Name Type Description Default
source Path | Mapping[str, object]

A dump file, or its parsed JSON: input and holding tables of hex addresses to words, as probe_map.py writes them.

BENCH_BANK_PATH
station int | None

The station number; defaults to the dump's station, else 1.

None
profile ExceptionProfile

The exception replies to give.

BENCH_1_02
regions tuple[MockRegion, ...] | None

The readable map; defaults to :func:zp_readable_regions.

None

Raises:

Type Description
TypeError

a table is not a mapping.

Source code in src/fujilib/testing/pair.py
def load_bank(
    source: Path | Mapping[str, object] = BENCH_BANK_PATH,
    *,
    station: int | None = None,
    profile: ExceptionProfile = ExceptionProfile.BENCH_1_02,
    regions: tuple[MockRegion, ...] | None = None,
) -> MockAnalyzerConfig:
    """A simulator configuration from a register dump.

    Args:
        source: A dump file, or its parsed JSON: ``input`` and ``holding``
            tables of hex addresses to words, as ``probe_map.py`` writes them.
        station: The station number; defaults to the dump's ``station``, else 1.
        profile: The exception replies to give.
        regions: The readable map; defaults to :func:`zp_readable_regions`.

    Raises:
        TypeError: a table is not a mapping.
    """
    data: Mapping[str, object] = (
        json.loads(source.read_text(encoding="utf-8")) if isinstance(source, Path) else source
    )
    dumped = data.get("station", 1)
    return MockAnalyzerConfig(
        station=station if station is not None else int(str(dumped)),
        profile=profile,
        input=_words(data, "input"),
        holding=_words(data, "holding"),
        regions=regions if regions is not None else zp_readable_regions(),
        description=str(data.get("description", "")),
    )

mock_analyzer_pair async

mock_analyzer_pair(
    config=None,
    *,
    request_timeout=DEFAULTS.request_timeout_s,
    inter_frame_idle=DEFAULTS.inter_frame_idle_s,
    startup_settle=0.0,
    read_retries=DEFAULTS.read_retries,
    resync_window=DEFAULTS.resync_window_s,
)

A client talking to one simulated analyzer, :data:DEFAULT_ZPA_BANK by default.

Source code in src/fujilib/testing/pair.py
@asynccontextmanager
async def mock_analyzer_pair(
    config: MockAnalyzerConfig | None = None,
    *,
    request_timeout: float = DEFAULTS.request_timeout_s,
    inter_frame_idle: float = DEFAULTS.inter_frame_idle_s,
    startup_settle: float = 0.0,
    read_retries: int = DEFAULTS.read_retries,
    resync_window: float = DEFAULTS.resync_window_s,
) -> AsyncGenerator[tuple[ModbusClient, MockAnalyzer]]:
    """A client talking to one simulated analyzer, :data:`DEFAULT_ZPA_BANK` by default."""
    analyzer = MockAnalyzer(config if config is not None else DEFAULT_ZPA_BANK)
    async with mock_port(
        analyzer,
        request_timeout=request_timeout,
        inter_frame_idle=inter_frame_idle,
        startup_settle=startup_settle,
        read_retries=read_retries,
        resync_window=resync_window,
    ) as (port, _line):
        yield port.client(analyzer.station), analyzer

mock_port async

mock_port(
    *analyzers,
    request_timeout=DEFAULTS.request_timeout_s,
    inter_frame_idle=DEFAULTS.inter_frame_idle_s,
    startup_settle=0.0,
    read_retries=DEFAULTS.read_retries,
    resync_window=DEFAULTS.resync_window_s,
)

A :class:ModbusPort on a line carrying analyzers.

Source code in src/fujilib/testing/pair.py
@asynccontextmanager
async def mock_port(
    *analyzers: MockAnalyzer,
    request_timeout: float = DEFAULTS.request_timeout_s,
    inter_frame_idle: float = DEFAULTS.inter_frame_idle_s,
    startup_settle: float = 0.0,
    read_retries: int = DEFAULTS.read_retries,
    resync_window: float = DEFAULTS.resync_window_s,
) -> AsyncGenerator[tuple[ModbusPort, MockLine]]:
    """A :class:`ModbusPort` on a line carrying ``analyzers``."""
    async with (
        mock_transport(*analyzers) as (transport, line),
        ModbusPort(
            transport,
            request_timeout=request_timeout,
            inter_frame_idle=inter_frame_idle,
            startup_settle=startup_settle,
            read_retries=read_retries,
            resync_window=resync_window,
        ) as port,
    ):
        yield port, line

mock_transport async

mock_transport(*analyzers, label=_MOCK_LABEL)

A transport whose line carries analyzers, answered by a :class:MockLine.

Both ends are closed on exit. A :class:~fujilib.testing.mock.MockWriteViolation raised while serving cancels the async with block and is raised from it.

The line is served by a task group, which would wrap any failure in an exception group. A single failure, the block's own or the line's, is raised as itself, so pytest.raises works around the whole block.

Source code in src/fujilib/testing/pair.py
@asynccontextmanager
async def mock_transport(
    *analyzers: MockAnalyzer, label: str = _MOCK_LABEL
) -> AsyncGenerator[tuple[SerialTransport, MockLine]]:
    """A transport whose line carries ``analyzers``, answered by a :class:`MockLine`.

    Both ends are closed on exit. A :class:`~fujilib.testing.mock.MockWriteViolation`
    raised while serving cancels the ``async with`` block and is raised from it.

    The line is served by a task group, which would wrap any failure in an
    exception group. A single failure, the block's own or the line's, is raised
    as itself, so ``pytest.raises`` works around the whole block.
    """
    config = SerialConfig(baudrate=FUJI_BAUDRATE)
    host, device = serial_port_pair(
        config_a=config, config_b=config, path_a=label, path_b=f"{label}/analyzer"
    )
    transport = SerialTransport(host, SerialSettings(port=label))
    line = MockLine(*analyzers)
    try:
        async with anyio.create_task_group() as tg:
            _ = tg.start_soon(line.serve, device)
            try:
                yield transport, line
            finally:
                tg.cancel_scope.cancel()
    except BaseExceptionGroup as group:
        if len(group.exceptions) == 1:
            raise group.exceptions[0] from None
        raise
    finally:
        with anyio.CancelScope(shield=True):
            await transport.aclose()
            await device.aclose()