Skip to content

Commit edfaa48

Browse files
committed
fix(port save): Improve consistency, documentation and tests
1 parent 62a201f commit edfaa48

File tree

3 files changed

+338
-100
lines changed

3 files changed

+338
-100
lines changed

ardupilot_methodic_configurator/backend_filesystem_program_settings.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,9 +459,18 @@ def motor_diagram_exists(frame_class: int, frame_type: int) -> bool:
459459

460460
@staticmethod
461461
def get_connection_history() -> list[str]:
462-
"""Get the list of previously used connection strings."""
462+
"""
463+
Get the list of previously used connection strings.
464+
465+
Returns the connection history from settings, filtering out any invalid entries.
466+
Only valid string entries are returned.
467+
468+
Returns:
469+
List of connection strings in most-recent-first order (up to 10 items).
470+
471+
"""
463472
settings = ProgramSettings._get_settings_as_dict()
464-
history: Any = settings.get("connection_history", [])
473+
history = settings.get("connection_history", [])
465474

466475
if not isinstance(history, list):
467476
return []
@@ -470,8 +479,25 @@ def get_connection_history() -> list[str]:
470479

471480
@staticmethod
472481
def store_connection(connection_string: str) -> None:
473-
"""Save a new connection string to history."""
474-
if not connection_string:
482+
"""
483+
Save a new connection string to history.
484+
485+
The history maintains up to 10 most recent connections in chronological order.
486+
If the connection already exists, it's moved to the top of the list.
487+
Empty strings, whitespace-only strings, and strings longer than 200 characters
488+
are ignored.
489+
490+
Args:
491+
connection_string: The connection string to store (max 200 characters).
492+
493+
"""
494+
if not connection_string or not connection_string.strip():
495+
return
496+
497+
connection_string = connection_string.strip()
498+
499+
# Reject connection strings that are too long
500+
if len(connection_string) > 200:
475501
return
476502

477503
settings = ProgramSettings._get_settings_as_dict()
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
#!/usr/bin/env python3
2+
"""
3+
BDD-style tests for the backend_filesystem_program_settings.py file.
4+
5+
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
6+
7+
SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
8+
9+
SPDX-License-Identifier: GPL-3.0-or-later
10+
"""
11+
12+
from typing import Any
13+
from unittest.mock import patch
14+
15+
import pytest
16+
17+
from ardupilot_methodic_configurator.backend_filesystem_program_settings import ProgramSettings
18+
19+
# pylint: disable=redefined-outer-name
20+
21+
22+
@pytest.fixture
23+
def mock_settings_file() -> dict[str, Any]:
24+
"""Fixture providing realistic settings data for testing."""
25+
return {
26+
"Format version": 1,
27+
"display_usage_popup": {
28+
"workflow_explanation": True,
29+
"component_editor": False,
30+
},
31+
"connection_history": ["COM3", "tcp:127.0.0.1:5760"],
32+
"directory_selection": {
33+
"template_dir": "/path/to/template",
34+
"new_base_dir": "/path/to/base",
35+
"vehicle_dir": "/path/to/vehicle",
36+
},
37+
"auto_open_doc_in_browser": True,
38+
"gui_complexity": "simple",
39+
"motor_test": {
40+
"duration": 2,
41+
"throttle_pct": 10,
42+
},
43+
}
44+
45+
46+
class TestConnectionHistoryManagement:
47+
"""Test user connection history management workflows."""
48+
49+
def test_user_can_store_new_connection(self, mock_settings_file: dict[str, Any]) -> None:
50+
"""
51+
User can save a new connection string to history.
52+
53+
GIVEN: A user successfully connects to a device
54+
WHEN: The connection is stored
55+
THEN: It should appear at the top of the connection history
56+
"""
57+
# Arrange: Mock settings and file operations (GIVEN)
58+
with (
59+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
60+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
61+
):
62+
# Act: User stores a new connection (WHEN)
63+
ProgramSettings.store_connection("udp:192.168.1.100:14550")
64+
65+
# Assert: New connection added to top of history (THEN)
66+
mock_set.assert_called_once()
67+
saved_settings = mock_set.call_args[0][0]
68+
assert saved_settings["connection_history"][0] == "udp:192.168.1.100:14550"
69+
70+
def test_user_can_store_multiple_connections_in_order(self, mock_settings_file: dict[str, Any]) -> None:
71+
"""
72+
User can store multiple connections and they maintain order.
73+
74+
GIVEN: A clean connection history
75+
WHEN: The user stores multiple connection strings
76+
THEN: The connections should be stored in most-recent-first order
77+
"""
78+
# Arrange: Start with empty history (GIVEN)
79+
mock_settings_file["connection_history"] = []
80+
81+
with (
82+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
83+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
84+
):
85+
# Act: User stores multiple connections (WHEN)
86+
ProgramSettings.store_connection("COM1")
87+
ProgramSettings.store_connection("COM2")
88+
ProgramSettings.store_connection("COM3")
89+
90+
# Assert: Connections in correct order (THEN)
91+
# We check the very last call to see the final state of history
92+
final_settings = mock_set.call_args[0][0]
93+
history = final_settings["connection_history"]
94+
assert history[0] == "COM3" # Most recent first
95+
assert history[1] == "COM2"
96+
assert history[2] == "COM1"
97+
98+
def test_user_reconnects_with_existing_connection(self, mock_settings_file: dict[str, Any]) -> None:
99+
"""
100+
User reconnects with a previously used connection string.
101+
102+
GIVEN: A connection history with multiple entries
103+
WHEN: The user connects with an existing connection string
104+
THEN: That connection should move to the top without creating duplicates
105+
"""
106+
# Arrange: Setup history with existing connections (GIVEN)
107+
mock_settings_file["connection_history"] = ["COM1", "COM2", "COM3"]
108+
109+
with (
110+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
111+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
112+
):
113+
# Act: User reconnects with COM2 (WHEN)
114+
ProgramSettings.store_connection("COM2")
115+
116+
# Assert: COM2 moved to top, no duplicates (THEN)
117+
saved_settings = mock_set.call_args[0][0]
118+
history = saved_settings["connection_history"]
119+
assert history[0] == "COM2" # Moved to top
120+
assert history[1] == "COM1"
121+
assert history[2] == "COM3"
122+
assert len(history) == 3 # No duplicates
123+
assert history.count("COM2") == 1
124+
125+
def test_system_enforces_history_limit(self, mock_settings_file: dict[str, Any]) -> None:
126+
"""
127+
System enforces maximum history size limit.
128+
129+
GIVEN: A connection history with 10 entries (at capacity)
130+
WHEN: The user stores an 11th connection
131+
THEN: The oldest connection should be removed to maintain the 10-item limit
132+
"""
133+
# Arrange: Fill history to capacity (GIVEN)
134+
mock_settings_file["connection_history"] = [f"COM{i}" for i in range(1, 11)] # COM1 through COM10
135+
136+
with (
137+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
138+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
139+
):
140+
# Act: Store 11th connection (WHEN)
141+
ProgramSettings.store_connection("COM11")
142+
143+
# Assert: History limited to 10, oldest removed (THEN)
144+
saved_settings = mock_set.call_args[0][0]
145+
history = saved_settings["connection_history"]
146+
assert len(history) == 10 # Limit enforced
147+
assert history[0] == "COM11" # New connection at top
148+
assert "COM10" not in history # Oldest removed
149+
150+
def test_system_ignores_empty_connection_string(self, mock_settings_file: dict[str, Any]) -> None:
151+
"""
152+
System ignores empty connection strings.
153+
154+
GIVEN: A user attempts to save an invalid connection
155+
WHEN: The connection string is empty
156+
THEN: The history should remain unchanged
157+
"""
158+
# Arrange: Setup initial history (GIVEN)
159+
initial_history = ["COM1", "COM2"]
160+
mock_settings_file["connection_history"] = initial_history.copy()
161+
162+
with (
163+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
164+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
165+
):
166+
# Act: Try to store empty string (WHEN)
167+
ProgramSettings.store_connection("")
168+
169+
# Assert: Settings not modified (THEN)
170+
mock_set.assert_not_called()
171+
172+
def test_system_ignores_whitespace_only_connection_string(self, mock_settings_file: dict[str, Any]) -> None:
173+
"""
174+
System ignores whitespace-only connection strings.
175+
176+
GIVEN: A user attempts to save a whitespace-only connection
177+
WHEN: The connection string contains only spaces, tabs, or newlines
178+
THEN: The history should remain unchanged
179+
"""
180+
# Arrange: Setup initial history (GIVEN)
181+
mock_settings_file["connection_history"] = ["COM1"]
182+
183+
with (
184+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
185+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
186+
):
187+
# Act: Try to store whitespace strings (WHEN)
188+
ProgramSettings.store_connection(" ")
189+
ProgramSettings.store_connection("\t\t")
190+
ProgramSettings.store_connection("\n\n")
191+
ProgramSettings.store_connection(" \t\n ")
192+
193+
# Assert: Settings not modified (THEN)
194+
mock_set.assert_not_called()
195+
196+
def test_system_strips_whitespace_from_valid_connections(self, mock_settings_file: dict[str, Any]) -> None:
197+
"""
198+
System strips leading and trailing whitespace from connections.
199+
200+
GIVEN: A user enters a connection string with whitespace
201+
WHEN: The connection string has valid content with extra spaces
202+
THEN: The whitespace should be stripped before storage
203+
"""
204+
# Arrange: Setup initial history (GIVEN)
205+
mock_settings_file["connection_history"] = []
206+
207+
with (
208+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
209+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
210+
):
211+
# Act: Store connection with whitespace (WHEN)
212+
ProgramSettings.store_connection(" COM3 ")
213+
214+
# Assert: Whitespace stripped (THEN)
215+
saved_settings = mock_set.call_args[0][0]
216+
history = saved_settings["connection_history"]
217+
assert history[0] == "COM3" # No whitespace
218+
219+
def test_system_rejects_excessively_long_connection_strings(self, mock_settings_file: dict[str, Any]) -> None:
220+
"""
221+
System rejects connection strings exceeding maximum length.
222+
223+
GIVEN: A user attempts to save an extremely long connection string
224+
WHEN: The connection string exceeds 200 characters
225+
THEN: The connection should be rejected and history unchanged
226+
"""
227+
# Arrange: Setup initial history (GIVEN)
228+
mock_settings_file["connection_history"] = ["COM1"]
229+
230+
with (
231+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
232+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
233+
):
234+
# Act: Try to store overly long string (WHEN)
235+
long_string = "x" * 201 # 201 characters
236+
ProgramSettings.store_connection(long_string)
237+
238+
# Assert: Settings not modified (THEN)
239+
mock_set.assert_not_called()
240+
241+
def test_system_accepts_connection_at_maximum_length(self, mock_settings_file: dict[str, Any]) -> None:
242+
"""
243+
System accepts connection strings at the maximum allowed length.
244+
245+
GIVEN: A user enters a connection string at the 200-character limit
246+
WHEN: The connection string is exactly 200 characters
247+
THEN: The connection should be accepted and stored
248+
"""
249+
# Arrange: Setup empty history (GIVEN)
250+
mock_settings_file["connection_history"] = []
251+
252+
with (
253+
patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file),
254+
patch.object(ProgramSettings, "_set_settings_from_dict") as mock_set,
255+
):
256+
# Act: Store 200-character string (WHEN)
257+
max_length_string = "x" * 200
258+
ProgramSettings.store_connection(max_length_string)
259+
260+
# Assert: Connection stored (THEN)
261+
mock_set.assert_called_once()
262+
saved_settings = mock_set.call_args[0][0]
263+
assert saved_settings["connection_history"][0] == max_length_string
264+
265+
def test_system_handles_corrupted_history_with_non_list_value(self, mock_settings_file: dict[str, Any]) -> None:
266+
"""
267+
System handles corrupted settings with non-list history gracefully.
268+
269+
GIVEN: Settings file contains invalid data type for connection_history
270+
WHEN: The system retrieves connection history
271+
THEN: An empty list should be returned without errors
272+
"""
273+
# Arrange: Corrupt history with non-list value (GIVEN)
274+
mock_settings_file["connection_history"] = "not a list"
275+
276+
with patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file):
277+
# Act: Get connection history (WHEN)
278+
result = ProgramSettings.get_connection_history()
279+
280+
# Assert: Returns empty list (THEN)
281+
assert result == []
282+
283+
def test_system_filters_non_string_values_from_history(self, mock_settings_file: dict[str, Any]) -> None:
284+
"""
285+
System filters out non-string values from connection history.
286+
287+
GIVEN: Settings file contains mixed data types in connection_history
288+
WHEN: The system retrieves connection history
289+
THEN: Only valid string entries should be returned
290+
"""
291+
# Arrange: History with mixed types (GIVEN)
292+
mock_settings_file["connection_history"] = [
293+
"COM1",
294+
123, # Invalid: number
295+
"COM2",
296+
None, # Invalid: None
297+
"COM3",
298+
{"port": "COM4"}, # Invalid: dict
299+
"COM5",
300+
]
301+
302+
with patch.object(ProgramSettings, "_get_settings_as_dict", return_value=mock_settings_file):
303+
# Act: Get connection history (WHEN)
304+
result = ProgramSettings.get_connection_history()
305+
306+
# Assert: Only strings returned (THEN)
307+
assert result == ["COM1", "COM2", "COM3", "COM5"]
308+
assert all(isinstance(item, str) for item in result)

0 commit comments

Comments
 (0)