Skip to content

Commit 438ac40

Browse files
committed
test(ui): add window scaling pytests
1 parent 97202d0 commit 438ac40

5 files changed

Lines changed: 765 additions & 4 deletions

tests/test_frontend_tkinter_base_window.py

Lines changed: 228 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from ardupilot_methodic_configurator.frontend_tkinter_base_window import (
3232
BaseWindow,
33+
ask_retry_cancel_popup,
3334
ask_yesno_popup,
3435
is_debugging,
3536
show_error_popup,
@@ -318,6 +319,56 @@ def test_user_sees_fallback_when_image_unavailable(self, image_test_context, moc
318319
with pytest.raises(FileNotFoundError, match="Image file not found"):
319320
mocked_base_window.put_image_in_label(parent_frame, "missing_image.png", fallback_text="Image not available")
320321

322+
def test_user_sees_fallback_label_when_image_has_zero_height(self, mocked_base_window) -> None:
323+
"""
324+
User sees a fallback label instead of a crash when an image reports zero height.
325+
326+
GIVEN: A corrupt or degenerate image file whose PIL size is (width, 0)
327+
WHEN: put_image_in_label() is called with that file
328+
THEN: A fallback label is returned (ValueError caught internally)
329+
"""
330+
mock_image = MagicMock()
331+
mock_image.size = (100, 0) # zero height triggers the ValueError guard
332+
mock_image.__enter__ = MagicMock(return_value=mock_image)
333+
mock_image.__exit__ = MagicMock(return_value=None)
334+
335+
with (
336+
patch("os.path.isfile", return_value=True),
337+
patch("PIL.Image.open", return_value=mock_image),
338+
patch("tkinter.ttk.Label") as mock_label,
339+
):
340+
parent_frame = MagicMock()
341+
result = mocked_base_window.put_image_in_label(parent_frame, "degenerate.png", fallback_text="bad image")
342+
343+
# Fallback label is returned, not the image label
344+
mock_label.assert_called_once()
345+
assert result == mock_label.return_value
346+
347+
def test_user_sees_fallback_label_when_image_has_zero_width(self, mocked_base_window) -> None:
348+
"""
349+
User sees a fallback label instead of a crash when an image reports zero width.
350+
351+
GIVEN: A corrupt or degenerate image file whose PIL size is (0, height)
352+
WHEN: put_image_in_label() is called with that file
353+
THEN: A fallback label is returned (ValueError caught internally)
354+
"""
355+
mock_image = MagicMock()
356+
mock_image.size = (0, 100) # zero width triggers the ValueError guard
357+
mock_image.__enter__ = MagicMock(return_value=mock_image)
358+
mock_image.__exit__ = MagicMock(return_value=None)
359+
360+
with (
361+
patch("os.path.isfile", return_value=True),
362+
patch("PIL.Image.open", return_value=mock_image),
363+
patch("tkinter.ttk.Label") as mock_label,
364+
):
365+
parent_frame = MagicMock()
366+
result = mocked_base_window.put_image_in_label(parent_frame, "degenerate.png", fallback_text="bad image")
367+
368+
# Fallback label is returned, not the image label
369+
mock_label.assert_called_once()
370+
assert result == mock_label.return_value
371+
321372
@pytest.mark.parametrize(
322373
("original_size", "target_height", "expected_width"),
323374
[
@@ -349,6 +400,34 @@ def test_maintains_image_aspect_ratios( # pylint: disable=too-many-arguments,to
349400
class TestErrorResilienceBehavior:
350401
"""Test how application handles error conditions gracefully."""
351402

403+
def test_app_skips_icon_setup_when_debugger_is_active(self) -> None:
404+
"""
405+
Application skips icon setup when a debugger is actively connected.
406+
407+
GIVEN: A developer has a VS Code debugger attached to the process
408+
WHEN: _setup_application_icon() is called
409+
THEN: Icon loading is skipped entirely (debugger cannot cope with it)
410+
"""
411+
# Clear PYTEST_CURRENT_TEST so the early test-environment guard doesn't trigger first
412+
env_without_pytest = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"}
413+
414+
with (
415+
patch.dict(os.environ, env_without_pytest, clear=True),
416+
patch("ardupilot_methodic_configurator.frontend_tkinter_base_window.is_debugging", return_value=True),
417+
patch.object(BaseWindow, "_setup_theme_and_styling"),
418+
patch.object(BaseWindow, "_get_dpi_scaling_factor", return_value=1.0),
419+
):
420+
window = BaseWindow.__new__(BaseWindow)
421+
window.root = MagicMock()
422+
window.main_frame = MagicMock()
423+
window.dpi_scaling_factor = 1.0
424+
425+
# Act: Call _setup_application_icon with debugger active
426+
window._setup_application_icon()
427+
428+
# Assert: icon was never set on the root window
429+
window.root.iconphoto.assert_not_called()
430+
352431
def test_user_sees_functional_app_despite_icon_issues(self) -> None:
353432
"""
354433
User sees functional application despite icon loading issues.
@@ -473,6 +552,36 @@ def test_user_sees_properly_positioned_dialogs_regardless_of_window_size(self, t
473552

474553
child.destroy()
475554

555+
def test_center_window_calls_update_idletasks_on_macos(self) -> None:
556+
"""
557+
center_window calls update_idletasks (not update) on macOS for correct rendering.
558+
559+
GIVEN: Application is running on macOS (Darwin)
560+
WHEN: center_window() positions a child window relative to a parent
561+
THEN: update_idletasks() is used instead of update() to avoid macOS-specific issues
562+
"""
563+
mock_window = MagicMock()
564+
mock_parent = MagicMock()
565+
mock_window.winfo_width.return_value = 1
566+
mock_window.winfo_height.return_value = 1
567+
mock_window.winfo_reqwidth.return_value = 200
568+
mock_window.winfo_reqheight.return_value = 100
569+
mock_parent.winfo_x.return_value = 0
570+
mock_parent.winfo_y.return_value = 0
571+
mock_parent.winfo_width.return_value = 800
572+
mock_parent.winfo_height.return_value = 600
573+
574+
with patch(
575+
"ardupilot_methodic_configurator.frontend_tkinter_base_window.platform_system",
576+
return_value="Darwin",
577+
):
578+
BaseWindow.center_window(mock_window, mock_parent)
579+
580+
# On Darwin: update_idletasks() is called (twice: once unconditionally, once in Darwin branch)
581+
# The key invariant is that update() is never called on macOS
582+
assert mock_window.update_idletasks.call_count == 2
583+
mock_window.update.assert_not_called()
584+
476585
def test_user_can_safely_close_windows_without_memory_leaks(self, tk_root) -> None:
477586
"""
478587
User can safely close windows without memory leaks.
@@ -1268,6 +1377,104 @@ def test_developer_sees_debug_mode_image_fallback(self, mocked_base_window) -> N
12681377
mock_label.assert_called_once_with(parent_frame) # Empty label for debugger
12691378

12701379

1380+
class TestWin32DpiDetection:
1381+
"""Test Win32 DPI detection added in this PR for Windows HiDPI support."""
1382+
1383+
def test_returns_system_dpi_when_win32_api_available(self) -> None:
1384+
"""
1385+
Returns system DPI when Win32 API is accessible.
1386+
1387+
GIVEN: Running on Windows with GetDpiForSystem available via ctypes
1388+
WHEN: _get_win32_system_dpi() is called
1389+
THEN: Returns the DPI value reported by the Win32 API
1390+
"""
1391+
mock_ctypes = MagicMock()
1392+
mock_ctypes.windll.user32.GetDpiForSystem.return_value = 144
1393+
1394+
with patch.dict("sys.modules", {"ctypes": mock_ctypes}):
1395+
result = BaseWindow._get_win32_system_dpi()
1396+
1397+
assert result == 144
1398+
1399+
def test_returns_zero_when_windll_raises_attribute_error(self) -> None:
1400+
"""
1401+
Returns 0 gracefully when ctypes.windll raises AttributeError (non-Windows).
1402+
1403+
GIVEN: Running on Linux/macOS where ctypes.windll is unavailable
1404+
WHEN: _get_win32_system_dpi() is called
1405+
THEN: Returns 0 without propagating the AttributeError
1406+
"""
1407+
mock_ctypes = MagicMock()
1408+
mock_ctypes.windll.user32.GetDpiForSystem.side_effect = AttributeError("windll not available")
1409+
1410+
with patch.dict("sys.modules", {"ctypes": mock_ctypes}):
1411+
result = BaseWindow._get_win32_system_dpi()
1412+
1413+
assert result == 0
1414+
1415+
def test_returns_zero_when_win32_raises_os_error(self) -> None:
1416+
"""
1417+
Returns 0 gracefully when Win32 call raises OSError.
1418+
1419+
GIVEN: Win32 API call fails with OSError (e.g., access denied)
1420+
WHEN: _get_win32_system_dpi() is called
1421+
THEN: Returns 0 without propagating the OSError
1422+
"""
1423+
mock_ctypes = MagicMock()
1424+
mock_ctypes.windll.user32.GetDpiForSystem.side_effect = OSError("Access denied")
1425+
1426+
with patch.dict("sys.modules", {"ctypes": mock_ctypes}):
1427+
result = BaseWindow._get_win32_system_dpi()
1428+
1429+
assert result == 0
1430+
1431+
def test_dpi_scaling_uses_win32_dpi_when_on_windows(self, dpi_test_window) -> None:
1432+
"""
1433+
_get_dpi_scaling_factor uses Win32 DPI on Windows, bypassing Tk virtualization.
1434+
1435+
GIVEN: Running on Windows with 150% display scaling (144 DPI)
1436+
WHEN: _get_dpi_scaling_factor() is called
1437+
THEN: Returns 1.5 derived from Win32 API, ignoring the Tk-reported values
1438+
"""
1439+
# dpi_test_window already patches _get_win32_system_dpi to 0;
1440+
# the inner patch.object on the instance overrides that for this call.
1441+
window, stack = dpi_test_window(96.0)
1442+
1443+
with (
1444+
stack,
1445+
patch(
1446+
"ardupilot_methodic_configurator.frontend_tkinter_base_window.platform_system",
1447+
return_value="Windows",
1448+
),
1449+
patch.object(window, "_get_win32_system_dpi", return_value=144),
1450+
):
1451+
result = window._get_dpi_scaling_factor()
1452+
1453+
assert result == pytest.approx(1.5)
1454+
1455+
def test_dpi_scaling_falls_through_to_tk_when_win32_returns_zero(self, dpi_test_window) -> None:
1456+
"""
1457+
_get_dpi_scaling_factor falls through to Tk path when Win32 returns 0.
1458+
1459+
GIVEN: Running on Windows but Win32 DPI query returns 0 (query failed)
1460+
WHEN: _get_dpi_scaling_factor() is called
1461+
THEN: Falls back to Tk-based measurement (192 DPI → 2.0 scaling factor)
1462+
"""
1463+
window, stack = dpi_test_window(192.0, tk_scaling=2.0)
1464+
1465+
with (
1466+
stack,
1467+
patch(
1468+
"ardupilot_methodic_configurator.frontend_tkinter_base_window.platform_system",
1469+
return_value="Windows",
1470+
),
1471+
patch.object(window, "_get_win32_system_dpi", return_value=0),
1472+
):
1473+
result = window._get_dpi_scaling_factor()
1474+
1475+
assert result == pytest.approx(2.0)
1476+
1477+
12711478
class TestScalingCalculationMethods:
12721479
"""Test the various DPI scaling calculation methods."""
12731480

@@ -1405,6 +1612,25 @@ def test_user_can_confirm_actions_with_yesno_popup(self) -> None:
14051612
assert result is True
14061613
mock_yesno.assert_called_once_with("Confirm Action", "Are you sure?")
14071614

1615+
def test_user_can_retry_or_cancel_with_retry_cancel_popup(self) -> None:
1616+
"""
1617+
User can choose to retry or cancel a failed operation.
1618+
1619+
GIVEN: An operation has failed and needs user decision to retry or abort
1620+
WHEN: The retry/cancel popup is triggered
1621+
THEN: Should return True when user chooses retry, False when user cancels
1622+
"""
1623+
# Arrange & Act: User sees retry/cancel dialog
1624+
with patch(
1625+
"ardupilot_methodic_configurator.frontend_tkinter_base_window.messagebox.askretrycancel",
1626+
return_value=True,
1627+
) as mock_retry:
1628+
result = ask_retry_cancel_popup("Retry?", "Operation failed. Retry?")
1629+
1630+
# Then: Should return user's choice
1631+
assert result is True
1632+
mock_retry.assert_called_once_with("Retry?", "Operation failed. Retry?")
1633+
14081634

14091635
class TestWindowLifecycleBehavior:
14101636
"""Test complete window lifecycle from creation to destruction."""
@@ -1777,9 +2003,9 @@ def test_uses_rendered_size_when_window_is_mapped(self) -> None:
17772003
# Mock window: winfo_width/height return actual rendered size (> 1),
17782004
# winfo_reqwidth/height return different (content-based) values
17792005
mock_window = MagicMock()
1780-
mock_window.winfo_width.return_value = 450 # actual rendered width
2006+
mock_window.winfo_width.return_value = 450 # actual rendered width
17812007
mock_window.winfo_height.return_value = 350 # actual rendered height
1782-
mock_window.winfo_reqwidth.return_value = 300 # content size (should NOT be used)
2008+
mock_window.winfo_reqwidth.return_value = 300 # content size (should NOT be used)
17832009
mock_window.winfo_reqheight.return_value = 200 # content size (should NOT be used)
17842010
mock_window.winfo_pointerx.return_value = 960
17852011
mock_window.winfo_pointery.return_value = 540

tests/test_frontend_tkinter_progress_window.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,72 @@ def test_user_sees_fc_init_progress_centered_on_screen_when_parent_is_withdrawn(
294294

295295
with contextlib.suppress(Exception):
296296
window.destroy()
297+
298+
def test_progress_window_falls_back_to_unit_scaling_on_dpi_detection_failure(self, tk_root) -> None:
299+
"""
300+
Progress window uses 1.0 DPI scaling when winfo_fpixels raises TclError.
301+
302+
GIVEN: A progress window is created in an environment where DPI detection fails
303+
WHEN: winfo_fpixels raises TclError inside the new DPI try/except block
304+
THEN: dpi_scaling_factor falls back to 1.0 and the window is created successfully
305+
"""
306+
with (
307+
patch.object(tk.Toplevel, "winfo_fpixels", side_effect=tk.TclError("no display")),
308+
patch("ardupilot_methodic_configurator.frontend_tkinter_base_window.BaseWindow.center_window_on_screen"),
309+
patch("ardupilot_methodic_configurator.frontend_tkinter_base_window.BaseWindow.center_window"),
310+
):
311+
# Should not raise — except handler sets dpi_scaling_factor = 1.0
312+
window = ProgressWindow(
313+
tk_root,
314+
title="DPI Test",
315+
message="Test: {}/{}",
316+
width=300,
317+
height=80,
318+
only_show_when_update_progress_called=True,
319+
)
320+
321+
# Window was created successfully with fallback scaling
322+
assert window is not None
323+
assert window.progress_window.winfo_exists()
324+
325+
with contextlib.suppress(Exception):
326+
window.destroy()
327+
328+
def test_already_shown_lazy_window_skips_deiconify_on_subsequent_updates(self, lazy_progress_window) -> None:
329+
"""
330+
Already-shown lazy window skips deiconify/lift/center on subsequent updates.
331+
332+
GIVEN: A lazy progress window that has already been shown by an initial update
333+
WHEN: update_progress_bar() is called a second time
334+
THEN: deiconify() and lift() are NOT called again
335+
AND: The progress bar value is still updated correctly (covers 119→123)
336+
"""
337+
# First call shows the window (sets _shown=True)
338+
lazy_progress_window.update_progress_bar(10, 100)
339+
assert lazy_progress_window._shown # pylint: disable=protected-access
340+
341+
# Track calls from the second invocation onward
342+
lazy_progress_window.progress_window.deiconify = MagicMock()
343+
lazy_progress_window.progress_window.lift = MagicMock()
344+
345+
# Second call: _shown=True AND only_show_when_update_progress_called=True
346+
# → neither if nor elif branch is taken (covers 119→123)
347+
lazy_progress_window.update_progress_bar(50, 100)
348+
349+
lazy_progress_window.progress_window.deiconify.assert_not_called()
350+
lazy_progress_window.progress_window.lift.assert_not_called()
351+
assert lazy_progress_window.progress_bar["value"] == 50
352+
353+
def test_update_progress_bar_does_nothing_when_progress_bar_is_none(self, progress_window) -> None:
354+
"""
355+
update_progress_bar skips widget updates when progress_bar is None.
356+
357+
GIVEN: A progress window whose progress_bar attribute has been set to None
358+
WHEN: update_progress_bar() is called
359+
THEN: No error is raised and widget state is unchanged (covers 123→exit)
360+
"""
361+
# Force progress_bar to None so the safety-check guard fails (covers 123→exit)
362+
progress_window.progress_bar = None
363+
364+
# Should not raise even though progress_bar is None
365+
progress_window.update_progress_bar(50, 100)

0 commit comments

Comments
 (0)