|
30 | 30 |
|
31 | 31 | from ardupilot_methodic_configurator.frontend_tkinter_base_window import ( |
32 | 32 | BaseWindow, |
| 33 | + ask_retry_cancel_popup, |
33 | 34 | ask_yesno_popup, |
34 | 35 | is_debugging, |
35 | 36 | show_error_popup, |
@@ -318,6 +319,56 @@ def test_user_sees_fallback_when_image_unavailable(self, image_test_context, moc |
318 | 319 | with pytest.raises(FileNotFoundError, match="Image file not found"): |
319 | 320 | mocked_base_window.put_image_in_label(parent_frame, "missing_image.png", fallback_text="Image not available") |
320 | 321 |
|
| 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 | + |
321 | 372 | @pytest.mark.parametrize( |
322 | 373 | ("original_size", "target_height", "expected_width"), |
323 | 374 | [ |
@@ -349,6 +400,34 @@ def test_maintains_image_aspect_ratios( # pylint: disable=too-many-arguments,to |
349 | 400 | class TestErrorResilienceBehavior: |
350 | 401 | """Test how application handles error conditions gracefully.""" |
351 | 402 |
|
| 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 | + |
352 | 431 | def test_user_sees_functional_app_despite_icon_issues(self) -> None: |
353 | 432 | """ |
354 | 433 | 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 |
473 | 552 |
|
474 | 553 | child.destroy() |
475 | 554 |
|
| 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 | + |
476 | 585 | def test_user_can_safely_close_windows_without_memory_leaks(self, tk_root) -> None: |
477 | 586 | """ |
478 | 587 | 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 |
1268 | 1377 | mock_label.assert_called_once_with(parent_frame) # Empty label for debugger |
1269 | 1378 |
|
1270 | 1379 |
|
| 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 | + |
1271 | 1478 | class TestScalingCalculationMethods: |
1272 | 1479 | """Test the various DPI scaling calculation methods.""" |
1273 | 1480 |
|
@@ -1405,6 +1612,25 @@ def test_user_can_confirm_actions_with_yesno_popup(self) -> None: |
1405 | 1612 | assert result is True |
1406 | 1613 | mock_yesno.assert_called_once_with("Confirm Action", "Are you sure?") |
1407 | 1614 |
|
| 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 | + |
1408 | 1634 |
|
1409 | 1635 | class TestWindowLifecycleBehavior: |
1410 | 1636 | """Test complete window lifecycle from creation to destruction.""" |
@@ -1777,9 +2003,9 @@ def test_uses_rendered_size_when_window_is_mapped(self) -> None: |
1777 | 2003 | # Mock window: winfo_width/height return actual rendered size (> 1), |
1778 | 2004 | # winfo_reqwidth/height return different (content-based) values |
1779 | 2005 | 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 |
1781 | 2007 | 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) |
1783 | 2009 | mock_window.winfo_reqheight.return_value = 200 # content size (should NOT be used) |
1784 | 2010 | mock_window.winfo_pointerx.return_value = 960 |
1785 | 2011 | mock_window.winfo_pointery.return_value = 540 |
|
0 commit comments