Skip to content

Commit 55abfbc

Browse files
committed
test(GUI): first real GUI tests
1 parent c4e80ff commit 55abfbc

File tree

9 files changed

+654
-27
lines changed

9 files changed

+654
-27
lines changed

.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[run]
22
source = ardupilot_methodic_configurator
3+
branch = True
34

45
[report]
56
include = ardupilot_methodic_configurator/*

.github/instructions/pytest_testing_instructions.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,14 @@ def test_integration_behavior(self, mock_api) -> None:
210210

211211
### Test Organization
212212

213+
Test files follow specific naming conventions to clearly indicate their purpose and scope:
214+
213215
```text
214216
tests/
215-
├── test_frontend_tkinter_component.py # UI component tests
216-
├── test_backend_logic.py # Business logic tests
217+
├── test_frontend_tkinter_component.py # UI component unit tests
218+
├── test_backend_logic.py # Business logic unit tests
219+
├── gui_*.py # GUI-focused tests (prefixed with gui_)
220+
├── integration_*.py # Integration tests (prefixed with integration_)
217221
├── test_integration_workflows.py # End-to-end scenarios
218222
└── conftest.py # Shared fixtures
219223
```

.github/workflows/pytest.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,28 @@ jobs:
4747
python-version: ${{ matrix.python-version }}
4848
activate-environment: true
4949

50+
- name: Install system dependencies for GUI testing
51+
run: |
52+
sudo apt-get update
53+
sudo apt-get install -y python3-tk scrot xdotool x11-utils
54+
5055
- name: Install dependencies and application
5156
# without --editable the coverage report is not generated correctly
5257
run: |
53-
uv pip install --editable .[dev]
58+
uv pip install --editable .[dev,ci_headless_tests]
5459
5560
- name: Test with pytest
5661
id: pytest
5762
continue-on-error: false
5863
run: |
5964
export LIBGL_ALWAYS_SOFTWARE=1
6065
export DISPLAY=:99
61-
Xvfb :99 -screen 0 1024x768x16 &
66+
# disable X authentication
67+
export XAUTHORITY=/dev/null
68+
# disable access control restrictions
69+
Xvfb :99 -screen 0 1024x768x16 -ac &
70+
# ensure Xvfb is fully started before running tests
71+
sleep 2
6272
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
6373
6474
- name: Fix coverage paths

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ dev = [
6767
"mypy==1.18.2",
6868
"pre-commit==4.3.0",
6969
"pylint==3.3.9",
70+
"pyautogui==0.9.54",
7071
"pyright==1.1.406",
7172
"pytest==8.4.2",
7273
"pytest-cov==7.0.0",
@@ -77,6 +78,10 @@ dev = [
7778
"types-requests==2.32.4.20250913",
7879
]
7980

81+
ci_headless_tests = [
82+
"pytest-xvfb==3.1.1",
83+
]
84+
8085
scripts = [
8186
"bs4==0.0.2",
8287
"selenium==4.36.0",
@@ -277,3 +282,4 @@ exclude = [".venv"]
277282
pythonVersion = "3.9"
278283
venvPath = "."
279284
venv = ".venv"
285+

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
python_files =
33
tests/test_*.py
44
tests/integration_*.py
5+
tests/gui_*.py
56
pythonpath = ardupilot_methodic_configurator
67
addopts = -v --strict-config --continue-on-collection-errors
78
markers =

tests/conftest.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@
1111
"""
1212

1313
import contextlib
14+
import json
1415
import os
1516
import tkinter as tk
1617
from collections.abc import Callable, Generator
1718
from typing import Any, NamedTuple, Optional
1819
from unittest.mock import patch
1920

21+
import pyautogui
2022
import pytest
2123
from test_data_model_vehicle_components_common import SAMPLE_DOC_DICT, ComponentDataModelFixtures
2224

25+
from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem
26+
from ardupilot_methodic_configurator.backend_flightcontroller import FlightController
27+
from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager
2328
from ardupilot_methodic_configurator.frontend_tkinter_base_window import BaseWindow
2429

2530
# ==================== SHARED TKINTER TESTING CONFIGURATION ====================
@@ -146,3 +151,91 @@ def component_datatypes() -> dict[str, Any]:
146151
def sample_doc_dict() -> dict[str, Any]:
147152
"""Create a sample doc_dict for testing."""
148153
return SAMPLE_DOC_DICT.copy()
154+
155+
156+
# ==================== GUI TESTING CONSTANTS ====================
157+
158+
159+
PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE = (
160+
"-/+",
161+
"Parameter",
162+
"Current Value",
163+
" ",
164+
"New Value",
165+
"Unit",
166+
"Why are you changing this parameter?",
167+
)
168+
169+
PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED = (
170+
"-/+",
171+
"Parameter",
172+
"Current Value",
173+
" ",
174+
"New Value",
175+
"Unit",
176+
"Upload",
177+
"Why are you changing this parameter?",
178+
)
179+
180+
181+
@pytest.fixture
182+
def gui_test_environment() -> None:
183+
"""Set up GUI test environment with screen validation."""
184+
# Verify screen environment is available
185+
screen_width, screen_height = pyautogui.size()
186+
assert screen_width > 0
187+
assert screen_height > 0
188+
189+
# Verify we can take a screenshot
190+
screenshot = pyautogui.screenshot()
191+
assert screenshot is not None
192+
assert screenshot.size[0] > 0
193+
assert screenshot.size[1] > 0
194+
195+
196+
@pytest.fixture
197+
def test_config_manager(tmp_path) -> ConfigurationManager:
198+
"""Create a test ConfigurationManager with minimal setup for GUI tests."""
199+
# Create a temporary directory structure
200+
vehicle_dir = tmp_path / "test_vehicle"
201+
vehicle_dir.mkdir()
202+
203+
# Create minimal parameter files
204+
(vehicle_dir / "00_default.param").write_text("# Test default parameters\n")
205+
(vehicle_dir / "04_board_orientation.param").write_text("# Test board orientation\n")
206+
207+
# Create minimal vehicle_components.json
208+
vehicle_components_data = {
209+
"Format version": 0,
210+
"Components": {
211+
"Flight Controller": {
212+
"Product": {"Manufacturer": "", "Model": "", "URL": "", "Version": ""},
213+
"Firmware": {"Type": "ArduCopter", "Version": "4.5.1"},
214+
"Specifications": {"MCU Series": ""},
215+
"Notes": "",
216+
},
217+
"Frame": {
218+
"Product": {"Manufacturer": "", "Model": "", "URL": "", "Version": ""},
219+
"Specifications": {"TOW min Kg": 0.1, "TOW max Kg": 0.1},
220+
"Notes": "",
221+
},
222+
"Battery Monitor": {
223+
"Product": {"Manufacturer": "", "Model": "", "URL": "", "Version": ""},
224+
"Firmware": {"Type": "", "Version": ""},
225+
"FC Connection": {"Type": "", "Protocol": ""},
226+
"Notes": "",
227+
},
228+
},
229+
}
230+
(vehicle_dir / "vehicle_components.json").write_text(json.dumps(vehicle_components_data, indent=2))
231+
232+
# Create mock FlightController
233+
fc = FlightController(reboot_time=5, baudrate=115200)
234+
235+
# Create LocalFilesystem
236+
filesystem = LocalFilesystem(
237+
str(vehicle_dir), "ArduCopter", "", allow_editing_template_files=False, save_component_to_system_templates=False
238+
)
239+
240+
# Create ConfigurationManager
241+
return ConfigurationManager("04_board_orientation.param", fc, filesystem)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
GUI tests for the ParameterEditorWindow using PyAutoGUI.
5+
6+
This module contains automated GUI tests for the Tkinter-based parameter editor.
7+
Tests verify that the GUI initializes correctly and displays expected elements.
8+
9+
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
10+
11+
SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
12+
13+
SPDX-License-Identifier: GPL-3.0-or-later
14+
"""
15+
16+
import tkinter as tk
17+
from tkinter import ttk
18+
19+
import pytest
20+
21+
from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager
22+
from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor import ParameterEditorWindow, show_about_window
23+
24+
25+
class TestParameterEditorWindow:
26+
"""Test cases for ParameterEditorWindow GUI initialization."""
27+
28+
def test_pyautogui_setup(self, gui_test_environment) -> None:
29+
"""Test that PyAutoGUI is properly configured for testing."""
30+
# The gui_test_environment fixture handles all the assertions
31+
32+
def test_basic_gui_creation(self, test_config_manager: ConfigurationManager) -> None:
33+
"""Test basic GUI creation without running mainloop."""
34+
# Create window but intercept mainloop
35+
original_mainloop = None
36+
window = None
37+
38+
def mock_mainloop(self) -> None: # pylint: disable=unused-argument
39+
"""Mock mainloop to prevent blocking."""
40+
41+
try:
42+
# Patch mainloop to prevent blocking
43+
original_mainloop = tk.Tk.mainloop
44+
tk.Tk.mainloop = mock_mainloop
45+
46+
# Create the window
47+
window = ParameterEditorWindow(test_config_manager)
48+
49+
# Basic checks
50+
assert window.root is not None
51+
assert hasattr(window, "configuration_manager")
52+
assert window.configuration_manager is test_config_manager
53+
54+
finally:
55+
# Restore original mainloop
56+
if original_mainloop:
57+
tk.Tk.mainloop = original_mainloop
58+
59+
# Clean up window
60+
if window and window.root:
61+
window.root.destroy()
62+
63+
@pytest.mark.skip(reason="GUI test requires display - run manually in GUI environment")
64+
def test_full_gui_with_pyautogui(self, test_config_manager: ConfigurationManager) -> None: # pylint: disable=unused-argument
65+
"""Full GUI test with PyAutoGUI - requires display."""
66+
# This test would run the full GUI and use PyAutoGUI to interact with it
67+
# For now, it's skipped as it requires a display environment
68+
69+
# Example of what the test could do:
70+
# 1. Start GUI in separate thread
71+
# 2. Use PyAutoGUI to locate window
72+
# 3. Take screenshots
73+
# 4. Simulate mouse/keyboard interactions
74+
# 5. Verify GUI behavior
75+
76+
pytest.skip("Full GUI test requires display environment")
77+
78+
def test_display_usage_popup_window(self, mocker) -> None:
79+
"""Test that the usage popup window can be created."""
80+
# Create a mock parent window
81+
parent = tk.Tk()
82+
parent.withdraw() # Hide the parent window
83+
84+
try:
85+
# Mock the UsagePopupWindow.display method to avoid actually showing the window
86+
mock_display = mocker.patch(
87+
"ardupilot_methodic_configurator.frontend_tkinter_parameter_editor.UsagePopupWindow.display"
88+
)
89+
90+
# Call the method
91+
ParameterEditorWindow._ParameterEditorWindow__display_usage_popup_window(parent) # pylint: disable=protected-access
92+
93+
# Verify that UsagePopupWindow.display was called
94+
mock_display.assert_called_once()
95+
args = mock_display.call_args[0]
96+
97+
# Check that the correct arguments were passed
98+
assert len(args) >= 5 # parent, window, title, key, size
99+
assert "How to use the parameter file editor and uploader window" in args[2] # title
100+
assert args[3] == "parameter_editor" # key
101+
assert args[4] == "690x360" # size
102+
103+
finally:
104+
parent.destroy()
105+
106+
def test_show_about_window(self, mocker) -> None: # pylint: disable=too-many-locals
107+
"""Test that the about window can be created."""
108+
# Create a mock root window
109+
root = tk.Tk()
110+
root.withdraw() # Hide the root window
111+
112+
try:
113+
# Mock webbrowser.open to avoid actually opening URLs
114+
mocker.patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor.webbrowser_open")
115+
116+
# Call the function
117+
show_about_window(root, "1.0.0")
118+
119+
# Find the about window (it should be a Toplevel child of root)
120+
about_windows = [child for child in root.winfo_children() if isinstance(child, tk.Toplevel)]
121+
122+
# There should be exactly one about window
123+
assert len(about_windows) == 1
124+
about_window = about_windows[0]
125+
126+
# Check window properties
127+
assert about_window.title() == "About"
128+
# Check that geometry contains the expected size (position may vary)
129+
geometry = about_window.geometry()
130+
assert "650x340" in geometry
131+
132+
# Check that the window contains the expected content
133+
# Find all labels in the window (using ttk.Label)
134+
def find_labels(widget) -> list:
135+
labels = []
136+
# Check for both tk.Label and ttk.Label
137+
if isinstance(widget, (tk.Label, ttk.Label)):
138+
labels.append(widget)
139+
for child in widget.winfo_children():
140+
labels.extend(find_labels(child))
141+
return labels
142+
143+
labels = find_labels(about_window)
144+
assert len(labels) > 0
145+
146+
# Check that at least one label contains version information
147+
version_found = False
148+
for label in labels:
149+
text = label.cget("text")
150+
if "ArduPilot Methodic Configurator Version: 1.0.0" in text:
151+
version_found = True
152+
break
153+
assert version_found, "Version information not found in about window"
154+
155+
# Check that buttons are created
156+
def find_buttons(widget) -> list:
157+
buttons = []
158+
# Check for both tk.Button and ttk.Button
159+
if isinstance(widget, (tk.Button, ttk.Button)):
160+
buttons.append(widget)
161+
for child in widget.winfo_children():
162+
buttons.extend(find_buttons(child))
163+
return buttons
164+
165+
buttons = find_buttons(about_window)
166+
expected_buttons = ["User Manual", "Support Forum", "Report a Bug", "Licenses", "Source Code"]
167+
button_texts = [btn.cget("text") for btn in buttons]
168+
169+
for expected_text in expected_buttons:
170+
assert expected_text in button_texts, f"Button '{expected_text}' not found"
171+
172+
# Clean up the about window
173+
about_window.destroy()
174+
175+
finally:
176+
root.destroy()

0 commit comments

Comments
 (0)