Repository: beeware/toga

Issue:
test_selection_change fails, goes to full screen on macOS
### Describe the bug

Okay, I've got a strange one. I'm getting a failure for `tests/widgets/test_selection::test_selection_change`. Running it in slow, and inserting pauses of my own, I see *what* is happening, I just don't know why: the probe clicks on the selection, which opens it up. It then attempts to click a little higher, on the next item up. Somehow, the selection always collapses the instant of the mouse click, so the click hits the green fullscreen button instead.

I recorded my screen while it happened. Stepping through the frames, I can see that the fullscreen button is clicked before the selection options have even finished visually disappearing:

![image](https://github.com/beeware/toga/assets/50283671/be1f6a2e-13dc-49d2-a851-e4e68b263a6b)

I'm also getting a failure for `tests/widgets/test_detailedlist.py::test_actions`, for a similar reason: it looks like the action menu collapses just before the action can be selected.

I don't know what would have changed to make this start happening; it appears to persist even if I checkout earlier versions of Toga.

### Steps to reproduce

1. Run `briefcase dev --test -- --slow tests/widgets/test_selection.py::test_selection_change` (or `
tests/widgets/test_detailedlist.py::test_actions`). (Due to #2382, I'm currently resorting to `run` or `dev -r`.)
2. See test failure. In selection_change, you can also see the test window go fullscreen.


### Expected behavior

The second click should select the other option in the selection, rather than the window's fullscreen button.

### Environment

- Operating System: macOS 14.2.1
- Python version: 3.10.13
- Software versions:
  - Briefcase: 0.3.17
  - Toga: from source, up-to-date with main



Discussion:
This is interesting - we have seen some intermittent test failures (maybe 1 in 30-50 test runs?) in CI on these exact tests. Run the tests again, and they almost always pass. Is this 100% reproducible for you, or intermittent?

Either way - if the bug is being cause because the menu is collapsing and we're hitting the "maximise" button, my immediate thought is that we should adding some padding to the widget so that there's no risk of the popup menu colliding with the maximise button.

There may also be a need for a timeout on the test - or, at least a restructure so that we don't wait forever for an event that won't happen.
Yes, so far it's 100% reproducible. Every time, across multiple Python versions. I hadn't touched Toga in probably a couple of weeks prior — I can't remember if maybe I updated the OS or something?

I wonder... padding could avoid it hitting the fullscreen button. But unless the specific position of that button is what's causing the selection to collapse and the click to go "behind" it, it still wouldn't select the correct item. I can try it and see.
Unfortunately I was correct; padding can clear the fullscreen button, but the click still doesn't register on the selection option.
One more data point: I created a Mac virtual machine in UTM, set up Toga there, and ran the testbed; I got the same result. So it's evidently not just some weird quirk of my current system setup. (Both host and guest are running 14.3.1.)

Resolving patch:
diff --git a/cocoa/src/toga_cocoa/widgets/detailedlist.py b/cocoa/src/toga_cocoa/widgets/detailedlist.py
--- a/cocoa/src/toga_cocoa/widgets/detailedlist.py
+++ b/cocoa/src/toga_cocoa/widgets/detailedlist.py
@@ -18,6 +18,11 @@ class TogaList(NSTableView):
     interface = objc_property(object, weak=True)
     impl = objc_property(object, weak=True)
 
+    @objc_method
+    def didCloseMenu_withEvent_(self, menu, event) -> None:
+        # When the menu closes, drop the reference to the menu object.
+        self.impl._popup = None
+
     @objc_method
     def menuForEvent_(self, event):
         if self.impl.primary_action_enabled or self.impl.secondary_action_enabled:
@@ -49,9 +54,12 @@ def menuForEvent_(self, event):
                 )
                 secondary_action_item.tag = row
 
-            return popup
         else:
-            return None
+            popup = None
+
+        # Preserve a Python reference to the popup for testing purposes.
+        self.impl._popup = popup
+        return popup
 
     @objc_method
     def primaryActionOnRow_(self, menuitem):
diff --git a/cocoa/src/toga_cocoa/widgets/internal/refresh.py b/cocoa/src/toga_cocoa/widgets/internal/refresh.py
--- a/cocoa/src/toga_cocoa/widgets/internal/refresh.py
+++ b/cocoa/src/toga_cocoa/widgets/internal/refresh.py
@@ -99,7 +99,12 @@ def constrainScrollPoint_(self, proposedNewOrigin: NSPoint) -> NSPoint:
             argtypes=[NSPoint],
         )
 
-        if self.superview and self.superview.is_refreshing:
+        # FIXME: This has been marked no-cover so that ARM64 testing can be enabled;
+        # ARM64 CI can only run on Sonoma, and it looks like Sonoma has turned off
+        # scroll elasticity by default, which prevents pull-to-refresh from working.
+        # See Toga#2412 for details. If that ticket is closed, it should be possible
+        # to remove this this no-cover.
+        if self.superview and self.superview.is_refreshing:  # pragma: no cover
             return NSMakePoint(
                 constrained.x,
                 max(


Test patch:
diff --git a/cocoa/tests_backend/widgets/detailedlist.py b/cocoa/tests_backend/widgets/detailedlist.py
--- a/cocoa/tests_backend/widgets/detailedlist.py
+++ b/cocoa/tests_backend/widgets/detailedlist.py
@@ -179,9 +179,10 @@ async def refresh_action(self, active=True):
             while self.scroll_position < 0:
                 await asyncio.sleep(0.01)
 
-    async def _perform_action(self, row, offset):
+    async def _perform_action(self, row, index):
         point = self.row_position(row)
-        # First click to show menu
+
+        # Click to show menu
         await self.mouse_event(
             NSEventType.RightMouseDown,
             point,
@@ -189,29 +190,17 @@ async def _perform_action(self, row, offset):
         )
         await self.redraw("Action menu has been displayed")
 
-        # Pick a point a little to the right of the point where the menu was displayed,
-        # and slightly lower (in reversed y coordinates) to select a menu item.
-        point2 = NSPoint(point.x + 10, point.y - offset)
-        await self.mouse_event(
-            NSEventType.LeftMouseDown,
-            point2,
-            delay=0.1,
-        )
-        await self.mouse_event(
-            NSEventType.LeftMouseUp,
-            point2,
-            delay=0.1,
-        )
+        popup = self.impl._popup
+        if popup:
+            popup.performActionForItemAtIndex(index)
+            popup.cancelTracking()
+
+            # Wait until the popup menu is fully disposed.
+            while self.impl._popup is not None:
+                await self.redraw("Action has been selected", delay=0.1)
 
     async def perform_primary_action(self, row, active=True):
-        # 10px is enough to select the first menu item. It doesn't matter whether the
-        # action is active or not; if the action is inactive, it will either press the
-        # wrong action, or press empty space.
-        await self._perform_action(row, 10)
+        await self._perform_action(row, 0)
 
     async def perform_secondary_action(self, row, active=True):
-        # 30px is enough to select the second menu item. However the secondary action
-        # will be in position 1 on the menu if the primary action is disabled. It
-        # doesn't matter whether the action is active or not; if the action is inactive,
-        # it will either press the wrong action, or press empty space.
-        await self._perform_action(row, 30 if self.impl.primary_action_enabled else 10)
+        await self._perform_action(row, 1 if self.impl.primary_action_enabled else 0)
diff --git a/cocoa/tests_backend/widgets/selection.py b/cocoa/tests_backend/widgets/selection.py
--- a/cocoa/tests_backend/widgets/selection.py
+++ b/cocoa/tests_backend/widgets/selection.py
@@ -46,8 +46,5 @@ async def select_item(self):
         # Use a short delaly instead.
         await self.mouse_event(NSEventType.LeftMouseDown, point, delay=0.1)
 
-        # macOS coordinate systems are backwards, so to select the item *above*
-        # the current selection, you need to *add* height.
-        point.y = point.y + self.height
-        await self.mouse_event(NSEventType.LeftMouseDown, point, delay=0.1)
-        await self.mouse_event(NSEventType.LeftMouseUp, point, delay=0.1)
+        self.native.menu.performActionForItemAtIndex(1)
+        self.native.menu.cancelTracking()
diff --git a/iOS/tests_backend/widgets/detailedlist.py b/iOS/tests_backend/widgets/detailedlist.py
--- a/iOS/tests_backend/widgets/detailedlist.py
+++ b/iOS/tests_backend/widgets/detailedlist.py
@@ -1,7 +1,7 @@
 import asyncio
-import platform
+import os
 
-from rubicon.objc.api import Block
+from rubicon.objc.api import Block, ObjCClass
 
 from toga_iOS.libs import (
     NSIndexPath,
@@ -13,6 +13,8 @@
 
 from .base import SimpleProbe, UIControlEventValueChanged
 
+UIDevice = ObjCClass("UIDevice")
+
 
 class DetailedListProbe(SimpleProbe):
     native_class = UITableView
@@ -50,11 +52,17 @@ def assert_cell_content(self, row, title, subtitle, icon=None):
     @property
     def max_scroll_position(self):
         max_value = int(self.native.contentSize.height - self.native.frame.size.height)
-        # The max value is a little higher on iOS 17.
-        # Not sure where the 34 extra pixels are coming from. It appears to be
-        # a constant, independent of the number of rows of data.
-        if int(platform.release().split(".")[0]) >= 17:
+        # The max value is a dependent on the device; devices that don't have a physical
+        # button report as being a little larger. A physical device will give you the
+        # model identifier as part of UIDevice.currentDevice.model; however, simulators
+        # return "iPhone" as the model, so we need to check the environment as well to
+        # reliably get the device identifier. "iPhone14,6" is an iPhone SE 3rd edition.
+        # As of Feb 2023, it's the only device currently sold that has a physical
+        # button.
+        model = os.getenv("SIMULATOR_MODEL_IDENTIFIER", UIDevice.currentDevice.model)
+        if model != "iPhone14,6":
             max_value += 34
+
         return max(0, max_value)
 
     @property