208 lines
8.0 KiB
Python
208 lines
8.0 KiB
Python
import hashlib
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "tools"))
|
|
|
|
from trikke_protocol import ( # noqa: E402
|
|
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
|
|
PACKET_TYPE_METADATA,
|
|
PACKET_TYPE_SAMPLES,
|
|
StreamParser,
|
|
sample_to_csv_row,
|
|
)
|
|
|
|
|
|
class ProtocolContractTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
compiler = shutil.which("cc")
|
|
if compiler is None:
|
|
raise unittest.SkipTest("host C compiler is unavailable")
|
|
cls.tempdir = tempfile.TemporaryDirectory()
|
|
executable = Path(cls.tempdir.name) / "protocol_fixture"
|
|
subprocess.run(
|
|
[
|
|
compiler,
|
|
"-std=c11",
|
|
"-Wall",
|
|
"-Wextra",
|
|
"-Werror",
|
|
"-I",
|
|
str(ROOT / "main"),
|
|
str(ROOT / "main" / "trikke_protocol.c"),
|
|
str(ROOT / "tests" / "protocol_fixture.c"),
|
|
"-o",
|
|
str(executable),
|
|
],
|
|
check=True,
|
|
)
|
|
fixture = subprocess.run([str(executable)], capture_output=True)
|
|
if fixture.returncode != 0:
|
|
stderr = fixture.stderr.decode(errors="replace").strip()
|
|
raise AssertionError(
|
|
f"protocol fixture exited {fixture.returncode}: {stderr}"
|
|
)
|
|
cls.encoded = fixture.stdout
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.tempdir.cleanup()
|
|
|
|
def test_c_encoder_to_python_parser_contract(self) -> None:
|
|
parser = StreamParser()
|
|
frames = []
|
|
stream = b"startup text\r\n" + self.encoded
|
|
for offset in range(0, len(stream), 7):
|
|
frames.extend(parser.feed(stream[offset : offset + 7]))
|
|
|
|
self.assertEqual(4, len(frames))
|
|
metadata_frame, sample_frame, full_frame, saturated_frame = frames
|
|
self.assertEqual(PACKET_TYPE_METADATA, metadata_frame.packet_type)
|
|
self.assertEqual(41, metadata_frame.packet_sequence)
|
|
self.assertEqual(2, metadata_frame.dropped_sample_count)
|
|
self.assertEqual(3, metadata_frame.loop_overrun_count)
|
|
self.assertEqual(100, metadata_frame.metadata.sample_rate_hz)
|
|
self.assertEqual((-1.5, -4.5, 12.0), metadata_frame.metadata.accel_offset_counts)
|
|
|
|
self.assertEqual(PACKET_TYPE_SAMPLES, sample_frame.packet_type)
|
|
self.assertEqual(42, sample_frame.packet_sequence)
|
|
self.assertEqual(2, len(sample_frame.samples))
|
|
self.assertEqual(1000, sample_frame.samples[0].sequence)
|
|
self.assertEqual(2_000_000, sample_frame.samples[0].timestamp_us)
|
|
self.assertEqual((1, -2, 258), sample_frame.samples[0].accel)
|
|
self.assertEqual(2_010_000, sample_frame.samples[1].timestamp_us)
|
|
self.assertEqual((40, -50, 60), sample_frame.samples[1].gyro)
|
|
self.assertEqual(0xFF, sample_frame.samples[1].gyro_status)
|
|
self.assertEqual(0, parser.crc_errors)
|
|
self.assertEqual(len(b"startup text\r\n"), parser.skipped_bytes)
|
|
self.assertEqual(0, parser.buffered_bytes)
|
|
|
|
self.assertEqual(8, len(full_frame.samples))
|
|
self.assertEqual(43, full_frame.packet_sequence)
|
|
self.assertEqual(2_007, full_frame.samples[-1].sequence)
|
|
self.assertEqual(3_070_000, full_frame.samples[-1].timestamp_us)
|
|
self.assertEqual(0, full_frame.flags)
|
|
|
|
self.assertEqual(44, saturated_frame.packet_sequence)
|
|
self.assertEqual(
|
|
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
|
|
saturated_frame.flags,
|
|
)
|
|
self.assertEqual(4_655_350, saturated_frame.samples[-1].timestamp_us)
|
|
|
|
row = sample_to_csv_row(
|
|
sample_frame.samples[0], metadata_frame.metadata, 3
|
|
)
|
|
self.assertEqual(23, len(row))
|
|
self.assertEqual((2, 1, 258), tuple(row[14:17]))
|
|
self.assertEqual(3, row[-1])
|
|
|
|
def test_crc_failure_resynchronizes_to_next_frame(self) -> None:
|
|
first_size = 36 + 48
|
|
damaged = bytearray(self.encoded[:first_size])
|
|
damaged[-1] ^= 0x80
|
|
parser = StreamParser()
|
|
frames = parser.feed(bytes(damaged) + self.encoded[first_size:])
|
|
self.assertEqual(1, parser.startup_crc_errors)
|
|
self.assertEqual(0, parser.crc_errors)
|
|
self.assertEqual(3, len(frames))
|
|
self.assertEqual(PACKET_TYPE_SAMPLES, frames[0].packet_type)
|
|
|
|
def test_crc_failure_after_sync_is_stream_error(self) -> None:
|
|
first_size = 36 + 48
|
|
second_size = 36 + 2 * 20
|
|
damaged = bytearray(self.encoded[first_size : first_size + second_size])
|
|
damaged[-1] ^= 0x80
|
|
parser = StreamParser()
|
|
frames = parser.feed(
|
|
self.encoded[:first_size]
|
|
+ bytes(damaged)
|
|
+ self.encoded[first_size + second_size :]
|
|
)
|
|
self.assertEqual(0, parser.startup_crc_errors)
|
|
self.assertEqual(1, parser.crc_errors)
|
|
self.assertEqual(3, len(frames))
|
|
self.assertEqual(PACKET_TYPE_METADATA, frames[0].packet_type)
|
|
|
|
def test_trailing_partial_frame_is_observable(self) -> None:
|
|
parser = StreamParser()
|
|
frames = parser.feed(self.encoded[:-5])
|
|
self.assertEqual(3, len(frames))
|
|
self.assertEqual(36 + 2 * 20 - 5, parser.buffered_bytes)
|
|
|
|
def test_hardware_outage_validation_artifacts(self) -> None:
|
|
expected = {
|
|
"forced_outage_3s.trk": {
|
|
"sha256": "01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6",
|
|
"sample_count": 2144,
|
|
"last_sequence": 2143,
|
|
"max_dropped": 0,
|
|
"gaps": [],
|
|
},
|
|
"forced_outage_7s.trk": {
|
|
"sha256": "2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3",
|
|
"sample_count": 1840,
|
|
"last_sequence": 1977,
|
|
"max_dropped": 138,
|
|
"gaps": [(511, 650, 1_390_000)],
|
|
},
|
|
}
|
|
|
|
for name, contract in expected.items():
|
|
with self.subTest(fixture=name):
|
|
data = (ROOT / "tests" / "fixtures" / name).read_bytes()
|
|
self.assertEqual(
|
|
contract["sha256"], hashlib.sha256(data).hexdigest()
|
|
)
|
|
|
|
parser = StreamParser()
|
|
frames = []
|
|
for offset in range(0, len(data), 257):
|
|
frames.extend(parser.feed(data[offset : offset + 257]))
|
|
|
|
self.assertEqual(0, parser.startup_crc_errors)
|
|
self.assertEqual(0, parser.crc_errors)
|
|
self.assertEqual(0, parser.header_errors)
|
|
self.assertEqual(0, parser.skipped_bytes)
|
|
self.assertEqual(0, parser.buffered_bytes)
|
|
self.assertTrue(frames)
|
|
|
|
samples = [sample for frame in frames for sample in frame.samples]
|
|
self.assertEqual(contract["sample_count"], len(samples))
|
|
self.assertEqual(0, samples[0].sequence)
|
|
self.assertEqual(contract["last_sequence"], samples[-1].sequence)
|
|
self.assertEqual(
|
|
contract["max_dropped"],
|
|
max(frame.dropped_sample_count for frame in frames),
|
|
)
|
|
self.assertEqual(
|
|
0, max(frame.loop_overrun_count for frame in frames)
|
|
)
|
|
self.assertFalse(
|
|
any(
|
|
frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED
|
|
for frame in frames
|
|
)
|
|
)
|
|
|
|
gaps = [
|
|
(
|
|
left.sequence,
|
|
right.sequence,
|
|
right.timestamp_us - left.timestamp_us,
|
|
)
|
|
for left, right in zip(samples, samples[1:])
|
|
if right.sequence != left.sequence + 1
|
|
]
|
|
self.assertEqual(contract["gaps"], gaps)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|