rm3100/tests/conftest.py

206 lines
7.6 KiB
Python
Raw Normal View History

"""Fakes standing in for the hardware, so the whole suite runs unplugged.
Three levels are faked, matching the three seams in the code:
FakeUsbDevice a CH347 at the libusb boundary -- ch347.py's own framing is
then the thing under test, byte for byte.
FakeBus an I2C master at the rm3100.py boundary, holding a register
map, so the driver's register sequences are under test.
FakeSensor an RM3100 at the logger.py boundary, replaying a scripted
timeline of DRDY transitions, so the miss-counting maths is
under test without waiting on real time.
"""
import sys
from pathlib import Path
import pytest
# The modules under test sit at the repo root, next to this directory.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
class FakeUsbDevice:
"""Records bulk writes and replays queued bulk reads."""
def __init__(self):
self.writes = [] # bytes sent to EP_OUT, in order
self.replies = [] # bytes to hand back, one per read()
self.short_write_by = 0 # subtract this from the reported write length
def queue(self, *replies):
"""Queue one reply per expected read, as bytes or an iterable of ints."""
self.replies.extend(bytes(r) for r in replies)
return self
def write(self, endpoint, data, timeout):
self.writes.append(bytes(data))
return len(data) - self.short_write_by
def read(self, endpoint, length, timeout):
if not self.replies:
raise AssertionError(
f"unexpected read of {length} bytes: nothing queued")
return bytearray(self.replies.pop(0))
@property
def last_write(self):
return self.writes[-1]
@pytest.fixture
def usb_device(monkeypatch):
"""A CH347 that never touches USB. Yields the device; build the bus with it."""
import usb.core
import usb.util
device = FakeUsbDevice()
monkeypatch.setattr(usb.core, "find", lambda **kwargs: device)
monkeypatch.setattr(usb.util, "claim_interface", lambda *a: None)
monkeypatch.setattr(usb.util, "release_interface", lambda *a: None)
monkeypatch.setattr(usb.util, "dispose_resources", lambda *a: None)
return device
class FakeBus:
"""An I2C bus holding one device's register map.
Implements write/read/write_read with the same (data, mono, wall) contract
as ch347.CH347I2C, so rm3100.RM3100 cannot tell the difference.
"""
def __init__(self, registers=None, address=0x23, combined=True):
self.address = address
self.registers = dict(registers or {})
self.writes = [] # (addr, bytes) of every write
self.pointer = 0
self.clock = 1000.0
if not combined:
# Some buses cannot do a repeated START; the driver has a fallback
# path for them and it needs exercising too. read_reg() looks the
# method up with getattr(..., None), so hiding it this way is the
# same to the driver as never having had one.
self.write_read = None
def _stamp(self):
self.clock += 0.001
return self.clock, self.clock + 1_700_000_000.0
def write(self, addr, data):
data = bytes(data)
self.writes.append((addr, data))
self.pointer = data[0]
for offset, value in enumerate(data[1:]):
self.registers[data[0] + offset] = value
def read(self, addr, count):
mono, wall = self._stamp()
data = bytes(self.registers.get(self.pointer + i, 0)
for i in range(count))
return data, mono, wall
def write_read(self, addr, data, count):
self.write(addr, data)
return self.read(addr, count)
class FakeSensor:
"""An RM3100 replaying a scripted DRDY timeline against a fake clock.
`events` is a list of (monotonic_time, counts) pairs: the instants at which a
measurement completes and what it reads. poll_ready() reports DRDY set once
the clock has passed the next event and the previous result was consumed.
Every poll and read advances the clock by a fixed cost, so a test states its
timeline in seconds and gets deterministic brackets out.
"""
POLL_COST = 0.0003
READ_COST = 0.0005
class Exhausted(Exception):
"""Raised once max_polls is reached, to stop an unbounded run."""
def __init__(self, events, poll_cost=POLL_COST, read_cost=READ_COST,
stalls=None, max_polls=None):
self.events = list(events)
self.poll_cost = poll_cost
self.read_cost = read_cost
# [(at_time, extra_seconds), ...] -- the first poll starting at or after
# at_time pays extra_seconds before returning. A host stall is the only
# thing that actually loses a measurement on this rig, and stating it in
# seconds rather than in poll counts keeps a test independent of how
# many times the loop happens to poll.
self.stalls = sorted(stalls or [])
# A run with no --duration never returns on its own, so a test of that
# path needs the sensor to be the thing that stops it.
self.max_polls = max_polls
self.now = 0.0
self.pending = None # counts of a completed, unread measurement
self.next_event = 0
self.polls = 0
self.reads = []
# -- clock plumbing -------------------------------------------------
def monotonic(self):
return self.now
def wall(self):
return self.now + 1_700_000_000.0
def _advance(self, dt):
self.now += dt
while (self.next_event < len(self.events)
and self.events[self.next_event][0] <= self.now):
# A completion overwrites any unread one: the chip does not queue,
# which is exactly why an unnoticed miss loses a measurement.
self.pending = self.events[self.next_event][1]
self.next_event += 1
# -- the RM3100 surface logger.py uses ------------------------------
def poll_ready(self):
self.polls += 1
if self.max_polls is not None and self.polls > self.max_polls:
raise self.Exhausted(f"stopped after {self.max_polls} polls")
stall = 0.0
if self.stalls and self.now >= self.stalls[0][0]:
stall = self.stalls.pop(0)[1]
self._advance(self.poll_cost + stall)
return self.pending is not None, self.now, self.wall()
def read_raw(self):
self._advance(self.read_cost)
counts = self.pending if self.pending is not None else (0, 0, 0)
self.pending = None
self.reads.append(counts)
return counts, self.now, self.wall()
@pytest.fixture
def fake_clock(monkeypatch):
"""Point time.monotonic and time.time at a FakeSensor's clock.
sample_loop() binds `monotonic = time.monotonic` when it runs, not at import,
so patching the module attribute is enough to control it.
"""
import time
def install(sensor):
monkeypatch.setattr(time, "monotonic", sensor.monotonic)
monkeypatch.setattr(time, "time", sensor.wall)
return sensor
return install
def grid_events(period, count, start=None, counts=(100, 200, 300), skip=()):
"""Measurement completions on a uniform grid, optionally dropping some.
A skipped index is a completion the chip still makes -- the grid never
pauses -- so it is present here; a test forces a *miss* by making the host
too slow to read it, not by removing it.
"""
start = period if start is None else start
return [(start + i * period, (counts[0] + i, counts[1], counts[2]))
for i in range(count) if i not in skip]