diff --git a/.coveragerc b/.coveragerc index 90b65475..2bdb30cb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,6 @@ [run] source = ardupilot_methodic_configurator +branch = True [report] include = ardupilot_methodic_configurator/* diff --git a/.github/instructions/pytest_testing_instructions.md b/.github/instructions/pytest_testing_instructions.md index d7a40706..d59b436b 100644 --- a/.github/instructions/pytest_testing_instructions.md +++ b/.github/instructions/pytest_testing_instructions.md @@ -210,10 +210,14 @@ def test_integration_behavior(self, mock_api) -> None: ### Test Organization +Test files follow specific naming conventions to clearly indicate their purpose and scope: + ```text tests/ -├── test_frontend_tkinter_component.py # UI component tests -├── test_backend_logic.py # Business logic tests +├── test_frontend_tkinter_component.py # UI component unit tests +├── test_backend_logic.py # Business logic unit tests +├── gui_*.py # GUI-focused tests (prefixed with gui_) +├── integration_*.py # Integration tests (prefixed with integration_) ├── test_integration_workflows.py # End-to-end scenarios └── conftest.py # Shared fixtures ``` diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 60bcc4d0..8197b896 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -47,10 +47,15 @@ jobs: python-version: ${{ matrix.python-version }} activate-environment: true + - name: Install system dependencies for GUI testing + run: | + sudo apt-get update + sudo apt-get install -y python3-tk scrot xdotool x11-utils gnome-screenshot + - name: Install dependencies and application # without --editable the coverage report is not generated correctly run: | - uv pip install --editable .[dev] + uv pip install --editable .[dev,ci_headless_tests] - name: Test with pytest id: pytest @@ -58,7 +63,12 @@ jobs: run: | export LIBGL_ALWAYS_SOFTWARE=1 export DISPLAY=:99 - Xvfb :99 -screen 0 1024x768x16 & + # disable X authentication + export XAUTHORITY=/dev/null + # disable access control restrictions + Xvfb :99 -screen 0 1024x768x16 -ac & + # ensure Xvfb is fully started before running tests + sleep 2 uv run pytest --cov=ardupilot_methodic_configurator --cov-report=xml:tests/coverage.xml --md=tests/results-${{ matrix.python-version }}.md --junit-xml=tests/results-junit.xml - name: Fix coverage paths diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index 061fae55..03f114d9 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -565,6 +565,12 @@ jobs: Write-Host "github.ref == 'refs/heads/master' && steps.check_tags.outputs.has_stable_tag == 'false':" if (("${{ github.ref }}" -eq $formatted) -and ("${{ steps.check_tags.outputs.has_stable_tag }}" -eq 'false')) { Write-Host 'true' } else { Write-Host 'false' } + - name: Delete previous Pre Release + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && steps.check_tags.outputs.has_stable_tag == 'false' + run: gh release delete latest-development-build --cleanup-tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Pre Release uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && steps.check_tags.outputs.has_stable_tag == 'false' diff --git a/pyproject.toml b/pyproject.toml index d16bbe3d..104134dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dev = [ "mypy==1.18.2", "pre-commit==4.3.0", "pylint==3.3.9", + "pyautogui==0.9.54", "pyright==1.1.406", "pytest==8.4.2", "pytest-cov==7.0.0", @@ -77,6 +78,10 @@ dev = [ "types-requests==2.32.4.20250913", ] +ci_headless_tests = [ + "pytest-xvfb==3.1.1", +] + scripts = [ "bs4==0.0.2", "selenium==4.36.0", diff --git a/pytest.ini b/pytest.ini index b7bb910a..a0a57821 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,6 +2,7 @@ python_files = tests/test_*.py tests/integration_*.py + tests/gui_*.py pythonpath = ardupilot_methodic_configurator addopts = -v --strict-config --continue-on-collection-errors markers = diff --git a/tests/conftest.py b/tests/conftest.py index ca037ea3..5a310a6d 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,15 +11,20 @@ """ import contextlib +import json import os import tkinter as tk from collections.abc import Callable, Generator from typing import Any, NamedTuple, Optional from unittest.mock import patch +import pyautogui import pytest from test_data_model_vehicle_components_common import SAMPLE_DOC_DICT, ComponentDataModelFixtures +from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem +from ardupilot_methodic_configurator.backend_flightcontroller import FlightController +from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager from ardupilot_methodic_configurator.frontend_tkinter_base_window import BaseWindow # ==================== SHARED TKINTER TESTING CONFIGURATION ==================== @@ -146,3 +151,91 @@ def component_datatypes() -> dict[str, Any]: def sample_doc_dict() -> dict[str, Any]: """Create a sample doc_dict for testing.""" return SAMPLE_DOC_DICT.copy() + + +# ==================== GUI TESTING CONSTANTS ==================== + + +PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE = ( + "-/+", + "Parameter", + "Current Value", + " ", + "New Value", + "Unit", + "Why are you changing this parameter?", +) + +PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED = ( + "-/+", + "Parameter", + "Current Value", + " ", + "New Value", + "Unit", + "Upload", + "Why are you changing this parameter?", +) + + +@pytest.fixture +def gui_test_environment() -> None: + """Set up GUI test environment with screen validation.""" + # Verify screen environment is available + screen_width, screen_height = pyautogui.size() + assert screen_width > 0 + assert screen_height > 0 + + # Verify we can take a screenshot + screenshot = pyautogui.screenshot() + assert screenshot is not None + assert screenshot.size[0] > 0 + assert screenshot.size[1] > 0 + + +@pytest.fixture +def test_config_manager(tmp_path) -> ConfigurationManager: + """Create a test ConfigurationManager with minimal setup for GUI tests.""" + # Create a temporary directory structure + vehicle_dir = tmp_path / "test_vehicle" + vehicle_dir.mkdir() + + # Create minimal parameter files + (vehicle_dir / "00_default.param").write_text("# Test default parameters\n") + (vehicle_dir / "04_board_orientation.param").write_text("# Test board orientation\n") + + # Create minimal vehicle_components.json + vehicle_components_data = { + "Format version": 0, + "Components": { + "Flight Controller": { + "Product": {"Manufacturer": "", "Model": "", "URL": "", "Version": ""}, + "Firmware": {"Type": "ArduCopter", "Version": "4.5.1"}, + "Specifications": {"MCU Series": ""}, + "Notes": "", + }, + "Frame": { + "Product": {"Manufacturer": "", "Model": "", "URL": "", "Version": ""}, + "Specifications": {"TOW min Kg": 0.1, "TOW max Kg": 0.1}, + "Notes": "", + }, + "Battery Monitor": { + "Product": {"Manufacturer": "", "Model": "", "URL": "", "Version": ""}, + "Firmware": {"Type": "", "Version": ""}, + "FC Connection": {"Type": "", "Protocol": ""}, + "Notes": "", + }, + }, + } + (vehicle_dir / "vehicle_components.json").write_text(json.dumps(vehicle_components_data, indent=2)) + + # Create mock FlightController + fc = FlightController(reboot_time=5, baudrate=115200) + + # Create LocalFilesystem + filesystem = LocalFilesystem( + str(vehicle_dir), "ArduCopter", "", allow_editing_template_files=False, save_component_to_system_templates=False + ) + + # Create ConfigurationManager + return ConfigurationManager("04_board_orientation.param", fc, filesystem) diff --git a/tests/gui_frontend_tkinter_parameter_editor.py b/tests/gui_frontend_tkinter_parameter_editor.py new file mode 100755 index 00000000..b830c8d0 --- /dev/null +++ b/tests/gui_frontend_tkinter_parameter_editor.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +""" +GUI tests for the ParameterEditorWindow using PyAutoGUI. + +This module contains automated GUI tests for the Tkinter-based parameter editor. +Tests verify that the GUI initializes correctly and displays expected elements. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import tkinter as tk +from tkinter import ttk + +import pytest + +from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager +from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor import ParameterEditorWindow, show_about_window + + +class TestParameterEditorWindow: + """Test cases for ParameterEditorWindow GUI initialization.""" + + def test_pyautogui_setup(self, gui_test_environment) -> None: + """Test that PyAutoGUI is properly configured for testing.""" + # The gui_test_environment fixture handles all the assertions + + def test_basic_gui_creation(self, test_config_manager: ConfigurationManager) -> None: + """Test basic GUI creation without running mainloop.""" + # Create window but intercept mainloop + original_mainloop = None + window = None + + def mock_mainloop(self) -> None: # pylint: disable=unused-argument + """Mock mainloop to prevent blocking.""" + + try: + # Patch mainloop to prevent blocking + original_mainloop = tk.Tk.mainloop + tk.Tk.mainloop = mock_mainloop + + # Create the window + window = ParameterEditorWindow(test_config_manager) + + # Basic checks + assert window.root is not None + assert hasattr(window, "configuration_manager") + assert window.configuration_manager is test_config_manager + + finally: + # Restore original mainloop + if original_mainloop: + tk.Tk.mainloop = original_mainloop + + # Clean up window + if window and window.root: + window.root.destroy() + + @pytest.mark.skip(reason="GUI test requires display - run manually in GUI environment") + def test_full_gui_with_pyautogui(self, test_config_manager: ConfigurationManager) -> None: # pylint: disable=unused-argument + """Full GUI test with PyAutoGUI - requires display.""" + # This test would run the full GUI and use PyAutoGUI to interact with it + # For now, it's skipped as it requires a display environment + + # Example of what the test could do: + # 1. Start GUI in separate thread + # 2. Use PyAutoGUI to locate window + # 3. Take screenshots + # 4. Simulate mouse/keyboard interactions + # 5. Verify GUI behavior + + pytest.skip("Full GUI test requires display environment") + + def test_display_usage_popup_window(self, mocker) -> None: + """Test that the usage popup window can be created.""" + # Create a mock parent window + parent = tk.Tk() + parent.withdraw() # Hide the parent window + + try: + # Mock the UsagePopupWindow.display method to avoid actually showing the window + mock_display = mocker.patch( + "ardupilot_methodic_configurator.frontend_tkinter_parameter_editor.UsagePopupWindow.display" + ) + + # Call the method + ParameterEditorWindow._ParameterEditorWindow__display_usage_popup_window(parent) # pylint: disable=protected-access + + # Verify that UsagePopupWindow.display was called + mock_display.assert_called_once() + args = mock_display.call_args[0] + + # Check that the correct arguments were passed + assert len(args) >= 5 # parent, window, title, key, size + assert "How to use the parameter file editor and uploader window" in args[2] # title + assert args[3] == "parameter_editor" # key + assert args[4] == "690x360" # size + + finally: + parent.destroy() + + def test_show_about_window(self, mocker) -> None: # pylint: disable=too-many-locals + """Test that the about window can be created.""" + # Create a mock root window + root = tk.Tk() + root.withdraw() # Hide the root window + + try: + # Mock webbrowser.open to avoid actually opening URLs + mocker.patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor.webbrowser_open") + + # Call the function + show_about_window(root, "1.0.0") + + # Find the about window (it should be a Toplevel child of root) + about_windows = [child for child in root.winfo_children() if isinstance(child, tk.Toplevel)] + + # There should be exactly one about window + assert len(about_windows) == 1 + about_window = about_windows[0] + + # Check window properties + assert about_window.title() == "About" + # Check that geometry contains the expected size (position may vary) + geometry = about_window.geometry() + assert "650x340" in geometry + + # Check that the window contains the expected content + # Find all labels in the window (using ttk.Label) + def find_labels(widget) -> list: + labels = [] + # Check for both tk.Label and ttk.Label + if isinstance(widget, (tk.Label, ttk.Label)): + labels.append(widget) + for child in widget.winfo_children(): + labels.extend(find_labels(child)) + return labels + + labels = find_labels(about_window) + assert len(labels) > 0 + + # Check that at least one label contains version information + version_found = False + for label in labels: + text = label.cget("text") + if "ArduPilot Methodic Configurator Version: 1.0.0" in text: + version_found = True + break + assert version_found, "Version information not found in about window" + + # Check that buttons are created + def find_buttons(widget) -> list: + buttons = [] + # Check for both tk.Button and ttk.Button + if isinstance(widget, (tk.Button, ttk.Button)): + buttons.append(widget) + for child in widget.winfo_children(): + buttons.extend(find_buttons(child)) + return buttons + + buttons = find_buttons(about_window) + expected_buttons = ["User Manual", "Support Forum", "Report a Bug", "Licenses", "Source Code"] + button_texts = [btn.cget("text") for btn in buttons] + + for expected_text in expected_buttons: + assert expected_text in button_texts, f"Button '{expected_text}' not found" + + # Clean up the about window + about_window.destroy() + + finally: + root.destroy() diff --git a/tests/gui_frontend_tkinter_parameter_editor_table.py b/tests/gui_frontend_tkinter_parameter_editor_table.py new file mode 100755 index 00000000..bb202abc --- /dev/null +++ b/tests/gui_frontend_tkinter_parameter_editor_table.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 + +""" +GUI tests for the ParameterEditorTable using PyAutoGUI. + +This module contains automated GUI tests for the Tkinter-based parameter editor table. +Tests verify that the table creation and widget generation works correctly. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import contextlib +import tkinter as tk +from collections.abc import Generator +from tkinter import ttk +from unittest.mock import Mock, patch + +import pytest +from conftest import PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED, PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE + +from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager +from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter +from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox +from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table import ParameterEditorTable + +# pylint: disable=protected-access + + +class TestParameterEditorTableUserWorkflows: + """Test user workflows and behaviors for ParameterEditorTable GUI components.""" + + @pytest.fixture + def mock_parameter_editor(self) -> Mock: + """Create a mock parameter editor with gui_complexity attribute.""" + mock_editor = Mock() + mock_editor.gui_complexity = "advanced" + return mock_editor + + @pytest.fixture + def parameter_table( + self, test_config_manager: ConfigurationManager, mock_parameter_editor: Mock + ) -> Generator[ParameterEditorTable, None, None]: + """Create a ParameterEditorTable instance for testing.""" + # Create a root window for the table + root = tk.Tk() + root.withdraw() # Hide the root window + + # Create the table + table = ParameterEditorTable(root, test_config_manager, mock_parameter_editor) + + yield table + + # Cleanup + with contextlib.suppress(tk.TclError): + root.destroy() + + def test_user_sees_pyautogui_environment_ready_for_testing(self, gui_test_environment) -> None: + """ + User can verify that the GUI testing environment is properly configured. + + GIVEN: A user wants to run automated GUI tests + WHEN: They check the PyAutoGUI environment setup + THEN: The screen capture and automation capabilities should be available + """ + # The gui_test_environment fixture handles all the assertions + + def test_user_sees_upload_column_based_on_gui_complexity_level(self, parameter_table: ParameterEditorTable) -> None: + """ + User sees the upload column displayed according to their GUI complexity preference. + + GIVEN: A user is viewing the parameter editor table + WHEN: They have different GUI complexity settings + THEN: The upload column should be shown/hidden appropriately + AND: Simple users don't see advanced features + AND: Advanced/Expert users see the upload column + """ + # Test with different GUI complexity levels + assert parameter_table._should_show_upload_column("simple") is False + assert parameter_table._should_show_upload_column("advanced") is True + assert parameter_table._should_show_upload_column("expert") is True + + # Test with None (should use parameter_editor.gui_complexity) + assert parameter_table._should_show_upload_column(None) is True # advanced + + def test_user_sees_clear_table_headers_with_helpful_tooltips(self, parameter_table: ParameterEditorTable) -> None: + """ + User sees clear, descriptive table headers with helpful tooltips. + + GIVEN: A user is viewing the parameter editor table + WHEN: They look at the table headers + THEN: Headers should be clear and descriptive + AND: Tooltips should provide additional guidance + AND: Headers should adapt based on upload column visibility + """ + # Test without upload column + headers, tooltips = parameter_table._create_headers_and_tooltips(show_upload_column=False) + + assert headers == PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE + assert len(tooltips) == len(headers) + + # Test with upload column + headers_with_upload, tooltips_with_upload = parameter_table._create_headers_and_tooltips(show_upload_column=True) + + assert headers_with_upload == PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED + assert len(tooltips_with_upload) == len(headers_with_upload) + + def test_user_sees_parameter_names_displayed_with_proper_formatting(self, parameter_table: ParameterEditorTable) -> None: + """ + User sees parameter names displayed with consistent formatting and padding. + + GIVEN: A user is viewing parameters in the table + WHEN: Parameters have different name lengths + THEN: All parameter names should be displayed with consistent 16-character padding + AND: The formatting should be user-friendly and readable + """ + # Create a mock parameter + mock_param = Mock(spec=ArduPilotParameter) + mock_param.name = "TEST_PARAM" + mock_param.is_readonly = False + mock_param.is_calibration = False + mock_param.tooltip_new_value = "Test tooltip" + + # Create the label + label = parameter_table._create_parameter_name(mock_param) + + # Verify it's a ttk.Label + assert isinstance(label, ttk.Label) + + # Verify the text (should be padded to 16 characters) + expected_text = "TEST_PARAM" + " " * (16 - len("TEST_PARAM")) + assert label.cget("text") == expected_text + + def test_user_sees_flight_controller_values_with_clear_indicators(self, parameter_table: ParameterEditorTable) -> None: + """ + User sees flight controller values clearly indicated in the table. + + GIVEN: A user is comparing parameter values + WHEN: Some parameters have flight controller values and others don't + THEN: Parameters with FC values should display the actual value + AND: Parameters without FC values should show "N/A" clearly + AND: The display should be unambiguous and user-friendly + """ + # Test with parameter that has FC value + mock_param = Mock(spec=ArduPilotParameter) + mock_param.has_fc_value = True + mock_param.fc_value_as_string = "1.5" + mock_param.fc_value_equals_default_value = True + mock_param.fc_value_is_below_limit.return_value = False + mock_param.fc_value_is_above_limit.return_value = False + mock_param.fc_value_has_unknown_bits_set.return_value = False + mock_param.tooltip_fc_value = "FC value tooltip" + + label = parameter_table._create_flightcontroller_value(mock_param) + assert isinstance(label, ttk.Label) + assert label.cget("text") == "1.5" + + # Test with parameter that doesn't have FC value + mock_param_no_fc = Mock(spec=ArduPilotParameter) + mock_param_no_fc.has_fc_value = False + + label_no_fc = parameter_table._create_flightcontroller_value(mock_param_no_fc) + assert isinstance(label_no_fc, ttk.Label) + assert label_no_fc.cget("text") == "N/A" + + def test_user_sees_clear_visual_indicators_for_parameter_differences(self, parameter_table: ParameterEditorTable) -> None: + """ + User sees clear visual indicators when parameter values differ from flight controller. + + GIVEN: A user is reviewing parameter changes + WHEN: Some parameters have different values than the flight controller + THEN: Different parameters should have clear visual indicators (≠ or !=) + AND: Same parameters should have neutral indicators + AND: The indicators should be immediately recognizable + """ + # Test with different parameter + mock_param_different = Mock(spec=ArduPilotParameter) + mock_param_different.is_different_from_fc = True + + label_different = parameter_table._create_value_different_label(mock_param_different) + assert isinstance(label_different, ttk.Label) + assert "≠" in label_different.cget("text") or "!=" in label_different.cget("text") + + # Test with same parameter + mock_param_same = Mock(spec=ArduPilotParameter) + mock_param_same.is_different_from_fc = False + + label_same = parameter_table._create_value_different_label(mock_param_same) + assert isinstance(label_same, ttk.Label) + assert label_same.cget("text") == " " + + def test_user_can_delete_parameters_using_clearly_labeled_buttons(self, parameter_table: ParameterEditorTable) -> None: + """ + User can delete parameters using clearly labeled buttons. + + GIVEN: A user wants to remove a parameter from their configuration + WHEN: They look for deletion functionality + THEN: They should see clearly labeled "Del" buttons + AND: The buttons should have proper click handlers + AND: The interface should be intuitive and discoverable + """ + button = parameter_table._create_delete_button("TEST_PARAM") + + assert isinstance(button, ttk.Button) + assert button.cget("text") == "Del" + + # Check that the button has a command (callback) + assert button.cget("command") is not None + + def test_user_sees_table_adapts_to_column_configurations(self, parameter_table: ParameterEditorTable) -> None: + """ + User sees the table layout properly adapts to different column configurations. + + GIVEN: A user is viewing the parameter table + WHEN: The table shows different columns based on settings + THEN: The table layout should adapt gracefully + AND: Column weights should be configured appropriately + AND: The layout should remain usable and professional + """ + # This method configures grid column weights, but doesn't return anything + # We just verify it doesn't raise an exception + parameter_table._configure_table_columns(show_upload_column=False) + parameter_table._configure_table_columns(show_upload_column=True) + + def test_user_sees_appropriate_input_widgets_for_parameter_types(self, parameter_table: ParameterEditorTable) -> None: + """ + User sees appropriate input widgets based on parameter characteristics. + + GIVEN: A user is editing parameters of different types + WHEN: Parameters have different properties (multiple choice, bitmask, editable, etc.) + THEN: They should see the correct input widget type for each parameter + AND: Widgets should be configured appropriately for the parameter type + AND: Non-editable parameters should be clearly disabled + """ + # Create mock change reason widget and value different label + change_reason_widget = ttk.Entry(parameter_table.view_port) + value_different_label = ttk.Label(parameter_table.view_port) + + # Test multiple choice parameter (should create combobox) + mock_multiple_choice_param = Mock(spec=ArduPilotParameter) + mock_multiple_choice_param.is_multiple_choice = True + mock_multiple_choice_param.choices_dict = {"Option1": "1", "Option2": "2"} + mock_multiple_choice_param.get_selected_value_from_dict.return_value = "Option1" + mock_multiple_choice_param.value_as_string = "Option1" # Should be the key, not the value + mock_multiple_choice_param.name = "MULTI_PARAM" + mock_multiple_choice_param.is_editable = True + mock_multiple_choice_param.new_value_equals_default_value = False + mock_multiple_choice_param.tooltip_new_value = "Multiple choice tooltip" + + widget = parameter_table._create_new_value_entry( + mock_multiple_choice_param, change_reason_widget, value_different_label + ) + # Should return a PairTupleCombobox for multiple choice parameters + assert isinstance(widget, PairTupleCombobox) + + # Test regular parameter (should create entry) + mock_regular_param = Mock(spec=ArduPilotParameter) + mock_regular_param.is_multiple_choice = False + mock_regular_param.value_as_string = "42.5" + mock_regular_param.name = "REGULAR_PARAM" + mock_regular_param.is_editable = True + mock_regular_param.new_value_equals_default_value = True + mock_regular_param.is_below_limit.return_value = False + mock_regular_param.is_above_limit.return_value = False + mock_regular_param.has_unknown_bits_set.return_value = False + mock_regular_param.tooltip_new_value = "Regular parameter tooltip" + + widget = parameter_table._create_new_value_entry(mock_regular_param, change_reason_widget, value_different_label) + # Should return a ttk.Entry for regular parameters + assert isinstance(widget, ttk.Entry) + + # Test non-editable parameter (should be disabled) + mock_non_editable_param = Mock(spec=ArduPilotParameter) + mock_non_editable_param.is_multiple_choice = False + mock_non_editable_param.value_as_string = "100" + mock_non_editable_param.name = "NON_EDITABLE_PARAM" + mock_non_editable_param.is_editable = False + mock_non_editable_param.new_value_equals_default_value = False + mock_non_editable_param.is_below_limit.return_value = False + mock_non_editable_param.is_above_limit.return_value = False + mock_non_editable_param.has_unknown_bits_set.return_value = False + mock_non_editable_param.tooltip_new_value = "Non-editable parameter tooltip" + + widget = parameter_table._create_new_value_entry(mock_non_editable_param, change_reason_widget, value_different_label) + # Should return a ttk.Entry that is disabled + assert isinstance(widget, ttk.Entry) + # For ttk widgets, state is a tuple, check if 'disabled' is in it + widget_state = widget.state() + assert "disabled" in widget_state + + def test_user_can_interact_with_bitmask_selection_dialog(self, parameter_table: ParameterEditorTable) -> None: + """ + User can interact with bitmask selection dialogs for bitmask parameters. + + GIVEN: A user is editing a bitmask parameter + WHEN: They double-click on the parameter value field + THEN: A bitmask selection window should open + AND: They should be able to select/deselect individual bit options + AND: The parameter value should update based on their selections + """ + # Create mock change reason widget and value different label + change_reason_widget = ttk.Entry(parameter_table.view_port) + value_different_label = ttk.Label(parameter_table.view_port) + + # Create a mock bitmask parameter + mock_bitmask_param = Mock(spec=ArduPilotParameter) + mock_bitmask_param.name = "BITMASK_PARAM" + mock_bitmask_param.value_as_string = "5" # Binary 101, so bits 0 and 2 set + mock_bitmask_param.tooltip_new_value = "Bitmask parameter tooltip" + mock_bitmask_param.bitmask_dict = {0: "Option 1", 1: "Option 2", 2: "Option 3"} + + # Mock the BitmaskHelper to return expected values + with ( + patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.BitmaskHelper") as mock_helper, + patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.BaseWindow.center_window"), + patch("tkinter.Toplevel") as mock_toplevel, + patch("tkinter.Checkbutton"), + patch("tkinter.BooleanVar"), + ): + mock_helper.get_checked_keys.return_value = {0, 2} # Bits 0 and 2 set + + # Create a mock event for double-click + mock_event = Mock() + mock_event.widget = Mock(spec=ttk.Entry) + mock_event.widget.get.return_value = "5" + + # Call the bitmask selection window method + parameter_table._open_bitmask_selection_window( + mock_event, mock_bitmask_param, change_reason_widget, value_different_label + ) + + # Verify that a window was created + mock_toplevel.assert_called_once() + + # Verify that bitmask helper methods were called + mock_helper.get_checked_keys.assert_called_once_with(5, mock_bitmask_param.bitmask_dict) + + @pytest.mark.skip(reason="Full table population requires complex parameter data setup") + def test_user_can_work_with_fully_populated_parameter_table(self, parameter_table: ParameterEditorTable) -> None: # pylint: disable=unused-argument + """ + User can work with a fully populated parameter table (integration test). + + GIVEN: A user has loaded a complete parameter set + WHEN: The table is fully populated with real parameter data + THEN: All parameters should be displayed correctly + AND: User interactions should work as expected + AND: The table should handle large datasets efficiently + + NOTE: This test is skipped because it requires complex setup with actual + parameter data and GUI components. Individual component tests above + verify the building blocks work correctly. + """ + pytest.skip("Full table population requires complex parameter data setup - focus on component testing instead") diff --git a/tests/test_frontend_tkinter_parameter_editor_table.py b/tests/test_frontend_tkinter_parameter_editor_table.py index 035786f7..47ef3b5d 100755 --- a/tests/test_frontend_tkinter_parameter_editor_table.py +++ b/tests/test_frontend_tkinter_parameter_editor_table.py @@ -26,6 +26,7 @@ setup_combobox_mousewheel_handling, ) from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table import ParameterEditorTable +from tests.conftest import PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED, PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE # pylint: disable=protected-access, redefined-outer-name, too-few-public-methods, too-many-lines @@ -1027,17 +1028,7 @@ def test_create_headers_and_tooltips_simple_mode(self, parameter_editor_table: P headers, tooltips = parameter_editor_table._create_headers_and_tooltips(show_upload_column=False) # Assert: Headers match expected simple mode structure - expected_headers = ( - "-/+", - "Parameter", - "Current Value", - " ", - "New Value", - "Unit", - "Why are you changing this parameter?", - ) - - assert headers == expected_headers + assert headers == PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE assert len(tooltips) == len(headers) assert len(tooltips) == 7 # No upload column tooltip @@ -1053,18 +1044,7 @@ def test_create_headers_and_tooltips_advanced_mode(self, parameter_editor_table: headers, tooltips = parameter_editor_table._create_headers_and_tooltips(show_upload_column=True) # Assert: Headers match expected advanced mode structure - expected_headers = ( - "-/+", - "Parameter", - "Current Value", - " ", - "New Value", - "Unit", - "Upload", - "Why are you changing this parameter?", - ) - - assert headers == expected_headers + assert headers == PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED assert len(tooltips) == len(headers) assert len(tooltips) == 8 # With upload column tooltip