"""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()