From c30b93154b672b1f73346a7b2af10304a8b6b930 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Wed, 28 Oct 2026 20:05:27 +0100 Subject: [PATCH 1/6] test(plugins): Added mutation tests to backends and data_models Signed-off-by: Dr.-Ing. Amilcar do Carmo Lucas --- CONTRIBUTING.md | 37 +- pyproject.toml | 50 +- tests/plugins/conftest.py | 11 +- ...st_data_model_accelerometer_calibration.py | 40 +- .../test_data_model_ahrs_orientation.py | 260 +++- .../plugins/test_data_model_esc_rpm_scale.py | 73 +- .../test_data_model_level_calibration.py | 19 +- .../plugins/test_data_model_rc_calibration.py | 350 ++++- tests/plugins/test_data_model_rc_receiver.py | 95 +- tests/plugins/test_data_model_servo_out.py | 231 +++- tests/plugins/test_gnss.py | 131 +- .../unit_data_model_compass_calibration.py | 89 +- tests/test_backend_data_telemetry.py | 291 ++++- .../test_backend_flightcontroller_commands.py | 1138 ++++++++++++++++- tests/test_esc_telemetry.py | 96 +- .../unit_backend_flightcontroller_commands.py | 115 +- 16 files changed, 2851 insertions(+), 175 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87ea40209..5bde08225 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ SITL_BINARY="$PWD/sitl/arducopter" PATH="$PWD/.venv/bin:$PATH" \ ### Mutation testing The optional [Mutmut](https://mutmut.readthedocs.io/en/latest/) suite checks whether -existing tests detect small changes to thirty-eight modules covering battery voltages, +existing tests detect small changes to modules covering battery voltages, parameter validation and serialization, firmware version parsing, parameter history, AutoTune acceleration/gain backoff, IMU helpers, firmware upload, log-analysis orchestration, usage popups, file browsing and task scheduling, motor-test UI, @@ -95,7 +95,10 @@ bootloader protocol handling, schema-validated filesystem IO, IMU log analysis, vehicle-component validation/display, flight-controller business rules, transfer progress accounting, configuration expression evaluation, firmware parameter migrations, battery-monitor telemetry and status handling, and configuration-step -processing. It runs on Linux, macOS, or Windows +processing. All plugin backends and data models are included, together with shared +data/ESC telemetry models, telemetry and radio backends, and flight-controller +command handling used by calibration and motor-testing plugins. +It runs on Linux, macOS, or Windows through WSL. Install it into the project virtual environment: @@ -119,6 +122,14 @@ and a working desktop display, without `xvfb-run`. The `[tool.mutmut]` section in `pyproject.toml` selects both the modules to mutate and the tests to run. Update those lists together when expanding the suite. +Plugin `backend_*.py` and `data_model_*.py` targets use globs, and the entire +`tests/plugins/` directory is selected, so newly added plugin implementations and +their tests are included automatically. Shared implementations outside the plugin +directory and their tests must still be added explicitly. +The command module's source-lint regression is deselected only for mutation runs: +it checks the hand-written source, while Mutmut generates thousands of function +variants that make that subprocess exceed the normal test timeout. Normal pytest +and the pylint CI job continue to run that check. Include the module's existing behavior, BDD, and unit tests when auditing the selection. A "no tests" result can indicate an omitted test file or missing association rather than missing tests in the repository. @@ -130,6 +141,8 @@ Add repository resources and test helpers used by selected tests to `also_copy`; the AutoTune tests need `TUNING_GUIDE_Heli.md`, component-model tests need the `tests/` directory for shared fixtures, and MAVLink inspector tests need the PowerShell completion module copied into Mutmut's test tree. +Plugin packaging regressions also require the `windows/`, `linux/`, and `macos/` +directories containing the frozen-build specifications. Generated copies and results are stored in the ignored `mutants/` directory; Mutmut resumes previous work there. For a fresh baseline, remove that generated directory first, especially after changing tests or dependencies. Run from the @@ -206,13 +219,19 @@ Keep a copy of previous results before refreshing `mutants/`, and report killed, surviving, unassociated, timed-out and crashed mutants separately. A partial run does not establish a new score for the full configured suite. -If a worker crashes or times out while the same mutant fails normally in a direct -pytest run, compare it in a separate temporary checkout with -`forkserver_warmup = "none"` under `[tool.mutmut]`. This initializes test-module -imports inside each worker instead of preloading them in the fork server and -can distinguish shared GUI import state from an actual mutant-induced hang. -It adds worker startup cost, so use it for diagnosis before changing the normal -suite configuration. Retain both runs' execution outcomes in the report. +The suite uses `forkserver_warmup = "none"` under `[tool.mutmut]` so test-module +imports, including Tk, initialize inside each worker. Preloading GUI modules in +the fork server produced worker crashes and timeouts during plugin mutation runs; +the same mutants were killed with worker-local imports. This costs additional +worker startup time. Keep worker counts modest and retain crashed or timed-out +outcomes separately when comparing runs; a worker failure alone does not show +that a mutant changes application behavior. +The shared plugin conftest runs direct backend/model assertions before Tk callers +when `MUTANT_UNDER_TEST` is set. GUI tests remain selected; this ordering lets +incorrect domain results fail before a GUI callback can stall on them. +Mutation jobs use pytest-timeout's thread watchdog so a blocked Tk C call cannot +outlive the per-test timeout while consuming too little CPU for Mutmut's CPU +limit. Normal pytest runs keep their existing timeout method. ### Optional local code graph diff --git a/pyproject.toml b/pyproject.toml index 9704c170d..a6b419585 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -190,10 +190,19 @@ skip = "*.pdef.xml,*.pdf,*.po" [tool.mutmut] source_paths = ["ardupilot_methodic_configurator/"] -# Focus on parameter handling, parsing, and calculations with targeted tests. +# Cover parameter handling, parsing, calculations, and every plugin backend/data model. only_mutate = [ + # Globs keep new plugin backends and data models in scope automatically. + "ardupilot_methodic_configurator/plugins/backend_*.py", + "ardupilot_methodic_configurator/plugins/data_model_*.py", + # Shared implementations used by telemetry and calibration plugins. + "ardupilot_methodic_configurator/backend_data_telemetry.py", + "ardupilot_methodic_configurator/backend_esc_telemetry.py", + "ardupilot_methodic_configurator/backend_radio_status.py", + "ardupilot_methodic_configurator/backend_flightcontroller_commands.py", + "ardupilot_methodic_configurator/data_model_data_telemetry.py", + "ardupilot_methodic_configurator/data_model_esc_telemetry.py", "ardupilot_methodic_configurator/data_model_configuration_step.py", - "ardupilot_methodic_configurator/plugins/data_model_battery_monitor.py", "ardupilot_methodic_configurator/data_model_parameter_upgrade.py", "ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py", "ardupilot_methodic_configurator/data_model_transfer_progress.py", @@ -209,7 +218,6 @@ only_mutate = [ "ardupilot_methodic_configurator/data_model_vehicle_components_import.py", "ardupilot_methodic_configurator/data_model_vehicle_components_json_schema.py", "ardupilot_methodic_configurator/backend_firmware_upload.py", - "ardupilot_methodic_configurator/plugins/data_model_motor_test.py", "ardupilot_methodic_configurator/frontend_tkinter_usage_popup_windows.py", "ardupilot_methodic_configurator/frontend_tkinter_log_analysis.py", "ardupilot_methodic_configurator/backend_flightcontroller_connection.py", @@ -220,9 +228,6 @@ only_mutate = [ "ardupilot_methodic_configurator/log_analysis/data_model_firmware_version.py", "ardupilot_methodic_configurator/log_analysis/data_model_parameter_history.py", "ardupilot_methodic_configurator/plugins/imu_helpers.py", - "ardupilot_methodic_configurator/plugins/data_model_autotune_backoff.py", - "ardupilot_methodic_configurator/plugins/data_model_autotune_accel_backoff.py", - "ardupilot_methodic_configurator/plugins/data_model_autotune_gain_backoff.py", "ardupilot_methodic_configurator/data_model_firmware_upload.py", "ardupilot_methodic_configurator/log_analysis/backend_log_analysis.py", "ardupilot_methodic_configurator/frontend_tkinter_usage_popup_window.py", @@ -243,11 +248,13 @@ do_not_mutate = [ "ardupilot_methodic_configurator/decode_devid.py", ] pytest_add_cli_args_test_selection = [ + # Include model, backend, unit, behavior, and real-widget plugin regressions. + "tests/plugins/", + "tests/test_backend_data_telemetry.py", + "tests/test_esc_telemetry.py", + "tests/test_backend_flightcontroller_commands.py", + "tests/unit_backend_flightcontroller_commands.py", "tests/test_data_model_configuration_step.py", - "tests/plugins/unit_data_model_battery_monitor.py", - "tests/plugins/unit_frontend_tkinter_battery_monitor.py", - "tests/plugins/bdd_battery_monitor.py", - "tests/plugins/acceptance_battery_monitor.py", "tests/test_data_model_parameter_upgrade.py", "tests/test_backend_flightcontroller_business_logic.py", "tests/test_data_model_transfer_progress.py", @@ -279,7 +286,6 @@ pytest_add_cli_args_test_selection = [ "tests/unit_data_model_vehicle_components_json_schema.py", "tests/test_backend_firmware_upload.py", "tests/test_data_model_firmware_upload.py", - "tests/plugins/test_data_model_motor_test.py", "tests/test_frontend_tkinter_usage_popup_windows.py", "tests/test_frontend_tkinter_log_analysis.py", "tests/test_backend_flightcontroller_connection.py", @@ -288,22 +294,30 @@ pytest_add_cli_args_test_selection = [ "tests/test_data_model_plane_landing.py", "tests/test_data_model_firmware_version.py", "tests/test_data_model_parameter_history.py", - "tests/plugins/test_imu_helpers.py", - "tests/plugins/test_autotune_accel_backoff.py", - "tests/plugins/test_autotune_gain_backoff.py", "tests/test_backend_log_analysis.py", "tests/test_backend_fc_diagnostics.py", "tests/test_frontend_tkinter_usage_popup_window.py", "tests/test_frontend_tkinter_file_browser.py", "tests/bdd_frontend_tkinter_file_browser.py", "tests/test_frontend_tkinter_file_browser_tasks.py", - "tests/plugins/test_frontend_tkinter_motor_test.py", "tests/test_backend_filesystem_json_with_schema.py", ] -pytest_add_cli_args = ["-q", "-x", "-p", "tests.plugins.conftest"] -# The AutoTune tests verify helicopter-stage guidance as well as package data. -also_copy = ["pytest.ini", "TUNING_GUIDE_Heli.md", "tests", "ardupilot_methodic_configurator_command_line_completion.psm1"] +pytest_add_cli_args = [ + "-q", "-x", "-p", "tests.plugins.conftest", + # A signal timeout cannot interrupt a blocked Tk C call. + "--timeout-method=thread", + # Lint the original source in normal pytest/CI, not Mutmut's generated variants. + "--deselect=tests/test_backend_flightcontroller_commands.py::test_command_ack_meets_pylint_statement_and_return_limits", +] +# Plugin tests also verify helicopter guidance and all three frozen-build specifications. +also_copy = [ + "pytest.ini", "TUNING_GUIDE_Heli.md", "tests", + "ardupilot_methodic_configurator_command_line_completion.psm1", + "windows", "linux", "macos", +] process_isolation = "forkserver" +# Tk imports must initialize inside each worker, not the fork server. +forkserver_warmup = "none" [tool.ruff] target-version = "py311" diff --git a/tests/plugins/conftest.py b/tests/plugins/conftest.py index 38fea72dc..f81eb0cb4 100755 --- a/tests/plugins/conftest.py +++ b/tests/plugins/conftest.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Shared fixtures for plugin recovery lifecycles and staged RC parameter editing. +Shared plugin fixtures and mutation-run ordering for fast domain assertions. This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator @@ -11,6 +11,7 @@ import tkinter as tk from collections.abc import Callable +from os import getenv from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock @@ -25,6 +26,14 @@ from ardupilot_methodic_configurator.plugins.plugin_protocol import PluginView +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Fail on direct domain assertions before exercising Tk callers during mutation runs.""" + if getenv("MUTANT_UNDER_TEST"): + items.sort( + key=lambda item: "frontend" in item.path.name or any(name in item.fixturenames for name in ("root", "tk_root")) + ) + + @pytest.fixture def pause_polling_plugin_for_recovery() -> Callable[[tk.Misc], Callable[[], None]]: """Exercise the real editor gate and require cancellation of every view-owned Tk timer.""" diff --git a/tests/plugins/test_data_model_accelerometer_calibration.py b/tests/plugins/test_data_model_accelerometer_calibration.py index d13694b73..d085deffc 100755 --- a/tests/plugins/test_data_model_accelerometer_calibration.py +++ b/tests/plugins/test_data_model_accelerometer_calibration.py @@ -94,16 +94,20 @@ def test_model_reports_disconnected_when_master_link_absent(self, disconnected_f class TestAccelerometerCalibrationCancellation: # pylint: disable=too-few-public-methods """Closing the wizard resets local state without claiming to cancel FC work.""" - def test_cancel_closes_locally_without_sending_an_fc_cancel_command(self, connected_flight_controller) -> None: + def test_cancel_closes_locally_without_sending_an_fc_cancel_command(self, connected_flight_controller, caplog) -> None: """The user can leave the wizard even though ArduPilot has no MAVLink cancel command.""" model = AccelerometerCalibrationDataModel(connected_flight_controller) model._current_position = mavutil.mavlink.ACCELCAL_VEHICLE_POS_LEFT - success, message = model.cancel_full_calibration() + with caplog.at_level("INFO"): + success, message = model.cancel_full_calibration() assert success is True - assert "wizard was closed" in message.lower() - assert "may still be finishing" in message.lower() + assert message == ( + "The calibration wizard was closed. The flight controller may still be finishing calibration; " + "check its status before continuing." + ) + assert caplog.messages == ["The full accelerometer calibration wizard was closed locally"] assert model._current_position is None connected_flight_controller.cancel_accel_calibration.assert_not_called() @@ -127,7 +131,7 @@ def test_simple_calibration_is_refused_when_disconnected(self, disconnected_flig assert message == "Flight controller not connected" disconnected_flight_controller.start_accel_calibration_simple.assert_not_called() - def test_simple_calibration_succeeds_when_backend_confirms(self, connected_flight_controller) -> None: + def test_simple_calibration_succeeds_when_backend_confirms(self, connected_flight_controller, caplog) -> None: """ A successful backend calibration is reported as success to the user. @@ -138,10 +142,12 @@ def test_simple_calibration_succeeds_when_backend_confirms(self, connected_fligh connected_flight_controller.start_accel_calibration_simple.return_value = (True, "") model = AccelerometerCalibrationDataModel(connected_flight_controller) - success, message = model.start_simple_calibration() + with caplog.at_level("INFO"): + success, message = model.start_simple_calibration() assert success is True assert message == "Calibration successful" + assert caplog.messages == ["Simple accelerometer calibration completed"] connected_flight_controller.start_accel_calibration_simple.assert_called_once_with() def test_simple_calibration_surfaces_backend_error_message(self, connected_flight_controller) -> None: @@ -267,7 +273,7 @@ def test_poll_returns_none_and_keeps_state_when_no_message_available(self, conne assert result is None assert model._current_position is None - def test_poll_records_requested_position_from_flight_controller(self, connected_flight_controller) -> None: + def test_poll_records_requested_position_from_flight_controller(self, connected_flight_controller, caplog) -> None: """ A new FC position request is returned and remembered for later confirmation. @@ -278,10 +284,12 @@ def test_poll_records_requested_position_from_flight_controller(self, connected_ connected_flight_controller.poll_accel_cal_vehicle_pos.return_value = mavutil.mavlink.ACCELCAL_VEHICLE_POS_NOSEDOWN model = AccelerometerCalibrationDataModel(connected_flight_controller) - result = model.poll_for_next_position() + with caplog.at_level("DEBUG"): + result = model.poll_for_next_position() assert result == mavutil.mavlink.ACCELCAL_VEHICLE_POS_NOSEDOWN assert model._current_position == mavutil.mavlink.ACCELCAL_VEHICLE_POS_NOSEDOWN + assert caplog.messages == ["FC requested calibration position: 4"] class TestAccelerometerCalibrationDataModelPositionHelpers: @@ -324,7 +332,7 @@ def test_unknown_position_falls_back_to_generic_label(self) -> None: label = model.get_position_label(9999) - assert "Unknown position" in label + assert label == "Unknown position 9999" assert "9999" in label def test_success_terminal_state_is_reported_complete_and_successful(self) -> None: @@ -387,7 +395,7 @@ def test_confirm_current_position_fails_before_flight_controller_requests_positi success, message = model.confirm_current_position() assert success is False - assert "No position has been requested" in message + assert message == "No position has been requested by the flight controller yet" flight_controller.confirm_accel_vehicle_pos.assert_not_called() def test_confirm_current_position_fails_after_success_completion(self) -> None: @@ -405,7 +413,7 @@ def test_confirm_current_position_fails_after_success_completion(self) -> None: success, message = model.confirm_current_position() assert success is False - assert "already completed" in message + assert message == "Calibration has already completed" flight_controller.confirm_accel_vehicle_pos.assert_not_called() def test_confirm_current_position_fails_after_failed_completion(self) -> None: @@ -423,10 +431,10 @@ def test_confirm_current_position_fails_after_failed_completion(self) -> None: success, message = model.confirm_current_position() assert success is False - assert "already completed" in message + assert message == "Calibration has already completed" flight_controller.confirm_accel_vehicle_pos.assert_not_called() - def test_confirm_current_position_delegates_normal_position_and_propagates_success_message(self) -> None: + def test_confirm_current_position_delegates_normal_position_and_propagates_success_message(self, caplog) -> None: """ A normal requested position is confirmed through the flight controller. @@ -440,7 +448,9 @@ def test_confirm_current_position_delegates_normal_position_and_propagates_succe model = AccelerometerCalibrationDataModel(flight_controller) model._current_position = mavutil.mavlink.ACCELCAL_VEHICLE_POS_LEVEL - success, message = model.confirm_current_position() + with caplog.at_level("DEBUG"): + success, message = model.confirm_current_position() + assert caplog.messages == ["Confirmed calibration position 1"] assert success is True assert message == "confirmed" @@ -483,4 +493,4 @@ def test_confirm_current_position_provides_default_error_when_backend_is_silent( success, message = model.confirm_current_position() assert success is False - assert "Failed to send position confirmation" in message + assert message == "Failed to send position confirmation" diff --git a/tests/plugins/test_data_model_ahrs_orientation.py b/tests/plugins/test_data_model_ahrs_orientation.py index f91a94fc1..19a74ba84 100755 --- a/tests/plugins/test_data_model_ahrs_orientation.py +++ b/tests/plugins/test_data_model_ahrs_orientation.py @@ -10,6 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later """ +from math import cos, radians, sin, sqrt from unittest.mock import MagicMock import pytest @@ -77,11 +78,17 @@ def test_left_side_capture_cannot_masquerade_as_the_required_right_pose(connecte # Then: Do not offer an incorrect preset or custom rotation. assert not success assert estimate is None - assert "inconsistent" in message + assert message == ( + "The captured poses are inconsistent with LEVEL, NOSE DOWN, and RIGHT. " + "Place the vehicle accurately and capture the sequence again." + ) assert model.compute_detected_position(0.0, 1000.0, 0.0) == "LEFT" -@pytest.mark.parametrize(("angle", "expected"), [(540.0, 180.0), (-540.0, -180.0), (725.0, 5.0)]) +@pytest.mark.parametrize( + ("angle", "expected"), + [(540.0, 180.0), (-540.0, -180.0), (725.0, 5.0), (180.0, 180.0), (-180.0, -180.0), (180.5, -179.5), (-180.5, 179.5)], +) def test_custom_rotation_angles_are_normalized_to_the_ardupilot_range(angle: float, expected: float) -> None: """ Keep equivalent custom rotations in the supported angle range. @@ -136,9 +143,10 @@ def test_detection_prerequisites_reject_custom_board_orientation(self, connected ok, message = model.validate_detection_prerequisites() assert ok is False - assert "custom/unsupported" in message - assert "upload" in message - assert "reboot" in message + assert message == ( + "AHRS_ORIENTATION could not be read or is a custom/unsupported rotation. " + "Set it to 0, upload it, reboot the flight controller, and reconnect before starting auto-detection." + ) def test_detection_prerequisites_reject_missing_board_orientation(self, connected_flight_controller) -> None: connected_flight_controller.fc_parameters = {} @@ -189,7 +197,7 @@ def test_model_rejects_missing_imu_sample_for_capture(self, connected_flight_con ok, msg = model.record_sample("LEVEL", None) assert ok is False - assert "No IMU sample" in msg + assert msg == "No IMU sample is available yet. Keep the vehicle still and try again." def test_model_refuses_estimation_when_required_samples_are_missing(self, connected_flight_controller) -> None: model = AhrsOrientationDataModel(connected_flight_controller) @@ -198,13 +206,21 @@ def test_model_refuses_estimation_when_required_samples_are_missing(self, connec assert ok is False assert estimate is None - assert "Missing required samples" in msg + assert msg == "Missing required samples. Capture LEVEL, NOSE DOWN, and RIGHT first." @pytest.mark.parametrize( ("expected_code", "samples"), [ (0, ((0.0, 0.0, -1000.0), (-1000.0, 0.0, 0.0), (0.0, -1000.0, 0.0))), (2, ((0.0, 0.0, -1000.0), (0.0, 1000.0, 0.0), (-1000.0, 0.0, 0.0))), + ( + 7, + ( + (0.0, 0.0, -1034.8491567170374), + (-723.5732414799375, -723.5732414799378, 0.0), + (682.2252637648505, -682.2252637648502, 0.0), + ), + ), (16, ((0.0, -1000.0, 0.0), (-1000.0, 0.0, 0.0), (0.0, 0.0, 1000.0))), # ArduPilot documents Pitch90 as the board nose/arrow pointing straight up. (24, ((1000.0, 0.0, 0.0), (0.0, 0.0, -1000.0), (0.0, -1000.0, 0.0))), @@ -244,7 +260,8 @@ def test_model_identifies_ardupilot_preset_from_independent_samples( assert estimate is not None assert estimate.best_code == expected_code assert estimate.is_preset_match is True - assert estimate.match_score_percent >= 90.0 + assert 0.0 <= estimate.match_score_percent <= 100.0 + assert estimate.match_score_percent == pytest.approx(100.0, abs=1e-5) @pytest.mark.parametrize("sample", [(0.0, 0.0, -700.0), (0.0, 0.0, -1400.0)]) def test_model_rejects_samples_outside_still_gravity_range(self, connected_flight_controller, sample) -> None: @@ -253,7 +270,11 @@ def test_model_rejects_samples_outside_still_gravity_range(self, connected_fligh ok, message = model.record_sample("LEVEL", sample) assert ok is False - assert "outside the expected still range" in message + expected_magnitude = "6.86" if sample[2] == -700.0 else "13.73" + assert message == ( + f"IMU magnitude {expected_magnitude} m/s^2 is outside the expected still range. " + "Keep the vehicle still and try again." + ) def test_model_rejects_pose_that_duplicates_another_step(self, connected_flight_controller) -> None: model = AhrsOrientationDataModel(connected_flight_controller) @@ -262,7 +283,7 @@ def test_model_rejects_pose_that_duplicates_another_step(self, connected_flight_ ok, message = model.record_sample("RIGHT", (0.0, 0.0, -1000.0)) assert ok is False - assert "too similar to LEVEL" in message + assert message == "RIGHT pose too similar to LEVEL. Reposition the vehicle further and try again." def test_model_allows_replacing_a_sample_for_the_same_step(self, connected_flight_controller) -> None: model = AhrsOrientationDataModel(connected_flight_controller) @@ -290,12 +311,13 @@ def test_custom_angles_are_the_board_to_body_correction_not_its_inverse(self, co def test_custom_angles_use_ardupilot_321_euler_convention(self, connected_flight_controller) -> None: """A multi-axis custom result must round-trip through ArduPilot's Rz * Ry * Rx convention.""" model = AhrsOrientationDataModel(connected_flight_controller) - body_from_board = model._mat_mul( - model._mat_mul(model._rot_z(40.0), model._rot_y(30.0)), - model._rot_x(20.0), - ) - board_from_body = [[body_from_board[col][row] for col in range(3)] for row in range(3)] - samples = [tuple(-1000.0 * board_from_body[row][col] for row in range(3)) for col in range(3)] + # Independent 3-2-1 direction cosines for roll=20, pitch=30, yaw=40. + # Add a tenth of the X axis to the right pose to exercise rejection of cross-axis error. + samples = [ + (-663.413948169, 473.021458440, -579.769465589), + (-623.011794043, -782.467319745, -97.985703107), + (500.0, -296.198132726, -813.797681349), + ] assert model.record_sample("LEVEL", samples[2])[0] is True assert model.record_sample("NOSE DOWN", samples[0])[0] is True @@ -321,4 +343,208 @@ def test_estimation_rejects_inconsistent_capture_geometry(self, connected_flight assert ok is False assert estimate is None - assert "captured poses are inconsistent" in message + assert message == ( + "The captured poses are inconsistent with LEVEL, NOSE DOWN, and RIGHT. " + "Place the vehicle accurately and capture the sequence again." + ) + + +@pytest.mark.parametrize("acceleration", [7.0, 7.5, 13.0]) +def test_capture_accepts_the_documented_still_acceleration_range(connected_flight_controller, acceleration: float) -> None: + """ + Pose is retained with clear confirmation. + + GIVEN: A still acceleration at or between the accepted limits + WHEN: The operator captures the level pose + THEN: The pose is retained with clear confirmation + """ + model = AhrsOrientationDataModel(connected_flight_controller) + sample = (0.0, 0.0, -acceleration * 1000.0 / 9.80665) + assert model.record_sample("LEVEL", sample) == (True, "Captured LEVEL sample") + + +def test_replacing_a_pose_still_checks_it_against_later_captures(connected_flight_controller) -> None: + """ + Duplicate is rejected and the original level pose remains. + + GIVEN: Distinct level and nose-down captures + WHEN: Level is replaced with the nose-down reading + THEN: The duplicate is rejected and the original level pose remains + """ + model = AhrsOrientationDataModel(connected_flight_controller) + assert model.record_sample("LEVEL", (0.0, 0.0, -1000.0))[0] + assert model.record_sample("NOSE DOWN", (-1000.0, 0.0, 0.0))[0] + assert model.record_sample("LEVEL", (-1000.0, 0.0, 0.0)) == ( + False, + "LEVEL pose too similar to NOSE DOWN. Reposition the vehicle further and try again.", + ) + + +def test_orientation_monitor_reuses_then_renews_the_imu_stream(connected_flight_controller) -> None: + """ + Physical board axes are recovered and the stream is requested once per activation. + + GIVEN: A board with a 45-degree yaw preset and distinct acceleration on every axis + WHEN: Monitoring polls, pauses and resumes + THEN: The physical board axes are recovered and the stream is requested once per activation + """ + controller = connected_flight_controller + controller.fc_parameters = {"AHRS_ORIENTATION": 1.0} + controller.request_scaled_imu_messages.return_value = (True, "") + controller.poll_scaled_imu.return_value = (100.0, 200.0, 300.0) + model = AhrsOrientationDataModel(controller) + for _ in range(2): + assert model.poll_imu_raw() == pytest.approx((150.0 * sqrt(2), 50.0 * sqrt(2), 300.0)) + controller.request_scaled_imu_messages.assert_called_once() + model.stop_imu_monitoring() + assert model.poll_imu_raw() == pytest.approx((150.0 * sqrt(2), 50.0 * sqrt(2), 300.0)) + assert controller.request_scaled_imu_messages.call_count == 2 + + +@pytest.mark.parametrize(("yaw", "score"), [(22.5, 85.0), (20.0, 87.2256555832839)]) +def test_halfway_between_yaw_presets_reports_custom_orientation_with_reduced_confidence( + connected_flight_controller, yaw: float, score: float +) -> None: + """ + Avoid recommending either of two equally plausible orientation presets. + + GIVEN: A board mounted near halfway between the None and Yaw45 presets + WHEN: Three independent gravity poses are captured + THEN: The custom yaw is recovered and confidence reflects the margin between presets + """ + model = AhrsOrientationDataModel(connected_flight_controller) + sine, cosine = sin(radians(yaw)), cos(radians(yaw)) + for step, sample in ( + ("LEVEL", (0.0, 0.0, -1000.0)), + ("NOSE DOWN", (-1000 * cosine, 1000 * sine, 0.0)), + ("RIGHT", (-1000 * sine, -1000 * cosine, 0.0)), + ): + assert model.record_sample(step, sample)[0] + success, estimate, message = model.estimate_orientation() + assert success is True + assert message == "Estimation complete" + assert estimate is not None + assert estimate.is_preset_match is False + assert estimate.match_score_percent == pytest.approx(score) + assert estimate.custom_yaw_deg == pytest.approx(yaw) + + +def test_historical_board_rotation_recovers_all_three_physical_acceleration_axes(connected_flight_controller) -> None: + """ + Undo the historical noninteger ArduPilot rotation on every IMU axis. + + GIVEN: Preset 38 is active and the controller reports distinct acceleration on all axes + WHEN: The orientation monitor reads the sample + THEN: Published ArduPilot direction cosines recover the physical board-frame acceleration + """ + controller = connected_flight_controller + controller.fc_parameters = {"AHRS_ORIENTATION": 38.0} + controller.request_scaled_imu_messages.return_value = (True, "") + controller.poll_scaled_imu.return_value = (100.0, 200.0, 300.0) + model = AhrsOrientationDataModel(controller) + assert model.poll_imu_raw() == pytest.approx((-331.819798693, -25.892864618, -170.953738647)) + + +def test_capture_accepts_a_pose_at_the_duplicate_alignment_boundary(connected_flight_controller) -> None: + """ + Keep the documented strict boundary for rejecting similar pose captures. + + GIVEN: A level capture and another still pose whose alignment is exactly 0.9 + WHEN: The second pose is captured + THEN: It is accepted rather than rejected as a duplicate. + """ + model = AhrsOrientationDataModel(connected_flight_controller) + assert model.record_sample("LEVEL", (0.0, 0.0, -1000.0))[0] + assert model.record_sample("NOSE DOWN", (sqrt(190000.0), 0.0, -900.0)) == (True, "Captured NOSE DOWN sample") + + +def test_estimation_accepts_the_minimum_consistent_pose_alignment(connected_flight_controller) -> None: + """ + Accept the exact minimum alignment allowed for the three-pose experiment. + + GIVEN: Level and nose-down are accurate while the right pose has alignment 0.9397 + WHEN: The orientation is estimated + THEN: The captures meet the consistency limit and identify the unrotated board. + """ + model = AhrsOrientationDataModel(connected_flight_controller) + for step, sample in ( + ("LEVEL", (0.0, 0.0, -1000.0)), + ("NOSE DOWN", (-1000.0, 0.0, 0.0)), + ("RIGHT", (-389.11675958218467, -1069.161285551235, 0.0)), + ): + assert model.record_sample(step, sample)[0] + success, estimate, message = model.estimate_orientation() + assert success is True + assert message == "Estimation complete" + assert estimate is not None + assert estimate.best_code == 0 + + +def test_nearby_preset_keeps_its_measured_match_score_instead_of_rounding_up_to_perfect(connected_flight_controller) -> None: + """ + Distinguish a close preset recommendation from a perfect orientation match. + + GIVEN: A board mounted ten degrees from the unrotated preset + WHEN: Three accurate independent gravity poses are captured + THEN: That preset is recommended with its measured 98.987-percent confidence. + """ + model = AhrsOrientationDataModel(connected_flight_controller) + sine, cosine = sin(radians(10)), cos(radians(10)) + for step, sample in ( + ("LEVEL", (0.0, 0.0, -1000.0)), + ("NOSE DOWN", (-1000 * cosine, 1000 * sine, 0.0)), + ("RIGHT", (-1000 * sine, -1000 * cosine, 0.0)), + ): + assert model.record_sample(step, sample)[0] + success, estimate, _message = model.estimate_orientation() + assert success is True + assert estimate is not None + assert estimate.best_code == 0 + assert estimate.is_preset_match is True + assert estimate.match_score_percent == pytest.approx(98.9871835341472) + + +def test_small_level_pose_error_does_not_flip_the_estimated_vehicle_axes(connected_flight_controller) -> None: + """ + Tolerate ordinary placement error without reflecting the fitted board orientation. + + GIVEN: Nose-down and right are accurate while level is tilted by five degrees + WHEN: The three-pose experiment is estimated + THEN: The unrotated preset remains a consistent recommendation. + """ + model = AhrsOrientationDataModel(connected_flight_controller) + for step, sample in ( + ("LEVEL", (0.0, 1000 * sin(radians(5)), -1000 * cos(radians(5)))), + ("NOSE DOWN", (-1000.0, 0.0, 0.0)), + ("RIGHT", (0.0, -1000.0, 0.0)), + ): + assert model.record_sample(step, sample)[0] + success, estimate, message = model.estimate_orientation() + assert success is True + assert message == "Estimation complete" + assert estimate is not None + assert estimate.best_code == 0 + + +def test_preset_recommendation_includes_the_exact_ninety_percent_match_boundary(connected_flight_controller) -> None: + """ + Include a consistent orientation that meets the preset confidence threshold exactly. + + GIVEN: Three independent captures for roll, pitch and yaw of 19.605966401 degrees + WHEN: The best preset scores exactly ninety percent with a clear margin over the runner-up + THEN: The preset is recommended rather than requiring a custom rotation. + """ + model = AhrsOrientationDataModel(connected_flight_controller) + samples = ( + (335.5496675230296, -316.0953420384153, -887.4064206251843), + (-887.4064206251843, 210.02965511184672, -410.3625087733981), + (-316.0953420384153, -925.1871587496315, 210.02965511184672), + ) + for step, sample in zip(model.get_required_steps(), samples, strict=True): + assert model.record_sample(step, sample)[0] + success, estimate, _message = model.estimate_orientation() + assert success is True + assert estimate is not None + assert estimate.best_code == 0 + assert estimate.match_score_percent == 90.0 + assert estimate.is_preset_match is True diff --git a/tests/plugins/test_data_model_esc_rpm_scale.py b/tests/plugins/test_data_model_esc_rpm_scale.py index 56b1116b8..f2f1a48bd 100755 --- a/tests/plugins/test_data_model_esc_rpm_scale.py +++ b/tests/plugins/test_data_model_esc_rpm_scale.py @@ -88,7 +88,7 @@ def test_user_cannot_upload_a_script_without_an_available_vehicle_directory( # Given: No usable destination for the generated script. filesystem.vehicle_dir = vehicle_directory # When / Then: Fail before controller I/O. - with pytest.raises(RuntimeError, match="Vehicle directory is not available"): + with pytest.raises(RuntimeError, match=r"^Vehicle directory is not available$"): model.generate_and_upload_script(0.714) flight_controller.upload_file.assert_not_called() @@ -217,11 +217,11 @@ def test_generates_scale_calls_for_hexa_frame(self, model) -> None: @pytest.mark.parametrize("scale_factor", [0.0, -1.0, inf, nan]) def test_rejects_invalid_scale_factors(self, model, scale_factor) -> None: - with pytest.raises(ValueError, match="finite positive"): + with pytest.raises(ValueError, match=r"^RPM scale factor must be a finite positive number$"): model.generate_script(scale_factor, 4) def test_rejects_non_positive_esc_count(self, model) -> None: - with pytest.raises(ValueError, match="output count must be positive"): + with pytest.raises(ValueError, match=r"^ESC output count must be positive$"): model.generate_script(0.714, 0) def test_writes_script_to_active_vehicle_directory(self, model, tmp_path: Path) -> None: @@ -281,3 +281,70 @@ def test_registers_esc_rpm_scale_creator(self) -> None: register.assert_called_once() assert register.call_args.args[0] == PLUGIN_ESC_RPM_SCALE + + +def test_legacy_component_layout_and_motor_product_still_enable_the_known_esc_correction(model, filesystem) -> None: + """ + Accept legacy component layouts and continue past incomplete ESC product data. + + GIVEN: Unwrapped components with an incomplete ESC product and a Hobbywing motor product + WHEN: Correction eligibility and telemetry protocol are checked + THEN: The motor identifies the known correction and the scripting protocol remains available + """ + filesystem.vehicle_components_fs.data = { + "ESC": {"Product": None, "ESC->FC Telemetry": {"Protocol": "Scripting"}}, + "Motors": {"Product": {"Manufacturer": "Hobbywing", "Model": "6X SE"}}, + } + assert model.is_hobbywing_6x_se() is True + assert model.is_scripting_telemetry_protocol() is True + + +def test_missing_layout_data_returns_unknown_count_instead_of_crashing() -> None: + """ + Tolerate unavailable motor metadata. + + GIVEN: No motor-layout data was loaded + WHEN: The frame's ESC count is queried + THEN: The count is explicitly unknown + """ + assert EscRpmScaleDataModel.esc_count_for_frame_class(1, {}) is None + + +def test_single_esc_script_targets_only_the_first_output() -> None: + """ + Generate a usable correction for a single ESC. + + GIVEN: A vehicle uses one ESC output + WHEN: The correction script is generated + THEN: Only ESC index zero is configured before the operator message + """ + assert EscRpmScaleDataModel.generate_script(0.714, 1) == ( + 'esc_telem:set_rpm_scale(0, 0.714)\ngcs:send_text(0, "LUA set RPM scale to 0.714")\n' + ) + + +def test_esc_count_uses_shipped_nonquad_motor_metadata(model, flight_controller, caplog: pytest.LogCaptureFixture) -> None: + """ + Resolve nonquad motor counts through the shipped metadata and schema. + + GIVEN: A connected hexa with no loader mocks + WHEN: The correction determines how many outputs need scaling + THEN: Six ESCs are selected without resource-loading diagnostics + """ + flight_controller.fc_parameters = {"FRAME_CLASS": 2} + assert model.esc_count() == 6 + assert caplog.messages == [] + + +@pytest.mark.parametrize(("scale", "literal"), [(1 / 3, "0.333333"), (1e-7, "1e-07")]) +def test_generated_script_uses_a_compact_scale_literal_consistently(scale: float, literal: str) -> None: + """ + Keep generated Lua and its operator confirmation readable for precise scale inputs. + + GIVEN: A scale requiring rounding or scientific notation + WHEN: A one-output RPM correction script is generated + THEN: Both the command and confirmation use the same six-significant-digit literal. + """ + assert EscRpmScaleDataModel.generate_script(scale, 1) == ( + f'esc_telem:set_rpm_scale(0, {literal})\ngcs:send_text(0, "LUA set RPM scale to {literal}")\n' + ) diff --git a/tests/plugins/test_data_model_level_calibration.py b/tests/plugins/test_data_model_level_calibration.py index e8eb1ab3b..cca2f8cda 100755 --- a/tests/plugins/test_data_model_level_calibration.py +++ b/tests/plugins/test_data_model_level_calibration.py @@ -56,13 +56,15 @@ def test_user_receives_started_status_before_controller_acknowledges(self, conne assert message == "Level calibration started" connected_flight_controller.start_accel_calibration_level.assert_called_once_with() - def test_user_can_poll_until_level_calibration_acknowledgment_arrives(self, connected_flight_controller) -> None: + def test_user_can_poll_until_level_calibration_acknowledgment_arrives(self, connected_flight_controller, caplog) -> None: """Pending and accepted ACKs are exposed without blocking the caller.""" connected_flight_controller.poll_accel_calibration_level.side_effect = [None, (True, "")] model = LevelCalibrationDataModel(connected_flight_controller) assert model.poll_level_calibration() is None - assert model.poll_level_calibration() == (True, "Level calibration successful") + with caplog.at_level("INFO"): + assert model.poll_level_calibration() == (True, "Level calibration successful") + assert caplog.messages == ["Level calibration completed"] assert connected_flight_controller.poll_accel_calibration_level.call_count == 2 def test_abandoning_calibration_releases_the_flight_controller_session(self, connected_flight_controller) -> None: @@ -80,6 +82,19 @@ def test_user_receives_controller_failure_when_polling_completes(self, connected assert model.poll_level_calibration() == (False, "Command failed") + def test_completed_failure_without_controller_detail_still_explains_the_result(self, connected_flight_controller) -> None: + """ + Give the operator an actionable result when the controller supplies no reason. + + GIVEN: Level calibration finishes unsuccessfully with an empty detail + WHEN: The operator polls for completion + THEN: A stable failure message is returned instead of an empty result + """ + connected_flight_controller.poll_accel_calibration_level.return_value = (False, "") + model = LevelCalibrationDataModel(connected_flight_controller) + + assert model.poll_level_calibration() == (False, "Level calibration failed") + def test_user_receives_controller_failure_reason(self, connected_flight_controller) -> None: """ A rejected calibration preserves the controller's actionable error. diff --git a/tests/plugins/test_data_model_rc_calibration.py b/tests/plugins/test_data_model_rc_calibration.py index 947f14b57..7fec6192c 100755 --- a/tests/plugins/test_data_model_rc_calibration.py +++ b/tests/plugins/test_data_model_rc_calibration.py @@ -108,10 +108,10 @@ def test_start_calibration_is_refused_when_disconnected(self, disconnected_fligh success, error_msg = model.start_calibration() assert success is False - assert error_msg != "" + assert error_msg == "Flight controller not connected" assert model._is_calibrating is False - def test_start_calibration_activates_tracking_when_connected(self, connected_flight_controller) -> None: + def test_start_calibration_activates_tracking_when_connected(self, connected_flight_controller, caplog) -> None: """ Calibration starts cleanly when a link is available. @@ -121,7 +121,9 @@ def test_start_calibration_activates_tracking_when_connected(self, connected_fli """ model = RCCalibrationDataModel(connected_flight_controller) - success, error_msg = model.start_calibration() + with caplog.at_level("INFO"): + success, error_msg = model.start_calibration() + assert caplog.messages == ["RC calibration started — move all sticks and switches to their extremes"] assert success is True assert error_msg == "" @@ -144,7 +146,7 @@ def test_start_calibration_clears_previous_min_max(self, connected_flight_contro assert not model._channel_min assert not model._channel_max - def test_cancel_calibration_discards_state_without_writing(self, connected_flight_controller) -> None: + def test_cancel_calibration_discards_state_without_writing(self, connected_flight_controller, caplog) -> None: """ Cancelling stops tracking and never writes parameters to the FC. @@ -157,7 +159,9 @@ def test_cancel_calibration_discards_state_without_writing(self, connected_fligh model._channel_min = {0: 1100} model._channel_max = {0: 1900} - success, error_msg = model.cancel_calibration() + with caplog.at_level("INFO"): + success, error_msg = model.cancel_calibration() + assert caplog.messages == ["RC calibration cancelled"] assert success is True assert error_msg == "" @@ -171,7 +175,7 @@ def test_cancel_calibration_discards_state_without_writing(self, connected_fligh class TestRCCalibrationDataModelFinish: """Calibration measurements become editable New values, never direct controller writes.""" - def test_finish_without_data_writes_no_parameters(self, connected_flight_controller) -> None: + def test_finish_without_data_writes_no_parameters(self, connected_flight_controller, caplog) -> None: """ Finishing with no recorded readings stages nothing. @@ -183,7 +187,8 @@ def test_finish_without_data_writes_no_parameters(self, connected_flight_control model.start_calibration() success, message = model.finish_calibration() assert success is False - assert message + assert message == "No RC calibration data recorded — nothing to save" + assert caplog.messages == [message] assert model._is_calibrating is False connected_flight_controller.set_param.assert_not_called() @@ -261,7 +266,7 @@ def test_finish_skips_stationary_channel_and_stages_only_channel_that_moved( controller.set_param.assert_not_called() def test_finish_with_only_stationary_channels_stages_nothing( - self, rc_staging_context: tuple[MagicMock, ParameterEditor] + self, rc_staging_context: tuple[MagicMock, ParameterEditor], caplog: pytest.LogCaptureFixture ) -> None: """ Finishing a run with no stick or switch movement leaves every value alone. @@ -277,10 +282,12 @@ def test_finish_with_only_stationary_channels_stages_nothing( controller.master.recv_match.side_effect = [_make_rc_channels_msg([1500]), None] model.get_rc_telemetry() - success, message = model.finish_calibration() + with caplog.at_level("INFO"): + success, message = model.finish_calibration() assert success is True - assert "No RC channels moved" in message + assert message == "No RC channels moved; no calibration parameters were staged." + assert caplog.messages == ["RC1 did not move; calibration values were not staged", message] assert {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} == before assert not model._channel_min assert not model._channel_max @@ -300,7 +307,7 @@ def test_standalone_finish_cannot_bypass_the_upload_workflow(self, connected_fli model._channel_max = {0: 1900} success, message = model.finish_calibration() assert success is False - assert "parameter editor" in message + assert message == "Open RC calibration in the parameter editor to stage changes for upload." assert model._channel_min == {0: 1100} connected_flight_controller.set_param.assert_not_called() @@ -351,6 +358,10 @@ def test_telemetry_maps_first_four_channels_to_axes(self, connected_flight_contr assert telemetry["pitch"] == 0.0 assert telemetry["throttle"] == 1000.0 assert telemetry["yaw"] == 0.0 + assert [entry.kwargs for entry in connected_flight_controller.master.recv_match.call_args_list] == [ + {"type": "RC_CHANNELS", "blocking": False}, + {"type": "HEARTBEAT", "blocking": False}, + ] def test_telemetry_keeps_all_channels_and_marks_invalid_values_unavailable(self, connected_flight_controller) -> None: """ @@ -425,7 +436,7 @@ def test_invalid_axis_channel_maps_to_zero(self, connected_flight_controller) -> assert telemetry["roll"] == 0.0 - def test_recv_match_exception_is_swallowed(self, connected_flight_controller) -> None: + def test_recv_match_exception_is_swallowed(self, connected_flight_controller, caplog) -> None: """ A telemetry read error never propagates to the GUI. @@ -436,7 +447,9 @@ def test_recv_match_exception_is_swallowed(self, connected_flight_controller) -> model = RCCalibrationDataModel(connected_flight_controller) connected_flight_controller.master.recv_match.side_effect = RuntimeError("link lost") - assert not model.get_rc_telemetry() + with caplog.at_level("DEBUG"): + assert not model.get_rc_telemetry() + assert caplog.messages == ["Error reading MAVLink telemetry: link lost"] def test_telemetry_includes_flight_mode_from_heartbeat(self, connected_flight_controller) -> None: """ @@ -543,6 +556,15 @@ def test_centered_roll_uses_the_configured_trim(self, connected_flight_controlle [ ({}, (1000, 2000, 1500)), ({"RC1_MIN": nan, "RC1_MAX": 2000}, (1000, 2000, 1500)), + ({"RC1_MIN": 1000, "RC1_MAX": nan}, (1000, 2000, 1500)), + ({"RC1_MIN": -100, "RC1_MAX": 2100}, (1000, 2000, 1500)), + ({"RC1_MIN": 0, "RC1_MAX": 2100}, (1000, 2000, 1500)), + ({"RC1_MIN": 1000, "RC1_MAX": 65535}, (1000, 2000, 1500)), + ({"RC1_MIN": 1, "RC1_MAX": 65534}, (1, 65534, 32768)), + ({"RC1_MIN": 1100, "RC1_MAX": 1900, "RC1_TRIM": nan}, (1100, 1900, 1500)), + ({"RC1_MIN": 1100, "RC1_MAX": 1900, "RC1_TRIM": 1000}, (1100, 1900, 1500)), + ({"RC1_MIN": 1100, "RC1_MAX": 1900, "RC1_TRIM": 1100}, (1100, 1900, 1100)), + ({"RC1_MIN": 1100, "RC1_MAX": 1900, "RC1_TRIM": 1900}, (1100, 1900, 1900)), ({"RC1_MIN": 2000, "RC1_MAX": 1000}, (1000, 2000, 1500)), ({"RC1_MIN": 1500.1, "RC1_MAX": 1500.2}, (1000, 2000, 1500)), ({"RC1_MIN": 1100, "RC1_MAX": 1900, "RC1_TRIM": 3000}, (1100, 1900, 1500)), @@ -655,7 +677,7 @@ def test_flight_mode_reports_not_connected_without_link(self, disconnected_fligh """ model = RCCalibrationDataModel(disconnected_flight_controller) - assert model.get_flight_mode() != "" + assert model.get_flight_mode() == "Not connected" def test_flight_mode_returns_custom_mode_from_heartbeat(self, connected_flight_controller) -> None: """ @@ -671,6 +693,7 @@ def test_flight_mode_returns_custom_mode_from_heartbeat(self, connected_flight_c connected_flight_controller.master.recv_match.return_value = hb assert model.get_flight_mode() == "5" + connected_flight_controller.master.recv_match.assert_called_once_with(type="HEARTBEAT", blocking=False) def test_flight_mode_returns_no_data_without_heartbeat(self, connected_flight_controller) -> None: """ @@ -683,9 +706,9 @@ def test_flight_mode_returns_no_data_without_heartbeat(self, connected_flight_co model = RCCalibrationDataModel(connected_flight_controller) connected_flight_controller.master.recv_match.return_value = None - assert model.get_flight_mode() != "" + assert model.get_flight_mode() == "No Data" - def test_flight_mode_swallows_recv_match_exception(self, connected_flight_controller) -> None: + def test_flight_mode_swallows_recv_match_exception(self, connected_flight_controller, caplog) -> None: """ A HEARTBEAT read error never propagates out of get_flight_mode. @@ -696,7 +719,9 @@ def test_flight_mode_swallows_recv_match_exception(self, connected_flight_contro model = RCCalibrationDataModel(connected_flight_controller) connected_flight_controller.master.recv_match.side_effect = RuntimeError("link lost") - assert model.get_flight_mode() != "" + with caplog.at_level("DEBUG"): + assert model.get_flight_mode() == "No Data" + assert caplog.messages == ["Error reading HEARTBEAT: link lost"] class TestTransmitterStickModes: @@ -794,7 +819,10 @@ def test_bulk_mapping_validation_prevents_partial_value_changes(self, rc_staging before = {name: (par.get_new_value(), par.change_reason) for name, par in editor.current_step_parameters.items()} success, message = RCCalibrationDataModel(controller, editor).set_mapping_mode(3) assert success is False - assert message + if protection != "range": + assert message == "Parameter RCMAP_YAW is read-only, forced or derived; no values were changed." + else: + assert message == "The value for RCMAP_YAW (1.0) should be greater than 2.0\n" assert { name: (par.get_new_value(), par.change_reason) for name, par in editor.current_step_parameters.items() } == before @@ -815,8 +843,9 @@ def test_missing_mapping_parameters_are_added_with_real_editor_api(self, rc_stag assert editor.current_step_parameters["RCMAP_PITCH"].get_new_value() == 3 controller.set_param.assert_not_called() + @pytest.mark.parametrize("unavailable", [False, True]) def test_failed_parameter_addition_rolls_back_preceding_additions( - self, rc_staging_context: tuple[MagicMock, ParameterEditor], monkeypatch: pytest.MonkeyPatch + self, rc_staging_context: tuple[MagicMock, ParameterEditor], monkeypatch: pytest.MonkeyPatch, unavailable: bool ) -> None: """ Unsupported mapping parameters cannot leave half-added rows. @@ -831,12 +860,15 @@ def test_failed_parameter_addition_rolls_back_preceding_additions( def add(name: str) -> bool: if name == "RCMAP_PITCH": + if unavailable: + return False message = "Missing" raise InvalidParameterNameError(message) return real_add(name) monkeypatch.setattr(editor, "add_parameter_to_current_file", add) - success, _message = RCCalibrationDataModel(controller, editor).set_mapping_mode(3) + success, message = RCCalibrationDataModel(controller, editor).set_mapping_mode(3) + assert message == ("Could not add parameter RCMAP_PITCH to this step." if unavailable else "Missing") assert success is False assert editor.current_step_parameters == {} controller.set_param.assert_not_called() @@ -972,7 +1004,7 @@ def test_unsupported_auxiliary_option_does_not_change_or_add_parameter_rows( success, message = model.set_channel_option(channel_number, "999") assert success is False - assert "valid RC channel option" in message + assert message == "Select a valid RC channel option." staged_values = {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} assert staged_values == original_values assert controller.fc_parameters == original_cache @@ -1122,3 +1154,279 @@ def test_rover_mode_channel_zero_leaves_auxiliary_channels_available( assert channels[7]["function_editable"] is True assert model.set_channel_option(8, "300")[0] is True controller.set_param.assert_not_called() + + +def test_staged_mapping_and_auxiliary_option_retain_upload_guidance_and_change_reasons(rc_staging_context) -> None: + """ + Preserve the reasons for pending RC edits and their required upload steps. + + GIVEN: Editable RC mapping and auxiliary-channel parameters + WHEN: The operator selects Mode 1 and assigns an option to the last channel + THEN: Pending values explain their origin and the mapping guidance includes upload and reboot + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + assert model.set_mapping_mode(1) == ( + True, + ( + "Changes staged. Press parameter upload to write them to the flight controller. " + "RC mapping changes require a flight-controller reboot after upload." + ), + ) + for name in ("RCMAP_PITCH", "RCMAP_THROTTLE"): + assert ( + editor.current_step_parameters[name].change_reason + == "RC mapping preset Mode 1 selected in the RC calibration plugin." + ) + assert model.set_channel_option(16, "300")[0] is True + assert editor.current_step_parameters["RC16_OPTION"].get_new_value() == 300 + assert ( + editor.current_step_parameters["RC16_OPTION"].change_reason + == "RC channel 16 option selected in the RC calibration plugin." + ) + controller.set_param.assert_not_called() + + +@pytest.mark.parametrize( + ("operation", "arguments", "guidance"), + [ + ("set_mapping_mode", (0,), "Select an RC mapping mode from 1 to 4."), + ("set_channel_option", (0, "300"), "Select an RC channel from 1 to 16."), + ("set_channel_option", (17, "300"), "Select an RC channel from 1 to 16."), + ("set_channel_option", (6, "bad"), "Select a valid RC channel option."), + ("set_channel_option", (1, "300"), "The primary-control and flight-mode channel functions cannot be changed here."), + ], +) +def test_invalid_rc_edits_return_the_guidance_needed_to_correct_the_request( + rc_staging_context, + operation: str, + arguments: tuple, + guidance: str, +) -> None: + """ + Explain invalid RC edits without changing staged values. + + GIVEN: An invalid mapping, channel, option or protected primary control + WHEN: The edit is requested + THEN: The operation fails with actionable guidance and preserves all pending values + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + previous = {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} + assert getattr(model, operation)(*arguments) == (False, guidance) + assert {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} == previous + + +def test_asymmetric_rc_calibration_preserves_observed_maximum_integer_trim_and_measurement_reason( + rc_staging_context, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Stage actual measured endpoints with an integer midpoint. + + GIVEN: Channel 1 moves between asymmetric endpoints while channel 2 stays still + WHEN: Calibration finishes + THEN: Only measured motion is staged, with a traceable reason and complete diagnostics + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + with caplog.at_level("INFO"): + assert model.start_calibration() == (True, "") + for reading in ([1001, 1500], [1888, 1500], [1700, 1500], [0, 0]): + controller.master.recv_match.side_effect = [_make_rc_channels_msg(reading), None] + model.get_rc_telemetry() + assert model.finish_calibration()[0] is True + assert caplog.messages == [ + "RC calibration started — move all sticks and switches to their extremes", + "RC1: MIN=1001 MAX=1888 TRIM=1444", + "RC2 did not move; calibration values were not staged", + ] + for suffix, expected in (("MIN", 1001), ("MAX", 1888), ("TRIM", 1444)): + parameter = editor.current_step_parameters[f"RC1_{suffix}"] + assert parameter.get_new_value() == expected + assert parameter.change_reason == "Measured in the RC calibration plugin." + controller.set_param.assert_not_called() + + +@pytest.mark.parametrize( + ("pwm", "expected"), [(1000, -1000), (1200, -500), (1400, 0), (1750, 500), (2100, 1000), (2200, 1000), (900, -1000)] +) +@pytest.mark.parametrize("reversed_channel", [0, 1]) +def test_asymmetric_roll_travel_uses_each_side_of_trim_and_clamps_the_preview( + connected_flight_controller: MagicMock, + pwm: int, + expected: int, + reversed_channel: int, +) -> None: + """ + Respect calibrated neutral and asymmetric travel on both sides of the stick. + + GIVEN: Roll has a trim of 1400 and endpoints 1000 to 2100 + WHEN: Samples cross neutral and exceed either endpoint, with or without reversal + THEN: The preview uses each side's span and remains within minus to plus 1000 + """ + controller = connected_flight_controller + controller.fc_parameters = {"RC1_MIN": 1000, "RC1_MAX": 2100, "RC1_TRIM": 1400, "RC1_REVERSED": reversed_channel} + controller.master.recv_match.side_effect = [_make_rc_channels_msg([pwm]), None] + assert RCCalibrationDataModel(controller).get_rc_telemetry()["roll"] == (expected if reversed_channel == 0 else -expected) + + +@pytest.mark.parametrize("trim", [1000, 2100]) +def test_roll_trim_at_an_endpoint_uses_a_safe_midpoint_for_the_stick_preview( + connected_flight_controller: MagicMock, + trim: int, +) -> None: + """ + Keep the stick preview usable when a neutral trim coincides with an endpoint. + + GIVEN: Roll trim equals either calibrated endpoint + WHEN: The receiver reports the midpoint of its travel + THEN: The preview is centered and the configured trim marker remains unchanged + """ + controller = connected_flight_controller + controller.fc_parameters = {"RC1_MIN": 1000, "RC1_MAX": 2100, "RC1_TRIM": trim} + controller.master.recv_match.side_effect = [_make_rc_channels_msg([1550]), None] + telemetry = RCCalibrationDataModel(controller).get_rc_telemetry() + assert telemetry["roll"] == 0.0 + assert telemetry["channels"][0]["trim"] == trim + + +@pytest.mark.parametrize(("mode_channel", "expected_mode"), [(None, 5), (nan, 5), (16.0, 16), (0.0, 0), (1.0, 1)]) +def test_offline_channel_rows_preserve_default_controls_and_the_configured_mode_channel( + connected_flight_controller: MagicMock, + mode_channel: float | None, + expected_mode: int, +) -> None: + """ + Present useful channel functions before mapping or receiver telemetry is available. + + GIVEN: Missing mapping parameters and a missing, invalid, last or disabled flight-mode channel + WHEN: The RC monitor requests channel rows + THEN: Primary controls use their defaults and Mode follows the valid channel or safe fallback + """ + controller = connected_flight_controller + controller.fc_parameters = {} if mode_channel is None else {"FLTMODE_CH": mode_channel} + model = RCCalibrationDataModel(controller) + rows = model.get_channel_calibration() + expected = {1: "Roll", 2: "Pitch", 3: "Throttle", 4: "Yaw"} + if expected_mode: + previous = expected.get(expected_mode) + expected[expected_mode] = f"{previous} / Mode" if previous else "Mode" + assert len(rows) == 16 + assert [row["function"] for row in rows] == [expected.get(channel, "") for channel in range(1, 17)] + assert all(row["option_value"] == 0 and row["option_label"] == "0" for row in rows) + assert all(not row["function_editable"] and row["option_choices"] == {} for row in rows) + assert model.get_preview_axes() == {"roll": 0.0, "pitch": 0.0, "throttle": 0.0, "yaw": 0.0} + + +@pytest.mark.parametrize("mapping", [0.0, 17.0, 1.5, nan]) +def test_invalid_primary_mapping_does_not_label_an_unassigned_channel_as_roll( + connected_flight_controller: MagicMock, + mapping: float, +) -> None: + """ + Leave invalid primary-control assignments visibly unassigned. + + GIVEN: The downloaded roll mapping is out of range, fractional or nonfinite + WHEN: The monitor displays channel functions + THEN: No channel is labelled Roll while other default primary controls remain visible + """ + connected_flight_controller.fc_parameters = {"RCMAP_ROLL": mapping} + rows = RCCalibrationDataModel(connected_flight_controller).get_channel_calibration() + assert all("Roll" not in row["function"] for row in rows) + assert [row["function"] for row in rows[1:4]] == ["Pitch", "Throttle", "Yaw"] + + +@pytest.mark.parametrize(("value", "label"), [(300.0, "300"), (nan, "")]) +def test_unknown_firmware_option_labels_preserve_numeric_values_or_unavailability( + connected_flight_controller: MagicMock, + value: float, + label: str, +) -> None: + """ + Show unlabelled firmware options without inventing a named function. + + GIVEN: The FC reports an auxiliary option but firmware choices are not loaded + WHEN: Its channel row is requested + THEN: A finite numeric value is shown directly and unavailable data has an empty label + """ + connected_flight_controller.fc_parameters = {"RC6_OPTION": value} + row = RCCalibrationDataModel(connected_flight_controller).get_channel_calibration()[5] + assert row["option_label"] == label + assert row["function_editable"] is False + assert row["option_choices"] == {} + + +@pytest.mark.parametrize( + ("axes", "expected"), + [ + ({}, ((0.0, 0.0), (0.0, 0.0))), + ({"yaw": -2000.0, "throttle": 2000.0, "roll": 2000.0, "pitch": -2000.0}, ((-1.0, 1.0), (1.0, 1.0))), + ({"yaw": 2000.0, "throttle": -2000.0, "roll": -2000.0, "pitch": 2000.0}, ((1.0, -1.0), (-1.0, -1.0))), + ], +) +def test_stick_preview_defaults_missing_axes_to_neutral_and_clamps_excess_travel(axes, expected) -> None: + """ + Keep transmitter stick coordinates neutral or inside their display bounds. + + GIVEN: Missing axes or stick travel beyond normalized endpoints + WHEN: Mode-2 stick coordinates are calculated + THEN: Missing input stays centered and excess input stays at the edge of the display + """ + assert RCCalibrationDataModel.stick_positions(axes, 2) == expected + + +@pytest.mark.parametrize("pwm", [0, 1500]) +def test_throttle_preview_uses_travel_midpoint_instead_of_calibrated_low_trim( + connected_flight_controller: MagicMock, pwm: int +) -> None: + """ + Keep throttle travel centered independently of the saved low-throttle trim. + + GIVEN: Throttle has calibrated endpoints 1000 and 2000 with a trim of 1100 + WHEN: A midpoint or unavailable PWM sample arrives + THEN: The throttle preview remains centered rather than treating trim as neutral. + """ + controller = connected_flight_controller + controller.fc_parameters = {"RC3_MIN": 1000, "RC3_MAX": 2000, "RC3_TRIM": 1100} + controller.master.recv_match.side_effect = [_make_rc_channels_msg([1500, 1500, pwm]), None] + assert RCCalibrationDataModel(controller).get_rc_telemetry()["throttle"] == 0.0 + + +@pytest.mark.parametrize("invalid_pwm", [0, 65535]) +def test_missing_receiver_samples_do_not_contaminate_recorded_calibration_extrema( + rc_staging_context, invalid_pwm: int +) -> None: + """ + Preserve real stick travel when a receiver temporarily reports unavailable PWM. + + GIVEN: Calibration sees a valid endpoint, an unavailable sample, and a second endpoint + WHEN: The operator finishes calibration + THEN: Only the two measured endpoints determine the staged limits and trim. + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + assert model.start_calibration() == (True, "") + for pwm in (1500, invalid_pwm, 1800): + controller.master.recv_match.side_effect = [_make_rc_channels_msg([pwm]), None] + telemetry = model.get_rc_telemetry() + assert telemetry["channels"][0]["value"] == (None if pwm == invalid_pwm else pwm) + assert model.finish_calibration()[0] + assert editor.current_step_parameters["RC1_MIN"].get_new_value() == 1500 + assert editor.current_step_parameters["RC1_MAX"].get_new_value() == 1800 + assert editor.current_step_parameters["RC1_TRIM"].get_new_value() == 1650 + + +def test_receiver_report_with_no_channels_does_not_invent_a_first_channel(connected_flight_controller: MagicMock) -> None: + """ + Leave the channel count empty when the receiver declares no available channels. + + GIVEN: A receiver declares zero channels while its first raw field retains an old value + WHEN: The calibration monitor polls + THEN: No receiver channel is treated as available. + """ + controller = connected_flight_controller + controller.master.recv_match.side_effect = [_make_rc_channels_msg([1700], chancount=0), None] + telemetry = RCCalibrationDataModel(controller).get_rc_telemetry() + assert all(channel["value"] is None for channel in telemetry["channels"]) + assert telemetry["roll"] == 0.0 diff --git a/tests/plugins/test_data_model_rc_receiver.py b/tests/plugins/test_data_model_rc_receiver.py index 799bb8861..bc3d7834c 100755 --- a/tests/plugins/test_data_model_rc_receiver.py +++ b/tests/plugins/test_data_model_rc_receiver.py @@ -309,6 +309,19 @@ def test_receiver_streams_are_requested_once_and_restored_when_monitor_pauses(re request.assert_called_with() +def test_pausing_an_inactive_disconnected_receiver_does_not_request_streams(receiver_context: SimpleNamespace) -> None: + """ + Leave an inactive connection alone when closing the receiver view. + + GIVEN a newly constructed receiver whose flight controller is disconnected, + WHEN monitoring is stopped before its first poll, + THEN no stream restoration command is sent. + """ + receiver_context.controller.master = None + receiver_context.model.stop_monitoring() + receiver_context.controller.request_rc_receiver_messages.assert_not_called() + + def test_reconnection_requests_streams_without_restoring_rates_on_the_new_vehicle(receiver_context: SimpleNamespace) -> None: """ Renew telemetry requests when the FC connection changes. @@ -328,7 +341,9 @@ def test_reconnection_requests_streams_without_restoring_rates_on_the_new_vehicl request.assert_called_once_with() -def test_io_failure_clears_live_success_but_monitor_can_recover(receiver_context: SimpleNamespace) -> None: +def test_io_failure_clears_live_success_but_monitor_can_recover( + receiver_context: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: """ Recover from a transient transport failure. @@ -339,7 +354,9 @@ def test_io_failure_clears_live_success_but_monitor_can_recover(receiver_context receiver_context.messages.append(rc_message()) receiver_context.model.get_receiver_state() receiver_context.controller.master.recv_match.side_effect = OSError("disconnected") - state = receiver_context.model.get_receiver_state() + with caplog.at_level("DEBUG"): + state = receiver_context.model.get_receiver_state() + assert caplog.messages == ["Error reading RC receiver telemetry: disconnected"] assert not state.receiving_channels assert state.rssi is None receiver_context.controller.master.recv_match.side_effect = [rc_message(), None] @@ -397,3 +414,77 @@ def test_frozen_build_collects_dynamic_receiver_modules(platform: str) -> None: """ spec = (_PACKAGE.parent / platform / "ardupilot_methodic_configurator.spec").read_text(encoding="utf-8") assert 'collect_submodules("ardupilot_methodic_configurator.plugins")' in spec.replace("'", '"') + + +def test_foreign_receiver_packets_do_not_hide_a_following_selected_controller_report( + receiver_context: SimpleNamespace, +) -> None: + """ + Continue reception after ignoring another component's telemetry. + + GIVEN: A foreign component followed by the selected controller's report + WHEN: Receiver telemetry is polled + THEN: The selected report is displayed with nonblocking receive calls + """ + foreign = rc_message((1800,) * 5) + foreign._header.srcComponent = 42 + receiver_context.messages.extend([foreign, rc_message((1200,) * 5)]) + assert receiver_context.model.get_receiver_state().channels == (1200,) * 5 + assert all(call.kwargs == {"blocking": False} for call in receiver_context.controller.master.recv_match.call_args_list) + + +@pytest.mark.parametrize(("count", "expected"), [(0, (None,) * 5), (1, (1, None, None, None, None))]) +def test_channel_count_and_smallest_valid_pwm_define_available_receiver_inputs( + receiver_context: SimpleNamespace, count: int, expected: tuple[int | None, ...] +) -> None: + """ + Follow the reported channel count even when unused fields contain values. + + GIVEN: A report whose unused fields are populated and first channel has PWM 1 + WHEN: The receiver advertises zero or one available channel + THEN: Only advertised valid input is exposed + """ + message = rc_message((1,) * 5) + message.chancount = count + receiver_context.messages.append(message) + assert receiver_context.model.get_receiver_state().channels == expected + + +def test_interval_request_and_restore_failures_remain_visible_in_receiver_diagnostics( + receiver_context: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: + """ + Explain firmware interval failures while leaving receiver monitoring usable. + + GIVEN: Firmware rejects the receiver stream interval commands + WHEN: Monitoring starts and then yields the link + THEN: Both failed operations are recorded with the firmware's reason + """ + receiver_context.controller.request_rc_receiver_messages.return_value = (False, "unsupported") + with caplog.at_level("DEBUG"): + receiver_context.model.get_receiver_state() + receiver_context.model.stop_monitoring() + assert caplog.messages == [ + "Could not request RC receiver telemetry intervals: unsupported", + "Could not restore RC receiver telemetry intervals: unsupported", + ] + + +def test_undiscovered_receiver_system_cannot_supply_a_successful_test(receiver_context: SimpleNamespace) -> None: + """ + Wait for a selected controller identity before accepting receiver telemetry. + + GIVEN: Discovery still has the broadcast system ID zero + WHEN: A packet with system ID zero appears on the link + THEN: It cannot establish a working receiver or a successful failsafe check + """ + controller = receiver_context.controller + controller.info.system_id = "" + controller.master.target_system = 0 + message = rc_message() + message._header.srcSystem = 0 + receiver_context.messages.append(message) + state = receiver_context.model.get_receiver_state() + assert state.receiving_channels is False + assert state.channels == (None,) * 5 + assert state.failsafe is None diff --git a/tests/plugins/test_data_model_servo_out.py b/tests/plugins/test_data_model_servo_out.py index a2b928ab1..9afdfbaab 100755 --- a/tests/plugins/test_data_model_servo_out.py +++ b/tests/plugins/test_data_model_servo_out.py @@ -62,7 +62,7 @@ def test_output_connection_types_use_the_canonical_pwm_output_choices(self) -> N """The servo model uses the same output labels as vehicle-component validation.""" assert SERVO_OUT_PWM_PORTS is PWM_OUT_PORTS - def test_main_out_quad_starts_motor_functions_at_first_servo_output(self, servo_model_factory) -> None: + def test_main_out_quad_starts_motor_functions_at_first_servo_output(self, servo_model_factory, caplog) -> None: """ Main Out wiring assigns a quad's motors to the first output bank. @@ -74,7 +74,10 @@ def test_main_out_quad_starts_motor_functions_at_first_servo_output(self, servo_ model = servo_model_factory("Main Out", 1) # Act (When) - recommendations, _message, _status = model.get_recommendations() + recommendations, message, status = model.get_recommendations() + assert caplog.messages == [] + assert message == "Recommended 4 motor output assignment(s)." + assert status is ServoOutRecommendationStatus.RECOMMENDATIONS_AVAILABLE # Assert (Then) assert recommendations == { @@ -139,7 +142,9 @@ def test_main_out_tricopter_keeps_the_yaw_servo_outside_the_esc_outputs(self, se "SERVO3_FUNCTION": 36, "SERVO7_FUNCTION": 39, } - assert "servo" in message.lower() + assert message == ( + "Recommended 4 motor output assignment(s). SERVO7 is the tricopter yaw servo; connect it to a servo, not an ESC." + ) def test_tricopter_keeps_yaw_output_reserved_when_an_esc_output_is_occupied(self, servo_model_factory) -> None: """An occupied early output must not move an ESC onto the reserved yaw-servo output.""" @@ -157,7 +162,9 @@ def test_tricopter_keeps_yaw_output_reserved_when_an_esc_output_is_occupied(self "SERVO4_FUNCTION": 36, "SERVO7_FUNCTION": 39, } - assert "servo" in message.lower() + assert message == ( + "Recommended 4 motor output assignment(s). SERVO7 is the tricopter yaw servo; connect it to a servo, not an ESC." + ) def test_aio_tricopter_keeps_the_yaw_servo_on_servo7(self, servo_model_factory) -> None: """AIO connection ordering must not move the tricopter yaw servo into the ESC bank.""" @@ -308,7 +315,7 @@ def test_malformed_frame_component_data_returns_a_recommendation_error(self, ser assert recommendations == {} assert status is ServoOutRecommendationStatus.ERROR - assert "supported motor-output mapping" in message + assert message == "FRAME_CLASS 0 does not have a supported motor-output mapping." def test_existing_motor_function_is_not_duplicated(self, servo_model_factory) -> None: """A motor function already routed elsewhere is never proposed a second time.""" @@ -501,7 +508,7 @@ def test_unrecognized_connection_does_not_propose_assignments(self, servo_model_ # Assert (Then) assert recommendations == {} - assert "Main Out or AIO" in message + assert message == "Select either Main Out or AIO as the FC-to-ESC connection type before assigning outputs." assert status is ServoOutRecommendationStatus.ERROR def test_complete_mapping_has_a_semantic_no_changes_status(self, servo_model_factory) -> None: @@ -577,7 +584,7 @@ def test_partial_application_names_assignments_that_could_not_be_applied(self) - # Assert (Then) assert applied == ["SERVO1_FUNCTION", "SERVO3_FUNCTION", "SERVO4_FUNCTION"] - assert "SERVO2_FUNCTION" in message + assert message == "Applied 3 motor output assignment(s). Could not apply: SERVO2_FUNCTION." def test_application_accepts_only_parameter_update_status_values(self) -> None: """ @@ -599,12 +606,15 @@ def test_application_accepts_only_parameter_update_status_values(self) -> None: model = ServoOutDataModel(filesystem, editor) # Act (When) - applied, message, _status = model.apply_recommendations() + applied, message, status = model.apply_recommendations() # Assert (Then) assert not applied - assert message.startswith("No servo output assignments could be applied.") - assert "SERVO1_FUNCTION" in message + assert status is ServoOutRecommendationStatus.ERROR + assert message == ( + "No servo output assignments could be applied. Could not apply: " + "SERVO1_FUNCTION, SERVO2_FUNCTION, SERVO3_FUNCTION, SERVO4_FUNCTION." + ) def test_application_preserves_unassigned_motor_warning(self) -> None: """Applying available outputs keeps the warning for motors still without an output.""" @@ -628,8 +638,7 @@ def test_application_preserves_unassigned_motor_warning(self) -> None: applied, message, _status = model.apply_recommendations() assert applied == ["SERVO4_FUNCTION"] - assert "Applied 1 motor output assignment(s)." in message - assert "Motor2, Motor3, Motor4 are not assigned to any output" in message + assert message == ("Applied 1 motor output assignment(s). Motor2, Motor3, Motor4 are not assigned to any output.") @pytest.mark.parametrize("frame_class", ["invalid", [], float("inf")]) @@ -651,7 +660,7 @@ def test_invalid_frame_configuration_cannot_propose_motor_outputs( # Then: Do not guess a frame or overwrite outputs. assert recommendations == {} assert status is ServoOutRecommendationStatus.ERROR - assert "supported motor-output mapping" in message + assert message == "FRAME_CLASS 0 does not have a supported motor-output mapping." @pytest.mark.parametrize("invalid_function", [[], "invalid", 5.5]) @@ -696,7 +705,7 @@ def test_unusable_motor_metadata_produces_an_actionable_mapping_error( # Then: Invalid records do not silently become a usable layout. assert recommendations == {} assert status is ServoOutRecommendationStatus.ERROR - assert "supported motor-output mapping" in message + assert message == "FRAME_CLASS 1 does not have a supported motor-output mapping." @pytest.mark.parametrize("occupied", [True, False]) @@ -749,26 +758,200 @@ def test_unavailable_servo_parameter_does_not_prevent_other_motor_assignments( # Then: One unavailable output does not prevent the others from being assigned. assert applied == ["SERVO2_FUNCTION", "SERVO3_FUNCTION", "SERVO4_FUNCTION"] assert status is ServoOutRecommendationStatus.APPLIED - assert "Could not apply: SERVO1_FUNCTION." in message + assert message == "Applied 3 motor output assignment(s). Could not apply: SERVO1_FUNCTION." -def test_a_frame_needing_too_many_outputs_is_rejected_before_assigning_any( +@pytest.mark.parametrize("motor_count", [14, 15]) +def test_frame_layouts_respect_the_available_output_capacity( servo_model_factory: Callable[[str, int, dict[str, object] | None], ServoOutDataModel], + motor_count: int, ) -> None: """ - Reject layouts exceeding the plugin's output capacity. + Accept a full output bank and reject layouts exceeding its capacity. - GIVEN: A supported frame layout requires fifteen outputs + GIVEN: A scripting frame layout requires fourteen or fifteen outputs WHEN: The user requests assignments for fourteen available outputs - THEN: No truncated mapping is offered and the capacity error is explained + THEN: All fourteen motors are assigned or the excess capacity is explained """ # Given: A realistic external layout exceeding this plugin's routing capacity. - model = servo_model_factory("Main Out", 1, None) - metadata = {"layouts": [{"Class": 1, "motors": [{"Number": number} for number in range(1, 16)]}]} + model = servo_model_factory("Main Out", 15, None) + metadata = {"layouts": [{"Class": 15, "motors": [{"Number": number} for number in range(1, motor_count + 1)]}]} with patch.object(model._motor_data_loader, "load_json_data", return_value=metadata): # When: Resolve the complete motor layout. recommendations, message, status = model.get_recommendations() # Then: Never silently omit the fifteenth motor. - assert recommendations == {} - assert status is ServoOutRecommendationStatus.ERROR - assert "requires more outputs" in message + if motor_count == 15: + assert recommendations == {} + assert status is ServoOutRecommendationStatus.ERROR + assert message == "FRAME_CLASS 15 requires more outputs than this plugin can assign." + else: + functions = (*range(33, 41), *range(82, 86), 160, 161) + assert recommendations == {f"SERVO{index}_FUNCTION": value for index, value in enumerate(functions, 1)} + assert status is ServoOutRecommendationStatus.RECOMMENDATIONS_AVAILABLE + assert message == "Recommended 14 motor output assignment(s)." + + +def test_multiple_later_servo_changes_explain_conflicts_without_discarding_recommendation_summary(servo_model_factory) -> None: + """ + Preserve the assignment summary and every conflicting later step. + + GIVEN: Two currently unassigned outputs have saved changes after this step + WHEN: Motor outputs are recommended + THEN: Those outputs are skipped and both conflicts follow the recommendation summary + """ + model = servo_model_factory("Main Out", 1) + model._parameter_editor.current_file = "10_servo_out.param" + model._local_filesystem.file_parameters = { + "10_servo_out.param": ParDict(), + "14_later_setup.param": ParDict({"SERVO1_FUNCTION": Par(0), "SERVO2_FUNCTION": Par(0)}), + "15_later_setup.param": ParDict({"SERVO1_FUNCTION": Par(0)}), + } + recommendations, message, status = model.get_recommendations() + assert recommendations == {f"SERVO{number + 2}_FUNCTION": 32 + number for number in range(1, 5)} + assert status is ServoOutRecommendationStatus.RECOMMENDATIONS_AVAILABLE + assert message == ( + "Recommended 4 motor output assignment(s). " + "Skipped outputs with later saved changes because those steps would overwrite assignments: " + "SERVO1_FUNCTION (14_later_setup.param), SERVO2_FUNCTION (14_later_setup.param)." + ) + + +def test_occupied_tricopter_yaw_output_is_preserved_and_reports_the_unassigned_servo(servo_model_factory) -> None: + """ + Preserve an occupied yaw-servo output and explain the missing assignment. + + GIVEN: A tricopter's SERVO7 is occupied by a non-motor function + WHEN: Outputs are recommended + THEN: Three ESC assignments are proposed and the unassigned yaw function is reported + """ + model = servo_model_factory("Main Out", 7, {"SERVO7_FUNCTION": SimpleNamespace(get_new_value=lambda: 70)}) + recommendations, message, status = model.get_recommendations() + assert recommendations == {"SERVO1_FUNCTION": 33, "SERVO2_FUNCTION": 34, "SERVO3_FUNCTION": 36} + assert status is ServoOutRecommendationStatus.RECOMMENDATIONS_AVAILABLE + assert message == "Recommended 3 motor output assignment(s). Motor7 is not assigned to any output." + + +@pytest.mark.parametrize( + ("frame_class", "summary"), + [(1, ""), (3, ""), (7, " SERVO7 is the tricopter yaw servo; connect it to a servo, not an ESC.")], +) +def test_applying_motor_outputs_retains_only_the_unresolved_assignment_guidance( + servo_model_factory, frame_class: int, summary: str +) -> None: + """ + Replace the recommendation summary with an accurate application result. + + GIVEN: A quad, octa or tricopter has free outputs and the editor accepts each assignment + WHEN: The recommendations are applied + THEN: The result reports the applied assignments without repeating the recommendation. + """ + model = servo_model_factory("Main Out", frame_class) + editor = cast("SimpleNamespace", model._parameter_editor) + editor.add_parameter_to_current_file = MagicMock(return_value=True) + editor.update_parameter_value = MagicMock(return_value=SimpleNamespace(status=ParameterValueUpdateStatus.UPDATED)) + applied, message, status = model.apply_recommendations() + count = 8 if frame_class == 3 else 4 + assert len(applied) == count + assert status is ServoOutRecommendationStatus.APPLIED + assert message == f"Applied {count} motor output assignment(s)." + recommendations, recommendation_message, _status = model.get_recommendations() + assert len(recommendations) == count + assert recommendation_message == f"Recommended {count} motor output assignment(s)." + summary + + +@pytest.mark.parametrize("source", ["saved", "controller", "staged"]) +def test_motor_assignments_ignore_servo_settings_that_are_not_output_functions(servo_model_factory, source: str) -> None: + """ + Interpret only SERVOx_FUNCTION values as existing motor assignments. + + GIVEN: The BLHeli output mask has the numeric value of Motor1's function + WHEN: Recommendations combine saved, controller and staged parameters + THEN: The mask does not make an unassigned Motor1 appear connected. + """ + model = servo_model_factory("Main Out", 1) + if source == "saved": + model._local_filesystem.file_parameters = {"10_servo.param": ParDict({"SERVO_BLH_MASK": Par(33)})} + elif source == "controller": + model._parameter_editor.fc_parameters["SERVO_BLH_MASK"] = 33 + else: + model._parameter_editor.current_step_parameters["SERVO_BLH_MASK"] = SimpleNamespace(get_new_value=lambda: 33) + recommendations, _message, status = model.get_recommendations() + assert status is ServoOutRecommendationStatus.RECOMMENDATIONS_AVAILABLE + assert recommendations == {f"SERVO{number}_FUNCTION": 32 + number for number in range(1, 5)} + + +def test_later_changes_to_occupied_outputs_do_not_warn_about_overwriting_new_assignments(servo_model_factory) -> None: + """ + Reserve future outputs only when they are currently available for assignment. + + GIVEN: A motor and a control output already have assignments that are changed by a later step + WHEN: The remaining motors are routed to free outputs + THEN: Occupied outputs are preserved and no overwrite warning refers to them. + """ + parameters = { + "SERVO1_FUNCTION": SimpleNamespace(get_new_value=lambda: 33), + "SERVO2_FUNCTION": SimpleNamespace(get_new_value=lambda: 4), + } + model = servo_model_factory("Main Out", 1, parameters) + model._parameter_editor.current_file = "10_servo.param" + model._local_filesystem.file_parameters = { + "10_servo.param": ParDict(), + "12_controls.param": ParDict({"SERVO1_FUNCTION": Par(33), "SERVO2_FUNCTION": Par(4)}), + } + recommendations, message, status = model.get_recommendations() + assert status is ServoOutRecommendationStatus.RECOMMENDATIONS_AVAILABLE + assert recommendations == {f"SERVO{number + 1}_FUNCTION": 32 + number for number in range(2, 5)} + assert message == "Recommended 3 motor output assignment(s)." + + +@pytest.mark.parametrize("bad_motors", [None, [], [None], [{"Number": "invalid"}]]) +def test_motor_layout_selection_continues_after_an_unusable_matching_layout(bad_motors: object) -> None: + """ + Recover from an unusable layout when another matching motor arrangement exists. + + GIVEN: Motor metadata contains a malformed matching layout before a usable one + WHEN: The frame class's motor numbers are resolved + THEN: The usable layout supplies all four motor numbers. + """ + data = { + "layouts": [ + {"Class": 1, "motors": bad_motors}, + {"Class": 1, "motors": [{"Number": number} for number in range(1, 5)]}, + ] + } + assert ServoOutDataModel._motor_numbers_for_frame_class(1, data) == (1, 2, 3, 4) + + +@pytest.mark.parametrize("unset", ["", "33.5"]) +def test_empty_functions_remain_assignable_and_fractional_functions_are_not_motor_assignments( + servo_model_factory, unset: str +) -> None: + """ + Avoid interpreting incomplete function edits as a valid motor assignment. + + GIVEN: One output has an empty edit or an invalid fractional function value + WHEN: A quad's motor outputs are recommended + THEN: Motor1 is assigned to the first genuinely free output. + """ + model = servo_model_factory("Main Out", 1, {"SERVO1_FUNCTION": SimpleNamespace(get_new_value=lambda: unset)}) + recommendations, _message, _status = model.get_recommendations() + first_output = 1 if unset == "" else 2 + assert recommendations == {f"SERVO{number + first_output - 1}_FUNCTION": 32 + number for number in range(1, 5)} + + +@pytest.mark.parametrize(("vehicle", "label", "frame_class"), [("Rover", "Boat", 2), ("", "Quad", 1)]) +def test_offline_frame_labels_use_the_selected_vehicle_dictionary_or_copter_fallback( + servo_model_factory, vehicle: str, label: str, frame_class: int +) -> None: + """ + Interpret saved frame labels using their vehicle's own numbering. + + GIVEN: No controller frame parameter is available and a saved frame label exists + WHEN: The offline frame class is resolved + THEN: Rover labels use Rover numbering and an unspecified vehicle uses Copter's fallback. + """ + model = servo_model_factory("Main Out", 1) + model._parameter_editor.fc_parameters.clear() + model._local_filesystem.vehicle_type = vehicle + model._local_filesystem.vehicle_components_fs.data["Components"]["Frame"] = {"Specifications": {"Frame class": label}} + assert model._get_frame_class() == frame_class diff --git a/tests/plugins/test_gnss.py b/tests/plugins/test_gnss.py index e5317f48f..bf12b1787 100755 --- a/tests/plugins/test_gnss.py +++ b/tests/plugins/test_gnss.py @@ -90,6 +90,7 @@ def test_no_fix_and_2d_fix_hide_unusable_coordinates(sample: TelemetrySample, fi row = GnssDataModel(MagicMock()).update(sample)[0] assert row[3] == row[6] == row[7] == "N/A" assert (row[1] == "N/A") is (fix != "2") + assert (row[2] == "N/A") is (fix != "2") assert row[5] == "19" @@ -125,8 +126,8 @@ def test_time_secondary_receiver_and_stale_data_remain_independent(sample: Telem assert rows[0][4] == "2023-11-14 22:13:20 UTC" assert rows[1][0] == "Waiting for GNSS data" assert model.update(replace(sample, time=13))[0][1] == "N/A" - assert model.update(replace(sample, connection=None))[0][0] == "Disconnected" - assert model.update(replace(sample, error=True))[0][0] == "Connection error" + assert model.update(replace(sample, connection=None)) == (("Disconnected", *("N/A",) * 7),) * 2 + assert model.update(replace(sample, error=True)) == (("Connection error", *("N/A",) * 7),) * 2 sample.messages[(2, 1, "GPS_RAW_INT")] = sample.messages.pop((1, 1, "GPS_RAW_INT")) assert model.update(sample)[0][1] == "N/A" @@ -153,7 +154,9 @@ def test_monitor_requests_and_parses_real_gnss_packets_without_changing_stream_r assert GnssDataModel(link.controller).update(sample)[0][1] == "52.0000000" assert send.call_count == 3 assert {call.args[4] for call in send.call_args_list} == {24, 124, 2} - assert all(call.args[2] == 512 for call in send.call_args_list) + assert [call.args for call in send.call_args_list] == [ + (1, 1, 512, 0, message_id, 0, 0, 0, 0, 0, 0) for message_id in (24, 124, 2) + ] backend.poll() assert send.call_count == 3 backend.close() @@ -253,16 +256,16 @@ def test_second_receiver_visibility_requires_fresh_detected_hardware(sample: Tel """ model = GnssDataModel(MagicMock(fc_parameters={"BRD_RTC_TZ_MIN": 0})) model.update(sample) - assert not model.second_receiver_present + assert model.second_receiver_present is False fields = dict(sample.messages[(1, 1, "GPS_RAW_INT")].fields) fields["fix_type"] = fix sample.messages[(1, 1, "GPS2_RAW")] = MessageSample(1, 52, 10, tuple(fields.items())) model.update(sample) assert model.second_receiver_present is visible model.update(replace(sample, time=13)) - assert not model.second_receiver_present + assert model.second_receiver_present is False model.update(replace(sample, connection=None)) - assert not model.second_receiver_present + assert model.second_receiver_present is False def test_second_receiver_column_follows_live_detection( @@ -335,3 +338,119 @@ def test_unknown_or_invalid_timezone_keeps_local_time_unavailable(sample: Teleme row = model.update(sample)[0] assert row[4] == "N/A" assert row[1] == "52.1234567" + + +@pytest.mark.parametrize( + ("fix", "status", "secondary"), + [ + ("0", "No GNSS", False), + ("1", "No fix", True), + ("2", "2D fix", True), + ("3", "3D fix", True), + ("4", "DGPS", True), + ("5", "RTK float", True), + ("6", "RTK fixed", True), + ("7", "Static", True), + ("8", "PPP", True), + ("9", "Unknown fix", False), + ("-1", "Unknown fix", False), + ("2.5", "Unknown fix", False), + ], +) +def test_receiver_fix_labels_and_presence_cover_every_reported_fix( + sample: TelemetrySample, fix: str, status: str, secondary: bool +) -> None: + """ + Both fix labels and secondary receiver visibility reflect that code. + + GIVEN: A primary and secondary receiver reporting a fix code + WHEN: Their telemetry is displayed + THEN: Both fix labels and secondary receiver visibility reflect that code + """ + fields = dict(sample.messages[(1, 1, "GPS_RAW_INT")].fields) + fields["fix_type"] = fix + message = MessageSample(1, 52, 10, tuple(fields.items())) + sample.messages.update({(1, 1, "GPS_RAW_INT"): message, (1, 1, "GPS2_RAW"): message}) + model = GnssDataModel(MagicMock()) + assert model.second_receiver_present is False + assert [row[0] for row in model.update(sample)] == [status, status] + assert model.second_receiver_present is secondary + + +@pytest.mark.parametrize( + ("changes", "expected"), + [ + ( + {"lat": "900000000", "lon": "-1800000000", "fix_type": "3", "satellites_visible": "0", "h_acc": "1", "v_acc": "1"}, + ("90.0000000", "-180.0000000", "-12.345", "0", "0.001", "0.001"), + ), + ({"lat": "905000000", "lon": "-1805000000"}, ("N/A", "N/A", "-12.345", "19", "0.450", "0.800")), + ], +) +def test_coordinate_limits_and_minimum_valid_accuracy_remain_distinct_from_unavailable( + sample: TelemetrySample, changes: dict[str, str], expected: tuple[str, ...] +) -> None: + """ + Valid endpoints remain visible and impossible coordinates are unavailable. + + GIVEN: Reports at coordinate limits, outside the globe, or with one-millimetre accuracy + WHEN: A receiver report is interpreted + THEN: Valid endpoints remain visible and impossible coordinates are unavailable + """ + fields = dict(sample.messages[(1, 1, "GPS_RAW_INT")].fields) + fields.update(changes) + sample.messages[(1, 1, "GPS_RAW_INT")] = MessageSample(1, 52, 10, tuple(fields.items())) + row = GnssDataModel(MagicMock()).update(sample)[0] + assert (*row[1:4], *row[5:]) == expected + + +@pytest.mark.parametrize(("timestamp", "expected"), [("1000000000000", "1970-01-12 13:46:40 UTC"), ("1e30", "N/A")]) +def test_utc_boundary_and_out_of_range_timestamps_are_displayed_safely( + sample: TelemetrySample, timestamp: str, expected: str +) -> None: + """ + Valid UTC is formatted and out-of-range dates are unavailable. + + GIVEN: The earliest accepted UTC timestamp or one outside the platform date range + WHEN: The receiver clock is displayed + THEN: Valid UTC is formatted and out-of-range dates are unavailable + """ + sample.messages[(1, 1, "SYSTEM_TIME")] = MessageSample(1, 20, 10, (("time_unix_usec", timestamp),)) + model = GnssDataModel(MagicMock(fc_parameters={"BRD_RTC_TZ_MIN": 0})) + assert model.update(sample)[0][4] == expected + + +@pytest.mark.parametrize("component", [0, 42]) +def test_receiver_reports_follow_the_selected_nondefault_component(sample: TelemetrySample, component: int) -> None: + """ + Displayed fix belongs to the selected component. + + GIVEN: The selected controller uses component 42 + WHEN: Its receiver report arrives alongside reports from component 1 + THEN: The displayed fix belongs to the selected component + """ + assert isinstance(sample.connection, SimpleNamespace) + sample.connection.target_component = component + report = sample.messages.pop((1, 1, "GPS_RAW_INT")) + sample.messages[(1, component or 1, "GPS_RAW_INT")] = report + assert GnssDataModel(MagicMock()).update(sample)[0][0] == "RTK fixed" + + +def test_gnss_request_write_failure_retains_connection_identity_and_poll_time(link: SimpleNamespace) -> None: + """ + Expose a failed GNSS request without losing the connection identity. + + GIVEN: A connected link whose GNSS command write fails + WHEN: The backend requests a fresh fix + THEN: The error snapshot retains the original connection and timestamp + """ + backend = GnssBackend(link.controller, clock=lambda: link.now) + try: + link.now = 42.0 + with patch.object(link.master.mav, "command_long_send", side_effect=OSError("write failed")): + snapshot = backend.poll() + assert snapshot.error is True + assert snapshot.connection is link.master + assert snapshot.time == 42.0 + finally: + backend.close() diff --git a/tests/plugins/unit_data_model_compass_calibration.py b/tests/plugins/unit_data_model_compass_calibration.py index ec198d631..13aadcc6c 100755 --- a/tests/plugins/unit_data_model_compass_calibration.py +++ b/tests/plugins/unit_data_model_compass_calibration.py @@ -64,7 +64,7 @@ class TestCompassCalibrationWithDisconnectedFlightController: """Test compass calibration behavior when the flight controller is disconnected.""" def test_user_sees_not_connected_state_when_fc_is_disconnected( - self, disconnected_compass_calibration_model: CompassCalibrationDataModel + self, disconnected_compass_calibration_model: CompassCalibrationDataModel, caplog: pytest.LogCaptureFixture ) -> None: """ The model reports that the flight controller is not connected. @@ -78,6 +78,7 @@ def test_user_sees_not_connected_state_when_fc_is_disconnected( # Assert assert result is False + assert caplog.messages == ["Flight controller not connected, cannot check compass status."] def test_user_cannot_start_calibration_without_a_flight_controller( self, disconnected_compass_calibration_model: CompassCalibrationDataModel, disconnected_flight_controller: MagicMock @@ -119,7 +120,10 @@ def test_user_cannot_cancel_calibration_without_a_flight_controller( disconnected_flight_controller.cancel_compass_calibration.assert_not_called() def test_user_sees_no_progress_data_when_fc_is_disconnected( - self, disconnected_compass_calibration_model: CompassCalibrationDataModel, disconnected_flight_controller: MagicMock + self, + disconnected_compass_calibration_model: CompassCalibrationDataModel, + disconnected_flight_controller: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """ Progress polling returns no updates when there is no connection. @@ -130,13 +134,40 @@ def test_user_sees_no_progress_data_when_fc_is_disconnected( AND: The backend is not called """ # Act - result = disconnected_compass_calibration_model.get_progress() + with caplog.at_level("DEBUG"): + result = disconnected_compass_calibration_model.get_progress() # Assert assert result == [] + assert caplog.messages == ["Compass calibration progress requested while disconnected."] disconnected_flight_controller.get_compass_calibration_progress.assert_not_called() +@pytest.mark.parametrize(("success", "detail"), [(True, ""), (False, "controller busy")]) +def test_operator_diagnostics_distinguish_accepted_and_rejected_compass_cancellation( + connected_flight_controller: MagicMock, success: bool, detail: str, caplog: pytest.LogCaptureFixture +) -> None: + """ + Preserve the controller's cancellation outcome in operator diagnostics. + + GIVEN: The controller accepts or rejects a compass cancellation + WHEN: The operator cancels calibration + THEN: The result and diagnostic record distinguish acceptance from rejection + """ + model = CompassCalibrationDataModel(connected_flight_controller) + connected_flight_controller.cancel_compass_calibration.return_value = (success, detail) + with caplog.at_level("DEBUG"): + result = model.cancel_calibration() + + assert result == (success, detail) + assert caplog.messages == [ + "Compass calibration cancel requested.", + "Compass calibration cancel accepted by flight controller." + if success + else f"Compass calibration cancel rejected: {detail}", + ] + + class TestCompassCalibrationWithConnectedFlightController: """Test compass calibration behavior when the flight controller is connected.""" @@ -372,3 +403,55 @@ def test_user_can_poll_progress_updates_from_the_backend( # Assert connected_flight_controller.get_compass_calibration_progress.assert_called_once() assert result == expected_data + + +@pytest.mark.parametrize(("success", "detail"), [(True, ""), (False, "controller busy")]) +def test_compass_start_diagnostics_preserve_enabled_sensors_and_controller_result( + connected_flight_controller: MagicMock, success: bool, detail: str, caplog: pytest.LogCaptureFixture +) -> None: + """ + Diagnostics retain the sensor flags and the controller's decision. + + GIVEN: One enabled compass and an accepted or rejected start request + WHEN: The operator starts calibration + THEN: Diagnostics retain the sensor flags and the controller's decision + """ + controller = connected_flight_controller + controller.fc_parameters["UNRELATED"] = 42 + controller.start_compass_calibration.return_value = (success, detail) + model = CompassCalibrationDataModel(controller) + with caplog.at_level("DEBUG"): + assert model.start_calibration() == (success, detail) + assert caplog.messages == [ + ( + "Compass calibration preflight: enabled compass ids=[0], raw flags=" + "{'COMPASS_ENABLE': 1, 'COMPASS_USE': 1, 'COMPASS_USE2': 0, 'COMPASS_USE3': 0}" + ), + "Compass calibration start requested for active compasses: [0]", + "Compass calibration start accepted by flight controller." + if success + else f"Compass calibration start rejected: {detail}", + ] + + +def test_disabled_compasses_explain_which_flags_the_operator_must_enable( + connected_flight_controller: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """ + Diagnostic and returned guidance name the settings to enable. + + GIVEN: Compass use is disabled + WHEN: The operator attempts calibration + THEN: The diagnostic and returned guidance name the settings to enable + """ + connected_flight_controller.fc_parameters["COMPASS_ENABLE"] = 0 + model = CompassCalibrationDataModel(connected_flight_controller) + with caplog.at_level("DEBUG"): + assert model.start_calibration() == ( + False, + "No active compasses are enabled. Set COMPASS_ENABLE=1 and enable at least one COMPASS_USE* parameter.", + ) + assert caplog.messages == [ + "Compass calibration preflight: COMPASS_ENABLE disabled or parameters unavailable.", + "No active compasses are enabled. Set COMPASS_ENABLE=1 and enable at least one COMPASS_USE* parameter.", + ] diff --git a/tests/test_backend_data_telemetry.py b/tests/test_backend_data_telemetry.py index 4f872bfc0..35868e48d 100755 --- a/tests/test_backend_data_telemetry.py +++ b/tests/test_backend_data_telemetry.py @@ -11,7 +11,9 @@ from collections.abc import Generator from dataclasses import replace -from threading import Event, Thread +from itertools import count +from threading import Event, RLock, Thread +from threading import enumerate as enumerate_threads from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -22,7 +24,9 @@ from telemetry_test_helpers import connected_telemetry_link, create_telemetry_link, feed from ardupilot_methodic_configurator.backend_data_telemetry import DataTelemetryBackend +from ardupilot_methodic_configurator.backend_esc_telemetry import EscTelemetryBackend from ardupilot_methodic_configurator.backend_mavftp import FTP_OP, MAVFTP, OP_WriteFile +from ardupilot_methodic_configurator.backend_radio_status import RadioStatusBackend from ardupilot_methodic_configurator.data_model_data_telemetry import ( DataTelemetryDataModel, MessageSample, @@ -185,7 +189,9 @@ def test_heartbeat_write_failure_reports_unavailable_and_can_recover(telemetry_l backend.close() -def test_gcs_heartbeat_recovers_from_a_write_failure_after_monitoring_closes(telemetry_link: SimpleNamespace) -> None: +def test_gcs_heartbeat_recovers_from_a_write_failure_after_monitoring_closes( + telemetry_link: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: """ Keep the GCS alive after a transient transport failure without reopening monitoring. @@ -208,7 +214,9 @@ def flaky_write(_packet: bytes) -> None: recovered.set() telemetry_link.master.write.side_effect = flaky_write - assert recovered.wait(4), "Heartbeat writes must retry while monitoring remains closed" + with caplog.at_level("DEBUG"): + assert recovered.wait(4), "Heartbeat writes must retry while monitoring remains closed" + assert caplog.messages == ["Error sending GCS heartbeat: temporary link failure"] def test_replacing_connection_stops_old_heartbeats_and_waits_for_monitoring(telemetry_link: SimpleNamespace) -> None: @@ -599,7 +607,9 @@ def test_observer_preserves_other_consumers_and_detaches_on_reconnect(telemetry_ backend.close() -def test_read_failure_clears_observer_and_reports_unavailable(telemetry_link: SimpleNamespace) -> None: +def test_read_failure_clears_observer_and_reports_unavailable( + telemetry_link: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: """ Recover safely from transport errors. @@ -607,10 +617,17 @@ def test_read_failure_clears_observer_and_reports_unavailable(telemetry_link: Si WHEN the transport fails while reading, THEN a read error replaces live data and the observer is detached. """ - backend = DataTelemetryBackend(telemetry_link.controller) + backend = DataTelemetryBackend(telemetry_link.controller, lambda: telemetry_link.now) backend.poll() - with patch.object(telemetry_link.master, "recv_match", side_effect=OSError("link lost")): + telemetry_link.now = 42.0 + with ( + patch.object(telemetry_link.master, "recv_match", side_effect=OSError("link lost")), + caplog.at_level("DEBUG"), + ): sample = backend.poll() + assert caplog.messages == ["Error reading data telemetry: link lost"] + assert sample.connection is telemetry_link.master + assert sample.time == 42.0 assert sample.error assert not sample.messages assert not telemetry_link.master.message_hooks @@ -704,3 +721,265 @@ def test_busy_stream_and_source_count_are_bounded(telemetry_link: SimpleNamespac assert len(backend.poll().messages) == 512 finally: backend.close() + + +def test_message_wire_sizes_accumulate_without_displaying_the_packet_type_as_a_field( + telemetry_link: SimpleNamespace, +) -> None: + """ + Count every received packet in per-message throughput. + + GIVEN: Two reports from the same MAVLink source and message type + WHEN: Their telemetry is captured + THEN: Counts and wire bytes accumulate while the synthetic type field is omitted + """ + backend = DataTelemetryBackend(telemetry_link.controller, lambda: telemetry_link.now) + try: + first = feed(telemetry_link, mavlink.MAVLink_heartbeat_message(2, 3, 0, 0, 3, 3)) + backend.poll() + second = feed(telemetry_link, mavlink.MAVLink_heartbeat_message(2, 3, 0, 1, 3, 3)) + report = backend.poll().messages[(1, 1, "HEARTBEAT")] + assert report.count == 2 + assert report.size == len(first) + len(second) + assert "mavpackettype" not in dict(report.fields) + finally: + backend.close() + + +def test_unknown_local_rssi_and_remote_noise_are_never_presented_as_measured_strength( + telemetry_link: SimpleNamespace, +) -> None: + """ + Preserve each radio field's unavailable sentinel independently. + + GIVEN: A modem reports unavailable RSSI and remote noise + WHEN: The telemetry backend captures its report + THEN: Those two fields are absent while other valid measurements remain + """ + backend = DataTelemetryBackend(telemetry_link.controller, lambda: telemetry_link.now) + try: + feed(telemetry_link, mavlink.MAVLink_radio_status_message(255, 254, 100, 0, 255, 1, 2)) + assert backend.poll().radio == RadioSample((1, 1), 0.0, None, 254, 0, None, 100, 1, 2) + finally: + backend.close() + + +def test_inspector_rows_retain_measured_rates_between_ticks_then_expire_at_three_seconds() -> None: + """ + Preserve measured message rates until the next interval or expiry. + + GIVEN: Cumulative message counts and bytes from one connection + WHEN: Polls establish a rate, reuse it and reach the stale boundary + THEN: Age, frequency and wire throughput reflect each interval without invented traffic + """ + model = DataTelemetryDataModel(MagicMock()) + connection = object() + key = (1, 1, "HEARTBEAT") + baseline = TelemetrySample(connection, 0, messages={key: MessageSample(1, 20, 0, ())}) + initial = model.update(baseline) + assert initial.connected is True + assert (initial.messages[0].hz, initial.messages[0].bytes_per_second, initial.messages[0].age) == (0, 0, 0) + measured = replace(baseline, time=1, messages={key: MessageSample(3, 64, 1, ())}) + for now, expected_age in ((1, 0), (1.25, 0.25)): + state = model.update(replace(measured, time=now)) + assert state.connected is True + assert state.receiving is True + row = state.messages[0] + assert (row.hz, row.bytes_per_second, row.age) == (2, 44, expected_age) + idle = model.update(replace(measured, time=2)) + assert (idle.messages[0].hz, idle.messages[0].bytes_per_second) == (0, 0) + stale = model.update(replace(measured, time=4)) + assert stale.receiving is False + assert (stale.messages[0].hz, stale.messages[0].bytes_per_second, stale.messages[0].age) == (0, 0, 3) + error = model.update(replace(measured, error=True)) + assert error.connected is True + assert error.error is True + assert not error.messages + disconnected = model.update(replace(measured, connection=None)) + assert disconnected.connected is False + assert disconnected.error is False + + +def test_radio_error_rate_is_unavailable_when_reports_are_three_seconds_apart() -> None: + """ + Rebaseline error rates after a gap in modem reports. + + GIVEN: Radio reports separated by exactly the stale interval + WHEN: The later report arrives with more receive errors + THEN: No rate is calculated across the stale interval + """ + model = DataTelemetryDataModel(MagicMock()) + connection = object() + radio = RadioSample((51, 68), 0, 100, 90, 20, 30, 80, 2, 0) + model.update(TelemetrySample(connection, 0, radio=radio)) + state = model.update(TelemetrySample(connection, 3, radio=replace(radio, time=3, rxerrors=5))) + assert state.radio_error_rate is None + + +def test_heartbeat_sender_reuses_the_existing_encoder_lock_and_preserves_requested_mavlink_version( + telemetry_link: SimpleNamespace, +) -> None: + """ + Share encoder synchronization and preserve MAVLink version requests. + + GIVEN: An encoder already has a send lock before heartbeat monitoring starts + WHEN: The heartbeat sender installs its wrapper and other tools send packets + THEN: The lock remains shared, default traffic uses MAVLink 2 and explicit MAVLink 1 is preserved + """ + master = telemetry_link.master + lock = RLock() + master.mav.amc_send_lock = lock + backend = RadioStatusBackend() + original_send = master.mav.send + try: + backend.start(master) + assert master.mav.amc_send_lock is lock + assert any(thread.name == "gcs-heartbeat" and thread.daemon for thread in enumerate_threads()) + heartbeat = mavlink.MAVLink_heartbeat_message(6, 8, 0, 0, 4, 3) + master.mav.send(heartbeat) + assert master.write.call_args.args[0][0] == 0xFD + master.mav.send(heartbeat, force_mavlink1=True) + assert master.write.call_args.args[0][0] == 0xFE + stopped_sender = master.mav.send + finally: + backend.close() + assert master.mav.send == original_send + with pytest.raises(OSError, match=r"^MAVLink sender has stopped$") as error: + stopped_sender(heartbeat) + assert str(error.value) == "MAVLink sender has stopped" + + +def test_heartbeat_shutdown_contains_port_close_errors_in_diagnostics( + telemetry_link: SimpleNamespace, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Contain transport cleanup failures while stopping the heartbeat worker. + + GIVEN: A port raises an I/O error when the stopped worker closes it + WHEN: The heartbeat backend shuts down + THEN: Shutdown completes and the close failure is recorded without escaping the worker + """ + backend = RadioStatusBackend() + telemetry_link.master.port = SimpleNamespace(close=MagicMock(side_effect=OSError("close failed"))) + backend.start(telemetry_link.master) + with caplog.at_level("DEBUG"): + backend.close() + assert caplog.messages == ["Error closing stopped GCS heartbeat port: close failed"] + + +def test_heartbeat_shutdown_tolerates_an_in_memory_connection_without_a_port( + telemetry_link: SimpleNamespace, + recwarn: pytest.WarningsRecorder, +) -> None: + """ + Shut down transports without optional serial-port cleanup methods. + + GIVEN: A MAVLink connection has no port object + WHEN: The heartbeat worker starts and then stops + THEN: No unhandled worker exception is emitted during cleanup + """ + backend = RadioStatusBackend() + if hasattr(telemetry_link.master, "port"): + del telemetry_link.master.port + backend.start(telemetry_link.master) + backend.close() + assert not recwarn.list + + +@pytest.mark.parametrize("backend_type", [DataTelemetryBackend, EscTelemetryBackend]) +def test_data_telemetry_deadline_yields_before_reading_more_packets( + telemetry_link: SimpleNamespace, backend_type: type[DataTelemetryBackend] | type[EscTelemetryBackend] +) -> None: + """ + Yield the GUI as soon as its receive budget expires. + + GIVEN: A queued heartbeat and a clock reaching the ten-millisecond deadline + WHEN: The telemetry backend polls + THEN: Reception is deferred to the next GUI tick without consuming the queued packet + """ + ticks = count(0.0, 0.01) + backend = backend_type(telemetry_link.controller, lambda: next(ticks)) + feed(telemetry_link, mavlink.MAVLink_heartbeat_message(2, 3, 0, 0, 3, 3)) + try: + with patch.object(telemetry_link.master, "recv_match", wraps=telemetry_link.master.recv_match) as receive: + snapshot = backend.poll() + receive.assert_not_called() + assert 0 <= snapshot.time <= 0.03 + assert not (snapshot.messages if isinstance(snapshot, TelemetrySample) else snapshot.readings) + finally: + backend.close() + + +def test_data_telemetry_disconnection_keeps_the_snapshot_clock(telemetry_link: SimpleNamespace) -> None: + """ + Preserve the poll time even when no controller is connected. + + GIVEN: A disconnected flight controller + WHEN: The telemetry backend polls at a known time + THEN: The disconnected snapshot retains that time + """ + telemetry_link.controller.master = None + backend = DataTelemetryBackend(telemetry_link.controller, lambda: 42.0) + assert backend.poll() == TelemetrySample(None, 42.0) + + +def test_inspector_truncates_long_wire_fields_to_its_display_budget(telemetry_link: SimpleNamespace) -> None: + """ + Bound the text retained for large MAVLink array fields. + + GIVEN an encoded file-transfer message with a full payload, + WHEN the real parser delivers it to the inspector backend, + THEN its payload field is truncated to 512 characters. + """ + backend = DataTelemetryBackend(telemetry_link.controller, lambda: telemetry_link.now) + payload = [255] * 251 + try: + feed(telemetry_link, mavlink.MAVLink_file_transfer_protocol_message(0, 1, 1, payload)) + sample = backend.poll() + fields = dict(sample.messages[(1, 1, "FILE_TRANSFER_PROTOCOL")].fields) + assert fields["payload"] == str(payload)[:512] + finally: + backend.close() + + +def test_cached_inspector_rates_expire_at_the_exact_stale_boundary(telemetry_link: SimpleNamespace) -> None: + """ + Clear displayed rates immediately when their last packet becomes stale. + + GIVEN a nonzero cached rate measured before the three-second stale boundary, + WHEN polling reaches exactly three seconds without a new measurement interval, + THEN the row remains visible with zero rates and no receiving indicator. + """ + model = DataTelemetryDataModel(telemetry_link.controller) + key = (1, 1, "HEARTBEAT") + connection = telemetry_link.master + model.update(TelemetrySample(connection, 0.0)) + messages = {key: MessageSample(1, 20, 0.0, ())} + active = model.update(TelemetrySample(connection, 2.5, messages=messages)) + assert active.messages[0].hz == pytest.approx(0.4) + stale = model.update(TelemetrySample(connection, 3.0, messages=messages)) + assert not stale.receiving + assert stale.messages[0].age == 3.0 + assert stale.messages[0].hz == stale.messages[0].bytes_per_second == 0.0 + + +def test_corrupt_wire_packets_increment_parse_errors_without_creating_inspector_rows(telemetry_link: SimpleNamespace) -> None: + """ + Count actual MAVLink parser errors independently from decoded message traffic. + + GIVEN: A MAVLink heartbeat packet whose checksum is corrupted + WHEN: The real parser receives its bytes + THEN: One parse error is reported and no valid message row is created + """ + packet = bytearray(mavlink.MAVLink_heartbeat_message(2, 3, 0, 0, 3, 3).pack(telemetry_link.sender)) + packet[-1] ^= 0xFF + telemetry_link.incoming.write(packet) + telemetry_link.incoming.seek(0) + backend = DataTelemetryBackend(telemetry_link.controller, lambda: telemetry_link.now) + try: + snapshot = backend.poll() + assert snapshot.parse_errors == 1 + assert not snapshot.messages + finally: + backend.close() diff --git a/tests/test_backend_flightcontroller_commands.py b/tests/test_backend_flightcontroller_commands.py index 9e816b2fa..e7757f770 100755 --- a/tests/test_backend_flightcontroller_commands.py +++ b/tests/test_backend_flightcontroller_commands.py @@ -19,10 +19,12 @@ import time from math import isnan from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch import pytest from pymavlink import mavutil +from pymavlink.dialects.v10 import ardupilotmega as mavlink1 from pymavlink.dialects.v20 import ardupilotmega as mavlink2 from ardupilot_methodic_configurator.backend_flightcontroller_commands import FlightControllerCommands @@ -97,9 +99,9 @@ def test_user_can_create_commands_manager(self) -> None: def test_commands_manager_requires_dependencies(self) -> None: """A command manager requires its parameter and connection managers.""" - with pytest.raises(ValueError, match="params_manager is required"): + with pytest.raises(ValueError, match=r"^params_manager is required$"): FlightControllerCommands(params_manager=None, connection_manager=Mock()) - with pytest.raises(ValueError, match="connection_manager is required"): + with pytest.raises(ValueError, match=r"^connection_manager is required$"): FlightControllerCommands(params_manager=Mock(), connection_manager=None) @@ -125,7 +127,10 @@ def test_abandoned_level_ack_cannot_complete_a_new_attempt(mock_connected_master assert commands.start_accel_calibration_level() == (True, "") commands.abort_level_calibration() - assert commands.start_accel_calibration_level()[0] is False + assert commands.start_accel_calibration_level() == ( + False, + "The previous level calibration is still finishing; try again shortly", + ) assert master.mav.command_long_send.call_count == 1 old_ack = MagicMock(command=mavutil.mavlink.MAV_CMD_PREFLIGHT_CALIBRATION) old_ack.result = mavutil.mavlink.MAV_RESULT_FAILED @@ -143,19 +148,23 @@ def test_expired_unpolled_level_calibration_can_restart(mock_connected_master: t now = [0.0] with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]): assert commands.start_accel_calibration_level() == (True, "") - now[0] = commands.LEVEL_CALIBRATION_ACK_TIMEOUT + 1 + now[0] = commands.LEVEL_CALIBRATION_ACK_TIMEOUT assert commands.start_accel_calibration_level() == (True, "") assert master.mav.command_long_send.call_count == 2 -def test_aborting_level_calibration_ignores_transport_errors(mock_connected_master: tuple[MagicMock, Mock]) -> None: +def test_aborting_level_calibration_ignores_transport_errors( + mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture +) -> None: """Closing a calibration on a dead serial link must still release local state.""" master, connection = mock_connected_master commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) assert commands.start_accel_calibration_level() == (True, "") master.recv_match.side_effect = OSError("serial link disappeared") - commands.abort_level_calibration() + with caplog.at_level("DEBUG"): + commands.abort_level_calibration() + assert caplog.messages == ["Could not drain abandoned level calibration messages: serial link disappeared"] assert commands._level_calibration.active is False # pylint: disable=protected-access assert commands._abandoned_level_deadline is None # pylint: disable=protected-access @@ -275,7 +284,19 @@ def test_user_can_test_individual_motor(self, mock_connected_master: tuple[Magic # Then: Command sent successfully assert success is True assert error == "" - mock_master.mav.command_long_send.assert_called_once() + mock_master.mav.command_long_send.assert_called_once_with( + mock_master.target_system, + mock_master.target_component, + mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, + 0, + 2, + 0, + 10, + 2, + 0, + 0, + 0, + ) def test_motor_test_fails_without_connection(self) -> None: """ @@ -374,7 +395,13 @@ def test_user_can_reset_all_parameters_to_defaults(self, mock_connected_master: mock_master.mav.command_long_send.assert_called_once() command_args = mock_master.mav.command_long_send.call_args.args assert command_args[2] == mavutil.mavlink.MAV_CMD_PREFLIGHT_STORAGE - assert command_args[4] == 2 + assert command_args[:4] == ( + mock_master.target_system, + mock_master.target_component, + mavutil.mavlink.MAV_CMD_PREFLIGHT_STORAGE, + 0, + ) + assert command_args[4:] == (2, 0, 0, 0, 0, 0, 0) class TestFlightControllerCommandsSendCommandAndWaitAck: @@ -397,11 +424,14 @@ def test_command_waits_for_acknowledgment(self, mock_connected_master: tuple[Mag commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) # When: Send command success, error = commands_mgr.send_command_and_wait_ack( - command=123, param1=0, param2=0, param3=0, param4=0, param5=0, param6=0, param7=0, timeout=1.0 + command=123, param1=1, param2=2, param3=3, param4=4, param5=5, param6=6, param7=7, timeout=1.0 ) # Then: ACK received assert success is True assert error == "" + mock_master.mav.command_long_send.assert_called_once_with( + mock_master.target_system, mock_master.target_component, 123, 0, 1, 2, 3, 4, 5, 6, 7 + ) def test_command_timeout_returns_error(self, mock_connected_master: tuple[MagicMock, Mock]) -> None: """ @@ -590,7 +620,13 @@ def test_user_can_test_all_motors(self, mock_connected_master: tuple[MagicMock, # Then: Commands should be sent assert success is True assert error == "" - assert mock_master.mav.command_long_send.call_count == 4 + assert [entry.args for entry in mock_master.mav.command_long_send.call_args_list] == [ + (mock_master.target_system, mock_master.target_component, mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, 0) + ] * 4 + assert [entry.kwargs for entry in mock_master.mav.command_long_send.call_args_list] == [ + {"param1": motor, "param2": 0, "param3": 25, "param4": 5, "param5": 0, "param6": 0, "param7": 0} + for motor in (1, 2, 3, 4) + ] def test_test_all_motors_fails_without_connection(self) -> None: """ @@ -647,6 +683,19 @@ def test_user_can_test_motors_in_sequence(self, mock_connected_master: tuple[Mag # Then: Command acknowledged assert success is True assert error == "" + mock_master.mav.command_long_send.assert_called_once_with( + mock_master.target_system, + mock_master.target_component, + mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, + 0, + 1, + 0, + 30, + 3, + 4, + mavutil.mavlink.MOTOR_TEST_ORDER_SEQUENCE, + 0, + ) def test_test_motors_in_sequence_fails_without_connection(self) -> None: """ @@ -703,6 +752,9 @@ def test_user_can_stop_all_motors(self, mock_connected_master: tuple[MagicMock, # Then: Command acknowledged assert success is True assert error == "" + mock_master.mav.command_long_send.assert_called_once_with( + mock_master.target_system, mock_master.target_component, mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, *(0,) * 8 + ) class TestFlightControllerCommandsWrapperMethods: @@ -840,8 +892,13 @@ def test_user_can_start_simple_accelerometer_calibration(self, mock_connected_ma assert error == "" mock_master.mav.command_long_send.assert_called_once() args = mock_master.mav.command_long_send.call_args.args - assert args[2] == mavutil.mavlink.MAV_CMD_PREFLIGHT_CALIBRATION - assert args[8] == 4.0 + assert args[:4] == ( + mock_master.target_system, + mock_master.target_component, + mavutil.mavlink.MAV_CMD_PREFLIGHT_CALIBRATION, + 0, + ) + assert args[4:] == (0, 0, 0, 0, 4.0, 0, 0) def test_simple_accelerometer_calibration_fails_without_connection(self) -> None: """ @@ -879,7 +936,13 @@ def test_user_can_start_full_accelerometer_calibration(self, mock_connected_mast mock_master.mav.command_long_send.assert_called_once() args = mock_master.mav.command_long_send.call_args.args assert args[2] == mavutil.mavlink.MAV_CMD_PREFLIGHT_CALIBRATION - assert args[8] == 1 + assert args[:4] == ( + mock_master.target_system, + mock_master.target_component, + mavutil.mavlink.MAV_CMD_PREFLIGHT_CALIBRATION, + 0, + ) + assert args[4:] == (0, 0, 0, 0, 1, 0, 0) def test_full_accelerometer_calibration_fails_without_connection(self) -> None: """ @@ -939,7 +1002,7 @@ def test_poll_accel_cal_vehicle_pos_returns_none_without_connection(self) -> Non assert commands_mgr.poll_accel_cal_vehicle_pos() is None def test_simple_accelerometer_calibration_reports_backend_failure( - self, mock_connected_master: tuple[MagicMock, Mock] + self, mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture ) -> None: """ A rejected simple calibration surfaces the acknowledgement failure. @@ -955,7 +1018,9 @@ def test_simple_accelerometer_calibration_reports_backend_failure( mock_master.recv_match.return_value = mock_ack commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) - success, error = commands_mgr.start_accel_calibration_simple() + with caplog.at_level("ERROR"): + success, error = commands_mgr.start_accel_calibration_simple() + assert caplog.messages == ["Command failed", "Simple accelerometer calibration failed: Command failed"] assert success is False assert error == "Command failed" @@ -995,10 +1060,16 @@ def test_user_can_run_level_accelerometer_calibration(self, mock_connected_maste mock_sleep.assert_not_called() args = mock_master.mav.command_long_send.call_args.args assert args[2] == mavutil.mavlink.MAV_CMD_PREFLIGHT_CALIBRATION - assert args[8] == 2.0 + assert args[:4] == ( + mock_master.target_system, + mock_master.target_component, + mavutil.mavlink.MAV_CMD_PREFLIGHT_CALIBRATION, + 0, + ) + assert args[4:] == (0, 0, 0, 0, 2.0, 0, 0) def test_level_calibration_reports_success_when_polled_acknowledgment_arrives( - self, mock_connected_master: tuple[MagicMock, Mock] + self, mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture ) -> None: """A later accepted ACK completes the previously started level trim.""" mock_master, mock_conn_mgr = mock_connected_master @@ -1008,7 +1079,9 @@ def test_level_calibration_reports_success_when_polled_acknowledgment_arrives( commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) assert commands_mgr.start_accel_calibration_level() == (True, "") - assert commands_mgr.poll_accel_calibration_level() == (True, "") + with caplog.at_level("INFO"): + assert commands_mgr.poll_accel_calibration_level() == (True, "") + assert caplog.messages == ["Level calibration completed successfully"] def test_level_accelerometer_calibration_retries_a_temporary_rejection( self, mock_connected_master: tuple[MagicMock, Mock] @@ -1316,7 +1389,9 @@ def test_poll_accel_cal_vehicle_pos_suppresses_stale_position_after_confirmation mock_master.recv_match.side_effect = [left_msg, None] assert commands_mgr.poll_accel_cal_vehicle_pos() == mavutil.mavlink.ACCELCAL_VEHICLE_POS_LEFT - def test_user_can_confirm_accelerometer_calibration_position(self, mock_connected_master: tuple[MagicMock, Mock]) -> None: + def test_user_can_confirm_accelerometer_calibration_position( + self, mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture + ) -> None: """ User confirmation of a position is sent back to the flight controller. @@ -1327,13 +1402,21 @@ def test_user_can_confirm_accelerometer_calibration_position(self, mock_connecte mock_master, mock_conn_mgr = mock_connected_master commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) - success, error = commands_mgr.confirm_accel_vehicle_pos(mavutil.mavlink.ACCELCAL_VEHICLE_POS_NOSEDOWN) + with caplog.at_level("DEBUG"): + success, error = commands_mgr.confirm_accel_vehicle_pos(mavutil.mavlink.ACCELCAL_VEHICLE_POS_NOSEDOWN) + assert caplog.messages == ["Sent ACCELCAL_VEHICLE_POS confirmation for position 4"] assert success is True assert error == "" args = mock_master.mav.command_long_send.call_args.args assert args[2] == mavutil.mavlink.MAV_CMD_ACCELCAL_VEHICLE_POS - assert args[4] == float(mavutil.mavlink.ACCELCAL_VEHICLE_POS_NOSEDOWN) + assert args[:4] == ( + mock_master.target_system, + mock_master.target_component, + mavutil.mavlink.MAV_CMD_ACCELCAL_VEHICLE_POS, + 0, + ) + assert args[4:] == (float(mavutil.mavlink.ACCELCAL_VEHICLE_POS_NOSEDOWN), 0, 0, 0, 0, 0, 0) def test_confirm_accelerometer_position_fails_without_connection(self) -> None: """ @@ -1616,7 +1699,8 @@ def test_send_command_exception_in_send(self) -> None: assert success is False assert "failed to send command" in error.lower() mock_err.assert_not_called() - mock_dbg.assert_called_once() + assert error == "Failed to send command: Serial port error" + mock_dbg.assert_called_once_with(error) def test_send_command_without_connection_logs_debug(self) -> None: """ @@ -1642,7 +1726,8 @@ def test_send_command_without_connection_logs_debug(self) -> None: assert success is False assert "no flight controller connection" in error.lower() mock_err.assert_not_called() - mock_dbg.assert_called_once() + assert error == "No flight controller connection available for command" + mock_dbg.assert_called_once_with(error) def test_request_periodic_battery_status_ack_timeout_logs_debug_not_error( self, mock_connected_master: tuple[MagicMock, Mock] @@ -1784,7 +1869,9 @@ def test_poll_scaled_imu_returns_none_on_exception(self, mock_connected_master: class TestFlightControllerCommandsRequestScaledImu: """Test requesting a periodic SCALED_IMU stream (hardware-free).""" - def test_request_scaled_imu_stream_succeeds_on_ack(self, mock_connected_master: tuple[MagicMock, Mock]) -> None: + def test_request_scaled_imu_stream_succeeds_on_ack( + self, mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture + ) -> None: """ Requesting the SCALED_IMU stream succeeds when the FC acknowledges. @@ -1802,7 +1889,9 @@ def test_request_scaled_imu_stream_succeeds_on_ack(self, mock_connected_master: commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) # When: Request stream - success, error = commands_mgr.request_scaled_imu_messages() + with caplog.at_level("DEBUG"): + success, error = commands_mgr.request_scaled_imu_messages() + assert caplog.messages == ["SCALED_IMU stream requested at 200000 µs interval"] # Then: Success and command sent assert success is True @@ -1853,7 +1942,9 @@ def test_request_scaled_imu_stream_fails_without_connection(self) -> None: assert success is False assert "connection" in error.lower() - def test_request_scaled_imu_stream_fails_on_denied_ack(self, mock_connected_master: tuple[MagicMock, Mock]) -> None: + def test_request_scaled_imu_stream_fails_on_denied_ack( + self, mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture + ) -> None: """ Requesting the SCALED_IMU stream fails when the FC denies the command. @@ -1870,7 +1961,9 @@ def test_request_scaled_imu_stream_fails_on_denied_ack(self, mock_connected_mast commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) # When: Request stream - success, error = commands_mgr.request_scaled_imu_messages() + with caplog.at_level("DEBUG"): + success, error = commands_mgr.request_scaled_imu_messages() + assert caplog.messages == ["Command denied", "SCALED_IMU stream request failed: Command denied"] # Then: Failure propagated from the denied ACK assert success is False @@ -2067,7 +2160,7 @@ def test_cell_voltages_are_summed_and_unavailable_readings_stay_unknown(self) -> """Given cell telemetry or unavailable fields, report pack voltage and retain unknown measurements.""" message = self._battery_message(0, 4200, -1) message.voltages = [4200, 4150] + [65535] * 8 - message.voltages_ext = [4100, 0, 0, 0] + message.voltages_ext = [4100, 1, 65535, 0] master = Mock() master.recv_match.side_effect = [message, self._battery_message(1, 65535), None] commands = FlightControllerCommands( @@ -2079,7 +2172,7 @@ def test_cell_voltages_are_summed_and_unavailable_readings_stay_unknown(self) -> assert error == "" assert readings is not None - assert readings[0][0] == pytest.approx(12.45) + assert readings[0][0] == pytest.approx(12.451) assert isnan(readings[0][1]) assert isnan(readings[1][0]) @@ -2105,3 +2198,990 @@ def test_user_can_read_pack_voltage_across_the_cell_sentinel_value(self, pack_mi assert error == "" assert readings is not None assert readings[0][0] == pytest.approx(pack_millivolts / 1000) + + +@pytest.mark.parametrize( + ("method", "arguments", "command", "message"), + [ + ( + "test_motor", + (2, "C", 7, 12, 4), + mavlink2.MAV_CMD_DO_MOTOR_TEST, + "Motor test command acknowledged: Motor C on output 7 at 12% thrust for 4 seconds", + ), + ( + "test_motors_in_sequence", + (2, 3, 12, 4), + mavlink2.MAV_CMD_DO_MOTOR_TEST, + "Sequential motor test command confirmed at 12% for 4 seconds per motor", + ), + ("stop_all_motors", (), mavlink2.MAV_CMD_DO_MOTOR_TEST, "Motor stop command confirmed"), + ( + "reset_all_parameters_to_default", + (), + mavlink2.MAV_CMD_PREFLIGHT_STORAGE, + "Parameter reset to defaults command confirmed by flight controller", + ), + ("start_compass_calibration", (), mavlink2.MAV_CMD_DO_START_MAG_CAL, "Compass calibration start command confirmed"), + ("cancel_compass_calibration", (), mavlink2.MAV_CMD_DO_CANCEL_MAG_CAL, "Compass calibration cancel command confirmed"), + ( + "start_accel_calibration_simple", + (), + mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, + "Simple accelerometer calibration completed successfully", + ), + ( + "send_accel_calibration_full_start", + (), + mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, + "Full accelerometer calibration start command sent", + ), + ], +) +def test_successful_command_diagnostics_preserve_the_requested_operation( # pylint: disable=too-many-arguments,too-many-positional-arguments + mock_connected_master: tuple[MagicMock, Mock], + method: str, + arguments: tuple, + command: int, + message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Diagnostic identifies the operation and its requested settings. + + GIVEN: The controller acknowledges an operator's command + WHEN: A motor, parameter reset or calibration operation completes + THEN: The diagnostic identifies the operation and its requested settings + """ + master, connection = mock_connected_master + ack = mavlink2.MAVLink_command_ack_message(command, mavlink2.MAV_RESULT_ACCEPTED) + master.recv_match.side_effect = [None, ack] + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with caplog.at_level("INFO"): + assert getattr(commands, method)(*arguments) == (True, "") + assert caplog.messages == [message] + + +@pytest.mark.parametrize( + ("method", "arguments", "reason"), + [ + ("reset_all_parameters_to_default", (), "parameter reset"), + ("test_motor", (0, "A", 1, 10, 2), "motor test"), + ("test_all_motors", (4, 10, 2), "motor test"), + ("test_motors_in_sequence", (1, 4, 10, 2), "motor test"), + ("stop_all_motors", (), "motor stop"), + ("start_accel_calibration_simple", (), "accelerometer calibration"), + ("start_accel_calibration_level", (), "level calibration"), + ("send_accel_calibration_full_start", (), "full accelerometer calibration"), + ("start_compass_calibration", (), "compass calibration"), + ("request_scaled_imu_messages", (), "SCALED_IMU stream request"), + ("request_periodic_battery_status", (), "battery status request"), + ], +) +def test_disconnected_commands_explain_the_operation_that_requires_a_connection( + method: str, + arguments: tuple, + reason: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + It fails with stable operation-specific guidance and the same diagnostic. + + GIVEN: The flight controller is disconnected + WHEN: The operator requests a command + THEN: It fails with stable operation-specific guidance and the same diagnostic + """ + connection = Mock(master=None) + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + message = f"No flight controller connection available for {reason}" + with caplog.at_level("DEBUG"): + assert getattr(commands, method)(*arguments) == (False, message) + assert caplog.messages == [message] + + +@pytest.mark.parametrize( + ("method", "arguments", "command", "prefix"), + [ + ("test_motor", (2, "C", 7, 12, 4), mavlink2.MAV_CMD_DO_MOTOR_TEST, "Motor test command failed"), + ("test_motors_in_sequence", (2, 3, 12, 4), mavlink2.MAV_CMD_DO_MOTOR_TEST, "Sequential motor test command failed"), + ("stop_all_motors", (), mavlink2.MAV_CMD_DO_MOTOR_TEST, "Motor stop command failed"), + ("reset_all_parameters_to_default", (), mavlink2.MAV_CMD_PREFLIGHT_STORAGE, "Parameter reset command failed"), + ("start_compass_calibration", (), mavlink2.MAV_CMD_DO_START_MAG_CAL, "Compass calibration start failed"), + ("cancel_compass_calibration", (), mavlink2.MAV_CMD_DO_CANCEL_MAG_CAL, "Compass calibration cancel failed"), + ], +) +def test_rejected_commands_report_the_requested_operation_and_preserve_cached_parameters( # pylint: disable=too-many-arguments,too-many-positional-arguments + mock_connected_master: tuple[MagicMock, Mock], + method: str, + arguments: tuple, + command: int, + prefix: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Preserve the operation and controller rejection in error guidance. + + GIVEN: A controller that denies an operator's command + WHEN: The acknowledgement arrives + THEN: The result and diagnostic explain the rejection and cached parameters stay intact + """ + master, connection = mock_connected_master + ack = mavlink2.MAVLink_command_ack_message(command, mavlink2.MAV_RESULT_DENIED) + replies = iter([None, ack]) + master.recv_match.side_effect = lambda **_kwargs: next(replies, None) + parameters = Mock(fc_parameters={"KEEP": 42}) + commands = FlightControllerCommands(params_manager=parameters, connection_manager=connection) + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=itertools.count(0, 0.1) + ), + caplog.at_level("ERROR"), + ): + assert getattr(commands, method)(*arguments) == (False, f"{prefix}: Command denied") + assert caplog.messages == ["Command denied", f"{prefix}: Command denied"] + assert parameters.fc_parameters == {"KEEP": 42} + + +@pytest.mark.parametrize( + ("result", "message"), + [ + (2, "Command denied"), + (3, "Command unsupported"), + (4, "Command failed"), + (99, "Command acknowledgment with unknown result: 99"), + ], +) +def test_level_failure_keeps_the_first_terminal_result_and_decodes_all_status_text( + mock_connected_master: tuple[MagicMock, Mock], + result: int, + message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Preserve firmware detail across unrelated messages and duplicate terminal acknowledgements. + + GIVEN: An active level calibration with progress, status text and a failed acknowledgement + WHEN: A different terminal acknowledgement follows and the text grace window expires + THEN: The first failure and cleaned firmware details are reported and calibration stops + """ + master, connection = mock_connected_master + unrelated = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_DO_MOTOR_TEST, 0) + progress = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, 5) + status = Mock(text=b" Hold \xff still \x00", get_type=Mock(return_value="STATUSTEXT")) + second_status = Mock(text="Check gyro X\x00", get_type=Mock(return_value="STATUSTEXT")) + failure = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, result) + duplicate = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, 2) + master.recv_match.side_effect = [unrelated, progress, status, second_status, failure, duplicate, None, None] + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + now = [0.0] + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]), + caplog.at_level("ERROR"), + ): + assert commands.start_accel_calibration_level() == (True, "") + assert commands.poll_accel_calibration_level() is None + now[0] = 0.5 + expected = f"{message}: Hold � still; Check gyro X" + assert commands.poll_accel_calibration_level() == (False, expected) + assert commands.poll_accel_calibration_level() is None + assert caplog.messages == [f"Level calibration failed: {expected}"] + master.recv_match.assert_called_with(type=["COMMAND_ACK", "STATUSTEXT"], blocking=False) + + +@pytest.mark.parametrize(("timestamp", "available"), [(0, False), (1, True), (None, False), ("unknown", False)]) +def test_battery_cache_uses_original_valid_receipt_times_and_skips_missing_primary_battery( + mock_connected_master: tuple[MagicMock, Mock], + timestamp: object, + available: bool, +) -> None: + """ + Recover a secondary battery from the generic pymavlink cache. + + GIVEN: Both monitors are enabled but only the secondary battery was parsed elsewhere + WHEN: Cached telemetry is read with a valid or unavailable receipt timestamp + THEN: Missing primary data does not hide the secondary battery and invalid times are rejected + """ + master, connection = mock_connected_master + battery = mavlink2.MAVLink_battery_status_message(1, 0, 0, 32767, [24800] + [65535] * 9, 210, -1, -1, -1) + battery._timestamp = timestamp # pylint: disable=protected-access + master.messages = {"BATTERY_STATUS": battery} + master.recv_match.return_value = None + commands = FlightControllerCommands( + params_manager=Mock(fc_parameters={"BATT_MONITOR": 4, "BATT2_MONITOR": 4}), connection_manager=connection + ) + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", return_value=1): + assert commands.get_battery_statuses() == ( + ({1: (24.8, 2.1)}, "") if available else (None, "Battery status not available from telemetry") + ) + master.recv_match.assert_called_once_with(type="BATTERY_STATUS", blocking=False, timeout=commands.BATTERY_STATUS_TIMEOUT) + + +@pytest.mark.parametrize( + ("connected", "parameters", "message"), + [ + (False, {"BATT_MONITOR": 4}, "No flight controller connection or parameters available"), + (True, {}, "No flight controller connection or parameters available"), + (True, {"BATT_MONITOR": 0}, "Battery monitoring is not enabled"), + ], +) +def test_battery_reading_explains_missing_connection_parameters_or_enabled_monitor( + connected: bool, + parameters: dict[str, int], + message: str, +) -> None: + """ + Explain the prerequisite that prevents a battery measurement. + + GIVEN: A missing connection, parameter cache or enabled battery monitor + WHEN: The operator requests battery readings + THEN: The returned guidance identifies the missing prerequisite + """ + connection = Mock(master=Mock() if connected else None) + commands = FlightControllerCommands(params_manager=Mock(fc_parameters=parameters), connection_manager=connection) + assert commands.get_battery_statuses() == (None, message) + + +@pytest.mark.parametrize(("vehicle", "expected"), [("ArduPlane", (4, 3)), ("ArduCopter", (1, 2))]) +def test_motor_frame_selection_uses_the_discovered_vehicle_type(vehicle: str, expected: tuple[int, int]) -> None: + """ + Prefer quadplane frame parameters only for the plane vehicle type. + + GIVEN: Both copter and quadplane frame groups in the parameter cache + WHEN: The motor test asks for frame information + THEN: Discovered vehicle type selects the appropriate group + """ + parameters = Mock(fc_parameters={"FRAME_CLASS": 1, "FRAME_TYPE": 2, "Q_FRAME_CLASS": 4, "Q_FRAME_TYPE": 3}) + connection = Mock(info=SimpleNamespace(vehicle_type=vehicle)) + commands = FlightControllerCommands(params_manager=parameters, connection_manager=connection) + assert commands.get_frame_info() == expected + + +@pytest.mark.parametrize( + ("operation", "arguments", "boundary", "result", "diagnostic"), + [ + ("poll_scaled_imu", (), "recv_match", None, "Exception while polling SCALED_IMU: link down"), + ("poll_accel_cal_vehicle_pos", (), "recv_match", None, "Exception while polling ACCELCAL_VEHICLE_POS: link down"), + ( + "get_battery_statuses", + (), + "recv_match", + (None, "Battery status not available from telemetry"), + "Failed to get battery statuses from telemetry: link down", + ), + ( + "confirm_accel_vehicle_pos", + (3,), + "send", + (False, "Failed to send position confirmation: link down"), + "Failed to send position confirmation: link down", + ), + ( + "send_accel_calibration_full_start", + (), + "send", + (False, "Failed to send full accelerometer calibration command: link down"), + "Failed to send full accelerometer calibration command: link down", + ), + ( + "start_accel_calibration_level", + (), + "send", + (False, "Failed to send level calibration command: link down"), + "Failed to send level calibration command: link down", + ), + ], +) +def test_transport_failures_keep_operation_specific_diagnostics_without_reaching_the_gui( # pylint: disable=too-many-arguments,too-many-positional-arguments + mock_connected_master: tuple[MagicMock, Mock], + operation: str, + arguments: tuple, + boundary: str, + result: object, + diagnostic: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Turn transport errors into safe results and useful operator diagnostics. + + GIVEN: The MAVLink transport fails while reading or sending + WHEN: A calibration or battery operation runs + THEN: The exception is contained and the diagnostic retains the operation and original reason + """ + master, connection = mock_connected_master + failing_io = master.recv_match if boundary == "recv_match" else master.mav.command_long_send + failing_io.side_effect = OSError("link down") + commands = FlightControllerCommands(params_manager=Mock(fc_parameters={"BATT_MONITOR": 4}), connection_manager=connection) + with caplog.at_level("DEBUG"): + assert getattr(commands, operation)(*arguments) == result + assert caplog.messages == [diagnostic] + + +@pytest.mark.parametrize( + ("operation", "ids"), [("request_scaled_imu_messages", (26,)), ("request_rc_receiver_messages", (65, 1))] +) +def test_stream_requests_preserve_default_interval_and_complete_command_payload( + mock_connected_master: tuple[MagicMock, Mock], + operation: str, + ids: tuple[int, ...], +) -> None: + """ + Request the intended telemetry streams without setting unrelated command fields. + + GIVEN: The controller acknowledges message-interval commands + WHEN: IMU or receiver monitoring requests its default streams + THEN: Every requested message uses a 200000-microsecond interval and zero unused parameters + """ + master, connection = mock_connected_master + master.recv_match.return_value = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_SET_MESSAGE_INTERVAL, 0) + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + assert getattr(commands, operation)() == (True, "") + assert [entry.args for entry in master.mav.command_long_send.call_args_list] == [ + (1, 1, mavlink2.MAV_CMD_SET_MESSAGE_INTERVAL, 0, message_id, 200000, 0, 0, 0, 0, 0) for message_id in ids + ] + + +@pytest.mark.parametrize("operation", ["request_scaled_imu_messages", "request_rc_receiver_messages"]) +def test_unacknowledged_monitor_stream_requests_use_the_short_gui_timeout( + mock_connected_master: tuple[MagicMock, Mock], + operation: str, +) -> None: + """ + Keep unsupported stream requests from blocking the GUI for a full command timeout. + + GIVEN: A controller that never acknowledges stream-interval commands + WHEN: Monitoring requests IMU or receiver streams + THEN: Each failed request reports the 0.8-second acknowledgement limit + """ + master, connection = mock_connected_master + master.recv_match.return_value = None + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=itertools.count()), + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_sleep"), + ): + success, message = getattr(commands, operation)() + assert success is False + expected = "Command acknowledgment timeout after 0.8 seconds" + assert message == (f"{expected}; {expected}" if operation == "request_rc_receiver_messages" else expected) + + +@pytest.mark.parametrize("failure", ["disconnect", "receive"]) +def test_running_level_calibration_reports_link_failure_and_releases_its_session( + mock_connected_master: tuple[MagicMock, Mock], + failure: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Release a running level calibration after losing the telemetry link. + + GIVEN: Level calibration has started and the connection disappears or its read fails + WHEN: The GUI polls for completion + THEN: Failure explains the link problem and subsequent polls are inactive + """ + master, connection = mock_connected_master + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + assert commands.start_accel_calibration_level() == (True, "") + if failure == "disconnect": + connection.master = None + message = "No flight controller connection available" + else: + master.recv_match.side_effect = OSError("link down") + message = "Failed to receive level calibration acknowledgment: link down" + with caplog.at_level("ERROR"): + assert commands.poll_accel_calibration_level() == (False, message) + assert commands.poll_accel_calibration_level() is None + assert caplog.messages == [f"Level calibration failed: {message}"] + + +def test_level_restart_waits_for_the_exact_abandoned_command_deadline( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Ignore unrelated or in-progress acknowledgements during abandoned calibration cleanup. + + GIVEN: A level calibration is abandoned while the controller may still be working + WHEN: Unrelated and in-progress ACKs arrive before its deadline + THEN: A new calibration stays blocked until the original deadline and cleanup reads never block + """ + master, connection = mock_connected_master + master.recv_match.return_value = None + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + now = [0.0] + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]): + assert commands.start_accel_calibration_level() == (True, "") + commands.abort_level_calibration() + unrelated = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_DO_MOTOR_TEST, 0) + progress = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, 5) + status = mavlink2.MAVLink_statustext_message(6, b"Calibrating") + master.recv_match.side_effect = [unrelated, progress, status, None] + now[0] = 44.999 + assert commands.start_accel_calibration_level() == ( + False, + "The previous level calibration is still finishing; try again shortly", + ) + master.recv_match.side_effect = None + now[0] = 45.0 + assert commands.start_accel_calibration_level() == (True, "") + assert master.mav.command_long_send.call_count == 2 + assert all( + entry.kwargs == {"type": ["COMMAND_ACK", "STATUSTEXT"], "blocking": False} + for entry in master.recv_match.call_args_list + ) + + +def test_active_level_calibration_rejects_a_concurrent_start_until_its_exact_ack_deadline( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Prevent overlapping level calibration commands while the first is active. + + GIVEN: A level calibration is awaiting acknowledgement + WHEN: The operator tries again before its deadline and then polls at that deadline + THEN: The duplicate start is rejected and the active operation times out precisely + """ + master, connection = mock_connected_master + master.recv_match.return_value = None + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + now = [0.0] + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]): + assert commands.start_accel_calibration_level() == (True, "") + assert commands.start_accel_calibration_level() == (False, "A level calibration is already in progress") + now[0] = 45.0 + assert commands.poll_accel_calibration_level() == (False, "Command acknowledgment timeout after 45.0 seconds") + master.mav.command_long_send.assert_called_once() + + +@pytest.mark.parametrize("accepted", [True, False]) +@pytest.mark.parametrize("interval", [None, 250000]) +def test_battery_stream_retries_preserve_payload_interval_and_each_acknowledgement_diagnostic( + mock_connected_master: tuple[MagicMock, Mock], + accepted: bool, + interval: int | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Retain battery stream retry results and the requested reporting interval. + + GIVEN: A controller consistently accepts or denies battery telemetry requests + WHEN: The monitor requests its default or a custom interval + THEN: Three complete requests and their diagnostics retain the chosen interval and result + """ + master, connection = mock_connected_master + master.recv_match.return_value = mavlink2.MAVLink_command_ack_message( + mavlink2.MAV_CMD_SET_MESSAGE_INTERVAL, mavlink2.MAV_RESULT_ACCEPTED if accepted else mavlink2.MAV_RESULT_DENIED + ) + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + expected_interval = 1000000 if interval is None else interval + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_sleep") as sleep, + caplog.at_level("DEBUG"), + ): + result = ( + commands.request_periodic_battery_status() + if interval is None + else commands.request_periodic_battery_status(interval) + ) + final_message = ( + f"Periodic BATTERY_STATUS messages confirmed every {expected_interval} microseconds" + if accepted + else "Failed to request periodic battery status: Command denied" + ) + assert result == ((True, "") if accepted else (False, final_message)) + expected_header = (1, 1, mavlink2.MAV_CMD_SET_MESSAGE_INTERVAL, 0) + expected_parameters = (mavlink2.MAVLINK_MSG_ID_BATTERY_STATUS, expected_interval, *(0,) * 5) + assert [entry.args for entry in master.mav.command_long_send.call_args_list] == [expected_header + expected_parameters] * 3 + expected_logs = [] + for attempt in (1, 2, 3): + if accepted: + expected_logs.append(f"BATTERY_STATUS stream request attempt {attempt} confirmed") + else: + expected_logs.extend(["Command denied", f"BATTERY_STATUS stream request attempt {attempt} failed: Command denied"]) + assert caplog.messages == [*expected_logs, final_message] + assert [entry.args for entry in sleep.call_args_list] == [(0.3,)] * 3 + ([(1.0,)] if accepted else []) + + +@pytest.mark.parametrize( + ("result", "message"), + [ + (0, ""), + (1, "Command temporarily rejected"), + (2, "Command denied"), + (3, "Command unsupported"), + (4, "Command failed"), + (99, "Command acknowledgment with unknown result: 99"), + ], +) +def test_command_results_preserve_the_controller_status_in_returned_and_logged_guidance( + mock_connected_master: tuple[MagicMock, Mock], + result: int, + message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Distinguish every terminal command acknowledgement result. + + GIVEN: A controller returns an accepted, rejected, failed or unknown result + WHEN: A command waits for its acknowledgement + THEN: The result and diagnostic preserve the exact controller status + """ + master, connection = mock_connected_master + master.recv_match.return_value = mavlink2.MAVLink_command_ack_message(999, result) + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with caplog.at_level("DEBUG"): + assert commands.send_command_and_wait_ack(999) == (result == 0, message) + assert caplog.messages == ([] if result == 0 else [message]) + + +def test_failed_command_stops_receiving_at_the_exact_status_text_grace_deadline( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Stop a rejected command's receive window at its exact grace deadline. + + GIVEN: A denied command followed by firmware status text + WHEN: The clock reaches the text grace deadline + THEN: The first failure and its firmware detail are returned without another receive + """ + master, connection = mock_connected_master + now = [0.0] + responses = iter( + [ + mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, 2), + mavlink2.MAVLink_statustext_message(6, b"Disarm first"), + ] + ) + + def receive(**_kwargs: object) -> object: + response = next(responses) + if response.get_type() == "STATUSTEXT": + now[0] = 0.5 + return response + + master.recv_match.side_effect = receive + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]): + assert commands.start_accel_calibration_simple() == (False, "Command denied: Disarm first") + assert master.recv_match.call_count == 2 + + +def test_parameter_reset_uses_the_full_ten_second_acknowledgement_window( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Allow the controller's longer storage-reset acknowledgement window. + + GIVEN: A controller sends no parameter-reset acknowledgement + WHEN: The ten-second reset window elapses + THEN: The failure reports that window rather than the shorter generic command limit + """ + master, connection = mock_connected_master + master.recv_match.return_value = None + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=itertools.count()), + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_sleep"), + ): + assert commands.reset_all_parameters_to_default() == ( + False, + "Parameter reset command failed: Command acknowledgment timeout after 10.0 seconds", + ) + + +def test_level_retry_waits_through_cooldown_then_reports_a_failed_retry_send( + mock_connected_master: tuple[MagicMock, Mock], + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Keep the cooldown and release calibration if its retry cannot be sent. + + GIVEN: The controller temporarily rejects level trim + WHEN: Polling continues before cooldown and the eventual retry write fails + THEN: No early retry is sent, the write failure is reported and the session becomes inactive + """ + master, connection = mock_connected_master + rejected = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, 1) + master.recv_match.side_effect = itertools.chain([rejected], itertools.repeat(None)) + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + now = [0.0] + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]), + caplog.at_level("INFO"), + ): + assert commands.start_accel_calibration_level() == (True, "") + assert commands.poll_accel_calibration_level() is None + now[0] = 0.5 + assert commands.poll_accel_calibration_level() is None + now[0] = 0.6 + assert commands.poll_accel_calibration_level() is None + master.mav.command_long_send.assert_called_once() + now[0] = 5.5 + master.mav.command_long_send.side_effect = OSError("link down") + assert commands.poll_accel_calibration_level() == (False, "Failed to send level calibration command: link down") + assert commands.poll_accel_calibration_level() is None + assert caplog.messages == [ + "Level calibration was temporarily rejected; retrying after the calibration cooldown.", + "Failed to send level calibration command: link down", + "Level calibration failed: Failed to send level calibration command: link down", + ] + + +def test_default_command_payload_and_five_second_timeout_remain_stable( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Preserve zero command parameters and the documented default response window. + + GIVEN: A connected controller sends no acknowledgement + WHEN: A caller sends a command without optional arguments + THEN: All seven parameters are zero and reception stops at exactly five seconds. + """ + master, connection = mock_connected_master + master.recv_match.return_value = None + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=[0.0, 0.0, 5.0]), + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_sleep") as pause, + ): + assert commands.send_command_and_wait_ack(999) == (False, "Command acknowledgment timeout after 5.0 seconds") + assert master.mav.command_long_send.call_args.args == (master.target_system, master.target_component, 999, *(0,) * 8) + master.recv_match.assert_called_once_with(type="COMMAND_ACK", blocking=False) + pause.assert_called_once_with(0.1) + + +@pytest.mark.parametrize("result", [1, 3, 99]) +def test_calibration_failure_keeps_multiple_firmware_details_until_the_grace_deadline( + mock_connected_master: tuple[MagicMock, Mock], result: int +) -> None: + """ + Preserve all firmware explanations after a failed calibration acknowledgement. + + GIVEN: A failed command followed by two firmware explanations + WHEN: The short text grace period expires + THEN: The first failure and both details are returned in their original order. + """ + master, connection = mock_connected_master + now = [0.0] + responses = iter( + [ + mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, result), + mavlink2.MAVLink_statustext_message(6, b"Disarm first"), + mavlink2.MAVLink_statustext_message(6, b"Gyro busy"), + ] + ) + + def receive(**_kwargs: object) -> object: + message = next(responses) + if message.get_type() == "STATUSTEXT" and message.text == "Gyro busy": + now[0] = 0.5 + return message + + master.recv_match.side_effect = receive + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + reason = { + 1: "Command temporarily rejected", + 3: "Command unsupported", + 99: "Command acknowledgment with unknown result: 99", + }[result] + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]): + assert commands.start_accel_calibration_simple() == (False, f"{reason}: Disarm first; Gyro busy") + assert master.recv_match.call_count == 3 + + +@pytest.mark.parametrize("progress", [0, 1]) +def test_generic_command_progress_logs_only_available_positive_percentages( + mock_connected_master: tuple[MagicMock, Mock], progress: int, caplog: pytest.LogCaptureFixture +) -> None: + """ + Show the first positive progress report without inventing a zero-percent update. + + GIVEN: A real in-progress acknowledgement followed by acceptance + WHEN: The command waits for its terminal acknowledgement + THEN: One percent is logged as progress and zero is treated as unavailable. + """ + master, connection = mock_connected_master + master.recv_match.side_effect = [ + mavlink2.MAVLink_command_ack_message(999, mavlink2.MAV_RESULT_IN_PROGRESS, progress=progress), + mavlink2.MAVLink_command_ack_message(999, mavlink2.MAV_RESULT_ACCEPTED), + ] + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with caplog.at_level("DEBUG"): + assert commands.send_command_and_wait_ack(999) == (True, "") + assert caplog.messages == (["Command in progress: 1%"] if progress else []) + + +def test_compass_start_bounds_stale_reply_drain_before_sending_the_new_command( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Bound the work spent clearing a busy command-response backlog. + + GIVEN: Old acknowledgements continuously arrive before a compass start + WHEN: The new start command is sent + THEN: At most 1000 stale replies are consumed before the new acknowledgement is read. + """ + master, connection = mock_connected_master + stale_reads = [] + acknowledgement = mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_DO_START_MAG_CAL, 0) + + def receive(**_kwargs: object) -> object: + if not master.mav.command_long_send.called: + stale_reads.append(acknowledgement) + return acknowledgement + + master.recv_match.side_effect = receive + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + assert commands.start_compass_calibration() == (True, "") + assert len(stale_reads) == 1000 + assert master.recv_match.call_count == 1001 + + +def test_compass_poll_keeps_updates_after_periodic_packets_and_firmware_status( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Drain all queued calibration updates despite interleaved periodic traffic. + + GIVEN: A heartbeat and firmware status precede a compass progress update + WHEN: The compass monitor polls + THEN: Both the status and later progress update remain visible. + """ + master, connection = mock_connected_master + master.recv_msg.side_effect = [ + mavlink2.MAVLink_heartbeat_message(2, 3, 0, 0, 3, 3), + mavlink2.MAVLink_statustext_message(6, b"Mag(0) rotate vehicle"), + mavlink2.MAVLink_mag_cal_report_message(0, 1, 4, 1, 0.3, *([0.0] * 9)), + mavlink2.MAVLink_mag_cal_progress_message(0, 1, 2, 0, 10, [0] * 10, 0, 0, 0), + None, + ] + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + updates = commands.get_compass_calibration_progress() + assert [update["type"] for update in updates] == ["STATUS_TEXT", "REPORT", "PROGRESS"] + assert updates[0]["text"] == "Mag(0) rotate vehicle" + assert updates[1]["fitness"] == 0.3 + assert updates[2]["completion_pct"] == 10 + + +def test_pausing_imu_poll_without_a_sample_does_not_log_a_transport_failure( + mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture +) -> None: + """ + Treat an empty IMU queue as normal monitoring behavior. + + GIVEN: A connected controller has not supplied an IMU sample yet + WHEN: The monitor polls its nonblocking stream + THEN: No sample and no error diagnostic are produced. + """ + master, connection = mock_connected_master + master.recv_match.return_value = None + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with caplog.at_level("DEBUG"): + assert commands.poll_scaled_imu() is None + assert not caplog.records + + +def test_confirming_accelerometer_position_offline_explains_the_required_connection(caplog: pytest.LogCaptureFixture) -> None: + """ + Refuse an offline calibration confirmation with actionable guidance. + + GIVEN: The flight controller is disconnected + WHEN: The operator confirms a physical accelerometer calibration position + THEN: The returned error and diagnostic identify the missing confirmation connection. + """ + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=Mock(master=None)) + message = "No flight controller connection available to confirm calibration position" + with caplog.at_level("ERROR"): + assert commands.confirm_accel_vehicle_pos(1) == (False, message) + assert caplog.messages == [message] + + +def test_aborting_a_rejected_level_command_does_not_quarantine_a_completed_operation( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Allow another level attempt after cancelling an already rejected command. + + GIVEN: Level calibration was denied and only its firmware-text grace window remains + WHEN: The operator closes the view and immediately starts another attempt + THEN: The rejected command does not block the new operation as unfinished. + """ + master, connection = mock_connected_master + master.recv_match.side_effect = [ + mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, 2), + None, + None, + ] + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", return_value=0): + assert commands.start_accel_calibration_level() == (True, "") + assert commands.poll_accel_calibration_level() is None + master.recv_match.side_effect = None + master.recv_match.return_value = None + commands.abort_level_calibration() + assert commands.start_accel_calibration_level() == (True, "") + assert master.mav.command_long_send.call_count == 2 + + +def test_mavlink_one_battery_status_without_extension_cells_still_reports_pack_voltage( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Accept battery telemetry from MAVLink 1 controllers without extension fields. + + GIVEN: A real MAVLink 1 battery message contains pack voltage and current + WHEN: Motor-test battery readings are requested + THEN: Missing MAVLink 2 extension cells do not discard the measurement. + """ + master, connection = mock_connected_master + master.recv_match.side_effect = [ + mavlink1.MAVLink_battery_status_message(0, 0, 0, 32767, [12400] + [65535] * 9, 210, -1, -1, -1), + None, + ] + master.messages = {} + commands = FlightControllerCommands(params_manager=Mock(fc_parameters={"BATT_MONITOR": 4}), connection_manager=connection) + assert commands.get_battery_statuses() == ({0: (12.4, 2.1)}, "") + + +def test_simple_calibration_timeout_preserves_firmware_guidance_and_its_full_response_window( + mock_connected_master: tuple[MagicMock, Mock], caplog: pytest.LogCaptureFixture +) -> None: + """ + Retain firmware guidance until the longer simple-calibration window expires. + + GIVEN: Firmware sends guidance and an unrelated command acknowledgement + WHEN: No calibration acknowledgement arrives within thirty seconds + THEN: The timeout includes that guidance and receive retries use a short pause. + """ + master, connection = mock_connected_master + now = [0.0] + responses = iter([mavlink2.MAVLink_statustext_message(6, b"Hold still"), mavlink2.MAVLink_command_ack_message(999, 0)]) + + def receive(**_kwargs: object) -> object: + message = next(responses) + if message.get_type() == "COMMAND_ACK": + now[0] = 29.9 + return message + + master.recv_match.side_effect = receive + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + expected = "Command acknowledgment timeout after 30.0 seconds: Hold still" + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]), + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_commands.time_sleep", + side_effect=lambda _duration: now.__setitem__(0, 30.0), + ) as pause, + caplog.at_level("DEBUG"), + ): + assert commands.start_accel_calibration_simple() == (False, expected) + pause.assert_called_once_with(0.1) + assert master.recv_match.call_count == 2 + assert caplog.messages == [expected, f"Simple accelerometer calibration failed: {expected}"] + + +def test_compass_start_does_not_ignore_a_rejection_after_progress_telemetry( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Respect a terminal rejection even if compass telemetry initially suggests startup. + + GIVEN: Calibration progress is followed by a denied acknowledgement + WHEN: The compass start waits for its final response + THEN: The denial takes precedence over the earlier progress indication. + """ + master, connection = mock_connected_master + connection.info = SimpleNamespace(system_id="1", component_id="1") + progress = mavlink2.MAVLink_mag_cal_progress_message(0, 1, 2, 0, 10, [0] * 10, 0, 0, 0) + progress._header.srcSystem, progress._header.srcComponent = 1, 1 # pylint: disable=protected-access + master.recv_match.side_effect = [ + None, + progress, + mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_DO_START_MAG_CAL, 2), + None, + ] + now = [0.0] + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]), + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_commands.time_sleep", + side_effect=lambda _duration: now.__setitem__(0, 0.5), + ), + ): + assert commands.start_compass_calibration() == (False, "Compass calibration start failed: Command denied") + + +def test_disconnected_level_view_can_close_and_restart_after_reconnection( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Discard an abandoned level exchange when its transport has disappeared. + + GIVEN: Level calibration loses its controller before the view closes + WHEN: Monitoring is aborted and the controller reconnects + THEN: A fresh level calibration can start without retaining an invalid quarantine deadline. + """ + master, connection = mock_connected_master + master.recv_match.return_value = None + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + assert commands.start_accel_calibration_level() == (True, "") + connection.master = None + commands.abort_level_calibration() + connection.master = master + assert commands.start_accel_calibration_level() == (True, "") + assert master.mav.command_long_send.call_count == 2 + + +def test_missing_primary_instance_does_not_hide_cached_secondary_battery( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Recover another reader's secondary battery even when the primary instance is absent. + + GIVEN: Two monitors are enabled and pymavlink has cached only battery instance one + WHEN: Motor-test battery data is requested after the receive queue was drained + THEN: The available secondary voltage and current are returned. + """ + master, connection = mock_connected_master + battery = mavlink2.MAVLink_battery_status_message(1, 0, 0, 32767, [24800] + [65535] * 9, 210, -1, -1, -1) + battery._timestamp = 1.0 # pylint: disable=protected-access + master.messages = {"BATTERY_STATUS[1]": battery} + master.recv_match.return_value = None + commands = FlightControllerCommands( + params_manager=Mock(fc_parameters={"BATT_MONITOR": 4, "BATT2_MONITOR": 4}), connection_manager=connection + ) + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", return_value=1.0): + assert commands.get_battery_statuses() == ({1: (24.8, 2.1)}, "") + + +def test_calibration_failure_waits_for_complete_chunked_firmware_guidance( + mock_connected_master: tuple[MagicMock, Mock], +) -> None: + """ + Assemble a long firmware explanation before appending it to the command failure. + + GIVEN: A denied calibration is followed by two MAVLink 2 status-text chunks + WHEN: The final chunk arrives before the status-text grace deadline + THEN: The complete message is returned once without including an unfinished fragment. + """ + master, connection = mock_connected_master + first = b"Disarm before starting accelerometer calibration. ".ljust(50, b" ") + last = b"Keep vehicle still." + now = [0.0] + responses = iter( + [ + mavlink2.MAVLink_command_ack_message(mavlink2.MAV_CMD_PREFLIGHT_CALIBRATION, 2), + mavlink2.MAVLink_statustext_message(6, first, id=7, chunk_seq=0), + mavlink2.MAVLink_statustext_message(6, last, id=7, chunk_seq=1), + ] + ) + + def receive(**_kwargs: object) -> object: + message = next(responses) + if message.get_type() == "STATUSTEXT" and message.chunk_seq == 1: + now[0] = 0.5 + return message + + master.recv_match.side_effect = receive + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=lambda: now[0]): + assert commands.start_accel_calibration_simple() == (False, "Command denied: " + (first + last).decode().strip()) diff --git a/tests/test_esc_telemetry.py b/tests/test_esc_telemetry.py index 6700068f7..6cb640d0d 100755 --- a/tests/test_esc_telemetry.py +++ b/tests/test_esc_telemetry.py @@ -236,7 +236,7 @@ def test_requests_are_bounded_and_do_not_change_stream_rates(link: SimpleNamespa """ backend = EscTelemetryBackend(link.controller, lambda: link.now) try: - for now in (0, 0.25, 0.5, 0.75, 1, 20): + for now in (0, 0.25, 0.5, 0.75, 1, 1.25, 1.75, 20): link.now = now backend.poll() assert link.master.write.call_count == 3 @@ -244,6 +244,8 @@ def test_requests_are_bounded_and_do_not_change_stream_rates(link: SimpleNamespa for call in link.master.write.call_args_list: command = decoder.parse_char(call.args[0]) assert command.command == mavlink.MAV_CMD_REQUEST_MESSAGE + assert command.confirmation == 0 + assert (command.param2, command.param3, command.param4, command.param5, command.param6, command.param7) == (0,) * 6 assert command.param1 == mavlink.MAVLINK_MSG_ID_ESC_TELEMETRY_1_TO_4 assert (command.target_system, command.target_component) == (1, 1) backend.close() @@ -253,7 +255,7 @@ def test_requests_are_bounded_and_do_not_change_stream_rates(link: SimpleNamespa backend.close() -def test_backend_recovers_from_disconnect_and_io_failure(link: SimpleNamespace) -> None: +def test_backend_recovers_from_disconnect_and_io_failure(link: SimpleNamespace, caplog: pytest.LogCaptureFixture) -> None: """ A failed telemetry write. @@ -263,13 +265,22 @@ def test_backend_recovers_from_disconnect_and_io_failure(link: SimpleNamespace) """ backend = EscTelemetryBackend(link.controller, lambda: link.now) link.master.write.side_effect = OSError("disconnected") - assert backend.poll().error + link.now = 12.0 + with caplog.at_level("DEBUG"): + failed = backend.poll() + assert failed.error + assert failed.connection is link.master + assert failed.time == 12.0 + assert caplog.messages == ["Error reading ESC telemetry: disconnected"] assert not link.master.message_hooks link.master.write.side_effect = None assert not backend.poll().error assert len(link.master.message_hooks) == 1 link.controller.master = None - assert backend.poll().connection is None + link.now = 42.0 + disconnected = backend.poll() + assert disconnected.connection is None + assert disconnected.time == 42.0 assert not link.master.message_hooks @@ -516,3 +527,80 @@ def test_busy_links_have_bounded_reads_and_preserve_other_observers(link: Simple assert len(replacement.master.message_hooks) == 1 backend.close() assert not replacement.master.message_hooks + + +def test_reusable_esc_table_labels_identify_every_measurement_and_its_units() -> None: + """ + Keep measurement labels and engineering units consistent in every ESC table host. + + GIVEN: The reusable ESC telemetry model + WHEN: A host requests its row labels + THEN: The six labels distinguish RPM, electrical units, temperature, consumption and packet count + """ + assert EscTelemetryDataModel.row_labels() == ( + "RPM", + "Voltage (V)", + "Current (A)", + "Temperature (°C)", + "Consumption (mAh)", + "Telemetry packets", + ) + + +def test_model_keeps_exactly_32_esc_columns_and_ignores_unusable_snapshot_entries(model: EscTelemetryDataModel) -> None: + """ + Preserve the table shape and discard invalid or unused ESC entries. + + GIVEN: First and last ESC readings alongside out-of-range, incomplete and zero-padded entries + WHEN: A reusable table host updates the model + THEN: Only ESCs 1 and 32 are detected and every measurement row has exactly 32 columns + """ + reading = EscTelemetryReading((1200, 2450, 123, 42, 77, 10), received_at=1.0) + entries = { + 0: reading, + 31: reading, + -1: reading, + 32: reading, + 1: EscTelemetryReading((0,) * 6), + 2: EscTelemetryReading((1200, 2450)), + } + rows = model.update(EscTelemetrySnapshot(object(), 1.0, entries)) + assert model.visible_esc_indices == (0, 31) + assert len(rows) == 6 + assert all(len(row) == 32 for row in rows) + assert [row[31] for row in rows] == ["1200", "24.50", "1.23", "42", "77", "10"] + assert all(row[index] == "-" for row in rows for index in range(1, 31)) + + +def test_rpm_updates_independently_of_a_frozen_electrical_telemetry_counter(model: EscTelemetryDataModel) -> None: + """ + Preserve faster RPM measurements while electrical telemetry waits for its next packet. + + GIVEN: An ESC reports electrical telemetry and a packet count of ten + WHEN: RPM changes without the electrical packet counter advancing + THEN: RPM updates immediately while voltage retains the last measured value. + """ + connection = object() + first = EscTelemetryReading((1000, 1200, 0, 25, 0, 10)) + model.update(EscTelemetrySnapshot(connection, 0.0, {0: first})) + latest = EscTelemetryReading((2000, 1300, 0, 25, 0, 10)) + rows = model.update(EscTelemetrySnapshot(connection, 1.0, {0: latest})) + assert rows[0][0] == "2000" + assert rows[1][0] == "12.00" + + +@pytest.mark.parametrize("failed", [True, False]) +def test_invalid_snapshots_cannot_repopulate_live_esc_readings(model: EscTelemetryDataModel, failed: bool) -> None: + """ + Discard observations attached to an error or disconnected snapshot. + + GIVEN: Cached ESC telemetry followed by a snapshot with an error or no connection + WHEN: The invalid snapshot still contains observations + THEN: All visible ESCs and displayed values are cleared. + """ + connection = object() + readings = {0: EscTelemetryReading((1000, 1200, 0, 25, 0, 10))} + model.update(EscTelemetrySnapshot(connection, 0.0, readings)) + rows = model.update(EscTelemetrySnapshot(connection if failed else None, 1.0, readings, error=failed)) + assert model.visible_esc_indices == () + assert all(value == "-" for row in rows for value in row) diff --git a/tests/unit_backend_flightcontroller_commands.py b/tests/unit_backend_flightcontroller_commands.py index e0b1b9839..9a984b0d3 100755 --- a/tests/unit_backend_flightcontroller_commands.py +++ b/tests/unit_backend_flightcontroller_commands.py @@ -109,6 +109,7 @@ def side_effect_recv_match(*_args, **_kwargs) -> object: success, _ = commands_mgr.send_command_and_wait_ack(command=888, timeout=0.3) assert success is False # Timed out + mock_debug.assert_any_call("Command in progress: %(progress)d%%", {"progress": 50}) # logging_debug SHOULD have been called with a progress-related message progress_log_calls = [c for c in mock_debug.call_args_list if c.args and "progress" in str(c.args[0]).lower()] assert progress_log_calls != [], "logging_debug should have logged progress when progress=50" @@ -322,7 +323,7 @@ def test_start_compass_calibration_returns_clear_error_when_disconnected(self) - assert success is False assert "No flight controller connection available" in error - def test_start_compass_calibration_sends_expected_mavlink_command(self) -> None: + def test_start_compass_calibration_sends_expected_mavlink_command(self, caplog: pytest.LogCaptureFixture) -> None: """ The backend sends the compass calibration start command with the expected parameters. @@ -341,7 +342,12 @@ def test_start_compass_calibration_sends_expected_mavlink_command(self) -> None: commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) commands_mgr._send_command_and_wait_ack_with_result = MagicMock(return_value=(True, "", 0)) - success, error = commands_mgr.start_compass_calibration() + with caplog.at_level("DEBUG"): + success, error = commands_mgr.start_compass_calibration() + assert caplog.messages == [ + "Sending compass calibration start command to target 1/1 with params p1=0 p2=1 p3=1", + "Compass calibration start command confirmed", + ] commands_mgr._send_command_and_wait_ack_with_result.assert_called_once() assert success is True @@ -351,6 +357,7 @@ def test_start_compass_calibration_sends_expected_mavlink_command(self) -> None: assert kwargs["param1"] == 0 assert kwargs["param2"] == 1 assert kwargs["param3"] == 1 + assert tuple(kwargs[f"param{i}"] for i in range(1, 8)) == (0, 1, 1, 0, 0, 0, 0) assert kwargs["timeout"] == 5.0 assert kwargs["capture_status_text"] is True assert kwargs["start_confirmation"][0] == "MAG_CAL_PROGRESS" @@ -400,7 +407,7 @@ def test_start_compass_calibration_accepts_active_progress_without_ack(self) -> assert success is True assert error == "" - def test_cancel_compass_calibration_returns_clear_error_when_disconnected(self) -> None: + def test_cancel_compass_calibration_returns_clear_error_when_disconnected(self, caplog: pytest.LogCaptureFixture) -> None: """ The backend rejects compass calibration cancel requests without a connection. @@ -416,7 +423,8 @@ def test_cancel_compass_calibration_returns_clear_error_when_disconnected(self) success, error = commands_mgr.cancel_compass_calibration() assert success is False - assert "No flight controller connection available" in error + assert error == "No flight controller connection available to cancel compass calibration" + assert caplog.messages == [error] def test_cancel_compass_calibration_sends_expected_mavlink_command(self) -> None: """ @@ -442,7 +450,9 @@ def test_cancel_compass_calibration_sends_expected_mavlink_command(self) -> None commands_mgr.send_command_and_wait_ack.assert_called_once() assert success is True assert error == "" - _, kwargs = commands_mgr.send_command_and_wait_ack.call_args + args, kwargs = commands_mgr.send_command_and_wait_ack.call_args + assert args == (mavutil.mavlink.MAV_CMD_DO_CANCEL_MAG_CAL,) + assert tuple(kwargs[f"param{i}"] for i in range(1, 8)) == (0, 0, 0, 0, 0, 0, 0) assert kwargs["param1"] == 0 assert kwargs["timeout"] == 5.0 @@ -471,7 +481,7 @@ def test_cancel_compass_calibration_surfaces_backend_rejection(self) -> None: assert "Compass calibration cancel failed" in error assert "Rejected" in error - def test_get_compass_calibration_progress_ignores_non_calibration_messages(self) -> None: + def test_get_compass_calibration_progress_ignores_non_calibration_messages(self, caplog: pytest.LogCaptureFixture) -> None: """ Periodic and unrelated MAVLink messages should be ignored during polling. @@ -492,11 +502,15 @@ def test_get_compass_calibration_progress_ignores_non_calibration_messages(self) commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) - result = commands_mgr.get_compass_calibration_progress() + with caplog.at_level("DEBUG"): + result = commands_mgr.get_compass_calibration_progress() + assert caplog.messages == ["Ignoring non calibration MAVLink message during compass calibration poll: UNKNOWN"] assert not result - def test_get_compass_calibration_progress_preserves_mixed_progress_and_report_messages(self) -> None: + def test_get_compass_calibration_progress_preserves_mixed_progress_and_report_messages( + self, caplog: pytest.LogCaptureFixture + ) -> None: """ Progress polling should drain raw messages without discarding non-report updates. @@ -532,7 +546,12 @@ def test_get_compass_calibration_progress_preserves_mixed_progress_and_report_me commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) - result = commands_mgr.get_compass_calibration_progress() + with caplog.at_level("DEBUG"): + result = commands_mgr.get_compass_calibration_progress() + assert caplog.messages == [ + "Compass calibration progress queued for compass 0: pct=42 status=1", + "Compass calibration report queued for compass 0: status=4 saved=True", + ] assert result == [ { @@ -553,7 +572,7 @@ def test_get_compass_calibration_progress_preserves_mixed_progress_and_report_me }, ] - def test_get_compass_calibration_progress_surfaces_status_text_feedback(self) -> None: + def test_get_compass_calibration_progress_surfaces_status_text_feedback(self, caplog: pytest.LogCaptureFixture) -> None: """ Progress polling should preserve calibration status text as user feedback. @@ -577,7 +596,11 @@ def test_get_compass_calibration_progress_surfaces_status_text_feedback(self) -> commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) - result = commands_mgr.get_compass_calibration_progress() + with caplog.at_level("DEBUG"): + result = commands_mgr.get_compass_calibration_progress() + assert caplog.messages == [ + "Compass calibration status text received: severity=6 text=Mag(0) good orientation: 12 21.2" + ] assert result == [ { @@ -588,7 +611,9 @@ def test_get_compass_calibration_progress_surfaces_status_text_feedback(self) -> }, ] - def test_get_compass_calibration_progress_uses_default_compass_id_when_status_text_has_no_marker(self) -> None: + def test_get_compass_calibration_progress_uses_default_compass_id_when_status_text_has_no_marker( + self, caplog: pytest.LogCaptureFixture + ) -> None: """ Status text without an embedded compass index should still be surfaced. @@ -611,11 +636,13 @@ def test_get_compass_calibration_progress_uses_default_compass_id_when_status_te mock_conn_mgr.master = mock_master commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) - result = commands_mgr.get_compass_calibration_progress() + with caplog.at_level("DEBUG"): + result = commands_mgr.get_compass_calibration_progress() + assert caplog.messages == ["Compass calibration status text received: severity=5 text=Calibration still running"] assert result == [{"type": "STATUS_TEXT", "compass_id": None, "status": 5, "text": "Calibration still running"}] - def test_get_compass_calibration_progress_recovers_from_recv_errors(self) -> None: + def test_get_compass_calibration_progress_recovers_from_recv_errors(self, caplog: pytest.LogCaptureFixture) -> None: """ Read errors should not crash the polling loop. @@ -632,7 +659,9 @@ def test_get_compass_calibration_progress_recovers_from_recv_errors(self) -> Non mock_conn_mgr.master = mock_master commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) - result = commands_mgr.get_compass_calibration_progress() + with caplog.at_level("DEBUG"): + result = commands_mgr.get_compass_calibration_progress() + assert caplog.messages == ["Error reading compass calibration progress: boom"] assert not result @@ -649,3 +678,59 @@ def test_get_compass_calibration_progress_returns_empty_when_disconnected(self) commands_mgr = FlightControllerCommands(params_manager=Mock(), connection_manager=mock_conn_mgr) assert not commands_mgr.get_compass_calibration_progress() + + +@pytest.mark.parametrize( + ("info_system", "info_component", "target_system", "target_component", "source", "status", "accepted"), + [ + ("1", "1", 1, 0, (1, 1), 1, True), + ("1", "1", 1, 0, (1, 1), 2, True), + ("1", "1", 1, 0, (1, 1), 3, True), + ("1", "1", 1, 0, (1, 1), 0, False), + ("1", "1", 1, 0, (1, 1), 4, False), + ("1", "1", 1, 0, (2, 1), 2, False), + ("1", "1", 1, 0, (1, 42), 2, False), + ("42", "2", 1, 1, (42, 2), 2, True), + ("", "", 42, 2, (42, 2), 2, True), + ("", "", 42, 2, (42, 3), 2, False), + ("", "", 42, 0, (42, 1), 2, True), + ("0", "1", 1, 1, (0, 1), 2, False), + ], +) +def test_only_active_compass_telemetry_from_the_selected_controller_confirms_start( # pylint: disable=too-many-arguments,too-many-positional-arguments + info_system: str, + info_component: str, + target_system: int, + target_component: int, + source: tuple[int, int], + status: int, + accepted: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Only the selected controller's active calibration confirms startup. + + GIVEN: An unacknowledged start and calibration telemetry with a source and status + WHEN: The acknowledgement window closes + THEN: Only the selected controller's active calibration confirms startup + """ + master = MagicMock(target_system=target_system, target_component=target_component) + progress = mavutil.mavlink.MAVLink_mag_cal_progress_message(0, 1, status, 0, 10, [0] * 10, 0, 0, 0) + progress._header.srcSystem, progress._header.srcComponent = source + master.recv_match.side_effect = [None, progress] + connection = Mock(master=master, info=Mock(system_id=info_system, component_id=info_component)) + commands = FlightControllerCommands(params_manager=Mock(), connection_manager=connection) + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller_commands.time_time", side_effect=[0, 0, 6]), + caplog.at_level("DEBUG"), + ): + success, detail = commands.start_compass_calibration() + assert success is accepted + assert detail == ("" if accepted else "Compass calibration start failed: Command acknowledgment timeout after 5.0 seconds") + if accepted: + assert "Command start confirmed by telemetry after acknowledgment timeout" in caplog.messages + + assert all( + entry.kwargs == {"type": ["COMMAND_ACK", "STATUSTEXT", "MAG_CAL_PROGRESS"], "blocking": False} + for entry in master.recv_match.call_args_list + ) From 380f89f7feeee1022e857a8b4195c1998f446fb2 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Wed, 28 Oct 2026 20:46:18 +0100 Subject: [PATCH 2/6] test(data_models): Added more mutation tests Signed-off-by: Dr.-Ing. Amilcar do Carmo Lucas --- CONTRIBUTING.md | 16 +- pyproject.toml | 11 + tests/bdd_backend_safe_file_io.py | 195 ++++++++++++++++++ tests/test_data_model_fc_diagnostics.py | 191 +++++++++++++++++ tests/test_data_model_file_upload.py | 105 +++++++++- ...data_model_parameter_compare_and_upload.py | 37 ++++ 6 files changed, 546 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5bde08225..a5d8b510c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,9 @@ progress accounting, configuration expression evaluation, firmware parameter migrations, battery-monitor telemetry and status handling, and configuration-step processing. All plugin backends and data models are included, together with shared data/ESC telemetry models, telemetry and radio backends, and flight-controller -command handling used by calibration and motor-testing plugins. +command handling used by calibration and motor-testing plugins. External parameter +upload selection, configured file-upload policy, atomic file replacement, and +flight-controller diagnostic assembly and device rows are also included. It runs on Linux, macOS, or Windows through WSL. @@ -207,6 +209,18 @@ evaluation context. The current rename planner returns no duplicate removals, so that feedback branch is unreachable through normal processing. Review these separately from lost metadata, incorrect live inputs, and conflicting renames. +For external parameter and configured file uploads, check omitted-edit warnings, +verified FC snapshots, complete per-file outcomes including filenames, and executor +progress callbacks. For atomic writes, verify that failures preserve the original +and remove temporary files, and that content is synced before replacement. +Encoding aliases and native newline defaults can behave identically on Linux; +directory-open flag changes can also be equivalent where `O_RDONLY` is zero. +For diagnostics, cover timeout boundaries, component isolation, pending-message +eviction, malformed text, and invalid entries before valid devices. Some split-limit +changes preserve the first token, category lookups normalize case, and the decoder +returns an error together with a missing decoded value. Review these separately +from device-ID and chunk-sequence boundary survivors and platform-specific I/O paths. + For a focused rerun, pass quoted module or function patterns to `mutmut run`, for example: diff --git a/pyproject.toml b/pyproject.toml index a6b419585..13711ce5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,6 +195,11 @@ only_mutate = [ # Globs keep new plugin backends and data models in scope automatically. "ardupilot_methodic_configurator/plugins/backend_*.py", "ardupilot_methodic_configurator/plugins/data_model_*.py", + # External parameter selection, file-upload policy, atomic writes, and diagnostics. + "ardupilot_methodic_configurator/data_model_parameter_compare_and_upload.py", + "ardupilot_methodic_configurator/data_model_file_upload.py", + "ardupilot_methodic_configurator/backend_safe_file_io.py", + "ardupilot_methodic_configurator/data_model_fc_diagnostics.py", # Shared implementations used by telemetry and calibration plugins. "ardupilot_methodic_configurator/backend_data_telemetry.py", "ardupilot_methodic_configurator/backend_esc_telemetry.py", @@ -250,6 +255,12 @@ do_not_mutate = [ pytest_add_cli_args_test_selection = [ # Include model, backend, unit, behavior, and real-widget plugin regressions. "tests/plugins/", + "tests/test_data_model_parameter_compare_and_upload.py", + "tests/test_data_model_file_upload.py", + "tests/unit_backend_safe_file_io.py", + "tests/bdd_backend_safe_file_io.py", + "tests/test_backend_safe_file_io_write_capture.py", + "tests/test_data_model_fc_diagnostics.py", "tests/test_backend_data_telemetry.py", "tests/test_esc_telemetry.py", "tests/test_backend_flightcontroller_commands.py", diff --git a/tests/bdd_backend_safe_file_io.py b/tests/bdd_backend_safe_file_io.py index aab4bc2f3..398fdffc1 100755 --- a/tests/bdd_backend_safe_file_io.py +++ b/tests/bdd_backend_safe_file_io.py @@ -12,11 +12,14 @@ SPDX-License-Identifier: GPL-3.0-or-later """ +import errno import io import json import os +import stat import sys from pathlib import Path +from typing import IO from unittest.mock import patch import pytest @@ -225,3 +228,195 @@ def test_directory_fsync_failure_does_not_break_write(self, tmp_file: Path) -> N assert tmp_file.exists() assert tmp_file.read_text(encoding="utf-8") == "test content" + + +@pytest.mark.parametrize("failure_stage", ["writer", "fsync", "replace"]) +def test_failed_update_preserves_the_original_and_removes_temporary_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure_stage: str +) -> None: + """ + A failed save must leave the last valid file available. + + GIVEN: An existing file and a failure during writing, syncing, or replacement + WHEN: The save fails after writing some temporary content + THEN: The original content survives, the error propagates, and no temporary file remains + """ + target = tmp_path / "settings.json" + target.write_text("original", encoding="utf-8") + + def fail(*_args: object) -> None: + message = "save failed" + raise OSError(message) + + def write(file: IO[str]) -> None: + file.write("partial update") + if failure_stage == "writer": + fail() + + if failure_stage != "writer": + monkeypatch.setattr(f"ardupilot_methodic_configurator.backend_safe_file_io.os.{failure_stage}", fail) + + with pytest.raises(OSError, match="save failed"): + safe_write(str(target), write) + + assert target.read_text(encoding="utf-8") == "original" + assert list(tmp_path.iterdir()) == [target] + + +@pytest.mark.skipif(sys.platform == "win32", reason="Requires POSIX file permissions") +@pytest.mark.parametrize(("umask", "expected_mode"), [(0o022, 0o644), (0o027, 0o640)]) +def test_new_file_uses_the_current_umask_without_changing_it(tmp_path: Path, umask: int, expected_mode: int) -> None: + """ + New files follow the caller's permission policy. + + GIVEN: A restrictive process umask + WHEN: A new file is saved atomically + THEN: Its mode matches normal file creation and the umask is restored + """ + target = tmp_path / "private.json" + previous_umask = os.umask(umask) + try: + safe_write(str(target), lambda file: file.write("content")) + resulting_umask = os.umask(previous_umask) + finally: + os.umask(previous_umask) + + assert stat.S_IMODE(target.stat().st_mode) == expected_mode + assert resulting_umask == umask + + +@pytest.mark.skipif(sys.platform == "win32", reason="Requires POSIX file permissions") +def test_overwrite_preserves_custom_permission_bits(tmp_path: Path) -> None: + """ + Atomic replacement preserves an existing file's access policy. + + GIVEN: An existing file with executable and restricted permission bits + WHEN: Its content is replaced + THEN: All permission bits survive the update + """ + target = tmp_path / "script" + target.write_text("original", encoding="utf-8") + target.chmod(0o751) + + safe_write(str(target), lambda file: file.write("updated")) + + assert stat.S_IMODE(target.stat().st_mode) == 0o751 + assert target.read_text(encoding="utf-8") == "updated" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Requires POSIX directory fsync") +def test_save_flushes_and_syncs_content_before_replacing_and_syncing_the_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + A successful save persists content before making the replacement visible. + + GIVEN: An existing file in a destination directory + WHEN: New content is saved + THEN: The temporary file is flushed and synced before replacement, then the directory is synced + """ + target = tmp_path / "settings.json" + target.write_text("original", encoding="utf-8") + events: list[str] = [] + real_fsync, real_replace = os.fsync, os.replace + directory_fds: list[int] = [] + + def sync(fd: int) -> None: + if stat.S_ISDIR(os.fstat(fd).st_mode): + events.append("directory synced") + directory_fds.append(fd) + else: + temporary_file = next(path for path in tmp_path.iterdir() if path != target) + assert temporary_file.read_bytes() == b"updated\n" + assert target.read_text(encoding="utf-8") == "original" + events.append("content synced") + real_fsync(fd) + + def replace(source: str, destination: str) -> None: + assert Path(source).parent == target.parent + assert destination == str(target) + assert events == ["content synced"] + events.append("replaced") + real_replace(source, destination) + + monkeypatch.setattr("ardupilot_methodic_configurator.backend_safe_file_io.os.fsync", sync) + monkeypatch.setattr("ardupilot_methodic_configurator.backend_safe_file_io.os.replace", replace) + + safe_write(str(target), lambda file: file.write("updated\n")) + + assert events == ["content synced", "replaced", "directory synced"] + assert target.read_bytes() == b"updated\n" + assert list(tmp_path.iterdir()) == [target] + with pytest.raises(OSError, match=os.strerror(errno.EBADF)) as error: + os.fstat(directory_fds[0]) + assert error.value.errno == errno.EBADF + + +def test_relative_destination_is_saved_in_the_current_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ + Relative filenames receive the same atomic-save guarantees as full paths. + + GIVEN: A destination with no directory component + WHEN: Its content is saved + THEN: The file appears in the current directory without leftover temporary files + """ + monkeypatch.chdir(tmp_path) + + safe_write("settings.json", lambda file: file.write("content")) + + target = tmp_path / "settings.json" + assert target.read_text(encoding="utf-8") == "content" + assert list(tmp_path.iterdir()) == [target] + + +@pytest.mark.parametrize("denied_operation", ["chmod", "chown"]) +def test_denied_metadata_preservation_does_not_discard_the_saved_content( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, denied_operation: str +) -> None: + """ + Unsupported metadata changes must not prevent an otherwise valid save. + + GIVEN: An existing file and an OS denial when preserving permissions or ownership + WHEN: The file is updated + THEN: The content is saved and the temporary file is removed + """ + if not hasattr(os, denied_operation): + pytest.skip(f"{denied_operation} is not available on this platform") + target = tmp_path / "settings.json" + target.write_text("original", encoding="utf-8") + + def deny(*_args: object) -> None: + message = "metadata denied" + raise PermissionError(message) + + monkeypatch.setattr(f"ardupilot_methodic_configurator.backend_safe_file_io.os.{denied_operation}", deny) + + safe_write(str(target), lambda file: file.write("updated")) + + assert target.read_text(encoding="utf-8") == "updated" + assert list(tmp_path.iterdir()) == [target] + + +def test_directory_open_failure_does_not_discard_a_completed_save(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ + Directory syncing remains best effort after a successful replacement. + + GIVEN: A filesystem that cannot open directories for syncing + WHEN: File replacement succeeds but opening its directory fails + THEN: The completed save remains available without reporting a failed write + """ + target = tmp_path / "settings.json" + real_open = os.open + + def open_file(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: + if path == str(tmp_path): + message = "directory open failed" + raise OSError(message) + return real_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr("ardupilot_methodic_configurator.backend_safe_file_io.os.open", open_file) + + safe_write(str(target), lambda file: file.write("updated")) + + assert target.read_text(encoding="utf-8") == "updated" + assert list(tmp_path.iterdir()) == [target] diff --git a/tests/test_data_model_fc_diagnostics.py b/tests/test_data_model_fc_diagnostics.py index 96e06d811..cbfdb9bf6 100755 --- a/tests/test_data_model_fc_diagnostics.py +++ b/tests/test_data_model_fc_diagnostics.py @@ -11,6 +11,7 @@ """ from itertools import permutations +from types import SimpleNamespace import pytest from pymavlink.dialects.v20.ardupilotmega import MAVLink_statustext_message @@ -231,3 +232,193 @@ def test_duplicate_compass_priority_ids_use_the_highest_priority(priority_order: rows = diagnostics.device_rows(parameters) assert rows == [("COMPASS1", "I2C", 0, 118, "LSM9DS1", 1, "COMPASS_DEV_ID: 423425")] + + +@pytest.mark.parametrize(("elapsed", "expected"), [(4.999, "A" * 50 + "tail"), (5.0, None)]) +def test_chunk_expiration_obeys_the_timeout_boundary( + monkeypatch: pytest.MonkeyPatch, elapsed: float, expected: str | None +) -> None: + """ + Expire unfinished diagnostics at the configured deadline. + + GIVEN: An unfinished diagnostic received at time zero + WHEN: Its final chunk arrives just before or exactly at the timeout + THEN: Only the continuation arriving before the deadline is assembled + """ + now = [0.0] + monkeypatch.setattr(diagnostics, "monotonic", lambda: now[0]) + assembler = diagnostics.StatusTextAssembler() + assert assembler.assemble(MAVLink_statustext_message(4, b"A" * 50, id=42, chunk_seq=0)) is None + + now[0] = elapsed + + assert assembler.assemble(MAVLink_statustext_message(4, b"tail", id=42, chunk_seq=1)) == expected + + +def test_components_on_the_same_vehicle_cannot_mix_diagnostic_chunks() -> None: + """ + Components have independent message sequences even within one vehicle. + + GIVEN: Two components using the same diagnostic message ID + WHEN: Their chunks arrive interleaved + THEN: Each completed message contains only its own component's text + """ + assembler = diagnostics.StatusTextAssembler() + for component, payload, sequence, expected in ( + (1, b"A" * 50, 0, None), + (2, b"B" * 50, 0, None), + (1, b"one", 1, "A" * 50 + "one"), + (2, b"two", 1, "B" * 50 + "two"), + ): + message = MAVLink_statustext_message(4, payload, id=42, chunk_seq=sequence) + message._header.srcSystem = 1 # pylint: disable=protected-access + message._header.srcComponent = component # pylint: disable=protected-access + assert assembler.assemble(message) == expected + + +def test_pending_limit_discards_only_the_oldest_unfinished_diagnostic() -> None: + """ + Keep recent unfinished messages when the pending-message limit is reached. + + GIVEN: One hundred unfinished diagnostics + WHEN: A new message starts after restarting the newest pending message + THEN: The oldest is evicted and recent messages still assemble correctly + """ + assembler = diagnostics.StatusTextAssembler() + for message_id in range(1, 101): + assert assembler.assemble(MAVLink_statustext_message(4, b"A" * 50, id=message_id, chunk_seq=0)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"B" * 50, id=100, chunk_seq=0)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"oldest", id=1, chunk_seq=1)) == "A" * 50 + "oldest" + # Refill the completed slot so the next new message must evict ID 2. + assert assembler.assemble(MAVLink_statustext_message(4, b"A" * 50, id=1, chunk_seq=0)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"C" * 50, id=101, chunk_seq=0)) is None + + assert assembler.assemble(MAVLink_statustext_message(4, b"old", id=2, chunk_seq=1)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"recent", id=1, chunk_seq=1)) == "A" * 50 + "recent" + assert assembler.assemble(MAVLink_statustext_message(4, b"restarted", id=100, chunk_seq=1)) == "B" * 50 + "restarted" + assert assembler.assemble(MAVLink_statustext_message(4, b"new", id=101, chunk_seq=1)) == "C" * 50 + "new" + + +@pytest.mark.parametrize("invalid_id", [float("nan"), float("inf"), -1, 0x1000000]) +def test_invalid_device_and_priority_entries_do_not_hide_later_valid_compasses(invalid_id: float) -> None: + """ + Malformed entries must not hide other detected devices or their priorities. + + GIVEN: Invalid device and priority entries before a valid compass + WHEN: The device table is derived + THEN: The valid compass remains visible with its configured rank + """ + rows = diagnostics.device_rows( + {"BARO1_DEVID": invalid_id, "COMPASS_PRIO1_ID": invalid_id, "COMPASS_DEV_ID": 423425, "COMPASS_PRIO2_ID": 423425} + ) + + assert rows == [("COMPASS1", "I2C", 0, 118, "LSM9DS1", 2, "COMPASS_DEV_ID: 423425")] + + +@pytest.mark.parametrize("parameter_name", ["ARSPD_DEVID", "AIRSPEED_DEVID"]) +def test_airspeed_devices_use_the_airspeed_type_table(parameter_name: str) -> None: + """ + Airspeed devices retain their category-specific sensor names. + + GIVEN: An airspeed device using either supported parameter prefix + WHEN: Diagnostics derives its row + THEN: The type comes from the airspeed table and no compass rank is assigned + """ + device_id = (2 << 16) | (40 << 8) | (1 << 3) | 1 + + rows = diagnostics.device_rows({parameter_name: device_id}) + + assert rows == [ + (parameter_name.removesuffix("_DEVID"), "I2C", 1, 40, "AIRSPEED_MS4525", None, f"{parameter_name}: {device_id}") + ] + + +@pytest.mark.parametrize("text", [" café\x00ignored", " café\x00ignored".encode()]) +def test_standalone_text_transports_decode_utf8_and_discard_null_padding(text: str | bytes) -> None: + """ + Text-only transports display the same diagnostic as wire-backed messages. + + GIVEN: A transport with a string or byte payload and no raw wire attribute + WHEN: Its standalone diagnostic is assembled + THEN: UTF-8 is preserved, null padding is discarded, and whitespace is trimmed + """ + assert diagnostics.StatusTextAssembler().assemble(SimpleNamespace(text=text)) == "café" + + +@pytest.mark.parametrize(("system", "component"), [(None, None), (1, None), (None, 1), ("1", 1), (1, "1")]) +def test_incomplete_source_identifiers_are_reported_as_unknown(system: object, component: object) -> None: + """ + A source is known only when both numeric identifiers are available. + + GIVEN: A transport missing or misreporting one source identifier + WHEN: Its packet source is requested + THEN: No partial or nonnumeric source identifier is returned + """ + message = SimpleNamespace(get_srcSystem=lambda: system, get_srcComponent=lambda: component) + + assert diagnostics.message_source(message) is None + assert diagnostics.message_source(SimpleNamespace()) is None + assert diagnostics.message_source(SimpleNamespace(get_srcSystem=lambda: 1)) is None + assert diagnostics.message_source(SimpleNamespace(get_srcComponent=lambda: 1)) is None + + +def test_padded_chunk_without_source_or_sequence_metadata_completes_immediately() -> None: + """ + A null-terminated first chunk is complete even with a full wire payload. + + GIVEN: A transport with no source or sequence metadata and fifty padded text bytes + WHEN: Its first chunk is assembled + THEN: The diagnostic is emitted immediately without waiting for a continuation + """ + message = SimpleNamespace(id=42, text=b"ready\x00" + b"\x00" * 44) + + assert diagnostics.StatusTextAssembler().assemble(message) == "ready" + + +@pytest.mark.parametrize("chunked", [False, True]) +def test_invalid_utf8_is_replaced_without_losing_the_diagnostic(chunked: bool) -> None: + """ + Invalid text bytes do not prevent diagnostic display. + + GIVEN: A standalone or chunked diagnostic containing invalid UTF-8 and null padding + WHEN: The complete message arrives + THEN: Invalid bytes are replaced and text after the first null is discarded + """ + assembler = diagnostics.StatusTextAssembler() + if chunked: + assert assembler.assemble(MAVLink_statustext_message(4, b"A" * 50, id=42, chunk_seq=0)) is None + message = MAVLink_statustext_message(4, b"\xff\x00ignored\x00", id=42 if chunked else 0, chunk_seq=1 if chunked else 0) + + assert assembler.assemble(message) == ("A" * 50 if chunked else "") + "\ufffd" + + +def test_completed_or_broken_sequences_cannot_accept_delayed_continuations() -> None: + """ + Retired sequences cannot produce duplicate or misleading diagnostics. + + GIVEN: One completed diagnostic and another with a missing chunk + WHEN: Delayed continuations arrive + THEN: Both sequences remain discarded + """ + assembler = diagnostics.StatusTextAssembler() + assert assembler.assemble(MAVLink_statustext_message(4, b"A" * 50, id=42, chunk_seq=0)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"tail", id=42, chunk_seq=1)) == "A" * 50 + "tail" + assert assembler.assemble(MAVLink_statustext_message(4, b"duplicate", id=42, chunk_seq=1)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"B" * 50, id=43, chunk_seq=0)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"gap", id=43, chunk_seq=2)) is None + assert assembler.assemble(MAVLink_statustext_message(4, b"late", id=43, chunk_seq=1)) is None + + +def test_dronecan_devices_display_the_bus_protocol_as_the_sensor_type() -> None: + """ + CAN device type fields represent node information rather than local sensor models. + + GIVEN: A detected compass on the DRONECAN bus + WHEN: Its diagnostic row is derived + THEN: The type identifies DRONECAN and the source parameter is retained + """ + device_id = (7 << 16) | (42 << 8) | (1 << 3) | 3 + + assert diagnostics.device_rows({"COMPASS_DEV_ID": device_id}) == [ + ("COMPASS1", "DRONECAN", 1, 42, "DRONECAN", None, f"COMPASS_DEV_ID: {device_id}") + ] diff --git a/tests/test_data_model_file_upload.py b/tests/test_data_model_file_upload.py index 7ba5ab808..b53ba1c54 100755 --- a/tests/test_data_model_file_upload.py +++ b/tests/test_data_model_file_upload.py @@ -18,6 +18,7 @@ from ardupilot_methodic_configurator.backend_flightcontroller_protocols import FileUploadCheckStatus, FileUploadResult from ardupilot_methodic_configurator.data_model_file_upload import ( FileUploadDisposition, + FileUploadOutcome, FileUploadWorkflow, ) @@ -62,7 +63,7 @@ def test_local_file_states_are_reported_without_remote_operations( progress_factory, ) - assert [outcome.disposition for outcome in outcomes] == [expected] + assert outcomes == [FileUploadOutcome("script.lua", "/APM/Scripts/script.lua", expected)] check_upload.assert_not_called() upload.assert_not_called() confirm.assert_not_called() @@ -77,7 +78,7 @@ def test_remote_current_file_skips_confirmation_and_upload() -> None: outcomes = workflow.upload_files([("script.lua", "/APM/Scripts/script.lua")], set(), confirm, progress_factory) - assert [outcome.disposition for outcome in outcomes] == [FileUploadDisposition.ALREADY_CURRENT] + assert outcomes == [FileUploadOutcome("script.lua", "/APM/Scripts/script.lua", FileUploadDisposition.ALREADY_CURRENT)] check_upload.assert_called_once_with("/APM/Scripts/script.lua", "script.lua") upload.assert_not_called() confirm.assert_not_called() @@ -92,7 +93,7 @@ def test_remote_verification_failure_is_a_per_file_outcome() -> None: outcomes = workflow.upload_files([("script.lua", "/APM/Scripts/script.lua")], set(), confirm, progress_factory) - assert [outcome.disposition for outcome in outcomes] == [FileUploadDisposition.VERIFICATION_FAILED] + assert outcomes == [FileUploadOutcome("script.lua", "/APM/Scripts/script.lua", FileUploadDisposition.VERIFICATION_FAILED)] check_upload.assert_called_once() confirm.assert_not_called() upload.assert_not_called() @@ -104,7 +105,7 @@ def test_no_connection_stops_before_remote_preflight() -> None: outcomes = workflow.upload_files([("script.lua", "/APM/Scripts/script.lua")], set(), confirm, progress_factory) - assert [outcome.disposition for outcome in outcomes] == [FileUploadDisposition.NO_CONNECTION] + assert outcomes == [FileUploadOutcome("script.lua", "/APM/Scripts/script.lua", FileUploadDisposition.NO_CONNECTION)] check_upload.assert_not_called() confirm.assert_not_called() upload.assert_not_called() @@ -117,7 +118,7 @@ def test_declined_upload_does_not_start_transfer() -> None: outcomes = workflow.upload_files([("script.lua", "/APM/Scripts/script.lua")], set(), confirm, progress_factory) - assert [outcome.disposition for outcome in outcomes] == [FileUploadDisposition.USER_DECLINED] + assert outcomes == [FileUploadOutcome("script.lua", "/APM/Scripts/script.lua", FileUploadDisposition.USER_DECLINED)] confirm.assert_called_once_with("script.lua", "/APM/Scripts/script.lua") upload.assert_not_called() progress_factory.assert_not_called() @@ -133,7 +134,7 @@ def test_upload_failure_is_returned_to_the_presenter() -> None: [("script.lua", "/APM/Scripts/script.lua")], set(), Mock(return_value=True), progress_factory ) - assert [outcome.disposition for outcome in outcomes] == [FileUploadDisposition.UPLOAD_FAILED] + assert outcomes == [FileUploadOutcome("script.lua", "/APM/Scripts/script.lua", FileUploadDisposition.UPLOAD_FAILED)] upload.assert_called_once() @@ -250,7 +251,7 @@ def report_progress(_local: str, _remote: str, callback: Callable[[int, int], No ], ) def test_provider_exception_does_not_skip_later_uploads( - provider_stage: str, error_type: type[Exception], expected: FileUploadDisposition + provider_stage: str, error_type: type[Exception], expected: FileUploadDisposition, caplog: pytest.LogCaptureFixture ) -> None: """ Recoverable provider errors are isolated to the affected file. @@ -274,8 +275,15 @@ def test_provider_exception_does_not_skip_later_uploads( progress_factory, ) - assert [outcome.disposition for outcome in outcomes] == [expected, FileUploadDisposition.UPLOADED] + assert outcomes == [ + FileUploadOutcome("first.lua", "/APM/Scripts/first.lua", expected), + FileUploadOutcome("second.lua", "/APM/Scripts/second.lua", FileUploadDisposition.UPLOADED), + ] assert upload.call_args.args[:2] == ("second.lua", "/APM/Scripts/second.lua") + assert "first.lua" in caplog.text + assert "file operation failed" in caplog.text + if provider_stage in {"check", "upload"}: + assert "/APM/Scripts/first.lua" in caplog.text def test_confirmation_exception_releases_batch_for_a_later_request() -> None: @@ -297,3 +305,84 @@ def test_confirmation_exception_releases_batch_for_a_later_request() -> None: outcomes = workflow.upload_files([("a.lua", "/APM/Scripts/a.lua")], set(), confirm, progress_factory) assert outcomes[0].disposition is FileUploadDisposition.UPLOADED + + +def test_executor_receives_file_size_and_runs_the_upload_with_progress() -> None: + """ + An executor can announce transfer start and run the supplied task. + + GIVEN: A one-byte local file and an executor that starts progress before I/O + WHEN: The executor invokes the supplied upload task + THEN: The transport receives the correct paths and progress reaches one callback + """ + workflow, _size, _check, upload, confirm, progress_factory = _create_workflow(file_size=1) + + def report_progress(_local: str, _remote: str, progress: Callable[[int, int], None]) -> FileUploadResult: + progress(1, 1) + return FileUploadResult.UPLOADED + + def execute( + task: Callable[[Callable[[int, int], None]], FileUploadResult], + size: int, + progress: Callable[[int, int], None], + ) -> FileUploadResult: + assert size == 1 + progress(0, size) + return task(progress) + + upload.side_effect = report_progress + + outcomes = workflow.upload_files([("a.lua", "/APM/Scripts/a.lua")], set(), confirm, progress_factory, run_upload=execute) + + assert [(outcome.local_filename, outcome.remote_filename, outcome.disposition) for outcome in outcomes] == [ + ("a.lua", "/APM/Scripts/a.lua", FileUploadDisposition.UPLOADED) + ] + assert upload.call_args.args[:2] == ("a.lua", "/APM/Scripts/a.lua") + progress_factory.assert_called_once_with() + assert progress_factory.return_value.call_args_list == [((0, 1),), ((1, 1),)] + + +def test_disconnect_stops_the_batch_without_checking_later_files() -> None: + """ + Stop processing a batch once its controller is disconnected. + + GIVEN: Two configured files and a disconnected controller + WHEN: Upload is requested + THEN: Only the first file gets a no-connection outcome and neither is transferred + """ + workflow, size, check, upload, confirm, progress_factory = _create_workflow(connected=False) + + outcomes = workflow.upload_files( + [("a.lua", "/APM/Scripts/a.lua"), ("b.lua", "/APM/Scripts/b.lua")], set(), confirm, progress_factory + ) + + assert len(outcomes) == 1 + assert outcomes[0].local_filename == "a.lua" + assert outcomes[0].disposition is FileUploadDisposition.NO_CONNECTION + size.assert_called_once_with("a.lua") + check.assert_not_called() + upload.assert_not_called() + + +def test_upload_can_report_progress_without_a_progress_presenter() -> None: + """ + A caller may decline to display progress without interrupting the transfer. + + GIVEN: A progress factory that returns no callback + WHEN: The transport sends multiple progress notifications + THEN: Upload succeeds and the factory is requested only once + """ + workflow, _size, _check, upload, confirm, progress_factory = _create_workflow() + progress_factory.return_value = None + + def report_progress(_local: str, _remote: str, progress: Callable[[int, int], None]) -> FileUploadResult: + progress(1, 12) + progress(12, 12) + return FileUploadResult.UPLOADED + + upload.side_effect = report_progress + + outcomes = workflow.upload_files([("a.lua", "/APM/Scripts/a.lua")], set(), confirm, progress_factory) + + assert outcomes[0].disposition is FileUploadDisposition.UPLOADED + progress_factory.assert_called_once_with() diff --git a/tests/test_data_model_parameter_compare_and_upload.py b/tests/test_data_model_parameter_compare_and_upload.py index 96abaed90..8e2100a50 100755 --- a/tests/test_data_model_parameter_compare_and_upload.py +++ b/tests/test_data_model_parameter_compare_and_upload.py @@ -94,3 +94,40 @@ def test_refresh_keeps_values_for_parameters_missing_from_fc_reply() -> None: refresh_external_fc_values({"ROLL_P": parameter}, {}) assert parameter.fc_value_as_string == "0.1" + + +def test_omitted_edits_are_sorted_and_all_named_in_the_warning() -> None: + """ + Show every omitted edit in a stable, readable warning. + + GIVEN: Two manually edited parameters supplied in reverse alphabetical order + WHEN: Neither is selected for upload + THEN: The warning names both edits on separate lines in alphabetical order + """ + parameters = {name: ArduPilotParameter(name, Par(1), fc_value=0) for name in ("Z_LAST", "A_FIRST")} + for parameter in parameters.values(): + parameter.set_new_value("2") + warning = MagicMock() + + omitted = unselected_manual_edits(parameters, parameters, []) + + assert omitted == ["A_FIRST", "Z_LAST"] + assert confirm_external_upload_selection(omitted, warning) is False + warning.assert_called_once_with( + "Manual parameter edits not selected", + "The following manually edited parameters differ from the flight controller " + "but are not selected for upload:\n\nA_FIRST\nZ_LAST", + ) + + +def test_selection_ignores_names_missing_from_the_external_file() -> None: + """ + Ignore stale selections without losing valid parameter objects. + + GIVEN: A selection containing one valid and one absent external parameter + WHEN: Uploadable parameters are collected + THEN: Only the selected existing parameter is returned unchanged + """ + parameter = ArduPilotParameter("ROLL_P", Par(0.2), fc_value=0.1) + + assert selected_external_parameters({"ROLL_P": parameter}, ["ABSENT", "ROLL_P"]) == {"ROLL_P": parameter} From 92d4daeb5d740009c8eacde237a236f6bd505fca Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Fri, 30 Oct 2026 15:36:41 +0100 Subject: [PATCH 3/6] test: expand mutation coverage for telemetry and file workflows Catch inspector sorting, diagnostic assembly and file-save regressions, and make asynchronous heartbeat assertions deterministic. - Add the MAVLink inspector model and dedicated tests to mutation testing, and document the expanded scope. - Distinguish every inspector sort key with independently ordered data. Cover empty startup, invalid headings in both directions, and stable tied rows across snapshot updates. - Test legacy STATUSTEXT metadata, malformed chunk numbers preserving pending messages, and completion at chunk 255. - Verify replacement-file ownership requests, contain directory-close errors, and preserve writer errors when temporary-file cleanup fails. - Distinguish local and remote upload filenames in error messages and outcomes while verifying processing continues with later files. - Check ESC requests at the exact one-second deadline and prevent catch-up bursts after polling pauses. - Require nonblocking receives from both telemetry backends and isolate heartbeat scheduling from receive-contract tests. - Capture heartbeat worker exceptions with threading.excepthook. - Hold the encoder lock across MAVLink version assertions and enable DEBUG capture before installing the transient write failure. Validation: - 306 affected and related tests pass under Xvfb. - Inspector audit improves from 38 to 40 killed out of 42 mutants, with two survivors and no timeouts or crashes. - Focused checks kill 11 previously surviving file/diagnostic mutations. - Separate focused proofs detect the ESC deadline survivor and blocking-read timeout mutation. Signed-off-by: Dr.-Ing. Amilcar do Carmo Lucas --- CONTRIBUTING.md | 3 +- pyproject.toml | 2 + tests/bdd_backend_safe_file_io.py | 83 +++++++++++++++++++ tests/test_backend_data_telemetry.py | 18 +++-- tests/test_data_model_fc_diagnostics.py | 49 ++++++++++++ tests/test_data_model_file_upload.py | 9 ++- tests/test_data_model_mavlink_inspector.py | 93 +++++++++++++++++++++- tests/test_esc_telemetry.py | 59 ++++++++++++++ 8 files changed, 300 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5d8b510c..758a43b46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,8 @@ processing. All plugin backends and data models are included, together with shar data/ESC telemetry models, telemetry and radio backends, and flight-controller command handling used by calibration and motor-testing plugins. External parameter upload selection, configured file-upload policy, atomic file replacement, and -flight-controller diagnostic assembly and device rows are also included. +flight-controller diagnostic assembly and device rows are also included, together +with MAVLink inspector sorting, source identifiers, and decoded-field presentation. It runs on Linux, macOS, or Windows through WSL. diff --git a/pyproject.toml b/pyproject.toml index 13711ce5e..4856cb9d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,6 +207,7 @@ only_mutate = [ "ardupilot_methodic_configurator/backend_flightcontroller_commands.py", "ardupilot_methodic_configurator/data_model_data_telemetry.py", "ardupilot_methodic_configurator/data_model_esc_telemetry.py", + "ardupilot_methodic_configurator/data_model_mavlink_inspector.py", "ardupilot_methodic_configurator/data_model_configuration_step.py", "ardupilot_methodic_configurator/data_model_parameter_upgrade.py", "ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py", @@ -302,6 +303,7 @@ pytest_add_cli_args_test_selection = [ "tests/test_backend_flightcontroller_connection.py", "tests/test_frontend_tkinter_fc_banner_window.py", "tests/test_frontend_tkinter_mavlink_inspector.py", + "tests/test_data_model_mavlink_inspector.py", "tests/test_data_model_plane_landing.py", "tests/test_data_model_firmware_version.py", "tests/test_data_model_parameter_history.py", diff --git a/tests/bdd_backend_safe_file_io.py b/tests/bdd_backend_safe_file_io.py index 398fdffc1..508ebe7e1 100755 --- a/tests/bdd_backend_safe_file_io.py +++ b/tests/bdd_backend_safe_file_io.py @@ -420,3 +420,86 @@ def open_file(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = assert target.read_text(encoding="utf-8") == "updated" assert list(tmp_path.iterdir()) == [target] + + +@pytest.mark.skipif(not hasattr(os, "chown"), reason="Ownership preservation requires os.chown") +def test_atomic_save_requests_the_original_owner_and_group_for_the_replacement(tmp_path: Path) -> None: + """ + Preserve the ownership policy when replacing an existing file. + + GIVEN: An existing file with an owner and group + WHEN: Its replacement is prepared for an atomic save + THEN: The OS is asked to apply the original owner and group to that replacement + """ + target = tmp_path / "settings.json" + target.write_text("original", encoding="utf-8") + original = target.stat() + + with patch("ardupilot_methodic_configurator.backend_safe_file_io.os.chown", autospec=True) as set_owner: + safe_write(str(target), lambda file: file.write("updated")) + + set_owner.assert_called_once() + replacement, owner, group = set_owner.call_args.args + assert (owner, group) == (original.st_uid, original.st_gid) + assert Path(replacement).parent == target.parent + assert Path(replacement) != target + assert target.read_text(encoding="utf-8") == "updated" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Requires POSIX directory descriptors") +def test_directory_close_failure_does_not_report_a_successful_save_as_failed(tmp_path: Path) -> None: + """ + A best-effort directory-close error must not undo a completed save. + + GIVEN: A directory descriptor whose close operation reports an I/O error + WHEN: The file is saved and its directory is synced + THEN: The save succeeds and the directory descriptor is released + """ + target = tmp_path / "settings.json" + real_close = os.close + closed: list[int] = [] + + def close_with_error(fd: int) -> None: + real_close(fd) + closed.append(fd) + message = "directory close failed" + raise OSError(message) + + with patch("ardupilot_methodic_configurator.backend_safe_file_io.os.close", side_effect=close_with_error): + safe_write(str(target), lambda file: file.write("updated")) + + assert len(closed) == 1 + with pytest.raises(OSError, match=os.strerror(errno.EBADF)) as error: + os.fstat(closed[0]) + assert error.value.errno == errno.EBADF + assert target.read_text(encoding="utf-8") == "updated" + assert list(tmp_path.iterdir()) == [target] + + +def test_temporary_file_cleanup_failure_preserves_the_original_write_error(tmp_path: Path) -> None: + """ + Report the original save failure even if temporary-file cleanup also fails. + + GIVEN: An existing file, a failed writer, and an OS error removing its temporary file + WHEN: Atomic saving aborts + THEN: The original error reaches the caller and the previous file remains intact + """ + target = tmp_path / "settings.json" + target.write_text("original", encoding="utf-8") + write_error = ValueError("cannot serialize settings") + + def fail_write(file: IO[str]) -> None: + file.write("partial update") + raise write_error + + with ( + patch( + "ardupilot_methodic_configurator.backend_safe_file_io.os.unlink", side_effect=OSError("cleanup denied") + ) as cleanup, + pytest.raises(ValueError, match="cannot serialize settings") as error, + ): + safe_write(str(target), fail_write) + + assert error.value is write_error + cleanup.assert_called_once() + assert target.read_text(encoding="utf-8") == "original" diff --git a/tests/test_backend_data_telemetry.py b/tests/test_backend_data_telemetry.py index 35868e48d..0a760c426 100755 --- a/tests/test_backend_data_telemetry.py +++ b/tests/test_backend_data_telemetry.py @@ -9,6 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later """ +import threading from collections.abc import Generator from dataclasses import replace from itertools import count @@ -213,8 +214,8 @@ def flaky_write(_packet: bytes) -> None: raise OSError(message) recovered.set() - telemetry_link.master.write.side_effect = flaky_write with caplog.at_level("DEBUG"): + telemetry_link.master.write.side_effect = flaky_write assert recovered.wait(4), "Heartbeat writes must retry while monitoring remains closed" assert caplog.messages == ["Error sending GCS heartbeat: temporary link failure"] @@ -836,10 +837,11 @@ def test_heartbeat_sender_reuses_the_existing_encoder_lock_and_preserves_request assert master.mav.amc_send_lock is lock assert any(thread.name == "gcs-heartbeat" and thread.daemon for thread in enumerate_threads()) heartbeat = mavlink.MAVLink_heartbeat_message(6, 8, 0, 0, 4, 3) - master.mav.send(heartbeat) - assert master.write.call_args.args[0][0] == 0xFD - master.mav.send(heartbeat, force_mavlink1=True) - assert master.write.call_args.args[0][0] == 0xFE + with master.mav.amc_send_lock: + master.mav.send(heartbeat) + assert master.write.call_args.args[0][0] == 0xFD + master.mav.send(heartbeat, force_mavlink1=True) + assert master.write.call_args.args[0][0] == 0xFE stopped_sender = master.mav.send finally: backend.close() @@ -870,7 +872,7 @@ def test_heartbeat_shutdown_contains_port_close_errors_in_diagnostics( def test_heartbeat_shutdown_tolerates_an_in_memory_connection_without_a_port( telemetry_link: SimpleNamespace, - recwarn: pytest.WarningsRecorder, + monkeypatch: pytest.MonkeyPatch, ) -> None: """ Shut down transports without optional serial-port cleanup methods. @@ -879,12 +881,14 @@ def test_heartbeat_shutdown_tolerates_an_in_memory_connection_without_a_port( WHEN: The heartbeat worker starts and then stops THEN: No unhandled worker exception is emitted during cleanup """ + worker_errors: list[threading.ExceptHookArgs] = [] + monkeypatch.setattr(threading, "excepthook", worker_errors.append) backend = RadioStatusBackend() if hasattr(telemetry_link.master, "port"): del telemetry_link.master.port backend.start(telemetry_link.master) backend.close() - assert not recwarn.list + assert not worker_errors @pytest.mark.parametrize("backend_type", [DataTelemetryBackend, EscTelemetryBackend]) diff --git a/tests/test_data_model_fc_diagnostics.py b/tests/test_data_model_fc_diagnostics.py index cbfdb9bf6..614a76ade 100755 --- a/tests/test_data_model_fc_diagnostics.py +++ b/tests/test_data_model_fc_diagnostics.py @@ -422,3 +422,52 @@ def test_dronecan_devices_display_the_bus_protocol_as_the_sensor_type() -> None: assert diagnostics.device_rows({"COMPASS_DEV_ID": device_id}) == [ ("COMPASS1", "DRONECAN", 1, 42, "DRONECAN", None, f"COMPASS_DEV_ID: {device_id}") ] + + +@pytest.mark.parametrize("message_id", [None, "legacy"]) +def test_legacy_transports_without_numeric_chunk_ids_display_full_length_text(message_id: str | None) -> None: + """ + Legacy diagnostics must not wait for chunks their transport cannot identify. + + GIVEN: Fifty bytes of text with absent or nonnumeric chunk metadata + WHEN: The diagnostic is assembled + THEN: The complete text is displayed immediately + """ + message = SimpleNamespace(text=b"A" * 50) + if message_id is not None: + message.id = message_id + + assert diagnostics.StatusTextAssembler().assemble(message) == "A" * 50 + + +@pytest.mark.parametrize("sequence", [None, "1", -1, 256]) +def test_malformed_chunk_numbers_do_not_crash_or_discard_a_valid_pending_message(sequence: object) -> None: + """ + Reject malformed chunk metadata without corrupting an ongoing diagnostic. + + GIVEN: A valid unfinished message followed by an invalid chunk number + WHEN: The invalid packet arrives before the valid continuation + THEN: It is ignored and the valid diagnostic still completes + """ + assembler = diagnostics.StatusTextAssembler() + assert assembler.assemble(SimpleNamespace(id=42, chunk_seq=0, text=b"A" * 50)) is None + + assert assembler.assemble(SimpleNamespace(id=42, chunk_seq=sequence, text=b"invalid")) is None + + assert assembler.assemble(SimpleNamespace(id=42, chunk_seq=1, text=b"tail")) == "A" * 50 + "tail" + + +def test_long_diagnostic_can_finish_at_the_last_valid_chunk_number(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Accept the full range of MAVLink's unsigned eight-bit chunk numbers. + + GIVEN: A diagnostic spanning every valid chunk number + WHEN: The final text arrives at chunk 255 before expiration + THEN: The entire message is returned without truncation + """ + monkeypatch.setattr(diagnostics, "monotonic", lambda: 0.0) + assembler = diagnostics.StatusTextAssembler() + for sequence in range(255): + assert assembler.assemble(MAVLink_statustext_message(4, b"A" * 50, id=42, chunk_seq=sequence)) is None + + assert assembler.assemble(MAVLink_statustext_message(4, b"tail", id=42, chunk_seq=255)) == "A" * (50 * 255) + "tail" diff --git a/tests/test_data_model_file_upload.py b/tests/test_data_model_file_upload.py index b53ba1c54..128cb727f 100755 --- a/tests/test_data_model_file_upload.py +++ b/tests/test_data_model_file_upload.py @@ -269,21 +269,22 @@ def test_provider_exception_does_not_skip_later_uploads( provider.side_effect = [error_type("file operation failed"), success] outcomes = workflow.upload_files( - [("first.lua", "/APM/Scripts/first.lua"), ("second.lua", "/APM/Scripts/second.lua")], + [("local-first.lua", "/APM/Scripts/remote-first.lua"), ("second.lua", "/APM/Scripts/second.lua")], set(), confirm, progress_factory, ) assert outcomes == [ - FileUploadOutcome("first.lua", "/APM/Scripts/first.lua", expected), + FileUploadOutcome("local-first.lua", "/APM/Scripts/remote-first.lua", expected), FileUploadOutcome("second.lua", "/APM/Scripts/second.lua", FileUploadDisposition.UPLOADED), ] assert upload.call_args.args[:2] == ("second.lua", "/APM/Scripts/second.lua") - assert "first.lua" in caplog.text assert "file operation failed" in caplog.text + if provider_stage in {"size", "upload"}: + assert "local-first.lua" in caplog.text if provider_stage in {"check", "upload"}: - assert "/APM/Scripts/first.lua" in caplog.text + assert "/APM/Scripts/remote-first.lua" in caplog.text def test_confirmation_exception_releases_batch_for_a_later_request() -> None: diff --git a/tests/test_data_model_mavlink_inspector.py b/tests/test_data_model_mavlink_inspector.py index 6d17a601b..e67bb6685 100755 --- a/tests/test_data_model_mavlink_inspector.py +++ b/tests/test_data_model_mavlink_inspector.py @@ -127,23 +127,26 @@ def test_switching_headings_starts_ascending_and_equal_values_stay_stable(messag assert model.rows == tied -def test_unknown_heading_does_not_change_the_existing_sort(messages: tuple[MessageRate, ...]) -> None: +@pytest.mark.parametrize("descending", [False, True]) +def test_unknown_heading_does_not_change_the_existing_sort(messages: tuple[MessageRate, ...], descending: bool) -> None: """ Reject an invalid column without corrupting valid inspection state. - GIVEN a populated model sorted by count, + GIVEN a populated model sorted by count in either direction, WHEN an unknown heading is requested, THEN the request fails and both the rows and sort preferences remain intact. """ model = MavlinkInspectorDataModel() model.update(TelemetryState(messages=messages)) model.sort_by("count") + if descending: + model.sort_by("count") before = model.rows - with pytest.raises(ValueError, match="Unknown MAVLink message column"): + with pytest.raises(ValueError, match=r"^Unknown MAVLink message column: payload$"): model.sort_by("payload") assert model.rows == before assert model.sort_column == "count" - assert not model.sort_descending + assert model.sort_descending is descending def test_same_message_from_different_senders_keeps_independent_fields(messages: tuple[MessageRate, ...]) -> None: @@ -164,3 +167,85 @@ def test_same_message_from_different_senders_keeps_independent_fields(messages: assert model.values(first) == (1, 1, "HEARTBEAT", 10, "10.0", "100.0", "10.0") model.update(TelemetryState(messages=(replace(first, sample=replace(first.sample, fields=())),))) assert model.fields("1:1:HEARTBEAT") == "" + + +@pytest.mark.parametrize( + ("column", "order"), + [ + ("system", (3, 1, 2, 0)), + ("component", (2, 0, 3, 1)), + ("message", (0, 2, 3, 1)), + ("count", (1, 3, 0, 2)), + ("hz", (3, 0, 2, 1)), + ("bytes", (2, 1, 3, 0)), + ("age", (1, 0, 3, 2)), + ], +) +def test_each_heading_orders_by_its_own_independent_measurement(column: str, order: tuple[int, ...]) -> None: + """ + Distinguish every sort key even when other message attributes disagree. + + GIVEN: IDs, names, counts, rates and ages that all have different orderings + WHEN: The user selects a column heading + THEN: Only the measurement belonging to that column determines the order + """ + rows = tuple( + MessageRate((system, component, name), MessageSample(count, 400, 0, ()), hz, rate, age) + for system, component, name, count, hz, rate, age in ( + (10, 2, "ATTITUDE", 20, 0.5, 100.0, 0.8), + (2, 10, "VFR_HUD", 3, 2.1, 40.0, 0.2), + (3, 1, "HEARTBEAT", 100, 1.3, 10.0, 2.5), + (1, 3, "SYS_STATUS", 10, 0.1, 90.0, 1.1), + ) + ) + model = MavlinkInspectorDataModel() + model.update(TelemetryState(messages=rows)) + + model.sort_by(column) + + assert model.rows == tuple(rows[index] for index in order) + + +@pytest.mark.parametrize("descending", [False, True]) +def test_tied_rows_follow_the_latest_snapshot_order_in_both_sort_directions( + messages: tuple[MessageRate, ...], descending: bool +) -> None: + """ + Preserve stable ordering when a live snapshot contains equal measurements. + + GIVEN: An ascending or descending frequency sort with tied message rates + WHEN: The next snapshot changes the incoming row order + THEN: Tied rows follow that new order while the chosen sort direction is retained + """ + tied = tuple(replace(row, hz=1.0) for row in messages) + model = MavlinkInspectorDataModel() + model.update(TelemetryState(messages=tied)) + model.sort_by("hz") + if descending: + model.sort_by("hz") + latest = tuple(reversed(tied)) + + model.update(TelemetryState(messages=latest)) + + assert model.rows == latest + assert model.sort_column == "hz" + assert model.sort_descending is descending + + +@pytest.mark.parametrize("column", [None, "system", "hz"]) +def test_empty_inspector_can_be_read_and_sorted_before_telemetry_arrives(column: str | None) -> None: + """ + Keep an inspector opened before connection usable while it waits for telemetry. + + GIVEN: A new inspector that has not received its first snapshot + WHEN: The user reads its rows and fields or chooses a sort heading + THEN: Its table and detail pane remain empty without raising an error + """ + model = MavlinkInspectorDataModel() + + assert not model.rows + assert model.fields("1:1:HEARTBEAT") == "" + if column is not None: + model.sort_by(column) + assert not model.rows + assert model.sort_column == column diff --git a/tests/test_esc_telemetry.py b/tests/test_esc_telemetry.py index 6cb640d0d..2e5b80ec4 100755 --- a/tests/test_esc_telemetry.py +++ b/tests/test_esc_telemetry.py @@ -21,8 +21,10 @@ from telemetry_test_helpers import create_telemetry_link, feed from ardupilot_methodic_configurator.__main__ import register_plugins +from ardupilot_methodic_configurator.backend_data_telemetry import DataTelemetryBackend from ardupilot_methodic_configurator.backend_esc_telemetry import EscTelemetryBackend from ardupilot_methodic_configurator.backend_flightcontroller_commands import FlightControllerCommands +from ardupilot_methodic_configurator.data_model_data_telemetry import TelemetrySample from ardupilot_methodic_configurator.data_model_esc_telemetry import ( MAX_ESCS, EscTelemetryDataModel, @@ -604,3 +606,60 @@ def test_invalid_snapshots_cannot_repopulate_live_esc_readings(model: EscTelemet rows = model.update(EscTelemetrySnapshot(connection if failed else None, 1.0, readings, error=failed)) assert model.visible_esc_indices == () assert all(value == "-" for row in rows for value in row) + + +@pytest.mark.parametrize(("elapsed", "requests"), [(0.999, 1), (1.0, 2), (1.001, 2), (5.0, 2)]) +def test_esc_request_is_due_at_exactly_one_second_without_catching_up_after_a_pause( + link: SimpleNamespace, elapsed: float, requests: int +) -> None: + """ + Request ESC telemetry at its deadline without flooding a resumed connection. + + GIVEN: An initial ESC request sent at time zero + WHEN: Polling resumes just before, at, or after its one-second deadline + THEN: A due request is sent immediately and missed intervals never produce a burst + """ + backend = EscTelemetryBackend(link.controller, lambda: link.now) + try: + backend.poll() + assert link.master.write.call_count == 1 + + link.now = elapsed + snapshot = backend.poll() + + assert not snapshot.error + assert link.master.write.call_count == requests + finally: + backend.close() + + +@pytest.mark.parametrize("backend_type", [DataTelemetryBackend, EscTelemetryBackend]) +def test_empty_telemetry_poll_never_waits_for_a_packet( + link: SimpleNamespace, backend_type: type[DataTelemetryBackend] | type[EscTelemetryBackend] +) -> None: + """ + An empty telemetry queue must not block the GUI refresh callback. + + GIVEN: A connected transport with no queued packets + WHEN: A telemetry backend polls within its receive budget + THEN: The receive operation never waits and returns a successful empty snapshot + """ + # Heartbeat scheduling is an external FC operation, independent of the receive contract. + link.controller.start_gcs_heartbeat.side_effect = None + backend = backend_type(link.controller, lambda: link.now) + real_receive = link.master.recv_match + + def receive(*, blocking: bool = False) -> object: + assert not blocking, "Telemetry polling must not wait for a packet" + return real_receive(blocking=blocking) + + try: + with patch.object(link.master, "recv_match", side_effect=receive) as receiver: + snapshot = backend.poll() + + receiver.assert_called_once() + assert snapshot.connection is link.master + assert not snapshot.error + assert not (snapshot.messages if isinstance(snapshot, TelemetrySample) else snapshot.readings) + finally: + backend.close() From aab17af48eb2e840ca8feffb1e0f40a15dfdd60a Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Fri, 30 Oct 2026 17:27:56 +0100 Subject: [PATCH 4/6] test: harden file browser, ESC analysis and parameter table workflows Add focused behavior tests to the largest remaining coverage gaps in the full-suite HTML report, then strengthen assertions against mutation survivors. Preserve production behavior and the original coverage data. File browser: - Add 72 cases for rename collisions and permissions, directory creation and cancellation, selection events, keyboard shortcuts and task errors. - Prevent overlapping operations and transfers after planning failures. - Report listing and deletion errors without actionable stale rows. - Cover 99 missed lines and 43 branches; line coverage rises from 86.3% to 95.1%. Kill all 153 mutations audited across four handlers. ESC availability and analysis: - Add 84 cases using real in-memory log records and firmware metadata. - Check missing and legacy telemetry, small positive error rates, report names, per-instance error peaks and their first matching timestamps. - Test exact RPM observation and armed-coverage thresholds, zero initial timestamps, unclosed intervals and later flights without earlier data. - Compare current across armed windows while excluding disarmed samples. - Check motor-spin tolerances, suggested values and documented DShot multipliers, including the exact 1000 Hz warning boundary. - Cover all 118 missed lines and 61 branches, bringing both to 100%. - Kill 861 of 878 module mutations, retaining 17 reviewed equivalent survivors without suppressions. Parameter editor table: - Add 58 cases using real Tk controls and controlled event delivery. - Verify case-insensitive filtering, single-match Enter behavior, explicit selection, shortcuts, double-click addition and cancellation. - Check the exact bulk-add confirmation boundary and partial feedback. - Verify initial bitmask selections, decimal previews, invalid/empty values, focus handling, reopening and replacement of row widgets. - Clear the difference marker when an edit matches the FC value. - Check manual locking of choice widgets and model-rejected additions. - Verify row reservation, ordering, the final Add action, completion callbacks and rejection of obsolete render generations. - Respect dismissed bitmask guidance and retain exception context in rendering diagnostics. - Bound the existing batch-drain test so endless scheduling fails by assertion instead of timing out. - Cover 98 missed lines; line coverage rises from 85.6% to 99.2%. - Kill 541 of 632 audited dialog/rendering mutations, up from 114. The baseline rendering timeout is killed and no audited mutation lacks test associations. Leave 91 survivors unsuppressed for further review. Add ESC analysis and the parameter table, together with their existing unit/integration/acceptance tests, to the mutation configuration and document the expanded scope. Validation: - 294 browser tests, 122 log-analysis tests and 190 parameter-table tests pass in separate focused runs under Xvfb. - Pre-commit, Ruff, Pylint and git diff --check pass. - Nine parallel mutation timeouts survive when rerun serially; final table results contain no timeouts or crashes. - Preserve combined coverage reports and mutation review artifacts. The table audit covers five methods rather than all 2336 generated module mutations. Its survivors include presentation, platform and uncovered defensive paths, as well as equivalent changes. The full repository test suite and weekly mutation job were not rerun. Signed-off-by: Dr.-Ing. Amilcar do Carmo Lucas --- CONTRIBUTING.md | 4 + pyproject.toml | 7 + tests/test_data_model_availability_models.py | 724 +++++++++++++- tests/test_frontend_tkinter_file_browser.py | 618 ++++++++++++ ...frontend_tkinter_parameter_editor_table.py | 935 +++++++++++++++++- 5 files changed, 2286 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 758a43b46..91baeaf0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,10 @@ command handling used by calibration and motor-testing plugins. External paramet upload selection, configured file-upload policy, atomic file replacement, and flight-controller diagnostic assembly and device rows are also included, together with MAVLink inspector sorting, source identifiers, and decoded-field presentation. +ESC log analysis covers armed-period RPM and current comparisons, per-output error +peaks, motor-spin margins, and documented DShot output rates. +Parameter-table tests cover add-dialog filtering and selection, bulk-add feedback, +bitmask editing, and completion of batched rendering. It runs on Linux, macOS, or Windows through WSL. diff --git a/pyproject.toml b/pyproject.toml index 4856cb9d1..1e9e912db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -231,6 +231,7 @@ only_mutate = [ "ardupilot_methodic_configurator/frontend_tkinter_mavlink_inspector.py", "ardupilot_methodic_configurator/log_analysis/data_model_plane_landing.py", "ardupilot_methodic_configurator/log_analysis/data_model_availability_imu.py", + "ardupilot_methodic_configurator/log_analysis/data_model_availability_esc.py", "ardupilot_methodic_configurator/log_analysis/data_model_firmware_version.py", "ardupilot_methodic_configurator/log_analysis/data_model_parameter_history.py", "ardupilot_methodic_configurator/plugins/imu_helpers.py", @@ -239,6 +240,7 @@ only_mutate = [ "ardupilot_methodic_configurator/frontend_tkinter_usage_popup_window.py", "ardupilot_methodic_configurator/frontend_tkinter_file_browser.py", "ardupilot_methodic_configurator/frontend_tkinter_file_browser_tasks.py", + "ardupilot_methodic_configurator/frontend_tkinter_parameter_editor_table.py", "ardupilot_methodic_configurator/plugins/frontend_tkinter_motor_test.py", "ardupilot_methodic_configurator/backend_filesystem_json_with_schema.py", ] @@ -282,6 +284,8 @@ pytest_add_cli_args_test_selection = [ "tests/test_bootloader_identity.py", "tests/test_bootloader_partial_read.py", "tests/test_data_model_imu_analysis.py", + "tests/test_data_model_availability_models.py", + "tests/test_data_model_log_analysis.py", "tests/test_bus_labels.py", "tests/test_data_model_vehicle_components_display.py", "tests/unit_data_model_vehicle_components_display.py", @@ -313,6 +317,9 @@ pytest_add_cli_args_test_selection = [ "tests/test_frontend_tkinter_file_browser.py", "tests/bdd_frontend_tkinter_file_browser.py", "tests/test_frontend_tkinter_file_browser_tasks.py", + "tests/test_frontend_tkinter_parameter_editor_table.py", + "tests/integration_frontend_tkinter_parameter_editor_table.py", + "tests/acceptance_bitmask_parameter_editor_usage_popup.py", "tests/test_backend_filesystem_json_with_schema.py", ] pytest_add_cli_args = [ diff --git a/tests/test_data_model_availability_models.py b/tests/test_data_model_availability_models.py index 148fd82d3..01e0bd308 100755 --- a/tests/test_data_model_availability_models.py +++ b/tests/test_data_model_availability_models.py @@ -3,12 +3,15 @@ """ Tests for log-analysis availability domain models. +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas SPDX-License-Identifier: GPL-3.0-or-later """ import numpy as np +import pytest from ardupilot_methodic_configurator.log_analysis.data_model_availability_battery import ( BatteryLogAnalysis, @@ -18,10 +21,49 @@ from ardupilot_methodic_configurator.log_analysis.data_model_availability_gnss import GPSLogAvailabilityModel from ardupilot_methodic_configurator.log_analysis.data_model_availability_pm import PmLogAvailabilityModel from ardupilot_methodic_configurator.log_analysis.data_model_log_analysis_context import LogAnalysisContext +from ardupilot_methodic_configurator.log_analysis.data_model_log_analysis_result import LogAnalysis, LogAnalysisResult from ardupilot_methodic_configurator.log_analysis.data_model_log_availability import LogAvailabilityState from ardupilot_methodic_configurator.log_analysis.data_model_log_data import LogData +def _esc_log( + records: list[tuple[float, float, float, float, float]], + arm_events: list[tuple[float, float]] | None = None, +) -> LogData: + """Build real ESC columns in canonical seconds, RPM, amps and percent.""" + log = LogData() + log.add_message_columns( + "ESC", + np.array(records, dtype=[("Instance", "f8"), ("TimeUS", "f8"), ("RPM", "f8"), ("Curr", "f8"), ("Err", "f8")]), + ) + if arm_events is not None: + log.add_message_columns("ARM", np.array(arm_events, dtype=[("ArmState", "f8"), ("TimeUS", "f8")])) + return log + + +def _esc_context(parameters: dict[str, float]) -> LogAnalysisContext: + """Resolve ESC findings against realistic parameter and configuration metadata.""" + context = _context( + parameters, + apm_doc={ + "MOT_PWM_TYPE": {"values": {"0": "Normal", "6": "DShot150", "9": "DShot600"}}, + "SERVO_DSHOT_RATE": { + "values": { + "0": "1kHz", + "1": "loop-rate", + "2": "double loop-rate", + "3": "triple loop-rate", + "4": "quadruple loop rate", + } + }, + }, + ) + context.configuration_steps["07_esc.param"]["derived_parameters"].update( + dict.fromkeys(("MOT_SPIN_ARM", "MOT_SPIN_MIN", "SERVO_DSHOT_RATE", "SCR_ENABLE"), 0.0) + ) + return context + + def _context( parameters: dict[str, float], *, @@ -94,7 +136,8 @@ def test_esc_model_uses_apm_doc_to_detect_non_dshot_configuration() -> None: assert result.available is False assert result.state == LogAvailabilityState.WARNING assert result.issues[0].config_step == "07_esc.param" - assert "DShot" in result.issues[0].message + assert result.reason == "ESC telemetry not logged" + assert result.issues[0].message == "Set MOT_PWM_TYPE to a DShot variant for ESC telemetry support" def test_battery_model_checks_present_log_data_without_datasource_access() -> None: @@ -229,3 +272,682 @@ def test_esc_analysis_skips_dshot_rate_for_pwm_output() -> None: ) assert not model.check_dshot_output_rate() + + +@pytest.mark.parametrize("error", [0.0, 0.25, 2.5]) +def test_esc_availability_checks_all_present_fields_and_reports_errors(error: float) -> None: + """ + Distinguish usable ESC telemetry from logged ESC errors. + + GIVEN complete ESC records with a healthy first instance and a second instance's error rate, + WHEN availability is checked, + THEN data stays available and only a positive error rate produces a configuration-linked warning. + """ + log = _esc_log([(2, 1, 1200, 3, 0), (9, 2, 1300, 4, error)]) + + result = EscLogAvailabilityModel(log, _esc_context({})).check() + + assert result.available is True + assert result.name == "ESC" + assert result.state == (LogAvailabilityState.WARNING if error else LogAvailabilityState.INFO) + if error: + assert [(issue.message, issue.config_step) for issue in result.issues] == [ + ("ESC error rate detected on at least one ESC instance", "07_esc.param") + ] + assert result.reason == "ESC data has availability issues" + else: + assert not result.issues + assert result.reason == "ESC data present and good for analysis" + + +@pytest.mark.parametrize("field", ["RPM", "Curr", "Err"]) +def test_esc_availability_explains_legacy_missing_fields(field: str) -> None: + """ + Identify the precise missing telemetry field in a legacy ESC schema. + + GIVEN an ESC record lacking one required field, + WHEN availability is checked, + THEN the report keeps the data available but explains that field's schema limitation. + """ + log = _esc_log([(2, 1, 1200, 3, 0)]) + columns = log.get_message_columns("ESC") + assert columns is not None + log.add_message_columns("ESC", columns[[name for name in columns.dtype.names if name != field]]) + + result = EscLogAvailabilityModel(log, _esc_context({})).check() + + assert result.available is True + assert result.state == LogAvailabilityState.WARNING + assert [issue.message for issue in result.issues] == [f"{field} field not present in this firmware's ESC schema"] + + +@pytest.mark.parametrize("empty_records", [False, True]) +@pytest.mark.parametrize( + ("parameters", "reason", "message", "step"), + [ + ( + {"MOT_PWM_TYPE": 6.0, "SCR_ENABLE": 0.0}, + "ESC telemetry not logged, scripting is disabled", + "Enable SCR_ENABLE if using scripted ESC telemetry", + "07_esc.param", + ), + ( + {"MOT_PWM_TYPE": 9.0, "SCR_ENABLE": 1.0}, + "ESC telemetry not logged, check ESC hardware supports telemetry and is wired correctly", + "No ESC messages found", + "07_esc.param", + ), + ( + {}, + "ESC telemetry not logged, check ESC hardware supports telemetry and is wired correctly", + "No ESC messages found", + "07_esc.param", + ), + ], +) +def test_missing_esc_data_reports_the_relevant_configuration_or_hardware_hint( + empty_records: bool, parameters: dict[str, float], reason: str, message: str, step: str +) -> None: + """ + Diagnose missing telemetry for both absent and empty ESC streams. + + GIVEN documented DShot outputs, disabled scripting, or unknown configuration, + WHEN no ESC samples were logged, + THEN availability reports the appropriate cause and its configuration step. + """ + log = _esc_log([]) if empty_records else LogData() + + result = EscLogAvailabilityModel(log, _esc_context(parameters)).check() + + assert result.available is False + assert result.state == LogAvailabilityState.WARNING + assert result.name == "ESC" + assert result.reason == reason + assert [(issue.message, issue.config_step) for issue in result.issues] == [(message, step)] + + +@pytest.mark.parametrize("empty_records", [False, True]) +def test_esc_analysis_without_samples_has_no_findings(empty_records: bool) -> None: + """ + Explain why detailed ESC analysis cannot proceed without samples. + + GIVEN an absent or empty ESC stream, + WHEN detailed analysis runs, + THEN the result is unavailable with an explicit reason and no speculative findings. + """ + log = _esc_log([]) if empty_records else LogData() + + assert EscLogAnalysis(log, _esc_context({})).analyse() == LogAnalysisResult( + available=False, outcomes=[], name="ESC Analysis", reason="No ESC data available" + ) + + +@pytest.mark.parametrize("spin_min", [0.12, 0.129, 0.129999999, 0.13, 0.14]) +def test_motor_spin_margin_preserves_tolerance_and_suggests_the_required_minimum(spin_min: float) -> None: + """ + Recommend a spin minimum only when the motor-spin margin is meaningfully too small. + + GIVEN MOT_SPIN_ARM of 0.1 and a spin minimum below, near, at, or above the required margin, + WHEN motor-spin settings are checked, + THEN only a real deficit produces an accurately valued fix in the ESC configuration step. + """ + outcomes = EscLogAnalysis( + LogData(), _esc_context({"MOT_SPIN_ARM": 0.1, "MOT_SPIN_MIN": spin_min}) + ).check_motor_spin_spread() + + if spin_min >= 0.129999999: + assert not outcomes + else: + assert len(outcomes) == 1 + finding = outcomes[0] + assert finding.param_name == "MOT_SPIN_MIN" + assert finding.suggested_value == pytest.approx(0.13) + assert finding.value == pytest.approx(-0.01 if spin_min == 0.12 else -0.001) + assert finding.timestamp_us is None + assert finding.related_step == "07_esc.param" + assert finding.message == ( + f"MOT_SPIN_MIN ({spin_min:.3f}) is below MOT_SPIN_ARM + 0.03 (0.130). " + "AMC's tuning guide requires MOT_SPIN_MIN to be at least 0.03 above MOT_SPIN_ARM " + "(0.100). Motors may not spin reliably once armed." + ) + + +@pytest.mark.parametrize("parameters", [{}, {"MOT_SPIN_ARM": 0.1}, {"MOT_SPIN_MIN": 0.12}]) +def test_motor_spin_margin_requires_both_settings(parameters: dict[str, float]) -> None: + """ + Avoid inventing a motor-spin fix when a required setting is unknown. + + GIVEN one or both motor-spin settings are absent, + WHEN the margin is checked, + THEN no finding or suggested parameter change is produced. + """ + assert not EscLogAnalysis(LogData(), _esc_context(parameters)).check_motor_spin_spread() + + +def test_esc_error_peaks_keep_instance_identity_time_and_first_tied_peak() -> None: + """ + Attribute each positive error peak to the correct ESC and timestamp. + + GIVEN interleaved, unsorted instances with unequal peaks, tied peaks, and a healthy ESC, + WHEN per-instance errors are checked, + THEN findings are ordered by instance and report each peak's first actual occurrence. + """ + log = _esc_log( + [ + (9, 1.25, 1000, 2, 2), + (2, 1.5, 1000, 2, 0.5), + (7, 2, 1000, 2, 0), + (9, 3.75, 1000, 2, 8), + (2, 4.5, 1000, 2, 0.5), + ] + ) + + assert EscLogAnalysis(log, _esc_context({})).check_per_instance_errors() == [ + LogAnalysis("ESC output 2 peak error rate: 0.5%", timestamp_us=1_500_000.0, value=0.5), + LogAnalysis("ESC output 9 peak error rate: 8.0%", timestamp_us=3_750_000.0, value=8.0), + ] + + +@pytest.mark.parametrize("field", ["Instance", "TimeUS", "RPM", "Curr", "Err"]) +def test_esc_checks_skip_schemas_missing_their_required_fields(field: str) -> None: + """ + Tolerate older schemas without accessing unavailable telemetry fields. + + GIVEN an ESC record missing one field needed by a detailed check, + WHEN that check runs, + THEN it returns no finding rather than failing or assuming a value. + """ + log = _esc_log([(2, 3, 0, 1, 2)], [(1, 2), (0, 6)]) + columns = log.get_message_columns("ESC") + assert columns is not None + log.add_message_columns("ESC", columns[[name for name in columns.dtype.names if name != field]]) + model = EscLogAnalysis(log, _esc_context({})) + + if field in {"Instance", "TimeUS", "RPM"}: + assert not model.check_rpm_while_armed() + if field in {"Instance", "TimeUS", "Curr"}: + assert not model.check_current_imbalance() + if field in {"Instance", "TimeUS", "Err"}: + assert not model.check_per_instance_errors() + + +@pytest.mark.parametrize( + ("code", "loop_rate", "expected_rate"), + [ + (0, 2000, 1000), + (1, 400, 400), + (1, 1000, 1000), + (1, 1001, None), + (2, 500, 1000), + (2, 501, None), + (3, 300, 900), + (3, 400, None), + (4, 250, 1000), + (4, 400, None), + ], +) +def test_dshot_rate_uses_documented_multipliers_and_warns_at_the_exact_boundary( + code: float, loop_rate: float, expected_rate: float | None +) -> None: + """ + Compute DShot rates from metadata and include the exact warning boundary. + + GIVEN a fixed 1 kHz rate or a documented loop-rate multiplier, + WHEN the effective DShot rate is checked, + THEN rates at or below 1000 Hz produce an actionable finding with the calculated rate. + """ + context = _esc_context({"MOT_PWM_TYPE": 6.0, "SCHED_LOOP_RATE": loop_rate, "SERVO_DSHOT_RATE": code}) + + outcomes = EscLogAnalysis(LogData(), context).check_dshot_output_rate() + + if expected_rate is None: + assert not outcomes + else: + assert len(outcomes) == 1 + finding = outcomes[0] + assert finding.value == expected_rate + assert finding.timestamp_us is None + assert finding.param_name == "SERVO_DSHOT_RATE" + assert finding.related_step == "07_esc.param" + label = context.apm_doc["SERVO_DSHOT_RATE"]["values"][str(code)] + assert finding.message == ( + f"Effective DShot output rate is {expected_rate:.0f}Hz (SCHED_LOOP_RATE={loop_rate:.0f}Hz, " + f"SERVO_DSHOT_RATE={label!r}), at or below the 1000Hz level Amilcar flagged as " + "problematic. apm.pdef.xml also states SERVO_DSHOT_RATE should never be set below 500Hz." + ) + + +@pytest.mark.parametrize("missing", ["MOT_PWM_TYPE", "SCHED_LOOP_RATE", "SERVO_DSHOT_RATE", "metadata"]) +def test_dshot_rate_requires_complete_parameters_and_metadata(missing: str) -> None: + """ + Avoid a DShot-rate warning based on incomplete configuration. + + GIVEN one required parameter or its documentation is missing, + WHEN effective output rate is checked, + THEN no unsupported calculation or finding is produced. + """ + context = _esc_context({"MOT_PWM_TYPE": 6.0, "SCHED_LOOP_RATE": 400.0, "SERVO_DSHOT_RATE": 1.0}) + if missing == "metadata": + context.apm_doc = None + else: + del context.parameters[missing] + + assert not EscLogAnalysis(LogData(), context).check_dshot_output_rate() + + +@pytest.mark.parametrize("label", [None, "unrecognized-rate", "DOUBLE LOOP-RATE"]) +def test_dshot_metadata_handles_unknown_labels_and_case_insensitive_known_labels(label: str | None) -> None: + """ + Resolve enum labels without guessing the meaning of unknown values. + + GIVEN a DShot rate with absent, unknown, or uppercase metadata, + WHEN its output rate is checked, + THEN unknown labels are skipped and a known uppercase label uses its documented multiplier. + """ + context = _esc_context({"MOT_PWM_TYPE": 9.0, "SCHED_LOOP_RATE": 400.0, "SERVO_DSHOT_RATE": 8.0}) + context.apm_doc["SERVO_DSHOT_RATE"]["values"] = {} if label is None else {"8": label} + + outcomes = EscLogAnalysis(LogData(), context).check_dshot_output_rate() + + if label == "DOUBLE LOOP-RATE": + assert len(outcomes) == 1 + assert outcomes[0].value == 800.0 + assert "'DOUBLE LOOP-RATE'" in outcomes[0].message + else: + assert not outcomes + + +@pytest.mark.parametrize( + ("currents", "expected_instances", "expected_means", "expected_times", "median"), + [ + ([10, 10, 10, 30], [4], [30], [5_000_000], 10), + ([1, 10, 10, 10], [9], [1], [3_000_000], 10), + ([10, 10, 11], [7], [11], [5_000_000], 10), + ([1, 2, 10, 10, 11], [9, 2], [1, 2], [3_000_000, 3_000_000], 10), + ], +) +def test_current_imbalance_identifies_the_smaller_group_and_its_most_extreme_armed_sample( + currents: list[float], expected_instances: list[int], expected_means: list[float], expected_times: list[int], median: float +) -> None: + """ + Separate high and low current outliers without mixing ESC identities or disarmed data. + + GIVEN interleaved ESCs with an outlying minority and extreme disarmed samples, + WHEN current imbalance is analysed, + THEN only the minority is reported with its armed mean and most extreme armed timestamp. + """ + identities = [9, 2, 7, 4, 12] + records = [ + (identity, time, 1000, current, 0) + for time in (1, 3, 5, 7) + for identity, mean in zip(identities, currents, strict=False) + for current in [mean - 1 if time == 3 else mean + 1 if time == 5 else 9999] + ] + log = _esc_log(records, [(1, 2), (0, 6)]) + + outcomes = EscLogAnalysis(log, _esc_context({})).check_current_imbalance() + + assert outcomes == [ + LogAnalysis( + f"ESC output {instance} drew {mean:.2f}A on average, " + f"current drawn by the other {len(currents) - len(expected_instances)} ESC output(s) (median {median:.2f}A). " + "If unexpected for this vehicle's configuration, check for a mechanical or wiring issue.", + timestamp_us=timestamp, + value=mean, + ) + for instance, mean, timestamp in zip(expected_instances, expected_means, expected_times, strict=True) + ] + + +@pytest.mark.parametrize("currents", [[10, 10], [10, 10, 10], [0, 5, 10], [1, 1, 10, 10]]) +def test_current_imbalance_requires_a_separated_minority_of_at_least_three_observed_escs(currents: list[float]) -> None: + """ + Avoid speculative current-imbalance findings for balanced or ambiguous groups. + + GIVEN too few ESCs, equal currents, equal gaps, or two equally sized groups, + WHEN current imbalance is checked, + THEN no minority-outlier finding is produced. + """ + log = _esc_log([(instance, 3, 1000, current, 0) for instance, current in enumerate(currents)], [(1, 2), (0, 6)]) + + assert not EscLogAnalysis(log, _esc_context({})).check_current_imbalance() + + +@pytest.mark.parametrize("arm_events", [None, [(1, 2), (0, 6)]]) +def test_current_imbalance_requires_armed_samples_from_every_instance(arm_events: list[tuple[float, float]] | None) -> None: + """ + Reject an incomplete comparison of ESC current means. + + GIVEN no armed interval or an ESC observed only before arming, + WHEN current imbalance is checked, + THEN no finding compares a partial set of ESC outputs. + """ + log = _esc_log([(2, 3, 1000, 10, 0), (7, 3, 1000, 10, 0), (9, 1, 1000, 1, 0)], arm_events) + + assert not EscLogAnalysis(log, _esc_context({})).check_current_imbalance() + + +def test_current_imbalance_combines_all_armed_windows_without_counting_disarmed_samples() -> None: + """ + Include every armed interval in the ESC current comparison. + + GIVEN two flights separated by a disarmed interval with misleading current values, + WHEN currents are compared, + THEN both flights contribute to the means and the disarmed interval contributes nothing. + """ + log = _esc_log( + [ + (2, 2, 1000, 10, 0), + (7, 2, 1000, 10, 0), + (9, 2, 1000, 0, 0), + (2, 4, 1000, 0, 0), + (7, 4, 1000, 0, 0), + (9, 4, 1000, 1000, 0), + (2, 6, 1000, 10, 0), + (7, 6, 1000, 10, 0), + (9, 6, 1000, 20, 0), + ], + [(1, 1), (0, 3), (1, 5), (0, 7)], + ) + + assert not EscLogAnalysis(log, _esc_context({})).check_current_imbalance() + + +@pytest.mark.parametrize( + ("sample_times", "armed_end", "warn"), + [ + ([2.0, 3.0], 4.0, True), + ([2.0, 3.0], 3.0, True), + ([2.0, 2.999], 4.0, False), + ([2.0, 3.0], 4.001, False), + ([2.0, 4.0], 4.0, True), + ([2.5], 4.0, False), + ], +) +def test_zero_rpm_warning_requires_one_second_and_half_the_armed_interval( + sample_times: list[float], armed_end: float, warn: bool +) -> None: + """ + Require sustained zero RPM with adequate coverage of the armed interval. + + GIVEN zero samples spanning the exact observation and coverage boundaries, + WHEN armed-period RPM is checked, + THEN only sufficiently long, sufficiently complete observations trigger a warning. + """ + log = _esc_log([(7, timestamp, 0, 1, 0) for timestamp in sample_times], [(1, 2), (0, armed_end)]) + + outcomes = EscLogAnalysis(log, _esc_context({})).check_rpm_while_armed() + + if warn: + assert outcomes == [ + LogAnalysis( + f"ESC output 7 reported zero RPM throughout an armed period (2.0s to {armed_end:.1f}s), " + "the motor may not have responded.", + timestamp_us=2_000_000.0, + value=0.0, + ) + ] + else: + assert not outcomes + + +def test_zero_rpm_is_isolated_by_instance_and_armed_window() -> None: + """ + Ignore disarmed zero RPM and moving motors while preserving stalled-motor findings. + + GIVEN interleaved ESC outputs before, during, and after arming, + WHEN RPM is analysed, + THEN only the ESC that remained stopped throughout the armed window is reported. + """ + log = _esc_log( + [ + (2, 1, 0, 1, 0), + (2, 2, 0, 1, 0), + (2, 4, 1000, 1, 0), + (2, 5, 0, 1, 0), + (7, 2, 0, 1, 0), + (7, 4, 0, 1, 0), + (9, 1, 0, 1, 0), + (9, 5, 0, 1, 0), + ], + [(1, 2), (0, 4)], + ) + + assert EscLogAnalysis(log, _esc_context({})).check_rpm_while_armed() == [ + LogAnalysis( + "ESC output 7 reported zero RPM throughout an armed period (2.0s to 4.0s), the motor may not have responded.", + timestamp_us=2_000_000.0, + value=0.0, + ) + ] + + +@pytest.mark.parametrize("field", ["ArmState", "TimeUS"]) +def test_armed_rpm_check_skips_an_incomplete_arm_schema(field: str) -> None: + """ + Avoid assuming armed periods when transition metadata is incomplete. + + GIVEN ARM records missing a transition state or timestamp, + WHEN zero-RPM analysis is requested, + THEN no warning is produced from an invented armed window. + """ + log = _esc_log([(7, 2, 0, 1, 0), (7, 4, 0, 1, 0)], [(1, 2), (0, 4)]) + columns = log.get_message_columns("ARM") + assert columns is not None + log.add_message_columns("ARM", columns[[name for name in columns.dtype.names if name != field]]) + + assert not EscLogAnalysis(log, _esc_context({})).check_rpm_while_armed() + + +def test_unclosed_armed_window_ends_at_the_last_arm_timestamp() -> None: + """ + Retain an armed interval even when the log ends before disarming. + + GIVEN repeated armed messages without a matching disarm, + WHEN zero RPM is analysed, + THEN the original arm time and the final transition timestamp bound the warning. + """ + log = _esc_log([(7, 2, 0, 1, 0), (7, 4, 0, 1, 0)], [(0, 1), (1, 2), (1, 4)]) + + assert EscLogAnalysis(log, _esc_context({})).check_rpm_while_armed() == [ + LogAnalysis( + "ESC output 7 reported zero RPM throughout an armed period (2.0s to 4.0s), the motor may not have responded.", + timestamp_us=2_000_000.0, + value=0.0, + ) + ] + + +def test_complete_esc_analysis_combines_independent_findings_and_configuration_links() -> None: + """ + Return a coherent detailed analysis with every applicable ESC check included. + + GIVEN present ESC data, zero armed RPM, positive errors, and deficient spin and output-rate settings, + WHEN the complete analysis runs, + THEN each independent issue appears once with the correct report identity and step. + """ + log = _esc_log( + [(7, 2, 0, 2, 3), (2, 2, 1000, 10, 0), (9, 2, 1000, 10, 0), (7, 4, 0, 2, 5), (2, 4, 1000, 10, 0), (9, 4, 1000, 10, 0)], + [(1, 2), (0, 4)], + ) + context = _esc_context( + {"MOT_SPIN_ARM": 0.1, "MOT_SPIN_MIN": 0.12, "MOT_PWM_TYPE": 6.0, "SCHED_LOOP_RATE": 400.0, "SERVO_DSHOT_RATE": 1.0} + ) + + result = EscLogAnalysis(log, context).analyse() + + assert result.available is True + assert result.name == "ESC Analysis" + assert result.reason == "ESC analysis complete" + assert result.related_step == "07_esc.param" + assert len(result.outcomes) == 5 + assert [finding.param_name for finding in result.outcomes] == ["MOT_SPIN_MIN", None, None, None, "SERVO_DSHOT_RATE"] + assert [finding.value for finding in result.outcomes] == pytest.approx([-0.01, 5, 0, 2, 400]) + assert [finding.timestamp_us for finding in result.outcomes] == [None, 4_000_000.0, 2_000_000.0, 2_000_000.0, None] + + +def test_fixed_dshot_rate_is_not_interpreted_as_a_loop_multiplier() -> None: + """ + Keep the fixed 1 kHz output rate separate from loop-rate multipliers. + + GIVEN documentation identifying an enum value as a fixed 1 kHz rate, + WHEN its loop multiplier is requested, + THEN no multiplier is returned, allowing the fixed-rate calculation to apply. + """ + model = EscLogAnalysis(LogData(), _esc_context({})) + + assert model._dshot_rate_multiplier(0.0) is None # pylint: disable=protected-access + + +@pytest.mark.parametrize( + ("method", "message"), + [ + ("check_rpm", "RPM values missing from ESC records"), + ("check_current", "Current values missing from ESC records"), + ("check_error_rate", "ESC error values missing from ESC records"), + ], +) +def test_individual_esc_field_checks_explain_a_schema_with_no_samples(method: str, message: str) -> None: + """ + Distinguish an empty telemetry field from an unsupported firmware schema. + + GIVEN an ESC schema with all required fields but no records, + WHEN a field-specific check is requested, + THEN its issue explains that values are missing rather than the field itself. + """ + model = EscLogAvailabilityModel(_esc_log([]), _esc_context({})) + + issues = getattr(model, method)() + + assert len(issues) == 1 + assert issues[0].message == message + assert issues[0].config_step == "" + + +@pytest.mark.parametrize("metadata", [None, {}]) +def test_absent_esc_metadata_still_produces_an_actionable_fallback_report(metadata: dict | None) -> None: + """ + Keep ESC absence diagnostics readable without optional metadata. + + GIVEN no parameter documentation and no matching configuration step, + WHEN an ESC stream is absent, + THEN the fallback ESC name and hardware hint remain available. + """ + context = _esc_context({}) + context.apm_doc = metadata + context.configuration_steps = {} + + result = EscLogAvailabilityModel(LogData(), context).check() + + assert result.available is False + assert result.name == "ESC" + assert result.reason == "ESC telemetry not logged, check ESC hardware supports telemetry and is wired correctly" + assert [(issue.message, issue.config_step) for issue in result.issues] == [("No ESC messages found", "")] + + +def test_current_imbalance_reports_a_mean_across_multiple_armed_intervals() -> None: + """ + Combine samples from separate flights when calculating an ESC's mean current. + + GIVEN a low-current ESC with different means at the ends of two armed intervals, + WHEN imbalance is analysed, + THEN the reported mean uses both intervals and the timestamp identifies the most extreme sample. + """ + log = _esc_log( + [ + (2, 3, 1000, 10, 0), + (7, 3, 1000, 10, 0), + (9, 3, 1000, 0, 0), + (2, 7, 1000, 14, 0), + (7, 7, 1000, 14, 0), + (9, 7, 1000, 2, 0), + ], + [(1, 1), (0, 3), (1, 5), (0, 7)], + ) + + assert EscLogAnalysis(log, _esc_context({})).check_current_imbalance() == [ + LogAnalysis( + "ESC output 9 drew 1.00A on average, current drawn by the other 2 ESC output(s) (median 12.00A). " + "If unexpected for this vehicle's configuration, check for a mechanical or wiring issue.", + timestamp_us=3_000_000.0, + value=1.0, + ) + ] + + +@pytest.mark.parametrize("method", ["check_per_instance_errors", "check_current_imbalance", "check_rpm_while_armed"]) +def test_individual_esc_analysis_checks_tolerate_an_absent_esc_stream(method: str) -> None: + """ + Safely skip an individual detailed check when ESC data was never recorded. + + GIVEN valid ARM transitions but no ESC stream, + WHEN an individual ESC check is requested, + THEN it returns no findings instead of accessing a nonexistent schema. + """ + log = LogData() + log.add_message_columns("ARM", np.array([(1, 2), (0, 4)], dtype=[("ArmState", "f8"), ("TimeUS", "f8")])) + + assert not getattr(EscLogAnalysis(log, _esc_context({})), method)() + + +def test_zero_rpm_in_a_later_flight_is_reported_when_the_esc_was_not_observed_in_the_first() -> None: + """ + Continue looking for stalled ESCs after an armed interval without samples. + + GIVEN no ESC observations in the first flight and sustained zero RPM in the second, + WHEN armed RPM is checked, + THEN the second flight still produces its correctly timed warning. + """ + log = _esc_log([(7, 5, 0, 1, 0), (7, 7, 0, 1, 0)], [(1, 1), (0, 3), (1, 5), (0, 7)]) + + assert EscLogAnalysis(log, _esc_context({})).check_rpm_while_armed() == [ + LogAnalysis( + "ESC output 7 reported zero RPM throughout an armed period (5.0s to 7.0s), the motor may not have responded.", + timestamp_us=5_000_000.0, + value=0.0, + ) + ] + + +@pytest.mark.parametrize( + ("events", "windows"), + [([], []), ([(0, 1)], []), ([(1, 0)], [(0.0, 0.0)]), ([(1, 0), (1, 1), (0, 2)], [(0.0, 2.0)])], +) +def test_arm_windows_preserve_initial_zero_timestamps_and_single_unclosed_transitions( + events: list[tuple[float, float]], windows: list[tuple[float, float]] +) -> None: + """ + Build armed windows without treating timestamp zero as a missing arm event. + + GIVEN empty, disarmed, singly armed, or repeated ARM transitions, + WHEN canonical armed intervals are requested, + THEN only armed periods are returned and zero-valued timestamps remain valid. + """ + model = EscLogAnalysis(_esc_log([], events), _esc_context({})) + + assert model._armed_time_windows() == windows # pylint: disable=protected-access + + +@pytest.mark.parametrize("registered_step", [False, True]) +def test_present_esc_report_uses_the_configured_display_name_or_its_fallback(registered_step: bool) -> None: + """ + Name a present ESC stream using its configuration metadata when available. + + GIVEN a custom ESC display name or no registered step, + WHEN healthy telemetry availability is checked, + THEN the report uses that configured name or the stable ESC fallback in both title and reason. + """ + context = _esc_context({}) + if registered_step: + context.configuration_steps["07_esc.param"]["related_bin_messages"]["ESC"]["name"] = "Motor telemetry" + else: + context.configuration_steps = {} + expected_name = "Motor telemetry" if registered_step else "ESC" + + result = EscLogAvailabilityModel(_esc_log([(7, 1, 1000, 2, 0)]), context).check() + + assert result.available is True + assert result.state == LogAvailabilityState.INFO + assert result.name == expected_name + assert result.reason == f"{expected_name} data present and good for analysis" + assert not result.issues diff --git a/tests/test_frontend_tkinter_file_browser.py b/tests/test_frontend_tkinter_file_browser.py index 425538733..7c49f1a62 100755 --- a/tests/test_frontend_tkinter_file_browser.py +++ b/tests/test_frontend_tkinter_file_browser.py @@ -49,6 +49,31 @@ # pylint: disable=too-many-lines, too-few-public-methods, too-many-public-methods, protected-access +@pytest.fixture(name="local_action_browser") +def _local_action_browser(tmp_path: Path) -> FileBrowserWindow: + """Provide real local entries and replace only UI and refresh boundaries.""" + window = _bare_window() + path = tmp_path / "flight.bin" + path.write_bytes(b"flight data") + window.local_entries = [LocalFileEntry(path.name, path, path.stat().st_size)] + window.remote_entries = [FlightControllerLogFile("flight.bin", "/flight.bin", 11)] + window.local_tree = MagicMock() + window.remote_tree = MagicMock() + window.local_tree.selection.return_value = ("0",) + window.remote_tree.selection.return_value = ("0",) + window.local_directory_var = MagicMock() + window.local_directory_var.get.return_value = str(tmp_path) + window.remote_directory_var = MagicMock() + window.remote_directory_var.get.return_value = "/APM/LOGS/" + window.ui = MagicMock() + window.root = MagicMock() + window.parameter_editor = MagicMock() + window.refresh_local_panel = MagicMock() + window.refresh_remote_panel = MagicMock() + window._panel_navigation_enabled = True + return window + + def _run_tk_until(tk_root: tk.Tk, condition: typing.Callable[[], bool]) -> None: """Process Tk events until a predicate succeeds or the operation times out.""" deadline = time.monotonic() + 2 @@ -2695,3 +2720,596 @@ def download(remote: str, _local: str, report: typing.Callable[[int, int], None] finally: for popup in popups: popup.destroy() + + +@pytest.mark.parametrize("via_dialog", [False, True], ids=["inline", "dialog"]) +@pytest.mark.parametrize("outcome", ["success", "collision", "permission-denied"]) +def test_local_rename_preserves_files_on_failure( + local_action_browser: FileBrowserWindow, via_dialog: bool, outcome: str +) -> None: + """ + Rename a local entry without losing either file when the operation fails. + + GIVEN a selected log and a successful, conflicting, or denied destination, + WHEN the user commits a rename inline or through the dialog, + THEN only success moves the file and refreshes the panel; failures explain why. + """ + window = local_action_browser + entry = window.local_entries[0] + target = entry.path.with_name("renamed.bin") + window.ui.askstring.return_value = target.name + if outcome == "collision": + target.write_bytes(b"existing log") + expected_error = "The target name already exists." if outcome == "collision" else "read-only filesystem" + with contextlib.ExitStack() as stack: + if outcome == "permission-denied": + stack.enter_context(patch.object(Path, "rename", side_effect=PermissionError(expected_error))) + if via_dialog: + window.rename_selected_local_entry() + else: + assert window._rename_entry("local", entry, target.name) is (outcome == "success") + + assert window._panel_navigation_enabled + if outcome == "success": + assert not entry.path.exists() + assert target.read_bytes() == b"flight data" + window.refresh_local_panel.assert_called_once_with() + window.ui.show_info.assert_called_once_with("Rename summary", "Renamed flight.bin to renamed.bin.") + window.ui.show_error.assert_not_called() + else: + assert entry.path.read_bytes() == b"flight data" + assert target.exists() is (outcome == "collision") + if outcome == "collision": + assert target.read_bytes() == b"existing log" + window.ui.show_error.assert_called_once_with("Rename error", expected_error) + window.ui.show_info.assert_not_called() + window.refresh_local_panel.assert_not_called() + + +@pytest.mark.parametrize( + ("scenario", "message"), + [ + ("cancel", None), + ("missing-parent", "The selected local directory does not exist."), + ("collision", "The target name already exists."), + ("permission-denied", "read-only filesystem"), + ], +) +def test_local_directory_creation_failure_keeps_existing_files( + local_action_browser: FileBrowserWindow, scenario: str, message: str | None +) -> None: + """ + Leave the current directory intact when a new directory cannot be created. + + GIVEN a cancelled prompt, missing parent, occupied name, or denied write, + WHEN the user requests a new directory, + THEN no success or refresh is reported and existing file contents are preserved. + """ + window = local_action_browser + entry = window.local_entries[0] + target = entry.path.parent / "new-folder" + window.ui.askstring.return_value = None if scenario == "cancel" else target.name + if scenario == "missing-parent": + window.local_directory_var.get.return_value = str(entry.path.parent / "missing") + if scenario == "collision": + target.write_bytes(b"keep me") + with contextlib.ExitStack() as stack: + if scenario == "permission-denied": + stack.enter_context(patch.object(Path, "mkdir", side_effect=PermissionError(message))) + window.create_new_local_directory() + + assert entry.path.read_bytes() == b"flight data" + if scenario == "collision": + assert target.read_bytes() == b"keep me" + else: + assert not target.exists() + if message is None: + window.ui.show_error.assert_not_called() + else: + window.ui.show_error.assert_called_once_with("New directory error", message) + window.ui.show_info.assert_not_called() + window.refresh_local_panel.assert_not_called() + assert window._panel_states["local"].pending_entry is None + + +@pytest.mark.parametrize("panel", ["local", "remote"]) +@pytest.mark.parametrize("selected", [(), ("0", "1"), ("2",)]) +def test_rename_requires_exactly_one_non_parent_entry( + local_action_browser: FileBrowserWindow, panel: str, selected: tuple[str, ...] +) -> None: + """ + Reject ambiguous rename selections before asking for a new name. + + GIVEN no selection, several entries, or only the parent-navigation row, + WHEN rename is requested in either panel, + THEN an actionable selection error appears and no rename is attempted. + """ + window = local_action_browser + local_parent = window.local_entries[0].path.parent + window.local_entries.extend( + [LocalFileEntry("other.bin", local_parent / "other.bin", 0), LocalFileEntry("..", local_parent, 0, is_directory=True)] + ) + window.remote_entries.extend( + [FlightControllerLogFile("other.bin", "/other.bin", 0), FlightControllerLogFile("..", "/", 0, is_directory=True)] + ) + tree = window.local_tree if panel == "local" else window.remote_tree + tree.selection.return_value = selected + + getattr(window, f"rename_selected_{panel}_entry")() + + window.ui.show_error.assert_called_once_with("Rename error", f"Select exactly one {panel} entry to rename.") + window.ui.askstring.assert_not_called() + window.parameter_editor.rename_remote_path.assert_not_called() + assert window.local_entries[0].path.read_bytes() == b"flight data" + + +@pytest.mark.parametrize("panel", ["local", "remote"]) +@pytest.mark.parametrize("event_kind", ["click", "selection"]) +@pytest.mark.parametrize("row", ["0", ""]) +def test_panel_events_remember_the_current_row_without_erasing_it_on_blank_events( + local_action_browser: FileBrowserWindow, panel: str, event_kind: str, row: str +) -> None: + """ + Keep the user's active panel and selected row in sync with tree events. + + GIVEN an earlier row and a click or selection event on either tree, + WHEN a populated or blank row produces the event, + THEN the panel changes, a real row replaces the remembered row, and blanks preserve it. + """ + window = local_action_browser + window.last_selected_panel = "remote" if panel == "local" else "local" + window._panel_states[panel].selected_item = "previous" + other_panel = "remote" if panel == "local" else "local" + window._panel_states[other_panel].selected_item = "untouched" + tree = window.local_tree if panel == "local" else window.remote_tree + tree.identify_row.return_value = row + tree.focus.return_value = row + window._update_transfer_buttons = MagicMock() + + if event_kind == "click": + window._remember_panel_from_click(panel, SimpleNamespace(y=37)) + tree.identify_row.assert_called_once_with(37) + window._update_transfer_buttons.assert_not_called() + else: + window._remember_panel_from_selection(panel) + window._update_transfer_buttons.assert_called_once_with() + + assert window.last_selected_panel == panel + assert window._panel_states[panel].selected_item == (row or "previous") + assert window._panel_states[other_panel].selected_item == "untouched" + + +@pytest.mark.parametrize("panel", ["local", "remote", "outside"]) +def test_select_all_keyboard_shortcut_excludes_parent_and_targets_the_event_panel( + local_action_browser: FileBrowserWindow, panel: str +) -> None: + """ + Select files in the panel receiving the keyboard shortcut. + + GIVEN two file trees with a parent row and file rows, + WHEN Ctrl+A arrives from either panel or an unrelated widget, + THEN the appropriate tree selects only its files and stops event propagation. + """ + window = local_action_browser + for tree in (window.local_tree, window.remote_tree): + tree.get_children.return_value = ("parent", "first", "second") + tree.item.side_effect = lambda item, _column: (".." if item == "parent" else item,) + event_tree = window.local_tree if panel == "local" else window.remote_tree if panel == "remote" else object() + + assert window._on_select_all_key(SimpleNamespace(widget=event_tree)) == "break" + + selected_tree = window.local_tree if panel == "local" else window.remote_tree + untouched_tree = window.remote_tree if panel == "local" else window.local_tree + selected_tree.selection_set.assert_called_once_with(["first", "second"]) + untouched_tree.selection_set.assert_not_called() + + +@pytest.mark.parametrize("disable_all", [False, True]) +def test_failed_task_start_restores_browser_controls(local_action_browser: FileBrowserWindow, disable_all: bool) -> None: + """ + Keep browser controls usable when the task runner cannot start work. + + GIVEN an idle runner that raises while starting an operation, + WHEN a task is submitted with either control-lock policy, + THEN controls are restored, no completion runs, and the original error propagates. + """ + window = local_action_browser + window._task_runner = MagicMock(active=False) + window._task_runner.start.side_effect = RuntimeError("worker unavailable") + window._set_operation_controls_state = MagicMock() + window._set_remote_controls_state = MagicMock() + window.close_button = MagicMock() + completion = MagicMock() + + with pytest.raises(RuntimeError, match=r"^worker unavailable$"): + window._start_task(MagicMock(), None, completion, disable_all_controls=disable_all) + + controls = window._set_operation_controls_state if disable_all else window._set_remote_controls_state + untouched = window._set_remote_controls_state if disable_all else window._set_operation_controls_state + assert controls.call_args_list == [call("disabled"), call("normal")] + untouched.assert_not_called() + assert window.close_button.configure.call_args_list == [call(state="disabled"), call(state="normal")] + assert window._all_controls_locked is False + completion.assert_not_called() + + +@pytest.mark.parametrize("direction", ["download", "upload"]) +@pytest.mark.parametrize("failure", ["exception", "invalid-plan"]) +def test_transfer_planning_failure_never_starts_a_transfer( + local_action_browser: FileBrowserWindow, direction: str, failure: str +) -> None: + """ + Report preparation failures before any transfer or overwrite can begin. + + GIVEN a selected file and a planner that raises or returns an invalid plan, + WHEN the user requests upload or download, + THEN the relevant error appears and neither backend transfer is called. + """ + window = local_action_browser + window.ui.ask_yesno.return_value = True + planner = "_prepare_remote_download" if direction == "download" else "_build_local_upload_plan" + settings = {"side_effect": OSError("planning denied")} if failure == "exception" else {"return_value": None} + with patch(f"ardupilot_methodic_configurator.frontend_tkinter_file_browser.{planner}", **settings): + getattr(window, "download_selected_remote_entries" if direction == "download" else "upload_selected_local_entries")() + + expected = ( + "planning denied" + if failure == "exception" + else ( + "Could not prepare the selected remote entries." + if direction == "download" + else "Could not prepare the selected local entries." + ) + ) + window.ui.show_error.assert_called_once_with(f"{direction.title()} error", expected) + window.parameter_editor.download_remote_file.assert_not_called() + window.parameter_editor.upload_file_to_fc.assert_not_called() + window.ui.create_progress_window.assert_not_called() + + +@pytest.mark.parametrize( + "action", + [ + "create_new_local_directory", + "rename_selected_local_entry", + "rename_selected_remote_entry", + "download_selected_remote_entries", + "upload_selected_local_entries", + "delete_selected_remote_entries", + "create_new_remote_directory", + "download_last_flight_log", + ], +) +def test_busy_browser_rejects_operations_before_prompts_or_filesystem_changes( + local_action_browser: FileBrowserWindow, action: str +) -> None: + """ + Prevent a second operation from interfering with active work. + + GIVEN a runner already executing a file operation, + WHEN another mutating or transfer action is requested, + THEN no prompt, backend call, refresh, or local file change occurs. + """ + window = local_action_browser + window._task_runner.active = True + + getattr(window, action)() + + assert not window.ui.mock_calls + assert not window.parameter_editor.mock_calls + window.refresh_local_panel.assert_not_called() + window.refresh_remote_panel.assert_not_called() + assert window.local_entries[0].path.read_bytes() == b"flight data" + + +@pytest.mark.parametrize("via_dialog", [False, True]) +def test_rename_cancellation_restores_keyboard_navigation(local_action_browser: FileBrowserWindow, via_dialog: bool) -> None: + """ + Resume keyboard navigation after cancelling either rename interface. + + GIVEN an active rename prompt, + WHEN the user cancels it, + THEN the file is untouched, no error appears, and navigation resumes. + """ + window = local_action_browser + window._panel_navigation_enabled = False + editor = MagicMock() + window.ui.askstring.return_value = None + + if via_dialog: + window._rename_entry_with_dialog("local", window.local_entries[0]) + else: + assert window._cancel_inline_rename(editor) == "break" + editor.destroy.assert_called_once_with() + + assert window._panel_navigation_enabled + assert window.local_entries[0].path.read_bytes() == b"flight data" + window.ui.show_error.assert_not_called() + window.refresh_local_panel.assert_not_called() + + +@pytest.mark.parametrize("is_directory", [False, True]) +def test_local_delete_failure_is_included_in_the_summary(local_action_browser: FileBrowserWindow, is_directory: bool) -> None: + """ + Report failed deletions without claiming success or deleting directory contents. + + GIVEN an already missing file or a nonempty directory, + WHEN the user confirms deletion, + THEN the path is listed as failed and existing nested contents are preserved. + """ + window = local_action_browser + path = window.local_entries[0].path + path.unlink() + if is_directory: + path.mkdir() + (path / "keep.bin").write_bytes(b"nested flight") + window.local_entries = [LocalFileEntry(path.name, path, 0, is_directory=is_directory)] + window.ui.ask_yesno.return_value = True + + window.delete_selected_local_entries() + + window.ui.show_error.assert_called_once_with("Local delete summary", f"Failed: {path}") + window.refresh_local_panel.assert_called_once_with() + if is_directory: + assert (path / "keep.bin").read_bytes() == b"nested flight" + + +@pytest.mark.parametrize("chosen", ["", "chosen-folder"]) +def test_directory_picker_refreshes_only_after_a_choice(local_action_browser: FileBrowserWindow, chosen: str) -> None: + """ + Keep the displayed directory when the directory picker is cancelled. + + GIVEN the current local directory, + WHEN the user chooses a different directory or cancels, + THEN only an actual choice updates the directory and triggers a refresh. + """ + window = local_action_browser + current = window.local_directory_var.get() + window.ui.askdirectory.return_value = chosen + + window.choose_local_directory() + + window.ui.askdirectory.assert_called_once_with(title="Select local directory", initialdir=current) + if chosen: + window.local_directory_var.set.assert_called_once_with(chosen) + window.refresh_local_panel.assert_called_once_with() + else: + window.local_directory_var.set.assert_not_called() + window.refresh_local_panel.assert_not_called() + + +@pytest.mark.parametrize("panel", ["local", "remote"]) +def test_enter_in_directory_entry_refreshes_only_its_panel(local_action_browser: FileBrowserWindow, panel: str) -> None: + """ + Load a typed directory with Enter without also triggering another action. + + GIVEN either directory entry receives Enter, + WHEN its key handler runs, + THEN the corresponding panel alone refreshes and the key event is consumed. + """ + window = local_action_browser + + assert getattr(window, f"_on_{panel}_directory_return")() == "break" + + getattr(window, f"refresh_{panel}_panel").assert_called_once_with() + getattr(window, f"refresh_{'remote' if panel == 'local' else 'local'}_panel").assert_not_called() + + +@pytest.mark.parametrize("value", [0.0, -1.0, float("inf"), float("nan")]) +def test_unrepresentable_modification_time_shows_a_placeholder(value: float) -> None: + """ + Keep the file list usable when a timestamp cannot be displayed. + + GIVEN a nonpositive or unrepresentable filesystem timestamp, + WHEN the browser formats its modification time, + THEN a stable placeholder is displayed instead of raising an exception. + """ + assert FileBrowserWindow._format_modified_time(value) == "-" + + +@pytest.mark.parametrize("action", ["download_selected_remote_entries", "upload_selected_local_entries"]) +@pytest.mark.parametrize("selection", [(), ("1",)]) +def test_transfer_ignores_empty_selection_and_parent_navigation( + local_action_browser: FileBrowserWindow, action: str, selection: tuple[str, ...] +) -> None: + """ + Never transfer a parent directory merely because its navigation row is selected. + + GIVEN no selected files or only the parent-navigation row, + WHEN a transfer is requested, + THEN no confirmation or backend work starts. + """ + window = local_action_browser + window.local_entries.append(LocalFileEntry("..", window.local_entries[0].path.parent, 0, is_directory=True)) + window.remote_entries.append(FlightControllerLogFile("..", "/", 0, is_directory=True)) + window.local_tree.selection.return_value = selection + window.remote_tree.selection.return_value = selection + + getattr(window, action)() + + assert not window.ui.mock_calls + assert not window.parameter_editor.mock_calls + + +@pytest.mark.parametrize("failure", ["missing", "permission-denied"]) +def test_local_refresh_failure_reports_the_reason_without_publishing_rows( + local_action_browser: FileBrowserWindow, failure: str +) -> None: + """ + Remove stale rows and explain why the requested local directory could not load. + + GIVEN a previous file listing and a missing or unreadable new directory, + WHEN the user refreshes, + THEN stale entries are cleared and the directory error is shown. + """ + window = local_action_browser + error = FileNotFoundError("gone") if failure == "missing" else PermissionError("listing denied") + expected = "The selected local directory does not exist." if failure == "missing" else "listing denied" + window.local_tree.get_children.return_value = ("0",) + with patch("ardupilot_methodic_configurator.frontend_tkinter_file_browser._read_local_directory", side_effect=error): + FileBrowserWindow.refresh_local_panel(window) + + assert window.local_entries == [] + window.local_tree.delete.assert_called_once_with("0") + window.ui.show_error.assert_called_once_with("Local directory error", expected) + window.local_tree.insert.assert_not_called() + + +@pytest.mark.parametrize("panel", ["local", "remote"]) +@pytest.mark.parametrize("row_visible", [False, True]) +def test_inline_rename_falls_back_to_a_dialog_and_reports_invalid_names( + local_action_browser: FileBrowserWindow, panel: str, row_visible: bool +) -> None: + """ + Offer a rename dialog when the selected row cannot host an inline editor. + + GIVEN a hidden row or a row without usable dimensions, + WHEN rename starts and the user enters a path instead of a name, + THEN a clear error appears, navigation resumes, and no file is renamed. + """ + window = local_action_browser + tree = window.local_tree if panel == "local" else window.remote_tree + entry = window.local_entries[0] if panel == "local" else window.remote_entries[0] + tree.bbox.return_value = (0, 0, 0, 10) if row_visible else () + window.ui.askstring.return_value = "../outside.bin" + + window._start_inline_rename(panel, "0", entry) + + window.ui.askstring.assert_called_once_with( + f"Rename {panel} entry", "New name:", initialvalue="flight.bin", parent=window.root + ) + window.ui.show_error.assert_called_once_with("Rename error", "The new name must be one file or directory name.") + assert window._panel_navigation_enabled + assert window.local_entries[0].path.read_bytes() == b"flight data" + window.parameter_editor.rename_remote_path.assert_not_called() + + +@pytest.mark.parametrize("disable_all", [False, True]) +def test_busy_task_runner_does_not_lock_controls_again(local_action_browser: FileBrowserWindow, disable_all: bool) -> None: + """ + Leave an active operation's controls and callbacks under its ownership. + + GIVEN an active task runner, + WHEN a caller attempts to submit another task, + THEN it is rejected without changing controls or invoking callbacks. + """ + window = local_action_browser + window._task_runner = MagicMock(active=True) + window._set_operation_controls_state = MagicMock() + window._set_remote_controls_state = MagicMock() + task, progress, completion = MagicMock(), MagicMock(), MagicMock() + + assert window._start_task(task, progress, completion, disable_all_controls=disable_all) is False + + window._task_runner.start.assert_not_called() + window._set_operation_controls_state.assert_not_called() + window._set_remote_controls_state.assert_not_called() + task.assert_not_called() + progress.assert_not_called() + completion.assert_not_called() + + +def test_failed_progress_task_start_closes_popup_and_restores_controls(local_action_browser: FileBrowserWindow) -> None: + """ + Clean up a progress popup when starting the worker raises. + + GIVEN a runner that cannot start and an already created progress popup, + WHEN a background operation is submitted, + THEN the popup closes once, controls unlock, and the original error propagates. + """ + window = local_action_browser + window._task_runner = MagicMock(active=False) + window._task_runner.start.side_effect = RuntimeError("worker unavailable") + window._set_operation_controls_state = MagicMock() + window.close_button = MagicMock() + popup = MagicMock() + window.ui.create_progress_window.return_value = popup + completion = MagicMock() + + with pytest.raises(RuntimeError, match=r"^worker unavailable$"): + window._start_background_operation("Rename", "Working", MagicMock(), completion) + + popup.destroy.assert_called_once_with() + assert window._set_operation_controls_state.call_args_list == [call("disabled"), call("normal")] + assert window._all_controls_locked is False + completion.assert_not_called() + + +def test_background_worker_failure_is_delivered_as_a_failed_operation(local_action_browser: FileBrowserWindow) -> None: + """ + Present an unexpected worker exception through normal operation completion. + + GIVEN a background worker that raises, + WHEN the operation finishes, + THEN completion receives the error as a failed result and the popup closes. + """ + window = local_action_browser + worker = MagicMock(side_effect=OSError("connection lost")) + completion = MagicMock() + popup = window.ui.create_progress_window.return_value + + assert window._start_background_operation("Rename", "Working", worker, completion) is True + + completion.assert_called_once_with([], ["connection lost"]) + popup.destroy.assert_called_once_with() + assert window._all_controls_locked is False + + +@pytest.mark.parametrize("name", ["new-folder", "../outside", ""]) +def test_new_local_directory_confirms_its_name_and_selects_only_a_created_directory( + local_action_browser: FileBrowserWindow, name: str +) -> None: + """ + Tie directory-creation feedback and pending selection to the user's actual choice. + + GIVEN a valid new directory name or an unsafe path, + WHEN the user confirms the directory prompt, + THEN only a valid name creates a directory, reports success, and schedules selection. + """ + window = local_action_browser + parent = window.local_entries[0].path.parent + window.ui.askstring.return_value = name + + window.create_new_local_directory() + + window.ui.askstring.assert_called_once_with("New local directory", "Directory name:", parent=window.root) + if name == "new-folder": + assert (parent / name).is_dir() + window.ui.show_info.assert_called_once_with("New directory summary", "Created local directory new-folder.") + window.ui.show_error.assert_not_called() + assert window._panel_states["local"].pending_entry == name + assert window._panel_states["remote"].pending_entry is None + window.refresh_local_panel.assert_called_once_with() + else: + assert sorted(path.name for path in parent.iterdir()) == ["flight.bin"] + window.ui.show_error.assert_called_once_with( + "New directory error", "The directory name must be a single directory name." + ) + window.ui.show_info.assert_not_called() + assert window._panel_states["local"].pending_entry is None + window.refresh_local_panel.assert_not_called() + + +@pytest.mark.parametrize("panel", ["local", "remote"]) +def test_inline_rename_cannot_mutate_files_during_an_active_operation( + local_action_browser: FileBrowserWindow, panel: str +) -> None: + """ + Recheck operation ownership when an inline rename is committed. + + GIVEN another file operation became active after the editor opened, + WHEN the editor attempts to commit a new name, + THEN neither the local filesystem nor the flight controller is changed. + """ + window = local_action_browser + window._task_runner.active = True + entry = window.local_entries[0] if panel == "local" else window.remote_entries[0] + + assert window._rename_entry(panel, entry, "renamed.bin") is False + + assert window.local_entries[0].path.read_bytes() == b"flight data" + assert not window.local_entries[0].path.with_name("renamed.bin").exists() + assert not window.parameter_editor.mock_calls + assert not window.ui.mock_calls + window.refresh_local_panel.assert_not_called() + window.refresh_remote_panel.assert_not_called() diff --git a/tests/test_frontend_tkinter_parameter_editor_table.py b/tests/test_frontend_tkinter_parameter_editor_table.py index 5cb61fef4..10f665ff5 100755 --- a/tests/test_frontend_tkinter_parameter_editor_table.py +++ b/tests/test_frontend_tkinter_parameter_editor_table.py @@ -13,6 +13,7 @@ import sys import tkinter as tk from collections.abc import Callable, Generator +from dataclasses import dataclass from tkinter import ttk from types import SimpleNamespace from typing import Any, cast @@ -27,6 +28,7 @@ from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict from ardupilot_methodic_configurator.data_model_parameter_editor import ( InvalidParameterNameError, + OperationNotPossibleError, ParameterEditor, ParameterValueUpdateResult, ParameterValueUpdateStatus, @@ -46,6 +48,129 @@ # pylint: disable=protected-access, redefined-outer-name, too-few-public-methods, too-many-lines +@dataclass +class _AddParameterDialog: + """Real dialog widgets with controlled delivery of their bound Tk events.""" + + table: ParameterEditorTable + root: tk.Toplevel + search: ttk.Entry + listbox: tk.Listbox + add_button: ttk.Button + bindings: dict[tuple[tk.Misc, str], Callable[[tk.Event], object]] + + def send(self, widget: tk.Misc, sequence: str) -> object: + """Deliver the event through the callback registered by the application.""" + event = tk.Event() + event.widget = widget + return self.bindings[widget, sequence](event) + + +def _descendants(widget: tk.Misc) -> Generator[tk.Misc, None, None]: + """Walk real widgets to find dialog controls without relying on Tk names.""" + for child in widget.winfo_children(): + yield child + yield from _descendants(child) + + +def _select_bits_one_and_two(window: tk.Toplevel, initial_mask: int) -> None: + """Verify the initial selection and preview before changing the mask to 6.""" + widgets = list(_descendants(window)) + checkboxes = {str(widget["text"]): widget for widget in widgets if isinstance(widget, ttk.Checkbutton)} + assert [checkboxes[f"Bit {bit}"].instate(["selected"]) for bit in range(3)] == ( + [True, True, False] if initial_mask == 3 else [False, False, False] + ) + preview = next(widget for widget in widgets if isinstance(widget, ttk.Label)) + assert preview["text"] == f"TEST_PARAM Value: {initial_mask}" + for bit in range(3): + checkbox = checkboxes[f"Bit {bit}"] + if checkbox.instate(["selected"]) != (bit in {1, 2}): + checkbox.invoke() + assert preview["text"] == "TEST_PARAM Value: 6" + + +def _assert_bitmask_reopen_binding( + table: ParameterEditorTable, + bindings: MagicMock, + event: tk.Event, + parameter: ArduPilotParameter, + hints: tuple[ttk.Entry, ttk.Label], +) -> None: + """Verify that a restored double-click binding preserves the row's context.""" + reopen = next( + call.args[1] for call in bindings.call_args_list if len(call.args) > 1 and call.args[0] == "" + ) + with patch.object(table, "_open_bitmask_selection_if_editable") as reopened: + reopen(event) + reopened.assert_called_once_with(event, parameter, *hints) + + +@pytest.fixture +def add_parameter_dialog(parameter_editor_table: ParameterEditorTable) -> Generator[_AddParameterDialog, None, None]: + """Open the real add-dialog contents and control the surrounding window/event loop.""" + table = parameter_editor_table + table.parameter_editor.get_possible_add_param_names.return_value = [ + "ATC_RAT_RLL_P", + "SERVO1_FUNCTION", + "ATC_RAT_PIT_P", + "BATT_CAPACITY", + ] + table.parameter_editor.add_parameters_to_current_file.side_effect = lambda names: (list(names), [], []) + table.parameter_editor.generate_bulk_add_feedback_message.return_value = ("success", "Added", "Parameters added") + root = tk.Toplevel(table.main_frame) + root.withdraw() + frame = ttk.Frame(root) + frame.pack() + shell = SimpleNamespace(root=root, main_frame=frame, calculate_scaled_geometry=lambda width, height: f"{width}x{height}") + bindings: dict[tuple[tk.Misc, str], Callable[[tk.Event], object]] = {} + original_bind = tk.Misc.bind + + def remember_binding( + widget: tk.Misc, + sequence: str | None = None, + func: Callable[[tk.Event], object] | str | None = None, + add: str | None = None, + ) -> str: + if sequence is not None and callable(func): + bindings[widget, sequence] = func + return original_bind(widget, sequence, func, add) + + with ( + patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.BaseWindow", return_value=shell + ) as shell_factory, + patch.object(table, "_center_and_focus_window"), + patch.object(tk.Misc, "bind", remember_binding), + patch.object(tk.Misc, "update"), + patch.object(tk.Misc, "update_idletasks"), + patch.object(tk.Misc, "wait_visibility"), + patch.object(tk.Misc, "wait_window"), + patch.object(root, "after"), + ): + try: + table._on_parameter_add() + shell_factory.assert_called_once_with(table.main_frame) + assert str(root.transient()) == str(table.main_frame) + widgets = list(_descendants(root)) + search = next((widget for widget in widgets if isinstance(widget, ttk.Entry)), None) + listbox = next((widget for widget in widgets if isinstance(widget, tk.Listbox)), None) + add_button = next((widget for widget in widgets if isinstance(widget, ttk.Button)), None) + assert isinstance(search, ttk.Entry) + assert isinstance(listbox, tk.Listbox) + assert isinstance(add_button, ttk.Button) + yield _AddParameterDialog( + table, + root, + search, + listbox, + add_button, + bindings, + ) + finally: + if root.winfo_exists(): + root.destroy() + + def create_mock_data_model_ardupilot_parameter( # pylint: disable=too-many-arguments, too-many-positional-arguments # noqa: PLR0913, PLR0917 name: str = "TEST_PARAM", value: float = 1.0, @@ -441,8 +566,13 @@ def test_bulk_upload_choice_survives_filter_change_during_batched_render( if individual_edit: table.upload_checkbutton_var["P0"].set(bulk_clicks == 1) table.repopulate_table(show_only_differences=True, gui_complexity="advanced") - while pending_batches: + # Even an interrupted render must finish within one callback per row + # plus the cancelled generation's callback. + for _callback_index in range(len(table.parameters) + 1): + if not pending_batches: + break pending_batches.pop(0)() + assert not pending_batches, "Rendering kept scheduling batches after the final row" expected_names = set() if bulk_clicks == 1 else set(table.parameters) if individual_edit: @@ -2846,3 +2976,806 @@ def test_older_macos_scrolls_to_new_rows_after_layout_settles(tk_root: tk.Tk) -> forced_render.assert_not_called() finally: parent.destroy() + + +@pytest.mark.parametrize("query", ["rat", "RaT", "ATC_RAT", "rll"]) +def test_add_dialog_filters_names_case_insensitively_and_enter_adds_all_matches( + add_parameter_dialog: _AddParameterDialog, query: str +) -> None: + """ + Add the filtered parameter set directly from the search entry. + + GIVEN a mixed list of candidate parameters, + WHEN the user enters a case-insensitive filter and presses Enter without selecting rows, + THEN only matching names are added in display order and the dialog closes. + """ + dialog = add_parameter_dialog + assert dialog.root.title() == "Add parameter(s)" + assert str(dialog.add_button["state"]) == "disabled" + dialog.search.insert(0, query) + expected = ["ATC_RAT_RLL_P"] if query == "rll" else ["ATC_RAT_RLL_P", "ATC_RAT_PIT_P"] + assert dialog.listbox.get(0, tk.END) == tuple(expected) + + dialog.send(dialog.search, "") + + dialog.table.parameter_editor.add_parameters_to_current_file.assert_called_once_with(expected) + dialog.table.parameter_editor_window.repopulate_parameter_table.assert_called_once_with() + assert dialog.table._pending_scroll_to_bottom is True + assert not dialog.root.winfo_exists() + + +@pytest.mark.parametrize("source", ["button", "search", "listbox", "window"]) +def test_add_dialog_adds_only_selected_names_and_converts_them_to_uppercase( + add_parameter_dialog: _AddParameterDialog, source: str +) -> None: + """ + Respect explicit selections regardless of how addition is activated. + + GIVEN a selection from the candidate list, + WHEN the user activates Add or presses Enter in a dialog control, + THEN only the selected names are submitted, normalized for the parameter model. + """ + dialog = add_parameter_dialog + dialog.listbox.delete(0, tk.END) + for name in ("atc_rat_rll_p", "batt_capacity", "servo1_function"): + dialog.listbox.insert(tk.END, name) + dialog.listbox.selection_set(0) + dialog.listbox.selection_set(2) + dialog.send(dialog.listbox, "<>") + assert dialog.add_button["text"] == "Add 2 selected parameters" + assert str(dialog.add_button["state"]) == "normal" + + if source == "button": + dialog.add_button.invoke() + else: + widget = {"search": dialog.search, "listbox": dialog.listbox, "window": dialog.root}[source] + dialog.send(widget, "") + + dialog.table.parameter_editor.add_parameters_to_current_file.assert_called_once_with(["ATC_RAT_RLL_P", "SERVO1_FUNCTION"]) + assert not dialog.root.winfo_exists() + + +@pytest.mark.parametrize("upper", [False, True]) +def test_add_dialog_selection_shortcuts_update_button_state_and_stop_event_propagation( + add_parameter_dialog: _AddParameterDialog, upper: bool +) -> None: + """ + Keep the add action in sync with select-all and deselect-all shortcuts. + + GIVEN a populated candidate list, + WHEN the user selects all, deselects all, then selects a single row, + THEN the button reports the count, disables for no selection, and handles uppercase shortcuts too. + """ + dialog = add_parameter_dialog + assert dialog.send(dialog.listbox, "" if upper else "") == "break" + assert dialog.listbox.curselection() == (0, 1, 2, 3) + assert dialog.add_button["text"] == "Add 4 selected parameters" + assert str(dialog.add_button["state"]) == "normal" + + assert dialog.send(dialog.listbox, "" if upper else "") == "break" + assert not dialog.listbox.curselection() + assert dialog.add_button["text"] == "No parameter selected" + assert str(dialog.add_button["state"]) == "disabled" + dialog.listbox.selection_set(1) + dialog.send(dialog.listbox, "<>") + assert dialog.add_button["text"] == "Add selected parameter" + assert str(dialog.add_button["state"]) == "normal" + dialog.table.parameter_editor.add_parameters_to_current_file.assert_not_called() + + +@pytest.mark.parametrize("source", ["search", "listbox", "window", "double-click"]) +def test_add_dialog_ignores_empty_selections_and_empty_filtered_results( + add_parameter_dialog: _AddParameterDialog, source: str +) -> None: + """ + Avoid adding any parameters when a filter has no matches. + + GIVEN no candidate names match the user's search, + WHEN addition is attempted through any keyboard or double-click handler, + THEN the model is untouched and the dialog remains usable. + """ + dialog = add_parameter_dialog + dialog.search.insert(0, "no_such_parameter") + assert dialog.listbox.size() == 0 + assert dialog.add_button["text"] == "No parameter selected" + assert str(dialog.add_button["state"]) == "disabled" + + widget = dialog.search if source == "search" else dialog.root if source == "window" else dialog.listbox + dialog.send(widget, "" if source == "double-click" else "") + + dialog.table.parameter_editor.add_parameters_to_current_file.assert_not_called() + assert dialog.root.winfo_exists() + + +@pytest.mark.parametrize("accepted", [False, True]) +@pytest.mark.parametrize("count", [ParameterEditor.MAX_BULK_ADD_SUGGESTIONS, ParameterEditor.MAX_BULK_ADD_SUGGESTIONS + 1]) +def test_add_dialog_confirms_only_above_the_bulk_add_limit( + add_parameter_dialog: _AddParameterDialog, accepted: bool, count: int +) -> None: + """ + Ask for confirmation only when the selected set exceeds the bulk-add limit. + + GIVEN a selection exactly at or one above the limit, + WHEN the user requests addition and accepts or declines confirmation, + THEN the boundary is respected and declining leaves the dialog and model unchanged. + """ + dialog = add_parameter_dialog + names = [f"PARAM_{index}" for index in range(count)] + dialog.listbox.delete(0, tk.END) + for name in names: + dialog.listbox.insert(tk.END, name) + dialog.listbox.selection_set(0, tk.END) + dialog.table._dialogs.ask_yes_no.return_value = accepted + + dialog.send(dialog.listbox, "") + + if count > ParameterEditor.MAX_BULK_ADD_SUGGESTIONS: + dialog.table._dialogs.ask_yes_no.assert_called_once_with( + "Confirm bulk addition", + "Adding many parameters at once can make it difficult to track changes and may impact GUI performance.\n" + f"Are you sure you want to add all {count} parameters?", + ) + else: + dialog.table._dialogs.ask_yes_no.assert_not_called() + if accepted or count == ParameterEditor.MAX_BULK_ADD_SUGGESTIONS: + dialog.table.parameter_editor.add_parameters_to_current_file.assert_called_once_with(names) + assert not dialog.root.winfo_exists() + else: + dialog.table.parameter_editor.add_parameters_to_current_file.assert_not_called() + assert dialog.root.winfo_exists() + assert dialog.listbox.curselection() == tuple(range(count)) + + +def test_double_click_adds_only_the_first_selected_parameter(add_parameter_dialog: _AddParameterDialog) -> None: + """ + Keep double-click addition limited to one parameter even with a multi-selection. + + GIVEN several selected candidates, + WHEN the user double-clicks the list, + THEN only the first selected name is added and the dialog closes. + """ + dialog = add_parameter_dialog + dialog.listbox.selection_set(1, 3) + + dialog.send(dialog.listbox, "") + + dialog.table.parameter_editor.add_parameters_to_current_file.assert_called_once_with(["SERVO1_FUNCTION"]) + assert not dialog.root.winfo_exists() + + +@pytest.mark.parametrize("platform", ["linux", "darwin"]) +def test_escape_closes_the_add_dialog_without_changing_parameters( + add_parameter_dialog: _AddParameterDialog, platform: str +) -> None: + """ + Cancel parameter addition consistently on Linux and macOS. + + GIVEN an open dialog with a selected candidate, + WHEN Escape is pressed, + THEN the dialog closes, releases a grab only where supported, and no parameter is added. + """ + dialog = add_parameter_dialog + dialog.listbox.selection_set(0) + with ( + patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.sys_platform", platform), + patch.object(dialog.root, "grab_release") as release, + ): + dialog.send(dialog.root, "") + + assert release.call_count == (0 if platform == "darwin" else 1) + assert not dialog.root.winfo_exists() + dialog.table.parameter_editor.add_parameters_to_current_file.assert_not_called() + + +@pytest.mark.parametrize("platform", ["linux", "darwin"]) +def test_bulk_add_reports_the_exact_model_results_before_closing_the_dialog( + add_parameter_dialog: _AddParameterDialog, platform: str +) -> None: + """ + Preserve partial-success feedback when adding a selection of parameters. + + GIVEN the model reports added, skipped, and failed names, + WHEN the selection is submitted, + THEN those lists reach feedback unchanged and the add dialog closes on either platform. + """ + dialog = add_parameter_dialog + editor = dialog.table.parameter_editor + editor.add_parameters_to_current_file.side_effect = None + editor.add_parameters_to_current_file.return_value = (["ATC_RAT_RLL_P"], ["SERVO1_FUNCTION"], ["ATC_RAT_PIT_P"]) + editor.generate_bulk_add_feedback_message.return_value = ( + "warning", + "Partial addition", + "Some names were skipped or failed", + ) + dialog.listbox.selection_set(0, 2) + with ( + patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.sys_platform", platform), + patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.show_warning_popup") as warning, + patch.object(dialog.root, "grab_release") as release, + ): + dialog.add_button.configure(state="normal") + dialog.add_button.invoke() + + editor.generate_bulk_add_feedback_message.assert_called_once_with( + ["ATC_RAT_RLL_P"], ["SERVO1_FUNCTION"], ["ATC_RAT_PIT_P"] + ) + warning.assert_called_once_with("Partial addition", "Some names were skipped or failed") + assert release.call_count == (0 if platform == "darwin" else 1) + assert not dialog.root.winfo_exists() + dialog.table.parameter_editor_window.repopulate_parameter_table.assert_called_once_with() + + +@pytest.mark.parametrize( + ("failure", "title"), + [ + (InvalidParameterNameError("bad name"), "Invalid parameter name."), + (OperationNotPossibleError("step is locked"), "Operation not possible"), + ], +) +def test_single_parameter_addition_reports_model_errors_without_refreshing( + parameter_editor_table: ParameterEditorTable, failure: Exception, title: str +) -> None: + """ + Keep existing rows intact when the model rejects a single addition. + + GIVEN a model that rejects a parameter name or the current operation, + WHEN addition is confirmed, + THEN the original reason is shown and neither a refresh nor bottom-scroll is scheduled. + """ + table = parameter_editor_table + table._pending_scroll_to_bottom = False + table.parameter_editor.add_parameter_to_current_file.side_effect = failure + + assert table._confirm_parameter_addition("TEST_PARAM") is False + + table._dialogs.show_error.assert_called_once_with(title, str(failure)) + table.parameter_editor_window.repopulate_parameter_table.assert_not_called() + assert table._pending_scroll_to_bottom is False + + +@pytest.mark.parametrize("batch_size", [None, 0, -1]) +def test_batched_rendering_rejects_invalid_batch_sizes_before_rendering( + parameter_editor_table: ParameterEditorTable, batch_size: int | None +) -> None: + """ + Reject invalid rendering settings before partially building the table. + + GIVEN no batch size or a nonpositive batch size, + WHEN batched rendering is explicitly requested, + THEN a clear error is raised and no completion notification is emitted. + """ + table = parameter_editor_table + table.options.render_batch_size = batch_size + complete = MagicMock() + table.options.render_complete_callback = complete + + with pytest.raises(ValueError, match=r"^render_batch_size must be a positive integer$"): + table._update_table_in_batches({}, "simple", scroll_to_bottom=False) + + complete.assert_not_called() + + +def test_batched_rendering_notifies_completion_and_bitmask_help_only_after_the_last_row( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Delay completion and bitmask help until every pending row is rendered. + + GIVEN an external table with a bitmask and a second row in separate batches, + WHEN the scheduled second batch runs, + THEN completion sees both rows, scrolling occurs once, and bitmask help appears once. + """ + table = parameter_editor_table + parameters = { + "BITS": create_mock_data_model_ardupilot_parameter(name="BITS", is_bitmask=True), + "OTHER": create_mock_data_model_ardupilot_parameter(name="OTHER"), + } + table.parameters = parameters + table.options.show_parameter_actions = False + table.options.render_batch_size = 1 + complete: list[tuple[str, ...]] = [] + table.options.render_complete_callback = lambda: complete.append(tuple(table._new_value_widgets)) + scheduled: list[Callable[[], None]] = [] + with ( + patch.object(table, "after_idle", side_effect=scheduled.append), + patch.object(table, "_apply_scroll_position") as scroll, + patch.object(tk.Misc, "update_idletasks"), + patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.UsagePopupWindow.should_display", + return_value=True, + ) as help_preference, + patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table." + "display_bitmask_parameters_editor_usage_popup" + ) as help_popup, + ): + table._update_table_in_batches(parameters, "simple", scroll_to_bottom=True) + assert tuple(table._new_value_widgets) == ("BITS",) + assert not complete + scroll.assert_not_called() + help_popup.assert_not_called() + assert len(scheduled) == 1 + + scheduled.pop(0)() + + assert complete == [("BITS", "OTHER")] + scroll.assert_called_once() + assert scroll.call_args.args == (True,) + help_popup.assert_called_once_with(table.main_frame) + help_preference.assert_called_once_with("bitmask_parameter_editor") + assert not scheduled + + +def test_batched_rendering_identifies_a_missing_parameter_and_does_not_report_completion( + parameter_editor_table: ParameterEditorTable, caplog: pytest.LogCaptureFixture +) -> None: + """ + Identify the affected parameter when a batch cannot construct its row. + + GIVEN a row whose data disappeared before widget creation, + WHEN the batch is rendered, + THEN the fatal error names the parameter and file, and completion is never reported. + """ + table = parameter_editor_table + table.options.render_batch_size = 1 + complete = MagicMock() + table.options.render_complete_callback = complete + with ( + patch.object(table, "_create_column_widgets", side_effect=KeyError("missing metadata")), + patch.object(tk.Misc, "update_idletasks"), + pytest.raises(SystemExit) as stopped, + ): + table._update_table_in_batches( + {"MISSING": create_mock_data_model_ardupilot_parameter(name="MISSING")}, "simple", scroll_to_bottom=False + ) + + assert stopped.value.code == 1 + errors = [record for record in caplog.records if record.levelname == "CRITICAL"] + assert len(errors) == 1 + assert errors[0].getMessage() == "Parameter MISSING not found in the test_file file: 'missing metadata'" + assert errors[0].exc_info is not None + assert isinstance(errors[0].exc_info[1], KeyError) + complete.assert_not_called() + + +@pytest.mark.parametrize("editable", [False, True]) +def test_external_choice_editability_preserves_or_restores_the_parameter_selection( + parameter_editor_table: ParameterEditorTable, editable: bool +) -> None: + """ + Restore a locked external choice without changing a manually editable selection. + + GIVEN a choice row whose displayed selection differs from its model value, + WHEN manual editability changes, + THEN locking restores the model value and unlocking permits choice selection. + """ + table = parameter_editor_table + parameter = create_mock_data_model_ardupilot_parameter(is_multiple_choice=True, value=0) + widget = PairTupleCombobox(table.view_port, list(parameter.choices_dict.items()), "0", parameter.name) + widget.current(1) + table._new_value_widgets[parameter.name] = widget + + table._set_external_value_widget_editability(parameter, editable) + + assert str(widget["state"]) == ("readonly" if editable else "disabled") + assert widget.get_selected_key() == ("1" if editable else "0") + assert parameter.value_as_string == "0" + + +def test_updating_a_choice_widget_as_entry_text_leaves_its_selection_unchanged( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Preserve choice labels when entry-specific synchronization is requested. + + GIVEN a multiple-choice widget showing a selected label, + WHEN the generic entry-text helper is called, + THEN the chosen key and displayed label remain intact. + """ + table = parameter_editor_table + parameter = create_mock_data_model_ardupilot_parameter(is_multiple_choice=True, value=0) + widget = PairTupleCombobox(table.view_port, list(parameter.choices_dict.items()), "1", parameter.name) + + table._update_new_value_entry_text(widget, parameter) + + assert widget.get_selected_key() == "1" + assert widget.get() == "Option 1" + + +@pytest.mark.parametrize("state", ["normal", "disabled"]) +def test_bitmask_editor_opens_only_for_an_editable_value_widget( + parameter_editor_table: ParameterEditorTable, state: str +) -> None: + """ + Respect the current row's lock when the user requests bitmask editing. + + GIVEN a normal or disabled bitmask value entry, + WHEN its double-click handler is requested, + THEN only an editable entry opens the bitmask dialog. + """ + table = parameter_editor_table + parameter = create_mock_data_model_ardupilot_parameter(is_bitmask=True) + entry = ttk.Entry(table.view_port, state=state) + label = ttk.Label(table.view_port) + event = tk.Event() + event.widget = entry + with patch.object(table, "_open_bitmask_selection_window") as opened: + table._open_bitmask_selection_if_editable(event, parameter, None, label) + + if state == "normal": + opened.assert_called_once_with(event, parameter, None, label) + else: + opened.assert_not_called() + + +@pytest.mark.parametrize("initial_text", ["3", "invalid", ""]) +@pytest.mark.parametrize("close_action", ["close", "focus-out"]) +@pytest.mark.parametrize("parent_closed", [False, True]) +def test_bitmask_dialog_updates_the_decimal_preview_and_applies_the_checked_bits( + parameter_editor_table: ParameterEditorTable, initial_text: str, close_action: str, parent_closed: bool +) -> None: + """ + Apply selected bits and recover from an invalid decimal value in the entry. + + GIVEN a bitmask entry with a valid or invalid initial decimal string, + WHEN the user selects bits 1 and 2 and closes or moves focus away from the dialog, + THEN the decimal preview and row show 6, validation runs, and the dialog releases row ownership. + """ + table = parameter_editor_table + table.main_frame = ttk.Frame(table.main_frame) + parameter = create_mock_data_model_ardupilot_parameter(is_bitmask=True, value=1, fc_value=1) + entry = ttk.Entry(table.view_port) + entry.insert(0, initial_text) + reason = ttk.Entry(table.view_port) + label = ttk.Label(table.view_port) + event = tk.Event() + event.widget = entry + + def update_parameter(_name: str, value: str, *, include_range_check: bool) -> ParameterValueUpdateResult: + assert include_range_check is True + parameter.set_new_value(value) + return ParameterValueUpdateResult(ParameterValueUpdateStatus.UPDATED) + + table.parameter_editor.update_parameter_value.side_effect = update_parameter + + def interact(window: tk.Toplevel, _target: tk.Misc | None = None) -> None: + assert table.bitmask_editor_open + assert window.title() == "Select TEST_PARAM Bitmask Options" + _select_bits_one_and_two(window, 3 if initial_text == "3" else 0) + if parent_closed: + table.main_frame.destroy() + if close_action == "close": + window.tk.call(window.protocol("WM_DELETE_WINDOW")) + else: + handler = next(call.args[2] for call in focus_bind.call_args_list if call.args[1] == "") + with patch.object(window, "focus_get", return_value=MagicMock(winfo_ismapped=MagicMock(return_value=True))): + handler(tk.Event()) + assert window.winfo_exists() + with patch.object(window, "focus_get", return_value=None): + handler(tk.Event()) + assert not window.winfo_exists() + assert entry.get() == "6" + assert label["text"] == NEW_VALUE_DIFFERENT_STR + + with ( + patch.object(table, "_center_and_focus_window"), + patch.object(tk.Misc, "update_idletasks"), + patch.object(tk.Toplevel, "bind", autospec=True) as focus_bind, + patch.object(tk.Toplevel, "wait_window", autospec=True, side_effect=interact), + patch.object(entry, "bind", wraps=entry.bind) as entry_bindings, + patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.show_tooltip") as tooltip, + ): + table._open_bitmask_selection_window(event, parameter, reason, label) + + assert not table.bitmask_editor_open + assert parameter.value_as_string == "6" + assert bool(entry.bind("")) is not parent_closed + if not parent_closed: + _assert_bitmask_reopen_binding(table, entry_bindings, event, parameter, (reason, label)) + table.parameter_editor.update_parameter_value.assert_called_once_with("TEST_PARAM", "6", include_range_check=True) + tooltip.assert_called_once_with(reason, parameter.tooltip_change_reason) + if initial_text == "invalid": + table._dialogs.show_error.assert_called_once_with( + "Invalid Bitmask Value", "The new value 'invalid' is not a valid integer for the TEST_PARAM bitmask." + ) + else: + table._dialogs.show_error.assert_not_called() + + +@pytest.mark.parametrize("added", [False, True]) +def test_single_parameter_addition_refreshes_only_after_the_model_accepts_it( + parameter_editor_table: ParameterEditorTable, added: bool +) -> None: + """ + Refresh and scroll only after the model actually adds a parameter. + + GIVEN the model accepts or declines addition, + WHEN a single parameter is confirmed, + THEN the return value and refresh behavior agree with the model result. + """ + table = parameter_editor_table + table.parameter_editor.add_parameter_to_current_file.return_value = added + table._pending_scroll_to_bottom = False + + assert table._confirm_parameter_addition("TEST_PARAM") is added + + assert table._pending_scroll_to_bottom is added + assert table.parameter_editor_window.repopulate_parameter_table.call_count == int(added) + table._dialogs.show_error.assert_not_called() + + +def test_parameter_dialog_parent_resolution_reports_when_no_toplevel_exists( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Explain an unavailable dialog parent instead of creating an unrelated window. + + GIVEN a detached table whose ancestor lookup returns no Tk toplevel, + WHEN a dialog parent is requested, + THEN a clear parent-resolution error is raised. + """ + table = parameter_editor_table + table.main_frame = MagicMock(spec=tk.Misc) + table.main_frame.master = None + table.main_frame.winfo_toplevel.return_value = None + + with pytest.raises(RuntimeError, match="Could not resolve parent toplevel window"): + table._get_parent_toplevel() + + +def test_unselected_choice_widget_does_not_submit_a_replacement_on_focus_out( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Preserve a parameter value when its choice widget has no selection. + + GIVEN a choice row whose displayed selection has been cleared, + WHEN the widget loses focus without selecting an option, + THEN no replacement value or model update is invented. + """ + table = parameter_editor_table + parameter = create_mock_data_model_ardupilot_parameter(is_multiple_choice=True, value=0) + label = ttk.Label(table.view_port) + widget = table._create_new_value_entry(parameter, None, label) + assert isinstance(widget, PairTupleCombobox) + widget.set("") + assert widget.get_selected_key() is None + event = tk.Event() + event.widget = widget + event.type = tk.EventType.FocusOut + + widget.testing_on_parameter_value_change(event) + + assert parameter.value_as_string == "0" + table.parameter_editor.update_parameter_value.assert_not_called() + + +@pytest.mark.parametrize("row_kind", ["bitmask", "readonly-bitmask", "numeric"]) +def test_batched_rendering_does_not_repeat_dismissed_bitmask_help( + parameter_editor_table: ParameterEditorTable, row_kind: str +) -> None: + """ + Respect a user's decision to dismiss bitmask-editor guidance. + + GIVEN a batched bitmask row and a saved preference not to show its help, + WHEN rendering completes, + THEN completion is notified without reopening the dismissed popup. + """ + table = parameter_editor_table + table.options.render_batch_size = 1 + table.options.show_parameter_actions = False + parameter = create_mock_data_model_ardupilot_parameter( + is_bitmask=row_kind != "numeric", is_readonly=row_kind == "readonly-bitmask" + ) + table.parameters = {parameter.name: parameter} + complete = MagicMock() + table.options.render_complete_callback = complete + with ( + patch.object(tk.Misc, "update_idletasks"), + patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.UsagePopupWindow.should_display", + return_value=False, + ) as preference, + patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table." + "display_bitmask_parameters_editor_usage_popup" + ) as help_popup, + ): + table._update_table_in_batches(table.parameters, "simple", scroll_to_bottom=False) + + if row_kind == "bitmask": + preference.assert_called_once_with("bitmask_parameter_editor") + else: + preference.assert_not_called() + help_popup.assert_not_called() + complete.assert_called_once_with() + + +def test_bitmask_dialog_applies_to_replacement_widgets_after_the_original_row_is_removed( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Apply a bitmask edit to the current row after recovery rebuilds the table. + + GIVEN a bitmask dialog open while its original row widgets are replaced, + WHEN the user selects another bit and closes the dialog, + THEN the replacement value and difference label update without touching destroyed widgets. + """ + table = parameter_editor_table + parameter = create_mock_data_model_ardupilot_parameter(is_bitmask=True, value=1, fc_value=3) + old_entry = ttk.Entry(table.view_port) + old_entry.insert(0, "1") + old_reason = ttk.Entry(table.view_port) + old_label = ttk.Label(table.view_port) + new_entry = ttk.Entry(table.view_port) + new_entry.insert(0, "0") + new_label = ttk.Label(table.view_port, text="stale") + table._new_value_widgets[parameter.name] = new_entry + table._value_is_different_labels[parameter.name] = new_label + event = tk.Event() + event.widget = old_entry + + def update_parameter(_name: str, value: str, *, include_range_check: bool) -> ParameterValueUpdateResult: + assert include_range_check is True + parameter.set_new_value(value) + return ParameterValueUpdateResult(ParameterValueUpdateStatus.UPDATED) + + table.parameter_editor.update_parameter_value.side_effect = update_parameter + + def replace_row_and_close(window: tk.Toplevel, _target: tk.Misc | None = None) -> None: + old_entry.destroy() + old_reason.destroy() + old_label.destroy() + checkbox = next( + widget for widget in _descendants(window) if isinstance(widget, ttk.Checkbutton) and widget["text"] == "Bit 1" + ) + checkbox.invoke() + window.tk.call(window.protocol("WM_DELETE_WINDOW")) + assert not window.winfo_exists() + + with ( + patch.object(table, "_center_and_focus_window"), + patch.object(tk.Misc, "update_idletasks"), + patch.object(tk.Toplevel, "wait_window", autospec=True, side_effect=replace_row_and_close), + patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.show_tooltip") as tooltip, + ): + table._open_bitmask_selection_window(event, parameter, old_reason, old_label) + + assert new_entry.get() == "3" + assert new_label["text"] == " " + assert parameter.value_as_string == "3" + assert not table.bitmask_editor_open + tooltip.assert_not_called() + table._dialogs.show_error.assert_not_called() + + +def test_normal_batched_table_reserves_rows_and_keeps_its_add_action_usable( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Keep a normal configuration table usable before and after batched rendering. + + GIVEN two normal parameters rendered in separate batches, + WHEN the final batch finishes, + THEN rows stay in order, the scrollbar reserves only actual rows, and Add opens the add workflow. + """ + table = parameter_editor_table + table.view_port = ttk.Frame(table.main_frame) + table.parameters = None + table.options.render_batch_size = 1 + table.options.show_parameter_actions = True + table.parameter_editor_window.gui_complexity = "advanced" + parameters = {name: create_mock_data_model_ardupilot_parameter(name=name, fc_value=0) for name in ("FIRST", "SECOND")} + table.parameter_editor.current_step_parameters = parameters + table.parameter_editor.should_display_bitmask_parameter_editor_usage.return_value = False + scheduled: list[Callable[[], None]] = [] + with ( + patch.object(table, "after_idle", side_effect=scheduled.append), + patch.object(tk.Misc, "update_idletasks"), + patch.object(table, "_apply_scroll_position"), + patch.object(table, "_on_parameter_add") as add, + patch.object(table, "_show_tooltip") as tooltip, + patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.UsagePopupWindow.should_display", + return_value=True, + ), + patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table." + "display_bitmask_parameters_editor_usage_popup" + ) as help_popup, + ): + table._update_table_in_batches(parameters, "advanced", scroll_to_bottom=False) + assert table.view_port.grid_rowconfigure(0)["minsize"] == 0 + assert all(table.view_port.grid_rowconfigure(row)["minsize"] > 0 for row in (1, 2)) + assert table.view_port.grid_rowconfigure(3)["minsize"] == 0 + assert tuple(table._new_value_widgets) == ("FIRST",) + assert len(scheduled) == 1 + scheduled.pop(0)() + + assert [table._new_value_widgets[name].grid_info()["row"] for name in parameters] == [1, 2] + assert set(table.upload_checkbutton_var) == set(parameters) + footer = next( + widget for widget in table.view_port.winfo_children() if isinstance(widget, ttk.Button) and widget["text"] == "Add" + ) + assert footer.grid_info()["row"] > 2 + tooltip.assert_any_call(footer, "Add a parameter to the test_file file") + footer.invoke() + add.assert_called_once_with() + help_popup.assert_not_called() + assert [call.args[0] for call in table.parameter_editor.should_display_bitmask_parameter_editor_usage.call_args_list] == [ + "FIRST", + "SECOND", + ] + + +def test_restarting_batched_rendering_discards_the_previous_generations_callbacks( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Prevent stale batches from restoring rows removed by a new table refresh. + + GIVEN one render waiting for a later batch, + WHEN another parameter set replaces the table before that callback runs, + THEN the old callback cannot add obsolete rows or emit another completion notification. + """ + table = parameter_editor_table + table.options.render_batch_size = 1 + table.options.show_parameter_actions = False + table.parameters = {name: create_mock_data_model_ardupilot_parameter(name=name) for name in ("OLD", "OLD_LATER")} + completions: list[tuple[str, ...]] = [] + table.options.render_complete_callback = lambda: completions.append(tuple(table._new_value_widgets)) + scheduled: list[Callable[[], None]] = [] + with ( + patch.object(table, "after_idle", side_effect=scheduled.append), + patch.object(tk.Misc, "update_idletasks"), + patch.object(table, "_apply_scroll_position"), + ): + table.repopulate_table(show_only_differences=False, gui_complexity="simple") + assert len(scheduled) == 1 + table.parameters = {"NEW": create_mock_data_model_ardupilot_parameter(name="NEW")} + table.repopulate_table(show_only_differences=False, gui_complexity="simple") + assert completions == [("NEW",)] + + scheduled.pop(0)() + + assert tuple(table._new_value_widgets) == ("NEW",) + assert completions == [("NEW",)] + assert not scheduled + + +def test_bitmask_editing_retains_row_ownership_when_the_dialog_is_reopened( + parameter_editor_table: ParameterEditorTable, +) -> None: + """ + Reacquire row ownership correctly for each consecutive bitmask edit. + + GIVEN a bitmask dialog that was closed without changing the value, + WHEN the same row's dialog is opened again, + THEN each open dialog owns the row and each completed dialog releases it. + """ + table = parameter_editor_table + parameter = create_mock_data_model_ardupilot_parameter(is_bitmask=True, value=1) + entry = ttk.Entry(table.view_port) + entry.insert(0, "1") + label = ttk.Label(table.view_port) + event = tk.Event() + event.widget = entry + table.parameter_editor.update_parameter_value.return_value = ParameterValueUpdateResult( + ParameterValueUpdateStatus.UNCHANGED + ) + + def close(window: tk.Toplevel, _target: tk.Misc | None = None) -> None: + assert table.bitmask_editor_open + window.tk.call(window.protocol("WM_DELETE_WINDOW")) + assert not window.winfo_exists() + + with ( + patch.object(table, "_center_and_focus_window"), + patch.object(tk.Misc, "update_idletasks"), + patch.object(tk.Toplevel, "wait_window", autospec=True, side_effect=close), + ): + for _dialog_index in range(2): + table._open_bitmask_selection_window(event, parameter, None, label) + assert not table.bitmask_editor_open + assert parameter.value_as_string == "1" + table._dialogs.show_error.assert_not_called() From 6f5afbe153d546c862c81540d1bff65751b65841 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Fri, 30 Oct 2026 19:28:17 +0100 Subject: [PATCH 5/6] fix(ty): fix ty linting issues Signed-off-by: Dr.-Ing. Amilcar do Carmo Lucas --- tests/test_data_model_availability_models.py | 14 +++++++++++--- tests/test_frontend_tkinter_file_browser.py | 5 +++-- ...test_frontend_tkinter_parameter_editor_table.py | 11 +++++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/test_data_model_availability_models.py b/tests/test_data_model_availability_models.py index 01e0bd308..bf14dd811 100755 --- a/tests/test_data_model_availability_models.py +++ b/tests/test_data_model_availability_models.py @@ -312,7 +312,9 @@ def test_esc_availability_explains_legacy_missing_fields(field: str) -> None: log = _esc_log([(2, 1, 1200, 3, 0)]) columns = log.get_message_columns("ESC") assert columns is not None - log.add_message_columns("ESC", columns[[name for name in columns.dtype.names if name != field]]) + names = columns.dtype.names + assert names is not None + log.add_message_columns("ESC", columns[[name for name in names if name != field]]) result = EscLogAvailabilityModel(log, _esc_context({})).check() @@ -460,7 +462,9 @@ def test_esc_checks_skip_schemas_missing_their_required_fields(field: str) -> No log = _esc_log([(2, 3, 0, 1, 2)], [(1, 2), (0, 6)]) columns = log.get_message_columns("ESC") assert columns is not None - log.add_message_columns("ESC", columns[[name for name in columns.dtype.names if name != field]]) + names = columns.dtype.names + assert names is not None + log.add_message_columns("ESC", columns[[name for name in names if name != field]]) model = EscLogAnalysis(log, _esc_context({})) if field in {"Instance", "TimeUS", "RPM"}: @@ -509,6 +513,7 @@ def test_dshot_rate_uses_documented_multipliers_and_warns_at_the_exact_boundary( assert finding.timestamp_us is None assert finding.param_name == "SERVO_DSHOT_RATE" assert finding.related_step == "07_esc.param" + assert context.apm_doc is not None label = context.apm_doc["SERVO_DSHOT_RATE"]["values"][str(code)] assert finding.message == ( f"Effective DShot output rate is {expected_rate:.0f}Hz (SCHED_LOOP_RATE={loop_rate:.0f}Hz, " @@ -545,6 +550,7 @@ def test_dshot_metadata_handles_unknown_labels_and_case_insensitive_known_labels THEN unknown labels are skipped and a known uppercase label uses its documented multiplier. """ context = _esc_context({"MOT_PWM_TYPE": 9.0, "SCHED_LOOP_RATE": 400.0, "SERVO_DSHOT_RATE": 8.0}) + assert context.apm_doc is not None context.apm_doc["SERVO_DSHOT_RATE"]["values"] = {} if label is None else {"8": label} outcomes = EscLogAnalysis(LogData(), context).check_dshot_output_rate() @@ -734,7 +740,9 @@ def test_armed_rpm_check_skips_an_incomplete_arm_schema(field: str) -> None: log = _esc_log([(7, 2, 0, 1, 0), (7, 4, 0, 1, 0)], [(1, 2), (0, 4)]) columns = log.get_message_columns("ARM") assert columns is not None - log.add_message_columns("ARM", columns[[name for name in columns.dtype.names if name != field]]) + names = columns.dtype.names + assert names is not None + log.add_message_columns("ARM", columns[[name for name in names if name != field]]) assert not EscLogAnalysis(log, _esc_context({})).check_rpm_while_armed() diff --git a/tests/test_frontend_tkinter_file_browser.py b/tests/test_frontend_tkinter_file_browser.py index 7c49f1a62..74845136e 100755 --- a/tests/test_frontend_tkinter_file_browser.py +++ b/tests/test_frontend_tkinter_file_browser.py @@ -2948,8 +2948,9 @@ def test_transfer_planning_failure_never_starts_a_transfer( window = local_action_browser window.ui.ask_yesno.return_value = True planner = "_prepare_remote_download" if direction == "download" else "_build_local_upload_plan" - settings = {"side_effect": OSError("planning denied")} if failure == "exception" else {"return_value": None} - with patch(f"ardupilot_methodic_configurator.frontend_tkinter_file_browser.{planner}", **settings): + with patch(f"ardupilot_methodic_configurator.frontend_tkinter_file_browser.{planner}", return_value=None) as mock_planner: + if failure == "exception": + mock_planner.side_effect = OSError("planning denied") getattr(window, "download_selected_remote_entries" if direction == "download" else "upload_selected_local_entries")() expected = ( diff --git a/tests/test_frontend_tkinter_parameter_editor_table.py b/tests/test_frontend_tkinter_parameter_editor_table.py index 10f665ff5..227c8dc3d 100755 --- a/tests/test_frontend_tkinter_parameter_editor_table.py +++ b/tests/test_frontend_tkinter_parameter_editor_table.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from tkinter import ttk from types import SimpleNamespace -from typing import Any, cast +from typing import Any, Literal, cast from unittest.mock import MagicMock, patch import pytest @@ -129,10 +129,13 @@ def remember_binding( widget: tk.Misc, sequence: str | None = None, func: Callable[[tk.Event], object] | str | None = None, - add: str | None = None, - ) -> str: + add: Literal["", "+"] | bool | None = None, + ) -> str | None: if sequence is not None and callable(func): bindings[widget, sequence] = func + if isinstance(func, str): + original_bind(widget, sequence, func, add) + return None return original_bind(widget, sequence, func, add) with ( @@ -3686,7 +3689,7 @@ def test_normal_batched_table_reserves_rows_and_keeps_its_add_action_usable( ): table._update_table_in_batches(parameters, "advanced", scroll_to_bottom=False) assert table.view_port.grid_rowconfigure(0)["minsize"] == 0 - assert all(table.view_port.grid_rowconfigure(row)["minsize"] > 0 for row in (1, 2)) + assert all(int(table.view_port.grid_rowconfigure(row)["minsize"]) > 0 for row in (1, 2)) assert table.view_port.grid_rowconfigure(3)["minsize"] == 0 assert tuple(table._new_value_widgets) == ("FIRST",) assert len(scheduled) == 1 From dd767ed7d0644a507bf367ab181a2c7ff416c33c Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Fri, 30 Oct 2026 19:59:10 +0100 Subject: [PATCH 6/6] test: isolate parameter table Tk roots Make the table fixture's Tk interpreter the default while tests run, then destroy it and restore the previous default root during cleanup. Add a regression that initializes the session root first and verifies that masterless search and bitmask variables update their controls. This prevents dialog tests from depending on GUI test execution order. Validate with all 4,642 mutation-baseline tests, the 191-test table suite, ty, Ruff, Pylint, and pre-commit. Signed-off-by: Dr.-Ing. Amilcar do Carmo Lucas --- ...frontend_tkinter_parameter_editor_table.py | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/tests/test_frontend_tkinter_parameter_editor_table.py b/tests/test_frontend_tkinter_parameter_editor_table.py index 227c8dc3d..23e685f73 100755 --- a/tests/test_frontend_tkinter_parameter_editor_table.py +++ b/tests/test_frontend_tkinter_parameter_editor_table.py @@ -223,10 +223,40 @@ def create_mock_data_model_ardupilot_parameter( # pylint: disable=too-many-argu @pytest.fixture def mock_master() -> Generator[tk.Tk, None, None]: - """Create a mock tkinter root window.""" - root = tk.Tk() - yield root - root.destroy() + """Give the table its own root, including for masterless dialog variables.""" + # Restore any session root after destroying this fixture's interpreter. + with patch.object(tk, "_default_root", None): + root = tk.Tk() + try: + yield root + finally: + root.destroy() + + +def test_dialog_variables_follow_table_root_when_session_root_already_exists( + root: tk.Tk, request: pytest.FixtureRequest +) -> None: + """ + Keep dialog variables and controls in the same Tcl interpreter. + + GIVEN: Another GUI test has already created the session root + WHEN: A table uses masterless search and bitmask variables on its own root + THEN: Search changes and checkbox clicks update the corresponding controls and variables + """ + # Request the table root only after the session root has been initialized. + table_root = request.getfixturevalue("mock_master") + assert table_root is not root + search_value = tk.StringVar(value="initial") + search = ttk.Entry(table_root, textvariable=search_value) + selected = tk.BooleanVar(value=False) + checkbox = ttk.Checkbutton(table_root, variable=selected) + + search_value.set("filtered parameter") + checkbox.invoke() + + assert search.get() == "filtered parameter" + assert selected.get() is True + assert root.winfo_exists() @pytest.fixture