Skip to content

Commit 97e1f7b

Browse files
Copilotamilcarlucas
andcommitted
test: add unit branch coverage for FC commands/params and split safe_write test helpers
- Add unit branch tests for FlightControllerCommands failure/missing-connection paths - Add unit tests for parameter download edge cases (MAVLink/MAVFTP timeout, exception, progress callback) - Add shared fixture for connected mock master in conftest - Move make_capture_safe_write test helper out of production code into tests - Add bdd safe_write behavior tests and helper-focused unit tests - Update vehicle components tests to import helper from tests module - Ignore tuning_report.csv in gitignore Agent-Logs-Url: https://github.com/ArduPilot/MethodicConfigurator/sessions/20139120-bd7b-4efe-8256-126ef58e373b Co-authored-by: amilcarlucas <24453563+amilcarlucas@users.noreply.github.com>
1 parent dd1e68a commit 97e1f7b

11 files changed

Lines changed: 1241 additions & 136 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ test.xml
2323
sitl/arducopter
2424
sitl/firmware-version.txt
2525
sitl/git-version.txt
26+
27+
tuning_report.csv

ardupilot_methodic_configurator/backend_safe_file_io.py

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@
99
SPDX-License-Identifier: GPL-3.0-or-later
1010
"""
1111

12-
import io
13-
import json
1412
import os
1513
import tempfile
1614
from contextlib import suppress
17-
from typing import IO, Any, Callable
15+
from typing import IO, Callable
1816

1917

2018
def safe_write(filepath: str, write_func: Callable[[IO[str]], object]) -> None:
@@ -75,38 +73,3 @@ def safe_write(filepath: str, write_func: Callable[[IO[str]], object]) -> None:
7573
if not replaced:
7674
with suppress(OSError):
7775
os.unlink(tmp_path)
78-
79-
80-
# ==================== SAFE-WRITE TEST HELPERS ====================
81-
82-
83-
def make_capture_safe_write() -> tuple[ # pragma: no cover
84-
dict[str, Any], list[bool], Callable[[str, Callable[[IO[str]], object]], None]
85-
]:
86-
"""
87-
Create a ``safe_write`` side-effect that captures written JSON data.
88-
89-
Returns a (captured_data, called, side_effect) triple:
90-
- ``captured_data``: dict updated with the JSON that the write_func produces.
91-
- ``called``: single-element list (``[False]``) flipped to ``True`` on invocation.
92-
- ``side_effect``: function to assign to ``mock_safe_write.side_effect``.
93-
94-
Usage::
95-
96-
captured_data, called, side_effect = make_capture_safe_write()
97-
mock_safe_write.side_effect = side_effect
98-
...
99-
assert called[0]
100-
assert captured_data == expected
101-
102-
"""
103-
captured_data: dict[str, Any] = {}
104-
called: list[bool] = [False]
105-
106-
def _capture(_filepath: str, write_func: Callable[[IO[str]], object]) -> None:
107-
fake_file = io.StringIO()
108-
write_func(fake_file)
109-
captured_data.update(json.loads(fake_file.getvalue()))
110-
called[0] = True
111-
112-
return captured_data, called, _capture

tests/bdd_backend_safe_file_io.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
BDD-style tests for the backend_safe_file_io.py file.
5+
6+
Tests for crash-safe atomic file writing utilities.
7+
8+
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
9+
10+
SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
11+
12+
SPDX-License-Identifier: GPL-3.0-or-later
13+
"""
14+
15+
import io
16+
import json
17+
import os
18+
import sys
19+
from pathlib import Path
20+
from unittest.mock import patch
21+
22+
import pytest
23+
24+
from ardupilot_methodic_configurator.backend_safe_file_io import safe_write
25+
26+
# pylint: disable=redefined-outer-name
27+
28+
29+
@pytest.fixture
30+
def tmp_file(tmp_path: Path) -> Path:
31+
"""Fixture providing a temporary file path."""
32+
return tmp_path / "test_output.json"
33+
34+
35+
class TestSafeWriteBasicBehavior:
36+
"""Tests for basic safe_write behavior."""
37+
38+
def test_user_can_write_content_to_new_file(self, tmp_file: Path) -> None:
39+
"""
40+
User can write content to a new file atomically.
41+
42+
GIVEN: A target file path that does not yet exist
43+
WHEN: User calls safe_write with a write function
44+
THEN: File should be created with the correct content
45+
AND: No temporary files should remain
46+
"""
47+
content = "Hello, World!\n"
48+
49+
safe_write(str(tmp_file), lambda f: f.write(content))
50+
51+
assert tmp_file.exists()
52+
assert tmp_file.read_text(encoding="utf-8") == content
53+
54+
def test_user_can_overwrite_existing_file(self, tmp_file: Path) -> None:
55+
"""
56+
User can atomically overwrite an existing file.
57+
58+
GIVEN: A target file already containing some content
59+
WHEN: User calls safe_write with new content
60+
THEN: File should contain only the new content
61+
AND: Original content should be replaced
62+
"""
63+
tmp_file.write_text("old content\n", encoding="utf-8")
64+
65+
safe_write(str(tmp_file), lambda f: f.write("new content\n"))
66+
67+
assert tmp_file.read_text(encoding="utf-8") == "new content\n"
68+
69+
def test_file_is_fully_written_before_target_is_replaced(self, tmp_file: Path) -> None:
70+
"""
71+
File is fully written to a temp location before replacing the target.
72+
73+
GIVEN: A target file with existing content
74+
WHEN: safe_write is called with large content
75+
THEN: Target should contain the complete new content
76+
AND: File should be readable immediately after write
77+
"""
78+
large_content = "x" * 100000 + "\n"
79+
80+
safe_write(str(tmp_file), lambda f: f.write(large_content))
81+
82+
result = tmp_file.read_text(encoding="utf-8")
83+
assert result == large_content
84+
85+
def test_user_can_write_json_content(self, tmp_file: Path) -> None:
86+
"""
87+
User can write JSON content to a file using safe_write.
88+
89+
GIVEN: A dictionary of data to persist
90+
WHEN: User writes JSON data via safe_write
91+
THEN: File should contain valid JSON with the expected data
92+
"""
93+
data = {"key": "value", "number": 42, "nested": {"list": [1, 2, 3]}}
94+
95+
safe_write(str(tmp_file), lambda f: json.dump(data, f, indent=2))
96+
97+
written = json.loads(tmp_file.read_text(encoding="utf-8"))
98+
assert written == data
99+
100+
def test_user_can_write_unicode_content(self, tmp_file: Path) -> None:
101+
"""
102+
User can write Unicode content to a file using safe_write.
103+
104+
GIVEN: Text content containing non-ASCII Unicode characters
105+
WHEN: User writes the content via safe_write
106+
THEN: File should be readable with the correct Unicode content preserved
107+
"""
108+
unicode_content = "Héllo Wörld — 日本語テスト\n"
109+
110+
safe_write(str(tmp_file), lambda f: f.write(unicode_content))
111+
112+
assert tmp_file.read_text(encoding="utf-8") == unicode_content
113+
114+
def test_no_temporary_files_remain_after_successful_write(self, tmp_path: Path) -> None:
115+
"""
116+
No temporary files remain after a successful write operation.
117+
118+
GIVEN: A target file path
119+
WHEN: safe_write completes successfully
120+
THEN: No .tmp files should remain in the directory
121+
"""
122+
tmp_file = tmp_path / "output.json"
123+
124+
safe_write(str(tmp_file), lambda f: f.write("content"))
125+
126+
tmp_files = list(tmp_path.glob("*.tmp"))
127+
assert not tmp_files, f"Found leftover temp files: {tmp_files}"
128+
129+
130+
class TestSafeWriteFilePermissions:
131+
"""Tests for safe_write file permission handling."""
132+
133+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits are not meaningful on Windows")
134+
def test_new_file_gets_default_permissions(self, tmp_file: Path) -> None:
135+
"""
136+
New file created by safe_write gets reasonable default permissions.
137+
138+
GIVEN: A target file path that does not exist
139+
WHEN: safe_write creates the file
140+
THEN: File should have readable permissions
141+
"""
142+
safe_write(str(tmp_file), lambda f: f.write("content"))
143+
144+
assert os.access(str(tmp_file), os.R_OK)
145+
assert os.access(str(tmp_file), os.W_OK)
146+
147+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits are not meaningful on Windows")
148+
def test_file_permissions_are_preserved_on_overwrite(self, tmp_file: Path) -> None:
149+
"""
150+
Existing file permissions are preserved when safe_write overwrites the file.
151+
152+
GIVEN: An existing file with specific permissions
153+
WHEN: safe_write overwrites it
154+
THEN: The file permissions should be preserved (or close to original)
155+
"""
156+
tmp_file.write_text("original content", encoding="utf-8")
157+
original_mode = os.stat(str(tmp_file)).st_mode
158+
159+
safe_write(str(tmp_file), lambda f: f.write("new content"))
160+
161+
new_mode = os.stat(str(tmp_file)).st_mode
162+
# Permissions should be preserved (same mode bits for owner/group/other read+write)
163+
assert (new_mode & 0o666) == (original_mode & 0o666)
164+
165+
def test_safe_write_handles_file_not_found_for_stat(self, tmp_path: Path) -> None:
166+
"""
167+
safe_write handles the case where the target doesn't exist (for stat).
168+
169+
GIVEN: A target file path that does not exist
170+
WHEN: safe_write tries to stat the target to preserve permissions
171+
THEN: FileNotFoundError should be handled gracefully
172+
AND: The write should still succeed with default permissions
173+
"""
174+
new_file = tmp_path / "nonexistent_parent" / "output.txt"
175+
new_file.parent.mkdir(parents=True)
176+
177+
safe_write(str(new_file), lambda f: f.write("content"))
178+
179+
assert new_file.exists()
180+
assert new_file.read_text(encoding="utf-8") == "content"
181+
182+
183+
class TestSafeWriteErrorHandling:
184+
"""Tests for safe_write error handling behavior."""
185+
186+
def test_cleanup_happens_when_write_fails(self, tmp_path: Path) -> None:
187+
"""
188+
Temporary file is cleaned up when write function raises an exception.
189+
190+
GIVEN: A write function that raises an exception
191+
WHEN: safe_write is called and the write fails
192+
THEN: No temporary files should remain in the directory
193+
AND: An exception should be raised to the caller
194+
"""
195+
196+
def failing_write(_f: io.TextIOWrapper) -> None:
197+
msg = "Simulated disk full"
198+
raise OSError(msg)
199+
200+
tmp_file = tmp_path / "output.txt"
201+
202+
with pytest.raises(OSError, match="Simulated disk full"):
203+
safe_write(str(tmp_file), failing_write)
204+
205+
tmp_files = list(tmp_path.glob("*.tmp"))
206+
assert not tmp_files, f"Temp files not cleaned up: {tmp_files}"
207+
208+
# Target should not have been created
209+
assert not tmp_file.exists()
210+
211+
def test_directory_fsync_failure_does_not_break_write(self, tmp_file: Path) -> None:
212+
"""
213+
Directory fsync failure does not break the write operation.
214+
215+
GIVEN: A system where directory fsync fails
216+
WHEN: safe_write is called
217+
THEN: The file write should still succeed
218+
AND: The error from fsync should be silently ignored
219+
"""
220+
with patch(
221+
"ardupilot_methodic_configurator.backend_safe_file_io.os.fsync",
222+
side_effect=[None, OSError("fsync failed")],
223+
):
224+
safe_write(str(tmp_file), lambda f: f.write("test content"))
225+
226+
assert tmp_file.exists()
227+
assert tmp_file.read_text(encoding="utf-8") == "test content"

tests/conftest.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from collections.abc import Callable, Generator
2424
from pathlib import Path
2525
from typing import Any, NamedTuple, Optional
26-
from unittest.mock import patch
26+
from unittest.mock import MagicMock, Mock, patch
2727

2828
import pyautogui
2929
import pytest
@@ -36,6 +36,26 @@
3636
from ardupilot_methodic_configurator.frontend_tkinter_base_window import BaseWindow
3737
from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table import NEW_VALUE_DIFFERENT_STR
3838

39+
# ==================== SHARED FLIGHT CONTROLLER FIXTURES ====================
40+
41+
42+
@pytest.fixture
43+
def mock_connected_master() -> tuple[MagicMock, Mock]:
44+
"""
45+
Provide a connected mock master and connection manager for flight controller tests.
46+
47+
Returns (mock_master, mock_conn_mgr) where mock_master has target_system=1 and
48+
target_component=1 pre-configured. Tests that require different system/component IDs
49+
should override those attributes directly after unpacking.
50+
"""
51+
mock_master = MagicMock()
52+
mock_master.target_system = 1
53+
mock_master.target_component = 1
54+
mock_conn_mgr = Mock()
55+
mock_conn_mgr.master = mock_master
56+
return mock_master, mock_conn_mgr
57+
58+
3959
# ==================== SHARED TKINTER TESTING CONFIGURATION ====================
4060

4161

tests/test_backend_filesystem_vehicle_components.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
from unittest.mock import mock_open, patch
1616

1717
import pytest
18+
from test_backend_safe_file_io_write_capture import make_capture_safe_write
1819

1920
from ardupilot_methodic_configurator.backend_filesystem_vehicle_components import VehicleComponents
20-
from ardupilot_methodic_configurator.backend_safe_file_io import make_capture_safe_write
2121
from ardupilot_methodic_configurator.data_model_template_overview import TemplateOverview
2222

2323
# pylint: disable=too-many-lines,too-many-public-methods,attribute-defined-outside-init

0 commit comments

Comments
 (0)