From ab0a1bfa2b12aef347ffafb420e397ba2acafdf8 Mon Sep 17 00:00:00 2001 From: bresch Date: Mon, 22 Jun 2026 12:33:28 +0200 Subject: [PATCH 1/2] feat(autotune): menu to add/edit presets --- autotune/CLAUDE.md | 84 +++++++++++ autotune/data_selection_window.py | 174 ++++++++++++---------- autotune/poetry.lock | 85 ++++++++++- autotune/preset_dialogs.py | 237 ++++++++++++++++++++++++++++++ autotune/presets.py | 123 ++++++++++++++++ autotune/pyproject.toml | 3 +- 6 files changed, 623 insertions(+), 83 deletions(-) create mode 100644 autotune/CLAUDE.md create mode 100644 autotune/preset_dialogs.py create mode 100644 autotune/presets.py diff --git a/autotune/CLAUDE.md b/autotune/CLAUDE.md new file mode 100644 index 0000000..25dcaa0 --- /dev/null +++ b/autotune/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the `autotune` module within the Flight Control Prototyping Scripts — a PyQt5 GUI application for PX4 fixed-wing/multirotor rate controller tuning via system identification from flight logs. + +## Setup and Running + +**Install with Poetry (recommended):** +```bash +cd /home/mathieu/src/Flight_Control_Prototyping_Scripts/autotune +poetry install +poetry run python3 autotune.py +``` + +**Or with venv:** +```bash +python3 -m venv virtualenv-test +source virtualenv-test/bin/activate +pip3 install numpy scipy pyulog control pyqt5 pyyaml +python3 autotune.py +``` + +**Run simulation-based test (no GUI data needed):** +```bash +poetry run python3 simulated_autotune.py +``` + +## Code Quality + +The root repo uses pre-commit hooks with **black** (formatting) and **isort** (imports, Black profile). Run manually: +```bash +cd /home/mathieu/src/Flight_Control_Prototyping_Scripts +poetry run black autotune/ +poetry run isort autotune/ +``` + +## Architecture + +### Data flow +``` +ULog Flight Log → data_extractor.py → data_selection_window.py → +system_identification.py (ARX/RLS) → pid_design.py (GMVC) → +autotune.py (Bode/step response validation) → PX4 gains +``` + +### Module responsibilities + +- **`autotune.py`** — Main PyQt5 GUI window (~900 lines). Orchestrates the full workflow: log loading, axis/vehicle-type selection, model parameter configuration, gain computation, and result visualization via matplotlib figures embedded in Qt. + +- **`data_extractor.py`** — Parses PX4 ULog binary logs using `pyulog`. Extracts and interpolates signals (rates, setpoints, actuator outputs, airspeed). Returns numpy arrays aligned to a common time base. + +- **`data_selection_window.py`** — Interactive matplotlib window for selecting the maneuver time window and inspecting signal quality/coherence before running identification. Loads input/output presets via `presets.py` and lets the user add/edit/delete them through `preset_dialogs.py`. + +- **`presets.py`** — Loads/saves input/output presets from the user-editable `presets.yaml` (next to the code). Seeds the file with `DEFAULT_PRESETS` on first run; falls back to defaults if the file is missing or unparseable. + +- **`preset_dialogs.py`** — `PresetEditDialog`: a Qt dialog to add, edit, or delete a preset, with an old→new signal diff that switches to "create" mode when the preset is renamed. + +- **`system_identification.py`** — Preprocesses signals (bias removal, filtering) and runs weighted RLS to fit an ARX model. Returns numerator/denominator polynomial coefficients. + +- **`arx_rls.py`** — Core recursive least-squares implementation. Assumes ARX model: `A(q⁻¹)y(k) = q⁻ᵈ B(q⁻¹)u(k) + A(q⁻¹)e(k)`. Uses matrix-inversion-free update for efficiency. + +- **`pid_design.py`** — Computes PX4-compatible P/I/D gains from identified model polynomials using **General Minimum Variance Control (GMVC)**. Inputs: ARX coefficients, sample time, rise time, damping ratio. + +- **`pid_analyse_window.py`** — Secondary PyQt5 window with pole-zero plots, Bode diagrams, stability margins, and disturbance response for the tuned controller. + +- **`closed_loop_sim.py`** — Simulates closed-loop behavior with the identified model and designed gains to validate stability before applying to the aircraft. + +- **`simulated_autotune.py`** — End-to-end test on a synthetic 2nd-order system (no flight log needed). + +### Key dependencies + +| Package | Purpose | +|---------|---------| +| `control` | Transfer functions, Bode plots, pole-zero, simulation | +| `pyulog` | PX4 ULog binary flight log parsing | +| `pyqt5` | GUI framework | +| `numpy` / `scipy` | Numerical computing, signal processing | +| `pyyaml` | Read/write the user-editable `presets.yaml` | + +### Sample logs for testing +Located in `logs/`: `quadrotor_sitl_jmavsim.ulg`, `quadrotor_x500.ulg`, `vtol_standard.ulg`. diff --git a/autotune/data_selection_window.py b/autotune/data_selection_window.py index 060cb74..0a4f994 100644 --- a/autotune/data_selection_window.py +++ b/autotune/data_selection_window.py @@ -4,6 +4,9 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.widgets import SpanSelector from pid_analyse_window import PIDAnalyseWindow +from preset_dialogs import PresetEditDialog +from presets import load_presets, save_presets +from PyQt5.QtGui import QBrush, QColor from PyQt5.QtWidgets import ( QComboBox, QDialog, @@ -13,7 +16,6 @@ QLabel, QMessageBox, QPushButton, - QRadioButton, QVBoxLayout, ) from scipy import signal @@ -24,76 +26,10 @@ class DataSelectionWindow(QDialog): def __init__(self, filename): QDialog.__init__(self) - self.preset_candidates = { - "Rollrate": { - "input": "vehicle_torque_setpoint/xyz[0].0", - "output": "vehicle_angular_velocity/xyz[0].0", - "input_legacy": "actuator_controls_0/control[0].0", - }, - "Pitchrate": { - "input": "vehicle_torque_setpoint/xyz[1].0", - "output": "vehicle_angular_velocity/xyz[1].0", - "input_legacy": "actuator_controls_0/control[1].0", - }, - "Yawrate": { - "input": "vehicle_torque_setpoint/xyz[2].0", - "output": "vehicle_angular_velocity/xyz[2].0", - "input_legacy": "actuator_controls_0/control[2].0", - }, - "Rollrate(FW)": { - "input": "vehicle_torque_setpoint/xyz[0].1", - "output": "vehicle_angular_velocity/xyz[0].0", - "input_legacy": "actuator_controls_1/control[0].0", - }, - "Pitchrate(FW)": { - "input": "vehicle_torque_setpoint/xyz[1].1", - "output": "vehicle_angular_velocity/xyz[1].0", - "input_legacy": "actuator_controls_1/control[1].0", - }, - "Yawrate(FW)": { - "input": "vehicle_torque_setpoint/xyz[2].1", - "output": "vehicle_angular_velocity/xyz[2].0", - "input_legacy": "actuator_controls_1/control[2].0", - }, - "Rollrate(closed-loop)": { - "input": "vehicle_rates_setpoint/roll.0", - "output": "vehicle_angular_velocity/xyz[0].0", - }, - "Pitchrate(closed-loop)": { - "input": "vehicle_rates_setpoint/pitch.0", - "output": "vehicle_angular_velocity/xyz[1].0", - }, - "Yawrate(closed-loop)": { - "input": "vehicle_rates_setpoint/yaw.0", - "output": "vehicle_angular_velocity/xyz[2].0", - }, - "GimbalRollRate": { - "input": "motor_state/roll.effort_cmd.0", - "output": "motor_angular_rates/roll.angular_rate.0", - }, - "GimbalPitchRate": { - "input": "motor_state/pitch.effort_cmd.0", - "output": "motor_angular_rates/pitch.angular_rate.0", - }, - "GimbalYawRate": { - "input": "motor_state/yaw.effort_cmd.0", - "output": "motor_angular_rates/yaw.angular_rate.0", - }, - "GimbalRollAtt": { - "input": "motor_control/motor_commands.roll.angular_rate_setpoint.0", - "output": "attitude_info/roll.0", - }, - "GimbalPitchAtt": { - "input": "motor_control/motor_commands.pitch.angular_rate_setpoint.0", - "output": "attitude_info/pitch.0", - }, - "GimbalYawAtt": { - "input": "motor_control/motor_commands.yaw.angular_rate_setpoint.0", - "output": "attitude_info/yaw.0", - }, - } + self.preset_candidates = load_presets() self.presets = {} + self.topic_names = [] self.t = [] self.u = [] @@ -121,7 +57,15 @@ def __init__(self, filename): self.combo_preset.setEditable(False) self.combo_preset.currentIndexChanged.connect(self.selectPreset) - in_out_group.addRow(QLabel("Preset:"), self.combo_preset) + preset_row = QHBoxLayout() + preset_row.addWidget(self.combo_preset) + self.btn_add_preset = QPushButton("Add") + self.btn_add_preset.clicked.connect(self.addPreset) + preset_row.addWidget(self.btn_add_preset) + self.btn_edit_preset = QPushButton("Edit") + self.btn_edit_preset.clicked.connect(self.editPreset) + preset_row.addWidget(self.btn_edit_preset) + in_out_group.addRow(QLabel("Preset:"), preset_row) self.combo_u = SearchableComboBox() self.combo_u.currentIndexChanged.connect(self.selectUData) @@ -187,30 +131,43 @@ def openFile(self): f"{topic.topic_name}/{topic.variable_name}.{topic.instance}" for topic in self.topics ] + self.topic_names = list_names self.combo_u.clear() self.combo_u.addItems(list_names) self.combo_y.clear() self.combo_y.addItems(list_names) - # Trigger preset selection. If no preset matches the available - # topics, leave the input/output combos for manual configuration. + # Show all presets, then select the first one that matches the + # available topics. Presets that don't match are still listed (and + # editable) but greyed out and won't auto-fill the input/output. self.fillPresets() - if self.presets: - self.combo_preset.setCurrentIndex(0) - self.selectPreset(0) + for i in range(self.combo_preset.count()): + if self.combo_preset.itemText(i) in self.presets: + self.combo_preset.setCurrentIndex(i) + self.selectPreset(i) + break def fillPresets(self): + self.combo_preset.blockSignals(True) self.combo_preset.clear() self.presets = {} - for candidate in self.preset_candidates: + # List every preset so the user can see and edit all of them. Track + # which ones match the loaded log's topics in self.presets. + for index, candidate in enumerate(self.preset_candidates): + self.combo_preset.addItem(candidate) (index_u, index_y) = self.findInputOutputIndex( self.preset_candidates[candidate] ) if index_u > -1 and index_y > -1: self.presets[candidate] = self.preset_candidates[candidate] + else: + # Grey out presets that don't match the current log. + item = self.combo_preset.model().item(index) + if item is not None: + item.setForeground(QBrush(QColor("gray"))) - self.combo_preset.addItems(list(self.presets.keys())) + self.combo_preset.blockSignals(False) def printRangeError(self): msg = QMessageBox() @@ -220,11 +177,10 @@ def printRangeError(self): msg.exec_() def selectPreset(self, index): - preset_keys = list(self.presets.keys()) - if index < 0 or index >= len(preset_keys): + name = self.combo_preset.itemText(index) + if name not in self.preset_candidates: return - preset_key = preset_keys[index] - preset = self.presets[preset_key] + preset = self.preset_candidates[name] (index_u, index_y) = self.findInputOutputIndex(preset) if index_u > -1: @@ -246,6 +202,62 @@ def findInputOutputIndex(self, preset): return (index_u, index_y) + def addPreset(self): + self._openPresetDialog(create=True) + + def editPreset(self): + self._openPresetDialog(create=False) + + def _openPresetDialog(self, create): + if not self.topic_names: + QMessageBox.information( + self, "No log loaded", "Open a log file before editing presets." + ) + return + + # The currently selected preset (from the topic-matched subset shown in + # the combo); may be empty if no preset matched the log. + selected_name = self.combo_preset.currentText() + if selected_name not in self.preset_candidates: + selected_name = None + + if not create and selected_name is None: + QMessageBox.information( + self, "No preset selected", "Select a preset to edit first." + ) + return + + dialog = PresetEditDialog( + self, + create, + selected_name, + self.preset_candidates, + self.topic_names, + self.combo_u.currentText(), + self.combo_y.currentText(), + ) + dialog.exec_() + + if dialog.result_action == "save": + if dialog.remove_name and dialog.remove_name != dialog.name: + self.preset_candidates.pop(dialog.remove_name, None) + self.preset_candidates[dialog.name] = dialog.preset + target = dialog.name + elif dialog.result_action == "delete": + self.preset_candidates.pop(dialog.remove_name, None) + target = None + else: + return + + save_presets(self.preset_candidates) + self.fillPresets() + + # Reselect the affected preset if it is still present and matches. + if target is not None: + index = self.combo_preset.findText(target) + if index > -1: + self.combo_preset.setCurrentIndex(index) + def selectUData(self, index): self.index_u = index (self.t, self.u) = self.data_extractor.getPreview(self.topics[index]) diff --git a/autotune/poetry.lock b/autotune/poetry.lock index 2b7d3ac..b7bf001 100644 --- a/autotune/poetry.lock +++ b/autotune/poetry.lock @@ -623,6 +623,89 @@ numpy = {version = ">=1.25", markers = "python_version >= \"3.9\""} [package.extras] test = ["ddt", "pytest"] +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + [[package]] name = "scipy" version = "1.15.3" @@ -702,4 +785,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.10, <4.0" -content-hash = "db831e3cde414745183aa5789dfb2f3b39b805d7c03e475a460a8c5989748608" +content-hash = "e6b7403c5772d6eb0d65fe846c0792921b43aa0f8f13e0c1c2e557c0cbfaade2" diff --git a/autotune/preset_dialogs.py b/autotune/preset_dialogs.py new file mode 100644 index 0000000..8e78d11 --- /dev/null +++ b/autotune/preset_dialogs.py @@ -0,0 +1,237 @@ +"""Dialog for adding/editing/renaming/deleting input-output presets.""" + +from PyQt5.QtWidgets import ( + QDialog, + QDialogButtonBox, + QGridLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, +) +from searchable_combo_box import SearchableComboBox + + +class PresetEditDialog(QDialog): + """Add or edit a single preset. + + The mode is fixed by the caller via ``create``: + - create=True: always makes a brand new preset (the name starts blank). + - create=False: edits ``original_name`` - keeping its name updates its + signals, typing a brand new name renames it, and it can be deleted. + + The name field autocompletes existing names for convenience, but a name + that collides with a *different* existing preset is rejected on confirm (it + never silently overwrites or switches target). + + The "new" input/output default to the current main-window selection. + + After ``exec_()``, read the outcome: + - result_action == "save": upsert ``preset`` under ``name``; if + ``remove_name`` is set and differs from ``name``, delete it first. + - result_action == "delete": delete preset ``remove_name``. + - result_action == "cancel": no change. + """ + + def __init__( + self, + parent, + create, + original_name, + existing_presets, + topic_list, + default_input, + default_output, + ): + super().__init__(parent) + self.setWindowTitle("Add preset" if create else "Edit preset") + + self.existing = existing_presets + + self.result_action = "cancel" + self.name = None + self.preset = None + self.remove_name = None + + # Fixed at construction. _creating True => create mode (no base preset). + # Otherwise we edit _base_name. The base never changes while the dialog + # is open, so autocomplete can't silently switch which preset is edited. + self._creating = bool(create) + self._base_name = None if self._creating else original_name + + layout = QVBoxLayout() + + # --- Name (searchable combo of existing presets) --- + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Name:")) + self.combo_name = SearchableComboBox() + self.combo_name.addItems(list(existing_presets.keys())) + self.combo_name.setEditText(self._base_name or "") + self.combo_name.editTextChanged.connect(self._update_mode) + name_row.addWidget(self.combo_name) + layout.addLayout(name_row) + + # --- old -> new grid --- + grid = QGridLayout() + grid.addWidget(QLabel("Signal"), 0, 0) + self.label_old_header = QLabel("Old") + grid.addWidget(self.label_old_header, 0, 1) + grid.addWidget(QLabel(""), 0, 2) + grid.addWidget(QLabel("New"), 0, 3) + + grid.addWidget(QLabel("Input:"), 1, 0) + self.label_old_input = QLabel("") + grid.addWidget(self.label_old_input, 1, 1) + self.arrow_input = QLabel("→") + grid.addWidget(self.arrow_input, 1, 2) + self.combo_new_input = SearchableComboBox() + self.combo_new_input.addItems(topic_list) + self._select(self.combo_new_input, default_input) + grid.addWidget(self.combo_new_input, 1, 3) + + grid.addWidget(QLabel("Output:"), 2, 0) + self.label_old_output = QLabel("") + grid.addWidget(self.label_old_output, 2, 1) + self.arrow_output = QLabel("→") + grid.addWidget(self.arrow_output, 2, 2) + self.combo_new_output = SearchableComboBox() + self.combo_new_output.addItems(topic_list) + self._select(self.combo_new_output, default_output) + grid.addWidget(self.combo_new_output, 2, 3) + + layout.addLayout(grid) + + # --- buttons --- + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.btn_confirm = self.buttons.button(QDialogButtonBox.Ok) + self.buttons.accepted.connect(self._on_confirm) + self.buttons.rejected.connect(self.reject) + + self.btn_delete = QPushButton("Delete preset") + self.btn_delete.setStyleSheet( + "color: white; background-color: #c0392b; font-weight: bold;" + ) + self.btn_delete.clicked.connect(self._on_delete) + + btn_row = QHBoxLayout() + btn_row.addWidget(self.btn_delete) + btn_row.addStretch() + btn_row.addWidget(self.buttons) + layout.addLayout(btn_row) + + self.setLayout(layout) + + if self._creating: + self.combo_name.setFocus() + self._update_mode() + + def _select(self, combo, text): + if not text: + return + index = combo.findText(text) + if index >= 0: + combo.setCurrentIndex(index) + else: + combo.setEditText(text) + + def _target_name(self): + return self.combo_name.currentText().strip() + + def _mode(self): + """Return 'create', 'update' or 'rename' for the current name. + + Derived from the explicit base preset, never from typed text matching. + """ + if self._creating or self._base_name is None: + return "create" + if self._target_name() == self._base_name: + return "update" + return "rename" + + def _source_name(self): + """Preset whose stored values feed the 'old' column and legacy keys.""" + return self._base_name if self._base_name in self.existing else None + + def _delete_target(self): + return self._base_name if self._base_name in self.existing else None + + def _update_mode(self): + mode = self._mode() + source = self._source_name() + src = self.existing.get(source, {}) if source else {} + + self.label_old_input.setText(src.get("input", "")) + self.label_old_output.setText(src.get("output", "")) + + show_old = mode in ("update", "rename") + for w in ( + self.label_old_header, + self.label_old_input, + self.label_old_output, + self.arrow_input, + self.arrow_output, + ): + w.setVisible(show_old) + + # Editing always reads "Update preset", whether or not the name changed. + self.btn_confirm.setText( + "Create new preset" if mode == "create" else "Update preset" + ) + + self.btn_delete.setVisible(self._delete_target() is not None) + + def _on_confirm(self): + name = self._target_name() + if not name: + QMessageBox.warning( + self, "Invalid name", "The preset name cannot be empty." + ) + return + + new_input = self.combo_new_input.currentText().strip() + new_output = self.combo_new_output.currentText().strip() + if not new_input or not new_output: + QMessageBox.warning( + self, "Invalid signals", "Input and output must both be set." + ) + return + + mode = self._mode() + if mode in ("create", "rename") and name in self.existing: + QMessageBox.warning( + self, + "Name already exists", + f"A preset named '{name}' already exists.", + ) + return + + # Preserve legacy fallbacks from the source preset. + source = self._source_name() + src = self.existing.get(source, {}) if source else {} + preset = {"input": new_input, "output": new_output} + for key in ("input_legacy", "output_legacy"): + if key in src: + preset[key] = src[key] + + self.name = name + self.preset = preset + self.remove_name = source if (mode == "rename" and source != name) else None + self.result_action = "save" + self.accept() + + def _on_delete(self): + target = self._delete_target() + if target is None: + return + reply = QMessageBox.question( + self, + "Delete preset", + f"Delete preset '{target}'?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self.remove_name = target + self.result_action = "delete" + self.accept() diff --git a/autotune/presets.py b/autotune/presets.py new file mode 100644 index 0000000..a212727 --- /dev/null +++ b/autotune/presets.py @@ -0,0 +1,123 @@ +"""Persistence for autotune input/output presets. + +Presets map a human-readable name to the input/output ULog signals used for +system identification. They live in an external, user-editable ``presets.yaml`` +file next to this module so they can be edited by hand or from the GUI without +touching the source code. If the file is missing it is seeded with the built-in +defaults below; if it is unreadable/corrupt we fall back to the defaults rather +than crashing. +""" + +import os + +import yaml + +# Built-in presets used to seed presets.yaml on first run (and as a fallback if +# the file cannot be read). Each preset maps a name to a dict with "input" and +# "output" topics, plus optional "input_legacy"/"output_legacy" fallbacks. +DEFAULT_PRESETS = { + "Rollrate": { + "input": "vehicle_torque_setpoint/xyz[0].0", + "output": "vehicle_angular_velocity/xyz[0].0", + "input_legacy": "actuator_controls_0/control[0].0", + }, + "Pitchrate": { + "input": "vehicle_torque_setpoint/xyz[1].0", + "output": "vehicle_angular_velocity/xyz[1].0", + "input_legacy": "actuator_controls_0/control[1].0", + }, + "Yawrate": { + "input": "vehicle_torque_setpoint/xyz[2].0", + "output": "vehicle_angular_velocity/xyz[2].0", + "input_legacy": "actuator_controls_0/control[2].0", + }, + "Rollrate(FW)": { + "input": "vehicle_torque_setpoint/xyz[0].1", + "output": "vehicle_angular_velocity/xyz[0].0", + "input_legacy": "actuator_controls_1/control[0].0", + }, + "Pitchrate(FW)": { + "input": "vehicle_torque_setpoint/xyz[1].1", + "output": "vehicle_angular_velocity/xyz[1].0", + "input_legacy": "actuator_controls_1/control[1].0", + }, + "Yawrate(FW)": { + "input": "vehicle_torque_setpoint/xyz[2].1", + "output": "vehicle_angular_velocity/xyz[2].0", + "input_legacy": "actuator_controls_1/control[2].0", + }, + "Rollrate(closed-loop)": { + "input": "vehicle_rates_setpoint/roll.0", + "output": "vehicle_angular_velocity/xyz[0].0", + }, + "Pitchrate(closed-loop)": { + "input": "vehicle_rates_setpoint/pitch.0", + "output": "vehicle_angular_velocity/xyz[1].0", + }, + "Yawrate(closed-loop)": { + "input": "vehicle_rates_setpoint/yaw.0", + "output": "vehicle_angular_velocity/xyz[2].0", + }, + "GimbalRollRate": { + "input": "motor_state/roll.effort_cmd.0", + "output": "motor_angular_rates/roll.angular_rate.0", + }, + "GimbalPitchRate": { + "input": "motor_state/pitch.effort_cmd.0", + "output": "motor_angular_rates/pitch.angular_rate.0", + }, + "GimbalYawRate": { + "input": "motor_state/yaw.effort_cmd.0", + "output": "motor_angular_rates/yaw.angular_rate.0", + }, + "GimbalRollAtt": { + "input": "motor_control/motor_commands.roll.angular_rate_setpoint.0", + "output": "attitude_info/roll.0", + }, + "GimbalPitchAtt": { + "input": "motor_control/motor_commands.pitch.angular_rate_setpoint.0", + "output": "attitude_info/pitch.0", + }, + "GimbalYawAtt": { + "input": "motor_control/motor_commands.yaw.angular_rate_setpoint.0", + "output": "attitude_info/yaw.0", + }, +} + + +def presets_file_path(): + """Return the path to presets.yaml next to this module.""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "presets.yaml") + + +def load_presets(): + """Load presets from presets.yaml. + + Seeds the file with DEFAULT_PRESETS if it does not exist. Falls back to a + copy of DEFAULT_PRESETS if the file is missing or cannot be parsed. + """ + path = presets_file_path() + + if not os.path.exists(path): + save_presets(DEFAULT_PRESETS) + return {k: dict(v) for k, v in DEFAULT_PRESETS.items()} + + try: + with open(path, "r") as f: + presets = yaml.safe_load(f) + if not isinstance(presets, dict): + raise ValueError("presets.yaml does not contain a mapping") + return presets + except (OSError, yaml.YAMLError, ValueError) as e: + print(f"Warning: could not read {path} ({e}); using built-in defaults.") + return {k: dict(v) for k, v in DEFAULT_PRESETS.items()} + + +def save_presets(presets): + """Write presets to presets.yaml, preserving insertion order.""" + path = presets_file_path() + try: + with open(path, "w") as f: + yaml.safe_dump(presets, f, sort_keys=False, default_flow_style=False) + except OSError as e: + print(f"Warning: could not write {path} ({e}).") diff --git a/autotune/pyproject.toml b/autotune/pyproject.toml index 9175d22..b3b54eb 100644 --- a/autotune/pyproject.toml +++ b/autotune/pyproject.toml @@ -12,7 +12,8 @@ dependencies = [ "control (>=0.10.2,<0.11.0)", "scipy (>=1.15.1,<2.0.0)", "pyqt5 (>=5.15.11,<6.0.0)", - "pyulog (>=1.2.2,<2.0.0)" + "pyulog (>=1.2.2,<2.0.0)", + "pyyaml (>=6.0,<7.0)" ] From e5eec151228df199fabc0dfdf745ff47893ab8ff Mon Sep 17 00:00:00 2001 From: bresch Date: Mon, 22 Jun 2026 12:43:38 +0200 Subject: [PATCH 2/2] feat(autotune): add unit tests for presets UI --- .github/workflows/autotune_tests.yml | 35 ++++++ autotune/CLAUDE.md | 10 ++ autotune/poetry.lock | 174 ++++++++++++++++++++++++++- autotune/pyproject.toml | 3 + autotune/test_preset_dialogs.py | 168 ++++++++++++++++++++++++++ autotune/test_presets.py | 70 +++++++++++ 6 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/autotune_tests.yml create mode 100644 autotune/test_preset_dialogs.py create mode 100644 autotune/test_presets.py diff --git a/.github/workflows/autotune_tests.yml b/.github/workflows/autotune_tests.yml new file mode 100644 index 0000000..2b5f157 --- /dev/null +++ b/.github/workflows/autotune_tests.yml @@ -0,0 +1,35 @@ +name: autotune_tests +on: + pull_request: + push: + branches: [master] + +jobs: + autotune_tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: autotune + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + # Qt's "offscreen" platform plugin still needs these shared libraries. + - name: Install Qt offscreen system libraries + run: | + sudo apt-get update + sudo apt-get install -y \ + libegl1 libgl1 libxkbcommon0 libdbus-1-3 + + - name: Install dependencies + run: | + pip install poetry + poetry install + + - name: Run tests + env: + QT_QPA_PLATFORM: offscreen + run: poetry run pytest -v diff --git a/autotune/CLAUDE.md b/autotune/CLAUDE.md index 25dcaa0..4c30cba 100644 --- a/autotune/CLAUDE.md +++ b/autotune/CLAUDE.md @@ -28,6 +28,16 @@ python3 autotune.py poetry run python3 simulated_autotune.py ``` +**Run the unit/functional tests:** +```bash +# pytest is in the dev group; the GUI tests need Qt's offscreen platform +QT_QPA_PLATFORM=offscreen poetry run pytest -v +``` +`test_presets.py` covers preset load/save (seed, round-trip, order, corrupt-file +fallback); `test_preset_dialogs.py` drives `PresetEditDialog` headlessly to verify +the add/edit/rename/create/delete behavior. These run in CI via +`.github/workflows/autotune_tests.yml`. + ## Code Quality The root repo uses pre-commit hooks with **black** (formatting) and **isort** (imports, Black profile). Run manually: diff --git a/autotune/poetry.lock b/autotune/poetry.lock index b7bf001..4119353 100644 --- a/autotune/poetry.lock +++ b/autotune/poetry.lock @@ -1,5 +1,18 @@ # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + [[package]] name = "contourpy" version = "1.3.2" @@ -115,6 +128,25 @@ files = [ docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] tests = ["pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "fonttools" version = "4.59.0" @@ -180,6 +212,18 @@ type1 = ["xattr ; sys_platform == \"darwin\""] unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "kiwisolver" version = "1.4.8" @@ -380,7 +424,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -511,6 +555,37 @@ tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "ole typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyparsing" version = "3.2.3" @@ -590,6 +665,30 @@ files = [ {file = "pyqt5_sip-12.17.0.tar.gz", hash = "sha256:682dadcdbd2239af9fdc0c0628e2776b820e128bec88b49b8d692fe682f90b4f"}, ] +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -782,7 +881,78 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +[[package]] +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + [metadata] lock-version = "2.1" python-versions = ">=3.10, <4.0" -content-hash = "e6b7403c5772d6eb0d65fe846c0792921b43aa0f8f13e0c1c2e557c0cbfaade2" +content-hash = "1eaed51593e28bdbde1b2932eda85f852fa8118cde71b6832e71379a53ddc4ad" diff --git a/autotune/pyproject.toml b/autotune/pyproject.toml index b3b54eb..2aa7ac7 100644 --- a/autotune/pyproject.toml +++ b/autotune/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ ] +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" + [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/autotune/test_preset_dialogs.py b/autotune/test_preset_dialogs.py new file mode 100644 index 0000000..483d581 --- /dev/null +++ b/autotune/test_preset_dialogs.py @@ -0,0 +1,168 @@ +"""Functional tests for the preset add/edit/rename/delete dialog. + +These drive PresetEditDialog headlessly (no real window is shown). Run with: + QT_QPA_PLATFORM=offscreen poetry run pytest test_preset_dialogs.py +The offscreen platform is also set automatically below as a fallback. +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import preset_dialogs # noqa: E402 +import pytest # noqa: E402 +from preset_dialogs import PresetEditDialog # noqa: E402 +from PyQt5.QtWidgets import QApplication, QMessageBox # noqa: E402 + +EXISTING = { + "Rollrate": {"input": "a/in.0", "output": "a/out.0", "input_legacy": "a/leg.0"}, + "Pitchrate": {"input": "b/in.0", "output": "b/out.0"}, +} +TOPICS = ["a/in.0", "a/out.0", "b/in.0", "b/out.0", "c/in.0", "c/out.0", "a/leg.0"] +SEL_IN, SEL_OUT = "c/in.0", "c/out.0" # "currently selected" main-window signals + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def make(qapp): + def _make(original_name, create=False): + # Fresh copy of EXISTING per dialog so tests stay isolated. + existing = {k: dict(v) for k, v in EXISTING.items()} + return PresetEditDialog( + None, create, original_name, existing, TOPICS, SEL_IN, SEL_OUT + ) + + return _make + + +@pytest.fixture +def no_modals(monkeypatch): + """Stub the modal warning/question boxes so headless tests never block.""" + monkeypatch.setattr(preset_dialogs.QMessageBox, "warning", lambda *a, **k: None) + monkeypatch.setattr( + preset_dialogs.QMessageBox, "question", lambda *a, **k: QMessageBox.Yes + ) + + +def test_edit_mode_defaults_to_current_selection(make): + d = make("Rollrate") + assert d._mode() == "update" + assert d.btn_confirm.text() == "Update preset" + # old column shows the stored preset, new combos default to the selection + assert d.label_old_input.text() == "a/in.0" + assert d.combo_new_input.currentText() == SEL_IN + assert d.combo_new_output.currentText() == SEL_OUT + assert d._delete_target() == "Rollrate" + + +def test_typing_unknown_name_renames(make): + d = make("Rollrate") + d.combo_name.setEditText("Rollrate_v2") + assert d._mode() == "rename" + assert d.btn_confirm.text() == "Update preset" + + d._on_confirm() + + assert d.result_action == "save" + assert d.name == "Rollrate_v2" + assert d.remove_name == "Rollrate" # old key removed by caller + # legacy fallback is carried over from the renamed preset + assert d.preset == { + "input": SEL_IN, + "output": SEL_OUT, + "input_legacy": "a/leg.0", + } + + +def test_edit_button_opens_in_update_mode(make): + d = make("Rollrate") # Edit button -> create=False + assert d._mode() == "update" + assert d.windowTitle() == "Edit preset" + + +def test_add_button_creates(make): + d = make(None, create=True) # Add button -> create=True + assert d._mode() == "create" + assert d.windowTitle() == "Add preset" + assert d.btn_confirm.text() == "Create new preset" + assert d.combo_name.currentText() == "" + assert d._delete_target() is None # nothing to delete while creating + + d.combo_name.setEditText("BrandNew") + d._on_confirm() + + assert d.result_action == "save" + assert d.name == "BrandNew" + assert d.remove_name is None # nothing removed + assert d.preset == {"input": SEL_IN, "output": SEL_OUT} + + +def test_renaming_to_existing_name_is_rejected(make, no_modals): + # Editing "Rollrate" and changing its name to another existing preset must + # be refused with feedback - never a silent overwrite/switch. + d = make("Rollrate") + d.combo_name.setEditText("Pitchrate") + assert d._mode() == "rename" + assert d._base_name == "Rollrate" # target never switches to Pitchrate + + d._on_confirm() + + assert d.result_action == "cancel" # blocked + + +def test_selecting_existing_in_edit_mode_does_not_switch_target(make, no_modals): + # Picking an existing name from the dropdown only fills the text; it does + # not silently switch which preset is being edited. + d = make("Rollrate") + d.combo_name.setEditText("Pitchrate") # what a dropdown pick does to the text + assert d._base_name == "Rollrate" + assert d.label_old_input.text() == "a/in.0" # still showing Rollrate + d._on_confirm() + assert d.result_action == "cancel" # collision, not a silent Pitchrate update + + +def test_add_then_typing_existing_name_stays_create(make, no_modals): + # In Add mode, typing a name that transiently (or fully) matches an existing + # preset must NOT turn into an edit/rename. + d = make(None, create=True) + d.combo_name.setEditText("Rollrate") # collides while typing + assert d._mode() == "create" + d.combo_name.setEditText("Rollrate2") # extended to a unique name + assert d._mode() == "create" + assert d.btn_confirm.text() == "Create new preset" + + d._on_confirm() + + assert d.result_action == "save" + assert d.name == "Rollrate2" + assert d.remove_name is None # existing "Rollrate" left untouched + assert d.preset == {"input": SEL_IN, "output": SEL_OUT} + + +def test_add_then_typing_existing_name_then_confirm_is_rejected(make, no_modals): + # Leaving an Add-mode name equal to an existing preset is rejected as a + # collision (rather than silently overwriting). + d = make(None, create=True) + d.combo_name.setEditText("Pitchrate") + assert d._mode() == "create" + d._on_confirm() + assert d.result_action == "cancel" + + +def test_empty_name_is_rejected(make, no_modals): + d = make(None, create=True) + d.combo_name.setEditText("") + d._on_confirm() + assert d.result_action == "cancel" + + +def test_delete_targets_selected_preset(make, no_modals): + d = make("Rollrate") + d._on_delete() + assert d.result_action == "delete" + assert d.remove_name == "Rollrate" diff --git a/autotune/test_presets.py b/autotune/test_presets.py new file mode 100644 index 0000000..2b3a873 --- /dev/null +++ b/autotune/test_presets.py @@ -0,0 +1,70 @@ +"""Unit tests for preset persistence (presets.py). + +Run with: poetry run pytest test_presets.py +""" + +import presets + + +def _redirect(monkeypatch, tmp_path): + """Point presets.py at a throwaway file so the real presets.yaml is safe.""" + path = tmp_path / "presets.yaml" + monkeypatch.setattr(presets, "presets_file_path", lambda: str(path)) + return path + + +def test_load_seeds_defaults_when_missing(tmp_path, monkeypatch): + path = _redirect(monkeypatch, tmp_path) + assert not path.exists() + + loaded = presets.load_presets() + + assert path.exists() # file is seeded on first run + assert loaded == presets.DEFAULT_PRESETS + + +def test_round_trip_preserves_insertion_order(tmp_path, monkeypatch): + _redirect(monkeypatch, tmp_path) + data = { + "Zulu": {"input": "z/in.0", "output": "z/out.0"}, + "Alpha": {"input": "a/in.0", "output": "a/out.0"}, + } + + presets.save_presets(data) + loaded = presets.load_presets() + + assert loaded == data + assert list(loaded.keys()) == ["Zulu", "Alpha"] # not alphabetised + + +def test_legacy_keys_survive_round_trip(tmp_path, monkeypatch): + _redirect(monkeypatch, tmp_path) + data = { + "Rollrate": { + "input": "vehicle_torque_setpoint/xyz[0].0", + "output": "vehicle_angular_velocity/xyz[0].0", + "input_legacy": "actuator_controls_0/control[0].0", + } + } + + presets.save_presets(data) + + assert presets.load_presets() == data + + +def test_corrupt_file_falls_back_to_defaults(tmp_path, monkeypatch): + path = _redirect(monkeypatch, tmp_path) + path.write_text(":\n - [ this is not valid yaml\n") + + loaded = presets.load_presets() + + assert loaded == presets.DEFAULT_PRESETS + + +def test_non_mapping_file_falls_back_to_defaults(tmp_path, monkeypatch): + path = _redirect(monkeypatch, tmp_path) + path.write_text("- just\n- a\n- list\n") + + loaded = presets.load_presets() + + assert loaded == presets.DEFAULT_PRESETS