Files
radar_system/python_app/tests/test_hardware_lifecycle.py
T
2026-10-06 14:31:07 +03:00

362 lines
18 KiB
Python

"""Failure-path coverage without physical instruments or protocol changes."""
from __future__ import annotations
import json
import os
import pty
from pathlib import Path
import tempfile
import threading
import tty
import unittest
from dataclasses import dataclass
from contextlib import ExitStack
from unittest.mock import Mock, patch
import numpy as np
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.librevna_driver.api.vna import VNAController
from python_app.hardware_full.librevna_driver.enums import PacketType
from python_app.hardware_full.librevna_driver.exceptions import DeviceDisconnectedError, TimeoutError as DeviceTimeout
from python_app.hardware_full.librevna_driver.models import Packet, VNADatapointPacket
from python_app.hardware_full.librevna_driver.session import LibreVNASession
from python_app.hardware_full.librevna_driver.transport.usb import USBTransport
from python_app.hardware_full.switch_drivers.h7992_driver import H7992Driver
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
from python_app.models.dataset_model import SweepCollection
from python_app.orchestration.shm.decoder import RAW_MAGIC, decode_trace_collection
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, producer_common
from python_app.workflows.radar_config_variants import scan_radar_config_variants
@dataclass
class _SweepPoint:
point_number: int
cycle: str
class HardwareCleanupTest(unittest.TestCase):
def test_partial_matrix_open_closes_every_component(self) -> None:
inner, output, input_ = Mock(), Mock(), Mock()
failure = OSError("input open failed")
input_.open.side_effect = failure
output.close.side_effect = OSError("output close failed")
service = SwitchedMatrixRadarService(inner, output, input_, 2, 4)
with self.assertRaises(OSError) as raised:
service.open()
self.assertIs(raised.exception, failure)
for resource in (inner, output, input_):
resource.close.assert_called_once()
def test_native_open_failure_allows_fresh_retry(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
backend = LibreVnaService()
failed, working = Mock(), Mock()
failed.connect.side_effect = OSError("USB unavailable")
failed.disconnect.side_effect = OSError("disconnect failed")
cleanup.enter_context(patch("python_app.hardware_full.librevna_service.LibreVNADevice", side_effect=[failed, working]))
with self.assertRaisesRegex(OSError, "USB unavailable"):
backend.open()
self.assertFalse(backend.is_open)
backend.open()
self.assertIs(backend._device, working)
working.disconnect.side_effect = OSError("device removed")
with self.assertRaises(OSError):
backend.close()
self.assertFalse(backend.is_open)
def test_gpio_initialization_failure_releases_lines(self) -> None:
lines = Mock(set_values=Mock(side_effect=OSError("GPIO write failed")))
driver = H7992Driver("test")
with patch("python_app.hardware_full.switch_drivers.h7992_driver.GpioOutputLines", return_value=lines):
with self.assertRaisesRegex(OSError, "GPIO write failed"):
driver.open()
lines.close.assert_called_once()
self.assertFalse(driver._is_open)
self.assertIsNone(driver._lines)
def test_serial_connect_is_idempotent_and_failed_close_clears_state(self) -> None:
connection = Mock(is_open=True)
protocol = Protocol("/dev/test")
with patch("serial.Serial", return_value=connection) as factory:
protocol.connect()
protocol.connect()
factory.assert_called_once()
connection.close.side_effect = OSError("serial removed")
with self.assertRaises(OSError):
protocol.disconnect()
self.assertFalse(protocol.is_connected)
class SessionLifecycleTest(unittest.TestCase):
def test_slow_sweep_completes_but_lost_progress_times_out_promptly(self) -> None:
for ifbw, dwell_us in ((10, 0), (1000, 100_000)):
with self.subTest(ifbw=ifbw, dwell_us=dwell_us):
session = LibreVNASession()
backend = LibreVnaService()
backend._device = Mock(vna=VNAController(session))
elapsed, index, stalled = 0.0, 0, False
def receive(_packet_type, *, timeout_s):
nonlocal elapsed, index
if stalled or timeout_s < 0.1:
elapsed += timeout_s
raise DeviceTimeout("No datapoint")
elapsed += 0.1
point = VNADatapointPacket(
1_000_000 + index, 0, index,
np.array([2.0, 1.0]), np.zeros(2), np.array([0x13, 0x02]),
)
index += 1
return Packet(PacketType.VNA_DATAPOINT, point)
with patch.object(session, "send"), patch.object(session, "get_device_info", return_value=Mock(num_ports=2)), patch.object(session, "wait_for_packet", side_effect=receive), patch("python_app.hardware_full.librevna_driver.session.time.monotonic", side_effect=lambda: elapsed):
backend.configure(RadarSweepModel(points=301, if_bandwidth_hz=ifbw, dwell_us=dwell_us))
result = backend.acquire()
self.assertEqual(len(result.x), 301)
np.testing.assert_array_equal(result.trace("s21"), np.full(301, 0.5))
elapsed, stalled = 0.0, True
with self.assertRaises(DeviceTimeout):
backend.acquire()
self.assertLess(elapsed, 5.0)
def test_acquisition_discards_tail_before_first_point_zero(self) -> None:
self._assert_complete_cycle([(1, "old"), (2, "old"), (0, "new"), (1, "new"), (2, "new")])
def test_acquisition_discards_incomplete_cycle_on_next_point_zero(self) -> None:
self._assert_complete_cycle([(0, "old"), (1, "old"), (0, "new"), (1, "new"), (2, "new")])
def _assert_complete_cycle(self, sequence) -> None:
session = LibreVNASession()
packets = [Packet(PacketType.VNA_DATAPOINT, _SweepPoint(point_number=index, cycle=cycle)) for index, cycle in sequence]
with patch.object(session, "wait_for_packet", side_effect=packets):
points = session.collect_indexed_payloads(
packet_type=PacketType.VNA_DATAPOINT, expected_points=3, timeout_s=1,
payload_type=_SweepPoint, payload_error="Unexpected datapoint",
)
self.assertEqual([p.point_number for p in points], [0, 1, 2])
self.assertEqual([p.cycle for p in points], ["new"] * 3)
def test_disconnect_wakes_packet_and_ack_waiters(self) -> None:
session = LibreVNASession()
write_started = threading.Event()
session._transport = Mock(write=Mock(side_effect=lambda *_, **__: write_started.set()))
errors = []
def waiting(action):
try:
action()
except Exception as exc:
errors.append(exc)
workers = [
threading.Thread(target=waiting, args=(lambda: session.wait_for_packet(PacketType.DEVICE_INFO, timeout_s=20),)),
threading.Thread(target=waiting, args=(lambda: session.send(Packet(PacketType.REQUEST_DEVICE_INFO), timeout_s=20),)),
]
for worker in workers:
worker.start()
self.assertTrue(write_started.wait(1))
session.disconnect()
for worker in workers:
worker.join(timeout=1)
self.assertFalse(worker.is_alive())
self.assertEqual(len(errors), 2)
self.assertTrue(all(isinstance(exc, DeviceDisconnectedError) for exc in errors))
self.assertFalse(session._awaiting_ack)
def test_write_failure_resets_ack_state(self) -> None:
session = LibreVNASession()
session._transport = Mock(write=Mock(side_effect=OSError("write failed")))
with self.assertRaises(OSError):
session.send(Packet(PacketType.REQUEST_DEVICE_INFO))
self.assertFalse(session._awaiting_ack)
self.assertIsNone(session._ack_result)
def test_stream_subscription_has_bounded_unused_queue(self) -> None:
with patch("python_app.hardware_full.librevna_driver.session._PACKET_QUEUE_MAX", 4):
session = LibreVNASession()
callback = Mock()
session.subscribe(PacketType.VNA_DATAPOINT, callback)
for index in range(20):
session._dispatch_packet(Packet(PacketType.VNA_DATAPOINT, index))
self.assertEqual(callback.call_count, 20)
self.assertEqual([p.payload for p in session._incoming[PacketType.VNA_DATAPOINT]], [16, 17, 18, 19])
self.assertIsNone(session._fatal_error)
def test_idle_free_running_sweep_does_not_poison_next_acquisition(self) -> None:
with patch("python_app.hardware_full.librevna_driver.session._PACKET_QUEUE_MAX", 2):
session = LibreVNASession()
for index in range(10):
session._dispatch_packet(Packet(PacketType.VNA_DATAPOINT, index))
self.assertEqual(len(session._incoming[PacketType.VNA_DATAPOINT]), 2)
self.assertIsNone(session._fatal_error)
session.clear_queue(PacketType.VNA_DATAPOINT)
session._dispatch_packet(Packet(PacketType.VNA_DATAPOINT, 0))
self.assertEqual(session.wait_for_packet(PacketType.VNA_DATAPOINT).payload, 0)
def test_synchronous_queue_overflow_is_reported(self) -> None:
with patch("python_app.hardware_full.librevna_driver.session._PACKET_QUEUE_MAX", 1):
session = LibreVNASession()
session._dispatch_packet(Packet(PacketType.DEVICE_INFO))
session._dispatch_packet(Packet(PacketType.DEVICE_INFO))
with self.assertRaisesRegex(DeviceDisconnectedError, "queue overflow"):
session.wait_for_packet(PacketType.DEVICE_INFO)
class UsbLifecycleTest(unittest.TestCase):
def test_failed_enumeration_releases_context(self) -> None:
transport = USBTransport(on_data=Mock())
context = Mock()
with patch("usb1.USBContext", return_value=context), patch.object(transport, "_iter_matching_devices", side_effect=OSError("enumeration failed")):
with self.assertRaisesRegex(OSError, "enumeration failed"):
transport.connect()
context.close.assert_called_once()
self.assertIsNone(transport._ctx)
self.assertFalse(transport.is_connected)
def test_failed_candidate_identity_read_closes_handle_and_context(self) -> None:
transport = USBTransport(on_data=Mock())
context, handle = Mock(), Mock(getSerialNumber=Mock(side_effect=OSError("identity failed")))
device = Mock(open=Mock(return_value=handle))
with patch("usb1.USBContext", return_value=context), patch.object(transport, "_iter_matching_devices", return_value=[(device, 0, 0)]):
with self.assertRaisesRegex(OSError, "identity failed"):
transport.connect()
handle.close.assert_called_once()
context.close.assert_called_once()
self.assertFalse(transport.is_connected)
def test_failed_thread_start_releases_claimed_handle_and_context(self) -> None:
transport = USBTransport(on_data=Mock())
context, handle = Mock(), Mock(kernelDriverActive=Mock(return_value=False))
device = Mock(open=Mock(return_value=handle))
with patch("usb1.USBContext", return_value=context), patch.object(transport, "_iter_matching_devices", return_value=[(device, 0, 0)]), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
with self.assertRaisesRegex(RuntimeError, "thread failed"):
transport.connect()
handle.releaseInterface.assert_called_once_with(transport.INTERFACE)
handle.close.assert_called_once()
context.close.assert_called_once()
self.assertIsNone(transport._rx_thread)
self.assertIsNone(transport._ctx)
self.assertFalse(transport.is_connected)
class TtyLifecycleTest(unittest.TestCase):
def test_close_interrupts_both_waiting_read_methods(self) -> None:
for combination in (None, (0, 0)):
with self.subTest(combination=combination):
master, slave = pty.openpty()
tty.setraw(slave)
reader = KamilAdcTtyReader(os.ttyname(slave))
errors = []
started = threading.Event()
reader.open()
def wait():
started.set()
try:
if combination is None:
reader.read_sweep(timeout_s=20)
else:
reader.read_sweep_for(combination, timeout_s=20)
except Exception as exc:
errors.append(exc)
worker = threading.Thread(target=wait)
try:
worker.start()
self.assertTrue(started.wait(1))
reader.close()
worker.join(timeout=1)
self.assertFalse(worker.is_alive())
self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], RuntimeError)
finally:
reader.close()
os.close(master)
os.close(slave)
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
read_fd, write_fd = os.pipe()
reader = KamilAdcTtyReader("/dev/test")
try:
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
with self.assertRaises(RuntimeError):
reader.open()
self.assertIsNone(reader._fd)
self.assertIsNone(reader._thread)
with self.assertRaises(OSError):
os.fstat(read_fd)
finally:
os.close(write_fd)
class ProducerCleanupTest(unittest.TestCase):
def test_partial_ring_initialization_closes_first_writer(self) -> None:
for module, model in ((matrix_raw_producer, "librevna_multi"), (kamil_adc_raw_producer, "kamil_adc")):
with self.subTest(producer=module.__name__):
config = RunConfigModel()
config.radar.model = model
config.rings.raw_tap.name = "/test_raw_tap"
writer = Mock()
with patch.object(producer_common.RunConfigModel, "load_from_path", return_value=config), patch.object(producer_common, "validate_acquisition_config"), patch.object(producer_common.signal, "signal"), patch.object(producer_common, "ShmRingWriter", side_effect=[writer, OSError("second ring failed")]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
with self.assertRaisesRegex(OSError, "second ring failed"):
module.main()
writer.close.assert_called_once()
def test_disabled_tap_opens_and_publishes_only_the_required_raw_ring(self) -> None:
config = RunConfigModel()
config.rings.raw_tap.name = ""
collection = SweepCollection(1, 50, capture_start_ns=10, capture_end_ns=20)
raw = Mock()
with patch.object(producer_common, "ShmRingWriter", return_value=raw) as factory, ExitStack() as cleanup:
publisher = producer_common.RawPublisher(config.rings, cleanup)
self.assertTrue(publisher.publish(collection))
factory.assert_called_once_with(config.rings.raw.name, config.rings.raw.capacity, config.rings.raw.slot_size_bytes)
raw.close.assert_called_once()
decoded = decode_trace_collection(raw.push.call_args.args[0], RAW_MAGIC)
self.assertEqual((decoded.collection_id, decoded.capture_start_ns, decoded.capture_end_ns), (1, 10, 20))
def test_rejected_raw_is_not_mirrored_but_tap_rejection_does_not_reject_raw(self) -> None:
raw, tap = Mock(push=Mock(return_value=False), slot_size_bytes=64), Mock(push=Mock(return_value=False))
config = RunConfigModel()
config.rings.raw_tap.name = "/test_raw_tap"
collection = SweepCollection(1, 50, capture_start_ns=10, capture_end_ns=20)
with patch.object(producer_common, "ShmRingWriter", side_effect=[raw, tap]), ExitStack() as cleanup:
publisher = producer_common.RawPublisher(config.rings, cleanup)
self.assertFalse(publisher.publish(collection))
tap.push.assert_not_called()
raw.push.return_value = True
self.assertTrue(publisher.publish(collection))
self.assertEqual(raw.push.call_args, tap.push.call_args)
raw.close.assert_called_once()
tap.close.assert_called_once()
class RadarVariantValidationTest(unittest.TestCase):
def test_scan_rejects_malformed_sweeps_and_keeps_valid_overlay(self) -> None:
with tempfile.TemporaryDirectory() as directory:
source = Path(directory)
for index, sweep in enumerate((
{"points": 1.5}, {"points": True}, {"if_bandwidth_hz": -1},
{"start_hz": 10, "stop_hz": 5}, {"start_hz": float("nan")},
{"points": 2, "stimulus_power_dbm": -20},
)):
(source / f"variant{index}.json").write_text(json.dumps({"radar": {"sweep": sweep}}))
base = RunConfigModel()
variants, summary = scan_radar_config_variants(directory, base_config=base)
self.assertEqual(summary.valid_variant_count, 1)
self.assertEqual(summary.skipped_file_count, 5)
self.assertEqual(variants[0].config.radar.sweep.points, 2)
self.assertEqual(variants[0].config.radar.sweep.power_dbm, -20)
self.assertEqual(base.radar.sweep.points, 1)
if __name__ == "__main__":
unittest.main()