"""Geometry edits preserve XYZ and reject stale or invalid processing results.""" from __future__ import annotations from dataclasses import asdict import os from pathlib import Path import unittest from unittest import mock os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6.QtCore import QSignalBlocker from PyQt6.QtWidgets import QApplication, QComboBox, QDoubleSpinBox from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor from python_app.tests.gui_test_support import isolated_window, widget from python_app.models.dataset_model import ResultCollection from python_app.models.gui_profile_schema import GuiProfileModel from python_app.models.run_config_schema import GprTxGeometryModel, GprRxGeometryModel class AntennaGeometryTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.app = QApplication.instance() or QApplication([]) def test_coordinates_keep_full_precision_and_default_missing_axes(self) -> None: entry = GprTxGeometryModel(output_pos=2, x_m=0.12345678912345, y_m=-2.5, z_m=3.5) editor = AntennaGeometryEditor("output_pos", [entry], position_count=4) self.assertEqual(editor.records(), [asdict(entry)]) editor.set_records([{"output_pos": 1, "x_m": -1}]) self.assertEqual(editor.records(), [{"output_pos": 1, "x_m": -1.0, "y_m": 0.0, "z_m": 0.0}]) def test_invalid_edit_rolls_back_without_emitting_a_change(self) -> None: editor = AntennaGeometryEditor("input_pos", [GprRxGeometryModel(input_pos=0, x_m=1.0)], position_count=2) changed = mock.Mock() editor.changed.connect(changed) with mock.patch("python_app.gui.antenna_geometry_editor.QMessageBox.warning") as warning: item = editor._table.item(0, 1) assert item is not None item.setText("nan") warning.assert_called_once() changed.assert_not_called() self.assertEqual(editor.records()[0]["x_m"], 1.0) def test_full_table_disables_add_and_removal_frees_a_position(self) -> None: editor = AntennaGeometryEditor("input_pos", [GprRxGeometryModel(input_pos=0, x_m=1.0)], position_count=1) self.assertFalse(editor._add_button.isEnabled()) editor._add_row() self.assertEqual(len(editor.records()), 1) editor._table.selectRow(0) editor._remove_button.click() self.assertEqual(editor.records(), []) self.assertTrue(editor._add_button.isEnabled()) changed = mock.Mock() editor.changed.connect(changed) editor._add_button.click() self.assertEqual(editor.records(), []) changed.assert_not_called() item = editor._table.item(0, 1) assert item is not None item.setText("1.25") self.assertEqual(editor.records()[0], {"input_pos": 0, "x_m": 1.25, "y_m": 0.0, "z_m": 0.0}) changed.assert_called_once() def test_invalid_web_geometry_is_rejected_before_mutating(self) -> None: editor = AntennaGeometryEditor("output_pos", [], position_count=2) for records in ([{"output_pos": 2}], [{"output_pos": 0}, {"output_pos": 0}], [{"output_pos": True}], [{"output_pos": 0.5}], [{"output_pos": "0"}], [{}], [None], [{"output_pos": 0, "x_m": None}], [{"output_pos": 0, "x_m": True}], [{"output_pos": 0, "y_m": float("nan")}], [{"output_pos": 0, "z_m": float("inf")}], [{"output_pos": 0, "x_m": 10**400}]): with self.subTest(records=records), self.assertRaises(ValueError): editor.set_records(records) self.assertEqual(editor.records(), []) class WindowGeometryTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.app = QApplication.instance() or QApplication([]) cls.window = isolated_window(cls.addClassCleanup) cls.window._pipeline.timer.stop() cls.initial_config = cls.window._profile.build_config() def setUp(self) -> None: self.w = self.window self.mode = self.w._view.processing.mode self.gain = widget(self.w._view.processing, "_bscan_gain", QDoubleSpinBox) self.w._profile.apply(GuiProfileModel(run_config=self.initial_config.clone()), Path("run_config.json")) def test_profile_and_live_config_preserve_all_coordinates(self) -> None: config = self.initial_config.clone() config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=1.25, y_m=-2.0, z_m=3.0)] config.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=-1.25, y_m=4.0, z_m=-5.0)] self.w._profile.apply(GuiProfileModel(run_config=config), Path("run_config.json")) rebuilt = self.w._profile.build_config() self.assertEqual(rebuilt.gpr, config.gpr) live = self.w._live.build().to_dict() self.assertEqual(live["tx_geometry"], [asdict(entry) for entry in config.gpr.tx_geometry]) self.assertEqual(live["rx_geometry"], [asdict(entry) for entry in config.gpr.rx_geometry]) schema = self.w._config.web_schema() self.assertFalse(any(entry.get("applies_on_start") for entry in schema)) def test_malformed_web_batch_restores_every_changed_widget(self) -> None: original_gain = self.gain.value() original_mode = self.mode.currentText() with mock.patch.object(self.w._log, "show_exception") as error, mock.patch.object(self.w._live.writer, "write") as write: self.w._live.apply_web({ "processor_mode": "bscan", "bscan_gain": original_gain + 1, "tx_geometry": [{"output_pos": 0}, {"output_pos": 0}], }) error.assert_called_once() write.assert_not_called() self.assertEqual(self.gain.value(), original_gain) self.assertEqual(self.mode.currentText(), original_mode) def test_stale_results_cannot_draw_over_new_geometry(self) -> None: revision = (1 << 63) + 123456789 self.w._plots.required_revision = revision with QSignalBlocker(self.mode): self.mode.setCurrentText("gpr") fresh = ResultCollection(collection_id=1, monotonic_ns=1, settings_revision=revision) with mock.patch.object(self.w._plots.gpr, "draw", return_value=True) as draw: self.assertFalse(self.w._plots.draw(ResultCollection(collection_id=1, monotonic_ns=1))) self.assertTrue(self.w._plots.draw(fresh)) draw.assert_called_once() self.assertIs(draw.call_args.args[0], fresh) self.assertFalse(self.w._plots.has_output(fresh)) def test_desktop_and_web_reject_empty_active_geometry(self) -> None: with QSignalBlocker(self.mode): self.mode.setCurrentText("gpr") editor = self.w._view.processing.geometries[0] original = editor.records() editor._table.selectAll() with mock.patch("python_app.gui.antenna_geometry_editor.QMessageBox.warning") as warning, \ mock.patch.object(self.w._live.writer, "write") as write: editor._remove_rows() warning.assert_called_once() write.assert_not_called() self.assertEqual(editor.records(), original) with mock.patch.object(self.w._log, "show_exception") as error: self.w._live.apply_web({"tx_geometry": []}) error.assert_called_once() self.assertEqual(editor.records(), original) def test_invalid_or_mixed_history_commands_do_not_clear_data(self) -> None: with mock.patch.object(self.w._log, "show_exception") as error, mock.patch.object(self.w._snapshots, "delete_history") as clear: self.w._live.apply_web({"history_command": "bogus"}) self.w._live.apply_web({"history_command": "clear_all", "bscan_gain": 2}) self.assertEqual(error.call_count, 2) clear.assert_not_called() if __name__ == "__main__": unittest.main()