web UI added and refactoring done
This commit is contained in:
@@ -30,6 +30,42 @@ class GuiProfileCodecTest(unittest.TestCase):
|
||||
self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0")
|
||||
self.assertEqual(encoded["gui"]["processing"]["pass_through"]["combo_filter"], "0:0,1:0")
|
||||
|
||||
def test_default_profile_round_trips_idempotently(self) -> None:
|
||||
# Full-subtree idempotence catches field-drop/mis-map regressions across every
|
||||
# sub-model, which the single combo_filter round-trip above cannot.
|
||||
once = GuiProfileModel().to_dict()
|
||||
twice = GuiProfileModel.from_dict(once).to_dict()
|
||||
self.assertEqual(once["gui"], twice["gui"])
|
||||
|
||||
def test_missing_gui_section_yields_none(self) -> None:
|
||||
self.assertIsNone(GuiProfileModel.from_dict({}).gui)
|
||||
|
||||
def test_unsupported_version_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "version"):
|
||||
GuiProfileModel.from_dict({"gui": {"version": 2}})
|
||||
|
||||
def test_invalid_combo_mode_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "combo_mode"):
|
||||
GuiProfileModel.from_dict({"gui": {"switches": {"combo_mode": "bogus"}}})
|
||||
|
||||
def test_wrong_typed_field_is_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError): # combos_text must be a JSON string, not a number
|
||||
GuiProfileModel.from_dict({"gui": {"switches": {"combos_text": 123}}})
|
||||
|
||||
def test_legacy_gpr_payload_migrates_selected_mode(self) -> None:
|
||||
# An old payload that selects 'gpr' but carries a legacy root gpr.mode is migrated.
|
||||
decoded = GuiProfileModel.from_dict({
|
||||
"gui": {"processing": {"selected_mode": "gpr"}},
|
||||
"gpr": {"relative_permittivity": 4.0, "mode": "point"},
|
||||
})
|
||||
assert decoded.gui is not None
|
||||
self.assertEqual(decoded.gui.processing.selected_mode, "legacy_gpr")
|
||||
|
||||
def test_modern_gpr_selection_is_not_migrated(self) -> None:
|
||||
decoded = GuiProfileModel.from_dict({"gui": {"processing": {"selected_mode": "gpr"}}})
|
||||
assert decoded.gui is not None
|
||||
self.assertEqual(decoded.gui.processing.selected_mode, "gpr")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -60,6 +60,46 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "kamil_adc"):
|
||||
build_kamil_adc_neutral_s21_sets(config, point_count=4)
|
||||
|
||||
@staticmethod
|
||||
def _kamil_config() -> RunConfigModel:
|
||||
return RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"sweep": {"start_hz": 1_000_000.0, "stop_hz": 4_000_000.0,
|
||||
"if_bandwidth_hz": 1.0, "stimulus_power_dbm": -10.0},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
|
||||
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
|
||||
}
|
||||
)
|
||||
|
||||
def test_point_count_zero_or_negative_raises(self) -> None:
|
||||
config = self._kamil_config()
|
||||
for bad in (0, -1):
|
||||
with self.subTest(point_count=bad), self.assertRaisesRegex(ValueError, "point count"):
|
||||
build_kamil_adc_neutral_s21_sets(config, point_count=bad)
|
||||
|
||||
def test_single_point_sweep(self) -> None:
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=1)
|
||||
for trace in calibration.traces:
|
||||
self.assertEqual(trace.frequency_hz.tolist(), [1_000_000.0])
|
||||
self.assertEqual(trace.s21.shape, (1,))
|
||||
|
||||
def test_dtypes_are_float32_and_complex64(self) -> None:
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
|
||||
trace = calibration.traces[0]
|
||||
self.assertEqual(trace.frequency_hz.dtype, np.float32)
|
||||
self.assertEqual(trace.s21.dtype, np.complex64)
|
||||
self.assertEqual(trace.s11.dtype, np.complex64)
|
||||
|
||||
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
|
||||
# The C++ through-calibrator divides measured/calibration, so calibration S21
|
||||
# must never be zero — that is the whole point of the '1+0j neutral' contract.
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
|
||||
for trace in calibration.traces:
|
||||
self.assertTrue(bool(np.all(trace.s21 != 0)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -140,6 +140,23 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_corrupt_frame_fails_fast_without_resync(self) -> None:
|
||||
"""A garbage frame (bad marker) mid-stream surfaces on read; the reader does
|
||||
NOT silently resync — fail-fast lets the producer die and the supervisor relaunch."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, 5, 5, marker=0x001A) # corrupt marker (not 0x000A)
|
||||
+ _start_frame(),
|
||||
)
|
||||
with self.assertRaises((ValueError, RuntimeError)):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_no_completed_sweep_times_out(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
|
||||
@@ -17,7 +17,9 @@ DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
|
||||
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
|
||||
)
|
||||
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = (
|
||||
"7777ff3702003d2acc2c0500881318ab3d2affa50a00000a8000000a80005106"
|
||||
# Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the
|
||||
# step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005).
|
||||
"7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406"
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +130,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
|
||||
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX)
|
||||
|
||||
def test_ld1_and_ld2_encode_current_step_identically(self) -> None:
|
||||
# Regression guard for the LD2 step-scale bug: both current-variation channels
|
||||
# must encode the step with the same scale (current_ma_to_n), like min/max.
|
||||
params = dict(static_temp1=28.0, static_temp2=28.9, static_current1=33.0, static_current2=35.0,
|
||||
min_value=33.0, max_value=35.0, step=0.05, time_step=50, delay_time=10,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID, pi_coeff1_p=2560, pi_coeff1_i=128,
|
||||
pi_coeff2_p=2560, pi_coeff2_i=128)
|
||||
ld1 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD1, **params)
|
||||
ld2 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD2, **params)
|
||||
# Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step).
|
||||
self.assertEqual(ld1[10:12], ld2[10:12])
|
||||
|
||||
def test_start_sequence_matches_device_main_order(self) -> None:
|
||||
fake_protocol = _FakeProtocol()
|
||||
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
|
||||
@@ -235,6 +249,37 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
"message_id": DEVICE_MAIN_MESSAGE_ID,
|
||||
},
|
||||
)
|
||||
# The variation handoff itself (type + params), not just call order, must be right.
|
||||
variation_payload = controller.calls[3][1]
|
||||
self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1)
|
||||
self.assertEqual(variation_payload["params"]["step"], 0.05)
|
||||
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
|
||||
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
|
||||
|
||||
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
|
||||
config = self._kamil_config(
|
||||
{
|
||||
"enabled": True,
|
||||
"port": "/dev/ttyUSB0",
|
||||
"mode": "manual",
|
||||
"manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0},
|
||||
}
|
||||
)
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
|
||||
applied = apply_kamil_adc_laser_control(config)
|
||||
self.assertTrue(applied)
|
||||
controller = _FakeLaserController.instances[0]
|
||||
self.assertEqual([name for name, _ in controller.calls], ["connect", "reset", "set_manual_mode", "disconnect"])
|
||||
self.assertEqual(controller.calls[2][1]["current1"], 30.0)
|
||||
|
||||
def test_apply_radar_unknown_variation_type_raises(self) -> None:
|
||||
config = self._kamil_config(
|
||||
{"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation",
|
||||
"variation": {"variation_type": "NOT_A_REAL_TYPE"}}
|
||||
)
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
|
||||
with self.assertRaisesRegex(ValueError, "variation_type"):
|
||||
apply_kamil_adc_laser_control(config)
|
||||
|
||||
def test_apply_radar_skips_disabled_laser_control(self) -> None:
|
||||
config = self._kamil_config({"enabled": False})
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Orchestration & recovery tests.
|
||||
|
||||
Pins the agreed semantics:
|
||||
* crash auto-restart retries FOREVER with capped exponential back-off (never gives
|
||||
up — unattended appliance); the failure streak resets when data flows again;
|
||||
* config writes are atomic — a serialization failure leaves no partial output and
|
||||
never corrupts an existing config;
|
||||
* the acquisition producer command is selected by radar.model;
|
||||
* process exit reports classify clean vs unexpected exits correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.process_supervisor import ProcessExitReport, ProcessSupervisor
|
||||
from python_app.orchestration.restart_policy import RestartPolicy
|
||||
|
||||
|
||||
class RestartPolicyTest(unittest.TestCase):
|
||||
def test_backoff_grows_and_caps(self) -> None:
|
||||
policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0, backoff_factor=2.0)
|
||||
self.assertEqual(policy.backoff_for(0), 3.0)
|
||||
self.assertEqual(policy.backoff_for(1), 6.0)
|
||||
self.assertEqual(policy.backoff_for(2), 12.0)
|
||||
self.assertEqual(policy.backoff_for(3), 24.0)
|
||||
self.assertEqual(policy.backoff_for(100), 60.0) # capped
|
||||
|
||||
def test_never_gives_up_even_after_huge_streak(self) -> None:
|
||||
# No attempt cap: a long failure streak still yields a finite, capped wait.
|
||||
policy = RestartPolicy()
|
||||
self.assertEqual(policy.backoff_for(10_000), policy.max_interval_s)
|
||||
|
||||
def test_should_restart_respects_backoff_window(self) -> None:
|
||||
policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0)
|
||||
# First failure (streak 0): needs >= 3s since last restart.
|
||||
self.assertFalse(policy.should_restart_now(now_s=102.0, last_restart_s=100.0, consecutive_failures=0))
|
||||
self.assertTrue(policy.should_restart_now(now_s=103.0, last_restart_s=100.0, consecutive_failures=0))
|
||||
# After 2 failures the window is 12s.
|
||||
self.assertFalse(policy.should_restart_now(now_s=111.0, last_restart_s=100.0, consecutive_failures=2))
|
||||
self.assertTrue(policy.should_restart_now(now_s=112.0, last_restart_s=100.0, consecutive_failures=2))
|
||||
|
||||
def test_first_restart_is_immediate(self) -> None:
|
||||
# last_restart defaults to 0.0 in the mixin, so the very first crash restarts now.
|
||||
self.assertTrue(RestartPolicy().should_restart_now(now_s=5_000.0, last_restart_s=0.0, consecutive_failures=0))
|
||||
|
||||
|
||||
class ConfigWriterTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.root = Path(self._dir.name)
|
||||
self.writer = ConfigWriter(self.root / "runtime")
|
||||
|
||||
def test_writes_loadable_config_and_leaves_no_tmp(self) -> None:
|
||||
out = self.root / "run_config.json"
|
||||
self.writer.write(RunConfigModel(), out)
|
||||
self.assertTrue(out.exists())
|
||||
self.assertFalse(out.with_suffix(".json.tmp").exists())
|
||||
RunConfigModel.from_dict(json.loads(out.read_text())) # round-trips back through the schema
|
||||
|
||||
def test_nan_fails_loudly_without_corrupting_existing(self) -> None:
|
||||
out = self.root / "run_config.json"
|
||||
self.writer.write(RunConfigModel(), out)
|
||||
original = out.read_text()
|
||||
|
||||
broken = RunConfigModel()
|
||||
broken.radar.sweep.if_bandwidth_hz = float("nan")
|
||||
with self.assertRaises(ValueError): # allow_nan=False
|
||||
self.writer.write(broken, out)
|
||||
|
||||
self.assertEqual(out.read_text(), original) # existing config untouched
|
||||
self.assertFalse(out.with_suffix(".json.tmp").exists()) # no half-written tmp left
|
||||
|
||||
|
||||
class RadarModelCommandTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.root = Path(self._dir.name)
|
||||
self.supervisor = ProcessSupervisor(self.root)
|
||||
|
||||
def _config_with_model(self, model: str) -> Path:
|
||||
path = self.root / "cfg.json"
|
||||
path.write_text(json.dumps({"radar": {"model": model}}))
|
||||
return path
|
||||
|
||||
def test_read_radar_model(self) -> None:
|
||||
self.assertEqual(ProcessSupervisor._read_radar_model(self._config_with_model("kamil_adc")), "kamil_adc")
|
||||
|
||||
def test_read_radar_model_defaults_on_malformed(self) -> None:
|
||||
path = self.root / "bad.json"
|
||||
path.write_text("[]") # not an object
|
||||
self.assertEqual(ProcessSupervisor._read_radar_model(path), "librevna")
|
||||
|
||||
def test_matrix_producer_for_multi_and_sn9000(self) -> None:
|
||||
for model in ("librevna_multi", "sn9000"):
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model(model))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
|
||||
def test_kamil_producer_for_kamil_adc(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
|
||||
|
||||
def test_native_orchestrator_for_librevna(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna"))
|
||||
self.assertTrue(cmd[0].endswith("build/bin/sweep_orchestrator"))
|
||||
|
||||
|
||||
class ProcessExitReportTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _report(*, code: int, clean: bool) -> ProcessExitReport:
|
||||
return ProcessExitReport(
|
||||
name="data_processor",
|
||||
command=["x"],
|
||||
working_directory=Path("/tmp"),
|
||||
return_code=code,
|
||||
stdout_path=Path("/nonexistent.out"),
|
||||
stderr_path=Path("/nonexistent.err"),
|
||||
expected_clean_exit=clean,
|
||||
)
|
||||
|
||||
def test_clean_exit_is_info(self) -> None:
|
||||
report = self._report(code=0, clean=True)
|
||||
self.assertEqual(report.level, "INFO")
|
||||
self.assertIn("completed normally", report.format())
|
||||
|
||||
def test_nonzero_exit_is_error(self) -> None:
|
||||
report = self._report(code=3, clean=False)
|
||||
self.assertEqual(report.level, "ERROR")
|
||||
self.assertIn("exited with code 3", report.format())
|
||||
|
||||
def test_unexpected_zero_exit_is_error(self) -> None:
|
||||
report = self._report(code=0, clean=False)
|
||||
self.assertEqual(report.level, "ERROR")
|
||||
self.assertIn("exited unexpectedly with code 0", report.format())
|
||||
|
||||
|
||||
class SupervisorLifecycleTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.supervisor = ProcessSupervisor(Path(self._dir.name))
|
||||
self.addCleanup(self.supervisor.stop_all)
|
||||
|
||||
def _await_reports(self, timeout_s: float = 3.0) -> list[ProcessExitReport]:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
reports = self.supervisor.collect_exit_reports()
|
||||
if reports:
|
||||
return reports
|
||||
time.sleep(0.02)
|
||||
return []
|
||||
|
||||
def test_spawn_alive_then_stop(self) -> None:
|
||||
self.supervisor._spawn("data_preprocessor",
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
allow_clean_exit=False)
|
||||
self.assertTrue(self.supervisor.is_running())
|
||||
self.assertIn("data_preprocessor", self.supervisor.pids())
|
||||
self.supervisor.stop()
|
||||
self.assertFalse(self.supervisor.is_running())
|
||||
|
||||
def test_unexpected_exit_reported_as_error(self) -> None:
|
||||
self.supervisor._spawn("data_processor",
|
||||
[sys.executable, "-c", "import sys; sys.exit(3)"],
|
||||
allow_clean_exit=False)
|
||||
reports = self._await_reports()
|
||||
self.assertEqual(len(reports), 1)
|
||||
self.assertEqual(reports[0].return_code, 3)
|
||||
self.assertEqual(reports[0].level, "ERROR")
|
||||
|
||||
def test_clean_exit_reported_as_info(self) -> None:
|
||||
self.supervisor._spawn("sweep_orchestrator",
|
||||
[sys.executable, "-c", "import sys; sys.exit(0)"],
|
||||
allow_clean_exit=True)
|
||||
reports = self._await_reports()
|
||||
self.assertEqual(len(reports), 1)
|
||||
self.assertTrue(reports[0].expected_clean_exit)
|
||||
self.assertEqual(reports[0].level, "INFO")
|
||||
|
||||
def test_stale_pid_guard_rejects_unrelated_processes(self) -> None:
|
||||
# The reap guard must never kill a recycled PID that is not one of our binaries.
|
||||
self.assertFalse(self.supervisor._is_stale_pipeline_pid(2_000_000_000)) # no such pid
|
||||
self.assertFalse(self.supervisor._is_stale_pipeline_pid(os.getpid())) # the test runner
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Processing-interpretation tests across processor modes.
|
||||
|
||||
Pins the agreed semantics (not just current behaviour):
|
||||
* GPR object filtering is mode-agnostic — keep finite rows with score >= threshold
|
||||
(a normalized float for coherent GPR, a pair count for legacy GPR; same compare)
|
||||
inside the visible X/Z window, then apply draw limits. When more than
|
||||
max_detected_objects survive, ALL are hidden; legacy GPR passes draw_limits=None
|
||||
(count rules disabled) but is otherwise filtered identically — so heatmap markers
|
||||
match the objects-only view in both modes.
|
||||
* B-scan level/colormap/mean-subtraction/history transforms are deterministic.
|
||||
* ProcessingLiveConfig normalizes optional position lists to concrete int lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import (
|
||||
apply_mean_ascan_subtraction,
|
||||
bscan_levels,
|
||||
bscan_lookup_table,
|
||||
build_lut,
|
||||
rebuild_bscan_history_from_results,
|
||||
)
|
||||
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
|
||||
from python_app.models.dataset_model import (
|
||||
ComboKey,
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
)
|
||||
from python_app.orchestration.gpr_locator import (
|
||||
apply_object_draw_limits,
|
||||
collection_has_gpr_payloads,
|
||||
collection_payload_by_name,
|
||||
collection_payloads_by_prefix,
|
||||
filter_object_rows,
|
||||
gpr_object_rows,
|
||||
)
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
|
||||
def _table(name: str, rows) -> ResultPayload:
|
||||
return ResultPayload(processing_name=name, kind=4, table=np.asarray(rows, dtype=np.float32))
|
||||
|
||||
|
||||
def _collection(payloads) -> ResultCollection:
|
||||
return ResultCollection(collection_id=1, monotonic_ns=1, collection_payloads=list(payloads))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Shared result-collection lookup helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
class CollectionLookupTest(unittest.TestCase):
|
||||
def test_payload_by_name_matches_name_and_optional_kind(self) -> None:
|
||||
col = _collection([_table("gpr_points", [[0, 0, 1]]), _table("gpr_region_centers", [[1, 1, 2, 9]])])
|
||||
self.assertIs(collection_payload_by_name(col, "gpr_points"), col.collection_payloads[0])
|
||||
self.assertIsNone(collection_payload_by_name(col, "missing"))
|
||||
self.assertIsNone(collection_payload_by_name(col, "gpr_points", kind=3)) # wrong kind
|
||||
|
||||
def test_payloads_by_prefix_returns_all_in_order(self) -> None:
|
||||
col = _collection([
|
||||
ResultPayload(processing_name="gpr_region_mask_0", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
|
||||
ResultPayload(processing_name="gpr_region_mask_1", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
|
||||
_table("gpr_points", [[0, 0, 1]]),
|
||||
])
|
||||
masks = collection_payloads_by_prefix(col, "gpr_region_mask_", kind=3)
|
||||
self.assertEqual([p.processing_name for p in masks], ["gpr_region_mask_0", "gpr_region_mask_1"])
|
||||
self.assertEqual(collection_payloads_by_prefix(col, "nope_"), [])
|
||||
|
||||
def test_has_gpr_payloads(self) -> None:
|
||||
self.assertTrue(collection_has_gpr_payloads(_collection([_table("gpr_points", [[0, 0, 1]])])))
|
||||
self.assertFalse(collection_has_gpr_payloads(_collection([_table("pass_through", [[0, 0, 1]])])))
|
||||
self.assertFalse(collection_has_gpr_payloads(_collection([])))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GPR object row extraction
|
||||
# --------------------------------------------------------------------------- #
|
||||
class GprObjectRowsTest(unittest.TestCase):
|
||||
def test_extracts_first_three_columns_of_points(self) -> None:
|
||||
rows = gpr_object_rows(_collection([_table("gpr_points", [[1, 2, 3], [4, 5, 6]])]))
|
||||
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)))
|
||||
|
||||
def test_falls_back_to_region_centers_and_drops_extra_columns(self) -> None:
|
||||
# region_centers is [x, z, score, pixel_count]; only the first 3 cols are used.
|
||||
rows = gpr_object_rows(_collection([_table("gpr_region_centers", [[1, 2, 3, 99]])]))
|
||||
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3]], dtype=np.float32)))
|
||||
|
||||
def test_empty_when_no_object_payloads(self) -> None:
|
||||
self.assertEqual(gpr_object_rows(_collection([_table("gpr_accumulator", [[1, 2, 3]])])).shape, (0, 3))
|
||||
|
||||
def test_rejects_table_with_too_few_columns(self) -> None:
|
||||
self.assertEqual(gpr_object_rows(_collection([_table("gpr_points", [[1, 2]])])).shape, (0, 3))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Object draw limits (hide-all-when-over, then top-M)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ApplyObjectDrawLimitsTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _rows(n: int) -> np.ndarray:
|
||||
return np.column_stack([np.arange(n), np.arange(n), np.arange(n)]).astype(np.float32)
|
||||
|
||||
def test_none_limits_passes_through(self) -> None:
|
||||
rows = self._rows(5)
|
||||
self.assertTrue(np.array_equal(apply_object_draw_limits(rows, None), rows))
|
||||
|
||||
def test_hides_all_when_over_max(self) -> None:
|
||||
self.assertEqual(apply_object_draw_limits(self._rows(6), (5, 3)).shape, (0, 3))
|
||||
|
||||
def test_keeps_top_m_when_within_max(self) -> None:
|
||||
out = apply_object_draw_limits(self._rows(4), (5, 2))
|
||||
self.assertEqual(out.shape, (2, 3))
|
||||
self.assertTrue(np.array_equal(out, self._rows(4)[:2]))
|
||||
|
||||
def test_max_zero_hides_any_objects(self) -> None:
|
||||
# max_detected_objects == 0 means "hide all" (any object exceeds it).
|
||||
self.assertEqual(apply_object_draw_limits(self._rows(1), (0, 5)).shape, (0, 3))
|
||||
|
||||
def test_empty_input_passes_through(self) -> None:
|
||||
empty = np.zeros((0, 3), dtype=np.float32)
|
||||
self.assertEqual(apply_object_draw_limits(empty, (5, 3)).shape, (0, 3))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mode-agnostic object filtering (the core both modes + the locator share)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FilterObjectRowsTest(unittest.TestCase):
|
||||
BOUNDS = {"x_bounds": (-2.0, 2.0), "z_bounds": (0.0, 10.0)}
|
||||
|
||||
def _filter(self, rows, *, min_score, draw_limits=None):
|
||||
return filter_object_rows(np.asarray(rows, dtype=np.float32), min_score=min_score,
|
||||
draw_limits=draw_limits, **self.BOUNDS)
|
||||
|
||||
def test_keeps_in_window_and_at_or_above_threshold(self) -> None:
|
||||
out = self._filter([[0.0, 5.0, 0.5], [1.0, 1.0, 0.9]], min_score=0.5) # score==threshold kept (inclusive)
|
||||
self.assertEqual(out.shape[0], 2)
|
||||
|
||||
def test_drops_below_threshold(self) -> None:
|
||||
out = self._filter([[0.0, 5.0, 0.4]], min_score=0.5)
|
||||
self.assertEqual(out.shape[0], 0)
|
||||
|
||||
def test_window_bounds_are_inclusive(self) -> None:
|
||||
out = self._filter([[2.0, 10.0, 1.0], [-2.0, 0.0, 1.0]], min_score=0.0) # exactly on each edge
|
||||
self.assertEqual(out.shape[0], 2)
|
||||
|
||||
def test_drops_outside_window(self) -> None:
|
||||
out = self._filter([[2.001, 5.0, 1.0], [0.0, 10.001, 1.0]], min_score=0.0)
|
||||
self.assertEqual(out.shape[0], 0)
|
||||
|
||||
def test_drops_non_finite_rows(self) -> None:
|
||||
out = self._filter([[np.nan, 5.0, 1.0], [0.0, np.inf, 1.0], [0.0, 5.0, 1.0]], min_score=0.0)
|
||||
self.assertEqual(out.shape[0], 1)
|
||||
|
||||
def test_legacy_pair_count_threshold_uses_same_compare(self) -> None:
|
||||
# legacy GPR passes an integer pair-count threshold; the >= compare is identical.
|
||||
out = self._filter([[0.0, 5.0, 3.0], [0.0, 5.0, 2.0]], min_score=3, draw_limits=None)
|
||||
self.assertTrue(np.array_equal(out, np.array([[0.0, 5.0, 3.0]], dtype=np.float32)))
|
||||
|
||||
def test_draw_limits_hide_all_when_over(self) -> None:
|
||||
rows = [[0.0, 5.0, 1.0]] * 4
|
||||
self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=(3, 2)).shape[0], 0)
|
||||
|
||||
def test_legacy_none_limits_skips_count_rule(self) -> None:
|
||||
rows = [[0.0, 5.0, 1.0]] * 4
|
||||
self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=None).shape[0], 4)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# B-scan transforms
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BscanTransformTest(unittest.TestCase):
|
||||
def test_mean_ascan_subtraction(self) -> None:
|
||||
history = deque([np.array([1.0, 1.0], dtype=np.float32), np.array([3.0, 3.0], dtype=np.float32)])
|
||||
self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=False),
|
||||
np.array([[1, 1], [3, 3]], dtype=np.float32)))
|
||||
self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=True),
|
||||
np.array([[-1, -1], [1, 1]], dtype=np.float32)))
|
||||
|
||||
def test_levels_abs_mode(self) -> None:
|
||||
self.assertEqual(bscan_levels(np.array([[1.0, 4.0], [2.0, 3.0]], dtype=np.float32), "abs"), (1.0, 4.0))
|
||||
|
||||
def test_levels_abs_degenerate(self) -> None:
|
||||
low, high = bscan_levels(np.full((2, 2), 5.0, dtype=np.float32), "abs")
|
||||
self.assertEqual(low, 5.0)
|
||||
self.assertGreater(high, low)
|
||||
|
||||
def test_levels_signed_mode_symmetric(self) -> None:
|
||||
self.assertEqual(bscan_levels(np.array([[-3.0, 1.0]], dtype=np.float32), "real"), (-3.0, 3.0))
|
||||
|
||||
def test_build_lut_shape_and_endpoints(self) -> None:
|
||||
lut = build_lut(["#000000", "#ffffff"])
|
||||
self.assertEqual(lut.shape, (256, 3))
|
||||
self.assertEqual(lut.dtype, np.uint8)
|
||||
self.assertTrue(np.array_equal(lut[0], [0, 0, 0]))
|
||||
self.assertTrue(np.array_equal(lut[-1], [255, 255, 255]))
|
||||
|
||||
def test_lookup_table_for_each_axis_mode(self) -> None:
|
||||
for mode in ("abs", "real", "phase"):
|
||||
self.assertEqual(bscan_lookup_table(mode).shape, (256, 3))
|
||||
|
||||
|
||||
class RebuildBscanHistoryTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _bscan_collection(cid: int, combo, depth, amps, *, name="bscan", kind=1) -> ResultCollection:
|
||||
trace = np.asarray(amps, dtype=np.float32).astype(np.complex64)
|
||||
payload = ResultPayload(processing_name=name, kind=kind,
|
||||
frequency_hz=np.asarray(depth, dtype=np.float32), trace=trace)
|
||||
block = ResultBlock(combo=ComboKey(input=combo[0], output=combo[1]), payloads=[payload])
|
||||
return ResultCollection(collection_id=cid, monotonic_ns=cid, blocks=[block])
|
||||
|
||||
def test_accumulates_sweeps_per_combo(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
||||
self._bscan_collection(2, (0, 0), [1.0, 2.0], [11.0, 21.0]),
|
||||
]
|
||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
||||
self.assertEqual(len(by_combo[(0, 0)]), 2)
|
||||
self.assertTrue(np.array_equal(axes[(0, 0)], np.array([1.0, 2.0], dtype=np.float32)))
|
||||
|
||||
def test_skips_non_bscan_and_mismatched_payloads(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [1.0, 2.0], name="other"), # wrong name
|
||||
self._bscan_collection(2, (0, 0), [1.0, 2.0], [1.0, 2.0], kind=2), # wrong kind
|
||||
self._bscan_collection(3, (0, 0), [1.0, 2.0, 3.0], [1.0, 2.0]), # size mismatch
|
||||
]
|
||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
||||
self.assertEqual(by_combo, {})
|
||||
|
||||
def test_depth_axis_change_resets_history(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
||||
self._bscan_collection(2, (0, 0), [1.0, 2.0, 3.0], [11.0, 21.0, 31.0]), # new depth axis
|
||||
]
|
||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
||||
self.assertEqual(len(by_combo[(0, 0)]), 1) # reset on axis change; only the latest sweep remains
|
||||
self.assertEqual(axes[(0, 0)].shape, (3,))
|
||||
|
||||
def test_floor_collection_id_excludes_older(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0], [10.0]),
|
||||
self._bscan_collection(2, (0, 0), [1.0], [20.0]),
|
||||
]
|
||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=1)
|
||||
self.assertEqual(len(by_combo[(0, 0)]), 1) # only collection_id > 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GPR display-range helpers (pure static math on the mixin)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class GprDisplayHelperTest(unittest.TestCase):
|
||||
def test_normalized_range_orders_and_expands(self) -> None:
|
||||
self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(2.0, 5.0), (2.0, 5.0))
|
||||
self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(5.0, 2.0), (2.0, 5.0)) # reordered
|
||||
low, high = AppWindowGprPlotMixin._normalized_display_range(3.0, 3.0) # zero span expands to 0.1
|
||||
self.assertAlmostEqual(high - low, 0.1)
|
||||
self.assertAlmostEqual(0.5 * (low + high), 3.0)
|
||||
|
||||
def test_display_y_min_keeps_surface_margin(self) -> None:
|
||||
self.assertEqual(AppWindowGprPlotMixin._gpr_display_y_min(2.0, 5.0), 2.0) # surface not visible
|
||||
self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(0.0, 10.0), -0.3) # 3% of span
|
||||
self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(-1.0, 1.0), -1.06) # min 0.06 margin
|
||||
|
||||
def test_object_label_candidates_are_distinct_positions(self) -> None:
|
||||
candidates = AppWindowGprPlotMixin._gpr_object_label_candidates(0.0, 0.0, x_span=1.0, z_span=1.0)
|
||||
self.assertEqual(len(candidates), 10)
|
||||
self.assertTrue(all(math.isfinite(x) and math.isfinite(z) for x, z, _ in candidates))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ProcessingLiveConfig normalization
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ProcessingLiveConfigTest(unittest.TestCase):
|
||||
def test_none_positions_become_empty_lists(self) -> None:
|
||||
cfg = ProcessingLiveConfig(gpr_input_positions=None, gpr_output_positions=None)
|
||||
self.assertEqual(cfg.gpr_input_positions, [])
|
||||
self.assertEqual(cfg.gpr_output_positions, [])
|
||||
|
||||
def test_positions_coerced_to_ints(self) -> None:
|
||||
cfg = ProcessingLiveConfig(gpr_input_positions=[1.5, 2.9], gpr_output_positions=[0.0])
|
||||
self.assertEqual(cfg.gpr_input_positions, [1, 2])
|
||||
self.assertEqual(cfg.gpr_output_positions, [0])
|
||||
|
||||
def test_to_dict_is_json_typed(self) -> None:
|
||||
data = ProcessingLiveConfig().to_dict()
|
||||
self.assertIsInstance(data["processor_mode"], str)
|
||||
self.assertIsInstance(data["gpr_input_positions"], list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""IPC/SHM tests: encode/decode round-trips, corruption handling, ring semantics.
|
||||
|
||||
Pins the agreed contract:
|
||||
* encode -> decode is lossless for raw/preprocessed traces and result payloads;
|
||||
* a corrupt/truncated frame raises a single, catchable ValueError (never a bare
|
||||
struct.error / UnicodeDecodeError);
|
||||
* the ring is latest-wins: on overflow the oldest slot is overwritten and a slow
|
||||
reader keeps the freshest frames;
|
||||
* peek_latest returns the newest frame without consuming it;
|
||||
* opening a missing/incompatible ring fails fast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import (
|
||||
ComboKey,
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
SweepCollection,
|
||||
TraceData,
|
||||
)
|
||||
from python_app.orchestration.shm.decoder import (
|
||||
PREPROC_MAGIC,
|
||||
RAW_MAGIC,
|
||||
RESULT_MAGIC,
|
||||
decode_result_collection,
|
||||
decode_trace_collection,
|
||||
)
|
||||
from python_app.orchestration.shm.ring_reader import ShmRingReader
|
||||
from python_app.orchestration.shm.ring_writer import ShmRingWriter
|
||||
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
|
||||
|
||||
|
||||
def _trace(in_pos: int, out_pos: int, n: int) -> TraceData:
|
||||
"""Build a trace with float32-exact data so round-trips compare exactly."""
|
||||
freq = np.arange(n, dtype=np.float32) + 1.0
|
||||
s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||
s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||
return TraceData(combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=freq, s11=s11, s21=s21)
|
||||
|
||||
|
||||
class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
def _assert_round_trips(self, magic: int) -> None:
|
||||
collection = SweepCollection(
|
||||
collection_id=7,
|
||||
monotonic_ns=123,
|
||||
traces=[_trace(0, 0, 4), _trace(3, 1, 2)],
|
||||
capture_start_ns=10,
|
||||
capture_end_ns=20,
|
||||
)
|
||||
decoded = decode_trace_collection(serialize_trace_collection(collection, magic), magic)
|
||||
self.assertEqual(decoded.collection_id, 7)
|
||||
self.assertEqual(decoded.monotonic_ns, 123)
|
||||
self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (10, 20))
|
||||
self.assertEqual(len(decoded.traces), 2)
|
||||
for original, got in zip(collection.traces, decoded.traces):
|
||||
self.assertEqual((got.combo.input, got.combo.output), (original.combo.input, original.combo.output))
|
||||
self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz))
|
||||
self.assertTrue(np.array_equal(got.s11, original.s11))
|
||||
self.assertTrue(np.array_equal(got.s21, original.s21))
|
||||
|
||||
def test_raw_round_trips(self) -> None:
|
||||
self._assert_round_trips(RAW_MAGIC)
|
||||
|
||||
def test_preprocessed_round_trips(self) -> None:
|
||||
self._assert_round_trips(PREPROC_MAGIC)
|
||||
|
||||
def test_empty_traces_round_trip(self) -> None:
|
||||
collection = SweepCollection(collection_id=1, monotonic_ns=2, traces=[])
|
||||
decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC)
|
||||
self.assertEqual(decoded.traces, [])
|
||||
|
||||
|
||||
class ResultCollectionRoundTripTest(unittest.TestCase):
|
||||
def test_all_payload_kinds_round_trip(self) -> None:
|
||||
image = np.arange(6, dtype=np.float32).reshape((2, 3))
|
||||
table = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], dtype=np.float32)
|
||||
collection = ResultCollection(
|
||||
collection_id=9,
|
||||
monotonic_ns=42,
|
||||
processing_duration_ns=1000,
|
||||
collection_payloads=[
|
||||
ResultPayload(
|
||||
processing_name="gpr_accumulator", kind=3,
|
||||
image_x_axis=np.array([0.0, 1.0, 2.0], dtype=np.float32),
|
||||
image_y_axis=np.array([0.0, 1.0], dtype=np.float32),
|
||||
image=image,
|
||||
),
|
||||
ResultPayload(processing_name="gpr_points", kind=4, table=table),
|
||||
],
|
||||
blocks=[
|
||||
ResultBlock(combo=ComboKey(input=1, output=0), payloads=[
|
||||
ResultPayload(
|
||||
processing_name="bscan", kind=1,
|
||||
frequency_hz=np.array([1.0, 2.0], dtype=np.float32),
|
||||
trace=np.array([1 + 1j, 2 - 2j], dtype=np.complex64),
|
||||
),
|
||||
ResultPayload(processing_name="snr", kind=2, scalar_value=2.5),
|
||||
]),
|
||||
],
|
||||
)
|
||||
decoded = decode_result_collection(serialize_result_collection(collection))
|
||||
self.assertEqual((decoded.collection_id, decoded.monotonic_ns, decoded.processing_duration_ns), (9, 42, 1000))
|
||||
acc, points = decoded.collection_payloads
|
||||
self.assertEqual(acc.processing_name, "gpr_accumulator")
|
||||
self.assertTrue(np.array_equal(acc.image, image))
|
||||
self.assertTrue(np.array_equal(points.table, table))
|
||||
block = decoded.blocks[0]
|
||||
self.assertEqual((block.combo.input, block.combo.output), (1, 0))
|
||||
self.assertTrue(np.array_equal(block.payloads[0].trace, np.array([1 + 1j, 2 - 2j], dtype=np.complex64)))
|
||||
self.assertAlmostEqual(block.payloads[1].scalar_value, 2.5)
|
||||
|
||||
|
||||
class CorruptFrameTest(unittest.TestCase):
|
||||
"""Any corruption surfaces as ValueError (the single catchable contract)."""
|
||||
|
||||
def _valid_trace_bytes(self) -> bytes:
|
||||
return serialize_trace_collection(
|
||||
SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(0, 0, 3)]), RAW_MAGIC
|
||||
)
|
||||
|
||||
def test_bad_magic(self) -> None:
|
||||
corrupt = b"\x00\x00\x00\x00" + self._valid_trace_bytes()[4:]
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(corrupt, RAW_MAGIC)
|
||||
|
||||
def test_truncated_buffer(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(self._valid_trace_bytes()[:18], RAW_MAGIC)
|
||||
|
||||
def test_absurd_trace_count(self) -> None:
|
||||
# Claims 1e6 traces but supplies none -> the first trace read runs off the end.
|
||||
corrupt = struct.pack("<IQQI", RAW_MAGIC, 1, 1, 1_000_000)
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(corrupt, RAW_MAGIC)
|
||||
|
||||
def test_unsupported_payload_kind(self) -> None:
|
||||
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 99, 0)
|
||||
with self.assertRaises(ValueError):
|
||||
decode_result_collection(corrupt)
|
||||
|
||||
def test_invalid_utf8_name(self) -> None:
|
||||
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 2, 1) + b"\xff"
|
||||
with self.assertRaises(ValueError):
|
||||
decode_result_collection(corrupt)
|
||||
|
||||
|
||||
class _RingTestCase(unittest.TestCase):
|
||||
"""Base case that creates/cleans named /dev/shm rings."""
|
||||
|
||||
def _ring_name(self, suffix: str = "") -> str:
|
||||
return f"/radar_test_{self._testMethodName}{suffix}"
|
||||
|
||||
def _writer(self, name: str, capacity: int, slot_size: int) -> ShmRingWriter:
|
||||
with suppress(OSError):
|
||||
(Path("/dev/shm") / name[1:]).unlink() # drop a leftover from a crashed run
|
||||
writer = ShmRingWriter(name, capacity, slot_size)
|
||||
self.addCleanup(self._cleanup, name, writer)
|
||||
return writer
|
||||
|
||||
def _reader(self, name: str, **kw: object) -> ShmRingReader:
|
||||
reader = ShmRingReader(name, **kw)
|
||||
self.addCleanup(self._safe_close, reader)
|
||||
return reader
|
||||
|
||||
@staticmethod
|
||||
def _safe_close(obj: object) -> None:
|
||||
with suppress(Exception):
|
||||
obj.close() # type: ignore[attr-defined]
|
||||
|
||||
@staticmethod
|
||||
def _cleanup(name: str, writer: ShmRingWriter) -> None:
|
||||
with suppress(Exception):
|
||||
writer.close()
|
||||
with suppress(OSError):
|
||||
(Path("/dev/shm") / name[1:]).unlink()
|
||||
|
||||
|
||||
class RingRoundTripTest(_RingTestCase):
|
||||
def test_fifo_round_trip(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=8, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
payloads = [f"frame{i}".encode() for i in range(5)]
|
||||
for p in payloads:
|
||||
self.assertTrue(writer.push(p))
|
||||
self.assertEqual([reader.pop_payload() for _ in payloads], payloads)
|
||||
self.assertIsNone(reader.pop_payload()) # empty afterwards
|
||||
|
||||
def test_oversized_payload_rejected(self) -> None:
|
||||
writer = self._writer(self._ring_name(), capacity=4, slot_size=8)
|
||||
self.assertFalse(writer.push(b"x" * 9)) # larger than the slot
|
||||
|
||||
|
||||
class RingOverflowTest(_RingTestCase):
|
||||
def test_latest_wins_drops_oldest(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=4, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
for i in range(7): # 3 more than capacity
|
||||
self.assertTrue(writer.push(f"f{i}".encode()))
|
||||
# The 4 newest survive; the 3 oldest were overwritten.
|
||||
survivors = []
|
||||
while (item := reader.pop_payload()) is not None:
|
||||
survivors.append(item)
|
||||
self.assertEqual(survivors, [b"f3", b"f4", b"f5", b"f6"])
|
||||
|
||||
|
||||
class PeekLatestTest(_RingTestCase):
|
||||
def test_peek_is_latest_and_non_consuming(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=8, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
self.assertIsNone(reader.peek_latest_payload()) # nothing published yet
|
||||
for i in range(3):
|
||||
writer.push(f"f{i}".encode())
|
||||
self.assertEqual(reader.peek_latest_payload(), b"f2") # newest
|
||||
self.assertEqual(reader.peek_latest_payload(), b"f2") # stable, not consumed
|
||||
self.assertEqual(reader.pop_payload(), b"f0") # consumer cursor untouched
|
||||
writer.push(b"f3")
|
||||
self.assertEqual(reader.peek_latest_payload(), b"f3") # follows the newest
|
||||
|
||||
|
||||
class RingOpenTest(_RingTestCase):
|
||||
def test_missing_ring_fails_fast(self) -> None:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
ShmRingReader("/radar_test_definitely_missing", open_timeout_s=0.1, open_poll_s=0.02)
|
||||
|
||||
def test_incompatible_header_rejected(self) -> None:
|
||||
name = "/radar_test_bad_header"
|
||||
path = Path("/dev/shm") / name[1:]
|
||||
path.write_bytes(b"\x00" * 128) # right size, wrong magic
|
||||
self.addCleanup(lambda: path.unlink(missing_ok=True))
|
||||
with self.assertRaises(RuntimeError):
|
||||
ShmRingReader(name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Storage + embedded WebUI tests.
|
||||
|
||||
Pins the agreed semantics:
|
||||
* NPZ set persistence round-trips traces + metadata (float32 wire precision);
|
||||
* vna_history export fails loud (ValueError) when no matching traces / bad stage;
|
||||
* the web bridge rejects unknown live-settings fields (HTTP 400), serves immutable
|
||||
snapshot copies, and forwards controls as Qt signals;
|
||||
* the web fan-out is latest-wins — a full client queue drops its oldest frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # before any Qt import
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
|
||||
AppWindowWebController,
|
||||
AppWindowWebMixin,
|
||||
)
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402
|
||||
from python_app.storage.npz.store import NpzStore # noqa: E402
|
||||
from python_app.storage.npz.vna_history_json import ( # noqa: E402
|
||||
_complex_to_points,
|
||||
_normalize_channel,
|
||||
build_vna_history_payload,
|
||||
)
|
||||
from python_app.webui.streaming import RingBroadcaster # noqa: E402
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
global _app
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _trace(in_pos: int, out_pos: int) -> TraceData:
|
||||
return TraceData(
|
||||
combo=ComboKey(input=in_pos, output=out_pos),
|
||||
frequency_hz=np.array([1.0, 2.0], dtype=np.float32),
|
||||
s11=np.array([1 + 1j, 2 + 2j], dtype=np.complex64),
|
||||
s21=np.array([3 + 3j, 4 - 4j], dtype=np.complex64),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# NPZ set persistence
|
||||
# --------------------------------------------------------------------------- #
|
||||
class NpzStoreRoundTripTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.store = NpzStore(Path(self._dir.name))
|
||||
|
||||
def test_save_then_load_round_trips_traces_and_metadata(self) -> None:
|
||||
col = SweepCollection(collection_id=7, monotonic_ns=11, traces=[_trace(0, 0), _trace(1, 0)],
|
||||
capture_start_ns=100, capture_end_ns=200)
|
||||
self.store.save_set("calibration", "radar1", "set1", col)
|
||||
loaded = self.store.load_set("calibration", "radar1", "set1")
|
||||
|
||||
self.assertEqual(loaded.collection_id, 7)
|
||||
self.assertEqual((loaded.capture_start_ns, loaded.capture_end_ns), (100, 200))
|
||||
self.assertEqual(len(loaded.traces), 2)
|
||||
by_combo = {(t.combo.input, t.combo.output): t for t in loaded.traces}
|
||||
self.assertTrue(np.array_equal(by_combo[(0, 0)].s21, _trace(0, 0).s21))
|
||||
self.assertTrue(np.array_equal(by_combo[(1, 0)].frequency_hz, _trace(1, 0).frequency_hz))
|
||||
|
||||
def test_set_appears_in_listing_and_leaves_no_tmp(self) -> None:
|
||||
self.store.save_set("calibration", "radar1", "set1", SweepCollection(collection_id=1, monotonic_ns=1,
|
||||
traces=[_trace(0, 0)]))
|
||||
self.assertIn("set1", self.store.list_sets("calibration", "radar1"))
|
||||
leftover = list(Path(self._dir.name).rglob("*.tmp"))
|
||||
self.assertEqual(leftover, [])
|
||||
|
||||
def test_load_missing_set_raises(self) -> None:
|
||||
with self.assertRaises(Exception):
|
||||
self.store.load_set("calibration", "radar1", "absent")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# vna_history export
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VnaHistoryTest(unittest.TestCase):
|
||||
def _sweeps(self, *combos) -> list[SweepCollection]:
|
||||
return [SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(i, o) for i, o in combos])]
|
||||
|
||||
def test_builds_payload_for_matching_combo(self) -> None:
|
||||
payload = build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0)
|
||||
self.assertEqual(payload["input_index"], 0)
|
||||
self.assertEqual(payload["channel"], "s21")
|
||||
self.assertEqual(payload["preprocessed_record_count"], 1)
|
||||
self.assertTrue(payload["sweep_history"])
|
||||
|
||||
def test_no_matching_traces_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=9, output_index=9)
|
||||
|
||||
def test_invalid_primary_stage_raises(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "primary_stage"):
|
||||
build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0, primary_stage="x")
|
||||
|
||||
def test_normalize_channel(self) -> None:
|
||||
self.assertEqual(_normalize_channel("S21"), "s21")
|
||||
self.assertEqual(_normalize_channel(" s11 "), "s11")
|
||||
with self.assertRaises(ValueError):
|
||||
_normalize_channel("s99")
|
||||
|
||||
def test_complex_to_points(self) -> None:
|
||||
self.assertEqual(_complex_to_points(np.array([1 + 2j, 3 - 4j])), [[1.0, 2.0], [3.0, -4.0]])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Web bridge controller
|
||||
# --------------------------------------------------------------------------- #
|
||||
class WebControllerTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.controller = AppWindowWebController()
|
||||
self.addCleanup(self.controller.deleteLater)
|
||||
|
||||
def test_rejects_unknown_field(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Unknown live-settings"):
|
||||
self.controller.apply_live_settings({"definitely_not_a_field": 1})
|
||||
|
||||
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
||||
received: list[dict] = []
|
||||
self.controller.apply_settings_requested.connect(received.append)
|
||||
out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5})
|
||||
self.assertEqual(received, [{"gpr_min_visible_score": 0.5}])
|
||||
self.assertIsInstance(out, list)
|
||||
|
||||
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
||||
self.controller.update_snapshot(status={"running": True}, live_settings=[{"name": "x"}], frame={"seq": 1})
|
||||
status = self.controller.status()
|
||||
self.assertEqual(status, {"running": True})
|
||||
status["running"] = False # mutating the copy must not affect the controller
|
||||
self.assertTrue(self.controller.status()["running"])
|
||||
self.assertEqual(self.controller.peek_frame(), {"seq": 1})
|
||||
|
||||
def test_frame_only_updates_when_present(self) -> None:
|
||||
self.controller.update_snapshot(status={}, live_settings=[], frame={"seq": 1})
|
||||
self.controller.update_snapshot(status={}, live_settings=[], frame=None) # no new grab
|
||||
self.assertEqual(self.controller.peek_frame(), {"seq": 1}) # keeps the last frame
|
||||
|
||||
def test_controls_emit_signals(self) -> None:
|
||||
fired: list[str] = []
|
||||
self.controller.start_requested.connect(lambda: fired.append("start"))
|
||||
self.controller.stop_requested.connect(lambda: fired.append("stop"))
|
||||
self.controller.single_capture_requested.connect(lambda: fired.append("single"))
|
||||
self.controller.capture_requested.connect(lambda: fired.append("capture"))
|
||||
self.controller.start()
|
||||
self.controller.stop()
|
||||
self.controller.single_capture()
|
||||
self.controller.capture_tmp_reference()
|
||||
self.assertEqual(fired, ["start", "stop", "single", "capture"])
|
||||
|
||||
|
||||
class WebPortTest(unittest.TestCase):
|
||||
def _port_with_env(self, value: str | None) -> int:
|
||||
env = {} if value is None else {"RADAR_SYSTEM_WEBUI_PORT": value}
|
||||
with mock.patch.dict(os.environ, env, clear=False):
|
||||
if value is None:
|
||||
os.environ.pop("RADAR_SYSTEM_WEBUI_PORT", None)
|
||||
return AppWindowWebMixin._web_ui_port()
|
||||
|
||||
def test_default_when_unset_or_invalid(self) -> None:
|
||||
self.assertEqual(self._port_with_env(None), 8080)
|
||||
self.assertEqual(self._port_with_env("abc"), 8080)
|
||||
self.assertEqual(self._port_with_env("99999"), 8080) # out of range
|
||||
self.assertEqual(self._port_with_env("0"), 8080)
|
||||
|
||||
def test_valid_port(self) -> None:
|
||||
self.assertEqual(self._port_with_env("9000"), 9000)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Latest-wins fan-out
|
||||
# --------------------------------------------------------------------------- #
|
||||
class RingBroadcasterFanOutTest(unittest.TestCase):
|
||||
def test_full_client_queue_drops_oldest(self) -> None:
|
||||
async def scenario() -> None:
|
||||
broadcaster = RingBroadcaster(controller=object())
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=1)
|
||||
broadcaster.register(queue)
|
||||
broadcaster._publish({"type": "frame", "seq": 1})
|
||||
broadcaster._publish({"type": "frame", "seq": 2}) # evicts seq 1
|
||||
self.assertEqual(queue.qsize(), 1)
|
||||
self.assertEqual(queue.get_nowait(), {"type": "frame", "seq": 2}) # newest survives
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_unregister_stops_delivery(self) -> None:
|
||||
async def scenario() -> None:
|
||||
broadcaster = RingBroadcaster(controller=object())
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=4)
|
||||
broadcaster.register(queue)
|
||||
broadcaster.unregister(queue)
|
||||
broadcaster.unregister(queue) # idempotent
|
||||
broadcaster._publish({"type": "frame", "seq": 1})
|
||||
self.assertEqual(queue.qsize(), 0)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user