Files
vt1200/harness/udpstream/tests/test_vidsend.py
T

386 lines
16 KiB
Python

"""Tests for the host sender: pacing, chunking, and the status back-channel.
The plan's acceptance run says "MAME with a tap interface: run vidsend.py for
200 frames at 30 fps". That run cannot happen in this repository: the MAME
driver creates no ``device_network_interface``, so the emulated LANCE is not
attached to the host at all (ruling R11). What can be tested here is every
layer below the socket, and that is what this file does:
* the *schedule* -- frame ``n`` leaves at ``t0 + n/fps``, measured on an
injected clock rather than by sleeping for real;
* the *wire form* -- a frame is chunked at 1450 bytes, only the last datagram is
padded to the 60-byte Ethernet minimum, and the pieces reassemble to exactly
the bytes ``vidsrc.StripSource`` produced;
* the *status* path -- a status frame is recognised in an Ethernet frame,
decoded field by field, and ``drops``/``crc_err`` decide the exit status;
* the *loop* -- a fake counterparty that reassembles datagrams, counts frames
and answers with status frames, so a 200-frame run's verdict is exercised
without a NIC or a terminal.
No test here touches a socket, and none of them claims anything about whether a
frame would arrive on a real wire.
"""
import io
import struct
import pytest
from harness.udpstream import vidsend
from harness.udpstream.frame import (BROADCAST, ETHERTYPE, HEADER_SIZE,
KIND_STATUS, TERMINAL_MAC, ethernet_frame,
pack_header, pack_status, unpack_header)
from harness.udpstream.vidsrc import CHUNK, ROW_BYTES, StripSource
#: The host's own address. Locally administered, so it cannot be mistaken for
#: the terminal's (08:00:2b:16:4b:5a, TERMINAL_MAC) in a datagram assertion.
HOST_MAC = b"\x02\x00\x00\x00\x00\x01"
class FakeClock:
"""A monotonic clock that only moves when something sleeps on it."""
def __init__(self):
self.t = 0.0
self.sleeps = []
def now(self):
return self.t
def sleep(self, seconds):
self.sleeps.append(seconds)
self.t += seconds
class RecordingTransport:
"""Stands in for the NIC: records each datagram and when it was sent."""
def __init__(self, clock):
self.clock = clock
self.datagrams = []
self.times = []
def __call__(self, data):
self.datagrams.append(data)
self.times.append(self.clock.now())
def make_sender(clock, transport, **kw):
kw.setdefault("rows", 8)
kw.setdefault("stretch", 128)
kw.setdefault("fps", 10.0)
kw.setdefault("status_wait", 0.0)
kw.setdefault("out", io.StringIO())
kw.setdefault("err", io.StringIO())
kw.setdefault("src", HOST_MAC)
return vidsend.Sender(transport=transport, clock=clock.now,
sleep=clock.sleep, **kw)
def status_frame(frames_rx=0, crc_err=0, drops=0, csr0=0x20F2, heap_free_kb=2048,
frame_no=0, body=None):
body = pack_status(frames_rx, crc_err, drops, csr0, heap_free_kb) \
if body is None else body
hdr = pack_header(KIND_STATUS, frame_no, len(body), 0, 0, payload=body)
return ethernet_frame(hdr + body, src=TERMINAL_MAC)
# --- pacing ---------------------------------------------------------------
def test_frame_n_leaves_at_t0_plus_n_over_fps():
"""The schedule, not "about 10 a second": exactly t0 + n/fps."""
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, fps=10.0, frames=5)
sender.run() # the exit status is the status verdict, tested below
assert transport.times == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4])
# And the sleeps are the differences: the first frame does not sleep.
assert clock.sleeps == pytest.approx([0.1, 0.1, 0.1, 0.1])
def test_pacing_does_not_accumulate_drift():
"""A slow frame is followed by a shorter sleep, not by a later schedule.
``t0 + n/fps`` is absolute, so the sender catches up instead of falling
behind by however long the previous frame took.
"""
clock = FakeClock()
sent_at = []
def transport(data):
sent_at.append(clock.now())
clock.t += 0.03 # this frame took 30 ms to send
sender = make_sender(clock, transport, fps=10.0, frames=4)
sender.run() # the exit status is the status verdict, tested below
assert sent_at == pytest.approx([0.0, 0.1, 0.2, 0.3])
def test_fps_zero_sends_unpaced():
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, fps=0.0, frames=3)
sender.run() # the exit status is the status verdict, tested below
assert transport.times == [0.0, 0.0, 0.0]
assert clock.sleeps == []
def test_a_csv_line_every_thirty_frames():
clock = FakeClock()
transport = RecordingTransport(clock)
out = io.StringIO()
sender = make_sender(clock, transport, fps=0.0, frames=65, out=out)
sender.run() # the exit status is the status verdict, tested below
lines = out.getvalue().strip().splitlines()
assert lines[0] == vidsend.CSV_HEADER == "frame,elapsed_s,fps,bytes"
# 30 and 60, not a line per frame and not 0 (nothing sent yet). stdout
# carries the table only; the prose (status, verdict) goes to stderr.
assert [ln.split(",")[0] for ln in lines[1:]] == ["30", "60"]
frame_col, elapsed_col, _fps, bytes_col = lines[1].split(",")
assert int(frame_col) == 30 and float(elapsed_col) == 0.0
assert int(bytes_col) > 0
# --- the wire form --------------------------------------------------------
def test_only_the_last_datagram_is_short_and_none_is_padded_mid_frame():
"""128 rows = 20,480 B: 15 datagrams at 1450, the last one alone short.
Each datagram carries the 14-byte Ethernet header in front of its chunk,
so a middle datagram is exactly CHUNK + 14 bytes; only the last one may be
padded to the minimum and here it is already well over it.
"""
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, rows=128, stretch=8, frames=1)
sender.run() # the exit status is the status verdict, tested below
dgrams = transport.datagrams
assert len(dgrams) == 15
assert [len(d) for d in dgrams[:-1]] == [CHUNK + 14] * 14
assert len(dgrams[-1]) == 14 + (HEADER_SIZE + 128 * ROW_BYTES - 14 * CHUNK)
def test_datagrams_reassemble_to_the_frame_the_encoder_built():
"""What the receiver gets back is byte for byte vidsrc's frame."""
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, rows=8, stretch=128, frames=1)
sender.run() # the exit status is the status verdict, tested below
body = b"".join(d[14:] for d in transport.datagrams)
want = StripSource(8, 128).encode(0, vidsend.bars(8, 0))
assert body == b"".join(want)
h = unpack_header(body)
assert h["crc_ok"] is True and h["kind"] == 1 and h["rows"] == 8
def test_a_datagram_carries_the_ethernet_header_and_padding():
"""The smallest valid strip (8 rows x 128) is 1296 bytes: one datagram."""
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, rows=8, stretch=128, frames=1)
sender.run() # the exit status is the status verdict, tested below
(d,) = transport.datagrams
assert len(d) == 14 + HEADER_SIZE + 8 * ROW_BYTES
assert d[:6] == BROADCAST and d[6:12] == HOST_MAC
assert d[12:14] == struct.pack(">H", ETHERTYPE)
def test_a_short_last_datagram_is_padded_and_a_mid_one_is_not():
"""The padding rule, stated where it can bite: Ethernet needs 60 bytes.
The smallest valid strip is 1296 bytes; chunked at 1255 the second (and
last) datagram carries 41 bytes of frame, so it must be padded to 60 while
the 1255-byte chunk before it must not be. Padding a middle chunk would
insert bytes into the frame body.
"""
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, rows=8, stretch=128, frames=1,
chunk=1255)
sender.run() # the exit status is the status verdict, tested below
sizes = [len(d) for d in transport.datagrams]
assert sizes == [1255 + 14, 60] # 1296 - 1255 = 41 bytes of frame
frame = b"".join(d[14:] for d in transport.datagrams)
assert frame[:1296] == b"".join(
StripSource(8, 128).encode(0, vidsend.bars(8, 0)))
assert frame[1296:] == bytes(5) # the pad, and only at the end
def test_frame_numbers_wrap_at_16_bits_and_bodies_differ_per_frame():
"""frame_no is a u16 field; the pattern must move or a stream is not one."""
assert vidsend.bars(8, 0) != vidsend.bars(8, 1)
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, fps=0.0, frames=2)
sender.frame_offset = 0xFFFE # next two frames straddle the wrap
sender.run() # the exit status is the status verdict, tested below
nos = [unpack_header(d[14:])["frame_no"] for d in transport.datagrams]
assert nos == [0xFFFE, 0xFFFF]
# --- the status path ------------------------------------------------------
def test_a_status_frame_is_decoded_from_an_ethernet_frame():
d = vidsend.decode_status_frame(status_frame(frames_rx=200, csr0=0x20F2,
heap_free_kb=2048))
assert d is not None
assert d["kind"] == KIND_STATUS and d["crc_ok"] is True
assert d["frames_rx"] == 200 and d["drops"] == 0 and d["crc_err"] == 0
assert d["csr0"] == 0x20F2 and d["heap_free_kb"] == 2048
def test_a_strip_frame_is_not_mistaken_for_a_status():
clock = FakeClock()
transport = RecordingTransport(clock)
sender = make_sender(clock, transport, fps=0.0, frames=1)
sender.run() # the exit status is the status verdict, tested below
assert vidsend.decode_status_frame(transport.datagrams[0]) is None
def test_a_status_frame_with_a_corrupt_header_is_flagged_not_decoded():
bad = bytearray(status_frame(frames_rx=1))
bad[14 + 8] ^= 0x01 # rows byte, inside the CRC prefix
d = vidsend.decode_status_frame(bytes(bad))
assert d is not None and d["crc_ok"] is False
def test_a_status_in_a_foreign_ethertype_is_ignored():
body = pack_status(1, 0, 0, 0, 0)
hdr = pack_header(KIND_STATUS, 0, len(body), 0, 0)
data = (BROADCAST + TERMINAL_MAC + struct.pack(">H", 0x88B6) + hdr + body)
assert vidsend.decode_status_frame(data) is None
# --- the verdict ----------------------------------------------------------
def run_with_statuses(*status_bytes, frames=3):
clock = FakeClock()
transport = RecordingTransport(clock)
pending = list(status_bytes)
def recv():
out, pending[:] = list(pending), []
return out
out, err = io.StringIO(), io.StringIO()
sender = vidsend.Sender(transport=transport, receive=recv, src=HOST_MAC,
clock=clock.now, sleep=clock.sleep, frames=frames,
fps=0.0, status_wait=0.0, rows=8, stretch=128,
out=out, err=err)
code = sender.run()
return sender, code, out.getvalue() + err.getvalue()
def test_exit_zero_only_when_drops_and_crc_err_are_clean():
_, code, text = run_with_statuses(status_frame(frames_rx=3))
assert code == 0
assert "drops=0" in text and "crc_err=0" in text
_, code, _ = run_with_statuses(status_frame(frames_rx=3, drops=1))
assert code == 1
_, code, _ = run_with_statuses(status_frame(frames_rx=3, crc_err=2))
assert code == 1
def test_exit_is_non_zero_when_no_status_arrives():
"""No status means the two conditions were never observed, so no PASS."""
_, code, text = run_with_statuses()
assert code == 1
assert "no status" in text.lower()
def test_a_status_with_a_bad_header_crc_does_not_count():
bad = bytearray(status_frame(frames_rx=3))
bad[14 + 3] ^= 0x01 # kind byte, inside the CRC prefix
_, code, text = run_with_statuses(bytes(bad))
assert code == 1
assert "crc" in text.lower()
# --- the whole loop, with a fake counterparty -----------------------------
class FakeTerminal:
"""Reassembles the sender's datagrams and answers with status frames.
This is the sender's counterparty, not a VT1200 model: it counts what
arrived and every 64 accepted frames hands back the status frame the
payload would send. It exists to exercise vidsend.py's loop, not to stand
in for the terminal -- the terminal's own status emission is checked in
MAME (VT1200_MODE=stream) and decoded by vidstream_status_check.py.
"""
def __init__(self, every=64, drops=0, crc_err=0):
self.every = every
self.drops = drops
self.crc_err = crc_err
self.buf = b""
self.frames_rx = 0
self.reported = []
self.pending = []
def send(self, data):
if data[12:14] != struct.pack(">H", ETHERTYPE):
return
self.buf += data[14:]
if len(self.buf) < HEADER_SIZE:
return
plen = struct.unpack_from("<H", self.buf, 6)[0]
if len(self.buf) < HEADER_SIZE + plen:
return
frame, self.buf = self.buf[:HEADER_SIZE + plen], b""
h = unpack_header(frame)
assert h["crc_ok"], "the sender built a frame the parser rejects"
self.frames_rx += 1
if self.frames_rx % self.every == 0:
self.reported.append(self.frames_rx)
self.pending.append(status_frame(
frames_rx=self.frames_rx, drops=self.drops,
crc_err=self.crc_err, frame_no=len(self.reported)))
def recv(self):
out, self.pending = self.pending, []
return out
def test_two_hundred_frames_get_three_periodic_statuses_and_a_clean_verdict():
"""The brief's run on the layer below the socket.
200 frames at 30 fps, a status every 64 accepted: 64, 128, 192, each with
drops=0 and crc_err=0, so the verdict is PASS. The brief's final reading,
``frames_rx == 200``, needs one more thing the sender cannot assume: the
terminal only reports on its 64th frame, so the last report is 192 unless
the terminal is told the stream ended -- in MAME that is the RX_EOF cell,
see vidstream.lua and the task-6 report.
"""
clock = FakeClock()
term = FakeTerminal()
out, err = io.StringIO(), io.StringIO()
sender = vidsend.Sender(transport=term.send, receive=term.recv,
src=HOST_MAC, clock=clock.now,
sleep=clock.sleep, rows=8, stretch=128,
frames=200, fps=30.0, status_wait=0.0, out=out,
err=err)
code = sender.run()
assert code == 0
assert term.frames_rx == 200
assert term.reported == [64, 128, 192]
assert [s["frames_rx"] for s in sender.statuses] == [64, 128, 192]
# The schedule: 200 frames at 30 fps take 199/30 s of virtual time.
assert clock.now() == pytest.approx(199 / 30.0)
assert "PASS" in err.getvalue()
def test_a_terminal_reporting_drops_makes_the_verdict_fail():
"""drops is the terminal's number, not something the sender invents."""
clock = FakeClock()
term = FakeTerminal(drops=1)
out, err = io.StringIO(), io.StringIO()
sender = vidsend.Sender(transport=term.send, receive=term.recv,
src=HOST_MAC, clock=clock.now,
sleep=clock.sleep, rows=8, stretch=128,
frames=64, fps=0.0, status_wait=0.0, out=out,
err=err)
assert sender.run() == 1
assert "drops=1" in err.getvalue()
assert "FAIL" in err.getvalue()