"""Run-config decode/validation tests. Pins the agreed semantics (not just current behaviour): * every config integer must be a genuine JSON int — "5", 5.0, true are errors; an explicit JSON null means "use the default"; * a sweep is a real range — stop_hz must be strictly greater than start_hz, and points a positive integer; * combos must index real switch positions and contain no duplicate input:output; * ring/gpr structural bounds are enforced in Python (so a bad config fails here, not in the C++ pipeline at boot). """ from __future__ import annotations import unittest from python_app.models.run_config_schema import ( ComboModel, GprModel, GprRxGeometryModel, GprTxGeometryModel, RadarSweepModel, RingEndpointModel, RunConfigModel, ) from python_app.models.run_config_validation import ( parse_combos_from_text, validate_acquisition_config, validate_combos, validate_gpr_model, validate_ring_endpoint, validate_sweep_model, ) def _valid_payload() -> dict: """A canonical, valid run-config payload (the schema defaults serialized).""" return RunConfigModel().to_dict() class StrictNumericTypingTest(unittest.TestCase): """Every config integer reads strictly; null falls back to the default.""" def test_switch_driver_is_only_a_guard_for_old_h7992_profiles(self) -> None: payload = _valid_payload() for port in ("port1", "port2"): switch = payload["switches"][port] self.assertNotIn("driver", switch) switch["driver"] = "h7992" self.assertNotIn("driver", RunConfigModel.from_dict(payload).to_dict()["switches"][port]) for unsupported in ("hmc349a", "unknown", "", None, 1, {}, []): switch["driver"] = unsupported with self.subTest(port=port, driver=unsupported), self.assertRaisesRegex(ValueError, "driver"): RunConfigModel.from_dict(payload) switch.pop("driver") switch["invert_logic"] = False self.assertNotIn("invert_logic", RunConfigModel.from_dict(payload).to_dict()["switches"][port]) for unsupported in (True, None, 0, "false", {}, []): switch["invert_logic"] = unsupported with self.subTest(port=port, invert_logic=unsupported), self.assertRaisesRegex(ValueError, "invert_logic"): RunConfigModel.from_dict(payload) switch.pop("invert_logic") def _reject_int(self, section: list[str], key: str, value: object) -> None: payload = _valid_payload() node = payload for part in section: node = node[part] node[key] = value with self.assertRaises(ValueError): RunConfigModel.from_dict(payload) def test_codec_int_rejects_string_float_bool(self) -> None: # radar.sweep.points is read by the codec's strict _read_int. for value in ("201", 201.0, True): with self.subTest(value=value): self._reject_int(["radar", "sweep"], "points", value) def test_validation_int_rejects_string_float_bool(self) -> None: # switch positions are read by run_config_validation._require_int. for value in ("4", 4.0, True): with self.subTest(value=value): self._reject_int(["switches", "port1"], "positions", value) def test_genuine_int_accepted(self) -> None: payload = _valid_payload() payload["radar"]["sweep"]["points"] = 256 self.assertEqual(RunConfigModel.from_dict(payload).radar.sweep.points, 256) def test_null_uses_default(self) -> None: payload = _valid_payload() payload["radar"]["sweep"]["points"] = None self.assertEqual( RunConfigModel.from_dict(payload).radar.sweep.points, RunConfigModel().radar.sweep.points, ) class StructuralTypingTest(unittest.TestCase): """A present-but-wrong-shaped section fails loudly instead of being dropped.""" def test_root_must_be_an_object(self) -> None: for payload in (None, [], 'bad', 42): with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'): RunConfigModel.from_dict(payload) def test_combos_must_be_an_array(self) -> None: payload = _valid_payload() payload["run"]["combos"] = {"input": 0, "output": 0} with self.assertRaisesRegex(ValueError, "run.combos must be a JSON array"): RunConfigModel.from_dict(payload) class AcquisitionValidationTest(unittest.TestCase): def test_default_combo_expansion_is_bounded_before_allocation(self) -> None: payload = _valid_payload() payload["switches"]["port1"]["positions"] = 65536 payload["run"]["combos"] = [] with self.assertRaisesRegex(ValueError, "Too many combos"): RunConfigModel.from_dict(payload) config = self.config("librevna_multi") config.radar.multi_device.input_switch_positions = 1 << 32 with self.assertRaisesRegex(ValueError, "Too many combos"): config.apply_device_model_constraints() def config(self, model: str = "librevna") -> RunConfigModel: config = RunConfigModel() config.radar.model = model config.radar.sweep.points = 32 for port, switch in enumerate((config.output_switch, config.input_switch), 1): switch.radar_port = port switch.gpio_chip = "/dev/gpiochip0" switch.pin_a, switch.pin_b = 17, 27 for name in ("raw", "preprocessed", "raw_tap", "preprocessed_tap"): getattr(config.rings, name).slot_size_bytes = 1 << 20 config.ensure_combos() return config def test_undersized_trace_rings_fail_before_start(self) -> None: config = self.config("librevna_multi") for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"): ring = getattr(config.rings, name) ring.name = "/test" original = ring.slot_size_bytes minimum_frame = 44 + len(config.combos) * (28 + 12 * config.radar.sweep.points) ring.slot_size_bytes = (minimum_frame - 1) // 8 * 8 with self.subTest(ring=name), self.assertRaisesRegex(ValueError, f"rings.{name}"): validate_acquisition_config(config) ring.slot_size_bytes = original validate_acquisition_config(config) def test_kamil_requires_native_mode(self) -> None: with self.assertRaisesRegex(ValueError, "native"): validate_acquisition_config(self.config("kamil_adc")) def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None: config = self.config("librevna_multi") config.radar.driver_mode = "native" config.radar.multi_device.output_switch_positions = 4 config.radar.multi_device.input_switch_positions = 4 config.ensure_combos() self.assertEqual(config.output_switch.positions, 8) self.assertEqual(config.input_switch.positions, 16) validate_acquisition_config(config) config.radar.multi_device.output_switch_positions = 5 config.ensure_combos() with self.assertRaisesRegex(ValueError, "supports 1..4"): validate_acquisition_config(config) config.radar.driver_mode = "mock" validate_acquisition_config(config) def test_bad_switch_settings_are_reported_before_hardware_open(self) -> None: for field, value in ( ("driver_mode", "typo"), ("default_position", 1), ("radar_port", 0), ("pin_a", -1), ("pin_b", 17), ("gpio_chip", ""), ): config = self.config() config.output_switch.driver_mode = "native" setattr(config.output_switch, field, value) with self.subTest(field=field), self.assertRaises(ValueError): validate_acquisition_config(config) class NotchValidationTest(unittest.TestCase): def test_malformed_bands_and_tapers_match_native_rejection(self) -> None: for band in (("1", 2), (True, 2), (None, 2), (1, float("nan")), (2, 1), (1, 1e100)): payload = _valid_payload() payload["preprocess"]["notch"]["bands_hz"] = [list(band)] with self.subTest(band=band), self.assertRaises(ValueError): RunConfigModel.from_dict(payload) for field, value in (("taper_width_hz", -1), ("taper_type", "typo")): payload = _valid_payload() payload["preprocess"]["notch"][field] = value with self.subTest(field=field), self.assertRaises(ValueError): RunConfigModel.from_dict(payload) class SweepValidationTest(unittest.TestCase): def test_nonfinite_fields_and_wire_overflow_are_rejected(self) -> None: for field in ('start_hz', 'stop_hz', 'if_bandwidth_hz', 'power_dbm'): for value in (float('nan'), float('inf')): sweep = RadarSweepModel() setattr(sweep, field, value) with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, field): validate_sweep_model(sweep) with self.assertRaisesRegex(ValueError, 'points'): validate_sweep_model(RadarSweepModel(points=2 ** 32)) def test_points_must_be_positive(self) -> None: for points in (0, -1): with self.subTest(points=points), self.assertRaisesRegex(ValueError, "points"): validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=points)) def test_stop_must_be_strictly_greater_than_start(self) -> None: with self.assertRaisesRegex(ValueError, "stop_hz"): # equal is not a range validate_sweep_model(RadarSweepModel(start_hz=2.0, stop_hz=2.0, points=1)) with self.assertRaisesRegex(ValueError, "stop_hz"): # inverted validate_sweep_model(RadarSweepModel(start_hz=3.0, stop_hz=2.0, points=1)) def test_valid_sweep_passes(self) -> None: validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=201)) class ComboValidationTest(unittest.TestCase): def test_direct_model_positions_cannot_be_silently_truncated(self) -> None: for value in (0.5, True, '0'): with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'integer'): combo = ComboModel(0, 0) setattr(combo, 'input', value) validate_combos([combo], input_positions=2, output_positions=2) def test_within_bounds_passes(self) -> None: validate_combos( [ComboModel(input=0, output=0), ComboModel(input=3, output=1)], input_positions=4, output_positions=2, ) def test_input_out_of_range_rejected(self) -> None: for inp in (-1, 4): with self.subTest(input=inp), self.assertRaisesRegex(ValueError, "input"): validate_combos([ComboModel(input=inp, output=0)], input_positions=4, output_positions=2) def test_output_out_of_range_rejected(self) -> None: for out in (-1, 2): with self.subTest(output=out), self.assertRaisesRegex(ValueError, "output"): validate_combos([ComboModel(input=0, output=out)], input_positions=4, output_positions=2) def test_duplicate_combo_rejected(self) -> None: with self.assertRaisesRegex(ValueError, "duplicate"): validate_combos( [ComboModel(input=0, output=0), ComboModel(input=0, output=0)], input_positions=4, output_positions=2, ) def test_out_of_range_combo_rejected_on_config_load(self) -> None: payload = _valid_payload() out_of_range = payload["run"]["combos"][0]["output"] + payload["switches"]["port2"]["positions"] payload["run"]["combos"].append({"input": 0, "output": out_of_range}) with self.assertRaisesRegex(ValueError, "out of range"): RunConfigModel.from_dict(payload) class RingValidationTest(unittest.TestCase): def test_slot_stride_must_keep_atomic_fields_aligned(self) -> None: with self.assertRaisesRegex(ValueError, 'multiple of 8'): validate_ring_endpoint(RingEndpointModel(name='r', slot_size_bytes=9)) def test_capacity_and_slot_must_be_positive(self) -> None: with self.assertRaisesRegex(ValueError, "capacity"): validate_ring_endpoint(RingEndpointModel(name="r", capacity=0, slot_size_bytes=16)) with self.assertRaisesRegex(ValueError, "slot_size"): validate_ring_endpoint(RingEndpointModel(name="r", capacity=4, slot_size_bytes=0)) def test_slot_size_uint32_limit(self) -> None: with self.assertRaisesRegex(ValueError, "uint32"): validate_ring_endpoint(RingEndpointModel(name="r", capacity=1, slot_size_bytes=1 << 32)) def test_segment_size_limit(self) -> None: with self.assertRaisesRegex(ValueError, "maximum ring segment"): validate_ring_endpoint(RingEndpointModel(name="r", capacity=1 << 40, slot_size_bytes=1024)) def test_valid_ring_passes(self) -> None: validate_ring_endpoint(RingEndpointModel(name="r", capacity=8, slot_size_bytes=4096)) class GprValidationTest(unittest.TestCase): @staticmethod def _gpr(**overrides: object) -> GprModel: model = GprModel(relative_permittivity=4.0) for key, value in overrides.items(): setattr(model, key, value) return model def _validate(self, gpr: GprModel) -> None: validate_gpr_model(gpr, input_switch_positions=4, output_switch_positions=2) def test_permittivity_must_be_positive(self) -> None: with self.assertRaisesRegex(ValueError, "relative_permittivity"): self._validate(self._gpr(relative_permittivity=0.0)) def test_tx_output_pos_out_of_range(self) -> None: with self.assertRaisesRegex(ValueError, "output_pos is out of range"): self._validate(self._gpr(tx_geometry=[GprTxGeometryModel(output_pos=5, x_m=0.0, y_m=0.0, z_m=0.0)])) def test_tx_duplicate_output_pos(self) -> None: with self.assertRaisesRegex(ValueError, "duplicate output_pos"): self._validate(self._gpr(tx_geometry=[ GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0), GprTxGeometryModel(output_pos=0, x_m=1.0, y_m=0.0, z_m=0.0), ])) def test_rx_input_pos_out_of_range(self) -> None: with self.assertRaisesRegex(ValueError, "input_pos is out of range"): self._validate(self._gpr(rx_geometry=[GprRxGeometryModel(input_pos=9, x_m=0.0, y_m=0.0, z_m=0.0)])) def test_valid_geometry_passes(self) -> None: self._validate(self._gpr( tx_geometry=[GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)], rx_geometry=[GprRxGeometryModel(input_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)], )) def test_direct_geometry_values_cannot_be_silently_coerced(self) -> None: for field, values, message in (("output_pos", (0.5, True, "0"), "integer"), ("x_m", (True, "0", None, float("nan"), float("inf"), 3.5e38, 10**400), "number")): for value in values: with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, message): entry = GprTxGeometryModel() setattr(entry, field, value) self._validate(self._gpr(tx_geometry=[entry])) class ParseCombosFromTextTest(unittest.TestCase): def test_parses_pairs(self) -> None: combos = parse_combos_from_text("0:0,1:0,3:1") self.assertEqual([(c.input, c.output) for c in combos], [(0, 0), (1, 0), (3, 1)]) def test_blank_text_is_empty_list(self) -> None: self.assertEqual(parse_combos_from_text(" "), []) def test_missing_colon_rejected(self) -> None: with self.assertRaisesRegex(ValueError, "Expected input:output"): parse_combos_from_text("00") def test_empty_side_rejected(self) -> None: with self.assertRaises(ValueError): parse_combos_from_text("0:") with self.assertRaises(ValueError): parse_combos_from_text(":0") def test_non_integer_rejected(self) -> None: with self.assertRaisesRegex(ValueError, "not an integer"): parse_combos_from_text("x:0") class RoundTripTest(unittest.TestCase): def test_default_config_round_trips_idempotently(self) -> None: once = RunConfigModel().to_dict() twice = RunConfigModel.from_dict(once).to_dict() self.assertEqual(once, twice) def test_set_values_are_preserved(self) -> None: payload = _valid_payload() payload["radar"]["sweep"].update(points=401, start_hz=1.0e9, stop_hz=5.0e9) model = RunConfigModel.from_dict(payload) self.assertEqual(model.radar.sweep.points, 401) self.assertEqual(model.radar.sweep.start_hz, 1.0e9) self.assertEqual(model.radar.sweep.stop_hz, 5.0e9) if __name__ == "__main__": unittest.main()