web UI added and refactoring done
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""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_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 _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_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 SweepValidationTest(unittest.TestCase):
|
||||
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_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_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:
|
||||
base = {"relative_permittivity": 4.0, "tx_geometry": [], "rx_geometry": []}
|
||||
base.update(overrides)
|
||||
return GprModel(**base) # type: ignore[arg-type]
|
||||
|
||||
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)],
|
||||
))
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user