325 lines
16 KiB
Python
325 lines
16 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 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_backends import NativeLibreVnaBackend
|
|
from python_app.hardware_full.librevna_driver.enums import PacketType
|
|
from python_app.hardware_full.librevna_driver.exceptions import DeviceDisconnectedError
|
|
from python_app.hardware_full.librevna_driver.models import Packet
|
|
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.switch_drivers.hmc349a_driver import HMC349ADriver
|
|
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
|
|
from python_app.models.run_config_model import RunConfigModel
|
|
from python_app.models.dataset_model import ComboKey, TraceData
|
|
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, replay_raw_producer
|
|
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:
|
|
backend = NativeLibreVnaBackend(None, 14)
|
|
failed, working = Mock(), Mock()
|
|
failed.connect.side_effect = OSError("USB unavailable")
|
|
failed.disconnect.side_effect = OSError("disconnect failed")
|
|
backend._libre_vna_device = Mock(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:
|
|
for driver_type, module in ((H7992Driver, "h7992_driver"), (HMC349ADriver, "hmc349a_driver")):
|
|
with self.subTest(driver=driver_type.__name__):
|
|
lines = Mock(set_values=Mock(side_effect=OSError("GPIO write failed")))
|
|
driver = driver_type("test")
|
|
with patch(f"python_app.hardware_full.switch_drivers.{module}.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_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_replay_rejects_small_slots_before_publishing_any_data(self) -> None:
|
|
module = replay_raw_producer
|
|
config = RunConfigModel()
|
|
config.radar.replay.dataset_dir = "/test/data"
|
|
config.rings.raw.slot_size_bytes = 1
|
|
trace = TraceData(
|
|
ComboKey(0, 0), np.array([1, 2], dtype=np.float32),
|
|
np.array([0, 0], dtype=np.complex64), np.array([1, 2], dtype=np.complex64),
|
|
)
|
|
dataset = module.ReplayDataset([[trace]], [20])
|
|
raw, tap = Mock(), Mock()
|
|
with patch.object(module.RunConfigModel, "load_from_path", return_value=config), patch.object(module.signal, "signal"), patch.object(module, "_load_dataset", return_value=dataset), patch.object(module, "ShmRingWriter", side_effect=[raw, tap]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
|
|
with self.assertRaisesRegex(ValueError, "Replay requires .* bytes per slot"):
|
|
module.main()
|
|
raw.push.assert_not_called()
|
|
tap.push.assert_not_called()
|
|
raw.close.assert_called_once()
|
|
tap.close.assert_called_once()
|
|
|
|
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"), (replay_raw_producer, "librevna")):
|
|
with self.subTest(producer=module.__name__):
|
|
config = RunConfigModel()
|
|
config.radar.model = model
|
|
config.radar.replay.dataset_dir = "/test/data"
|
|
writer = Mock()
|
|
with patch.object(module.RunConfigModel, "load_from_path", return_value=config), patch.object(module.signal, "signal"), patch.object(module, "ShmRingWriter", side_effect=[writer, OSError("second ring failed")]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
|
|
if module is replay_raw_producer:
|
|
with patch.object(module, "_load_dataset", return_value=Mock(median_gap_ns=1, __len__=Mock(return_value=1))):
|
|
with self.assertRaisesRegex(OSError, "second ring failed"):
|
|
module.main()
|
|
else:
|
|
with self.assertRaisesRegex(OSError, "second ring failed"):
|
|
module.main()
|
|
writer.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()
|