Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add polling based window waiting for testbed #3047

Merged
merged 13 commits into from
Dec 23, 2024

Conversation

proneon267
Copy link
Contributor

Fixes #3016

This PR adds polling based waiting mechanism to window_probe.wait_for_window().

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

@proneon267 proneon267 marked this pull request as draft December 17, 2024 19:44
@proneon267 proneon267 marked this pull request as ready for review December 18, 2024 07:09
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've taken an initial look at this... and you need to slow down and think about the problem a little more.

We already have tests. They're testing the right thing. If I wanted to see a different structure to those tests, I would have commented to that effect during the review process of those tests. This piece of work is to improve the implementation of the assertion to be polling based, rather than timing based.

The assertion already has most, if not all the information we need about the thing being asserted. wait_for_window(full_screen=True) means... wait until the window is full screen. That's all it needs to do. It might be worth considering a minor refactor so that wait_for_window(full_screen=True) is wait_for_window_state(FULL_SCREEN)... but that's essentially the only change we need here.

You're trying to do a radical redesign that mixes test assertions in with probe monitoring. When you're having to introduce a new hundred line test assertion helper method to make a refactor work, that's a pretty good sign that you're not on the right track.

I'm guessing you've gone down this track because of the change that was recently introduced to handle assertions in closing dialogs... but you need to remember why that was introduced. We literally needed to insert an assertion before a dialog was closed. There was no option but to pass that test method in to the method displaying the dialog. In this case, we have no such restriction.

@proneon267
Copy link
Contributor Author

You were right, the solution was much more simpler than what I had presented. In the heat of the moment, I didn't realize that the assertion condition related to polling was ultimately only related to state.

@proneon267 proneon267 force-pushed the polling_based_window_wait branch from 1464115 to 081ac8f Compare December 19, 2024 16:42
@proneon267
Copy link
Contributor Author

Sometimes, the macOS-arm64 CI stops at the start for no apparent reason at all, like in: https://github.com/beeware/toga/actions/runs/12416992758/job/34667120013. This doesn't seem to be related to #3016 and looks like github action randomly stopping the test run. So, I am not sure how to address it.

Comment on lines 811 to 817
# Required to ensure complete coverage of fullscreen handling on macOS.
if states[-1] == WindowState.FULLSCREEN:
await second_window_probe.wait_for_window(
"Waiting for completion of fullscreen pending state handling",
full_screen=True,
)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required to ensure coverage on line 99:

self.impl._pending_state_transition
and self.impl._pending_state_transition != WindowState.FULLSCREEN
):
# Directly exiting fullscreen without a delay will result in error:
# ````2024-08-09 15:46:39.050 python[2646:37395] not in fullscreen state````
# and any subsequent window state calls to the OS will not work or will be
# glitchy.
self.performSelector(
SEL("delayedFullScreenExit:"), withObject=None, afterDelay=0
)
else:
self.impl._pending_state_transition = None

This is because, wait_for_window() waits only for the window to switch to FULLSCREEN(i.e., states[-1]), and then the test is completed. Then window is closed in window_cleanup. wait_for_window() doesn't wait for windowDidEnterFullScreen_ notification, and so line 99 doesn't get the chance to execute.

Earlier, line 99 was getting covered as we were using fixed length delays instead of polling, and so the test waited long enough for windowDidEnterFullScreen_ to be notified to the window and allowed line 99 to be executed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the reasoning, but I don't like the approach you've taken here. AFAICT, the issue is that the assertion on window states is evaluating the instantaneous state, and not checking for pending state.

Copy link
Contributor Author

@proneon267 proneon267 Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that's the issue. Could wait_for_window() include an optional assertion_test_method parameter? This would allow checking additional window properties beyond just the state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, I think since the condition to ensure coverage is much simpler and only a single assertion, so I have ensured coverage by doing the assertion at the window probe.

@freakboy3742
Copy link
Member

Sometimes, the macOS-arm64 CI stops at the start for no apparent reason at all, like in: https://github.com/beeware/toga/actions/runs/12416992758/job/34667120013. This doesn't seem to be related to #3016 and looks like github action randomly stopping the test run. So, I am not sure how to address it.

That mode of failure usually indicates a hard crash of the app - usually a segfault due to some sort of memory allocation issue. We've had these in the past, and they're really hard to diagnose and fix.

The good news is that #2978 should indirectly address this. The reason we get the memory allocation issues is because of race conditions in the way the pytest suite cleans up async fixtures. #2978 uses an updated version of Rubicon that prevents that class of memory allocation issue from ever occurring.

Unfortunately, we can't land that patch until we cut a new release of Rubicon, and I'm currently on leave, so I have restricted availability to manage a release until the new year.

@proneon267
Copy link
Contributor Author

proneon267 commented Dec 20, 2024

I see, does it also mean this PR will get merged after #2978 is merged?

@freakboy3742
Copy link
Member

I see, does it also mean this PR will get merged after #2978 is merged?

I haven't done a final review of this PR yet, so merging is dependent on that review being completed.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments inline, mostly about further opportunities for simplification.

The biggest concern, though, is the extent of the changes to the tests themselves. You've added an explanation of one specific change - which is good - but there's a lot of other modifications to the tests that need to be explained. It's entirely possible that these changes are necessary - but it's not obvious that they're necessary, and as such, that's something that should be explained in any notes to the reviewer.

The purpose of regression tests is to prevent... regressions - that is, changes in behavior. If you change an implementation and the behaviour of a test, it's a lot harder to prove that you haven't introduced a regression. Not impossible - just a lot harder. If I change only the implementation, and the tests still pass, then it's obvious that I haven't changed behaviour.

message,
delay=(0.5 if (full_screen or state_switch_not_from_normal) else 0.1),
)
await self.redraw(message, delay=(0.5 if full_screen else 0.1))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between wait_for_window(full_screen=True) and wait_for_window(expected_state=FULLSCREEN)? Looking at the tests, you've updated most (if not all) of the existing calls to use the new parameter... so why keep the old one? If it's purely because of the "cleanup" mechanism - that's something that can be replaced with a literal self.redraw() - essentially reproducing the old implementation of wait_for_window for the one place we need a "fixed" interval. Or, add a wait_for_window_close() probe method, if that capability is needed in more than one place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the old parameters are only used for window_cleanup. Adding a wait_for_window_close() probe method would allow us to remove the old parameters entirely.

"App is in presentation mode", full_screen=True
)
assert app.in_presentation_mode

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the test changing? The test shouldn't be changing at all if all we're doing is changing how the test is evaluated.

Copy link
Contributor Author

@proneon267 proneon267 Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have only changed wait_for_window() to use the new parameter. The reason for why I have moved the wait_for_window() call to inside the for loop is because, we need to wait for each window to transition to the expected state. The polling mechanism checks each window individually to confirm its state transition.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, but as a result of this change, you're not waiting for main_window any more. Adding the extra window checks makes sense, but removing the main window check seems like a fairly major change in behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, wait_for_window() was pausing the test for a fixed delay, and so it didn't matter on which window probe wait_for_window() was called. Hence, I had used main_window probe for wait_for_window(). But, now since wait_for_window() checks each window individually to confirm its state transition, so waiting for main_window isn't required anymore.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but main window isn't in window_list - so the main window isn't being checked any more. A check that was previously being performed isn't being performed now. If the main_window is already in the right state, it should be a no-op.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have reverted the call to main_window_probe.wait_for_window(), but the assert app.in_presentation_mode assertion needs to be inside the loop. This is because the assertion will be true only when the polling mechanism has completed waiting for the specified window to transition into the new state.

Comment on lines 811 to 817
# Required to ensure complete coverage of fullscreen handling on macOS.
if states[-1] == WindowState.FULLSCREEN:
await second_window_probe.wait_for_window(
"Waiting for completion of fullscreen pending state handling",
full_screen=True,
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the reasoning, but I don't like the approach you've taken here. AFAICT, the issue is that the assertion on window states is evaluating the instantaneous state, and not checking for pending state.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting close, but not quite. Details inline.

The Textual failure should be fixed by #3052, which should be merged shortly.

continue
raise exception

async def wait_for_window_close(self, pre_close_window_state):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't pre_close_window_state be determined from the window itself?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once, window.close() is called, then determining the window state would be unreliable. To avoid passing the parameter, we could determine the pre_close_window_state and close the window at the window probe.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I change wait_for_window_close() to close_and_wait_for_window(), so that we could determine the pre_close_window_state and close the window at the window probe?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you start proposing method names like close_and_wait_for_window(), you should be stepping back and asking "is there a better name for this?". In this case - it's literally a cleanup method.. so... window_probe.cleanup() (doing both the close and wait) would make sense to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the subject of naming, expected_state is also fairly verbose - unless you're expecting a different state to be passed into the window, state is fine as an argument name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added a window_probe.cleanup() method and replaced expected_state with state.

@@ -27,16 +30,34 @@ async def wait_for_window(
message,
minimize=False,
full_screen=False,
state_switch_not_from_normal=False,
expected_state=None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we still using the minimize and full_screen arguments? If so... why? My original question about this was "What's the difference between full_screen=True and expected_state=FULL_SCREEN" - if the answer is "nothing", then why do we need the other arguments?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like, I had missed cleanup of unused parameters. I have removed them now.

"App is in presentation mode", full_screen=True
)
assert app.in_presentation_mode

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, but as a result of this change, you're not waiting for main_window any more. Adding the extra window checks makes sense, but removing the main window check seems like a fairly major change in behavior.

"App is in presentation mode", full_screen=True
)
assert app.in_presentation_mode

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but main window isn't in window_list - so the main window isn't being checked any more. A check that was previously being performed isn't being performed now. If the main_window is already in the right state, it should be a no-op.

continue
raise exception

async def wait_for_window_close(self, pre_close_window_state):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you start proposing method names like close_and_wait_for_window(), you should be stepping back and asking "is there a better name for this?". In this case - it's literally a cleanup method.. so... window_probe.cleanup() (doing both the close and wait) would make sense to me.

continue
raise exception

async def wait_for_window_close(self, pre_close_window_state):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the subject of naming, expected_state is also fairly verbose - unless you're expecting a different state to be passed into the window, state is fine as an argument name.

@proneon267
Copy link
Contributor Author

proneon267 commented Dec 21, 2024

On reviewing previously failed CI test runs:https://github.com/beeware/toga/actions/runs/12415226949/job/34661335696, I noticed that on Android:
tests/window/test_window.py::test_window_state_content_size_increase[WindowState.FULLSCREEN] has a tendency to fail intermittently. The test fails not due to assertion failure of window state, rather assertion failure of the content size increase:

I/python.stdout: =================================== FAILURES ===================================
I/python.stdout: _______ test_window_state_content_size_increase[WindowState.FULLSCREEN] ________
I/python.stdout: Traceback (most recent call last):
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/runner.py", line 341, in from_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/runner.py", line 242, in <lambda>
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_hooks.py", line 513, in __call__
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_manager.py", line 120, in _hookexec
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 139, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 122, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/threadexception.py", line 92, in pytest_runtest_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/threadexception.py", line 68, in thread_exception_runtest_hook
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 122, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/unraisableexception.py", line 95, in pytest_runtest_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/unraisableexception.py", line 70, in unraisable_exception_runtest_hook
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 122, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/logging.py", line 846, in pytest_runtest_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/logging.py", line 829, in _runtest_for
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 122, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/capture.py", line 880, in pytest_runtest_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 122, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/skipping.py", line 257, in pytest_runtest_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 103, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/runner.py", line 174, in pytest_runtest_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pytest_asyncio/plugin.py", line 533, in runtest
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/python.py", line 1627, in runtest
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_hooks.py", line 513, in __call__
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_manager.py", line 120, in _hookexec
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 182, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_result.py", line 100, in get_result
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pluggy/_callers.py", line 103, in _multicall
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/_pytest/python.py", line 159, in pytest_pyfunc_call
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/requirements/pytest_asyncio/plugin.py", line 1026, in inner
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/app/tests/conftest.py", line 176, in run_until_complete
I/python.stdout:     return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
I/python.stdout:            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
I/python.stdout:   File "stdlib/concurrent/futures/_base.py", line 456, in result
I/python.stdout:   File "stdlib/concurrent/futures/_base.py", line 401, in __get_result
I/python.stdout:   File "/data/data/org.beeware.toga.testbed/files/chaquopy/AssetFinder/app/tests/window/test_window.py", line 269, in test_window_state_content_size_increase
I/python.stdout:     assert (
I/python.stdout: AssertionError: assert (411.42857142857144 > 411.42857142857144 or 717.7142857142857 > 717.7142857142857)
I/python.stdout: ---------------------------- Captured stdout setup -----------------------------
I/python.stdout: Resetting main_window
I/python.stdout: ----------------------------- Captured stdout call -----------------------------
I/python.stdout: Main window is shown
I/python.stdout: Main window is in WindowState.FULLSCREEN
I/python.stdout: =========================== short test summary info ============================
I/python.stdout: FAILED tests/window/test_window.py::test_window_state_content_size_increase[WindowState.FULLSCREEN] - assert (411.42857142857144 > 411.42857142857144 or 717.7142857142857 > 717.7142857142857)
I/python.stdout: ====== 1 failed, 400 passed, 98 skipped, 40 xfailed in 413.81s (0:06:53) =======
I/python.stdout: Backfilling empty coverage stack...
I/python.stdout: Backfilling empty coverage stack...

The assertion failure occurs since the polling mechanism waits only until the window transitions into the expected state(FULLSCREEN) and then starts asserting other properties, which may not have updated. To account for this, Could wait_for_window() include an optional assertion_test_method parameter? This would allow the polling mechanism to check additional window properties beyond just the state.

@freakboy3742
Copy link
Member

The assertion failure occurs since the polling mechanism waits only until the window transitions into the expected state(FULLSCREEN) and then starts asserting other properties, which may not have updated. To account for this, Could wait_for_window() include an optional assertion_test_method parameter? This would allow the polling mechanism to check additional window properties beyond just the state.

Agreed this is failure mode that should be accounted for; but I'm not sure I follow what you're proposing. If there's something on Android that needs to complete to guarantee that the window is in Full Screen format in addition to the state variable, then that should be part of the Android probe implementation for wait_for_window. That's not something that requires an additional argument, because it's not an argument that is meaningful on any other platform.

@proneon267
Copy link
Contributor Author

proneon267 commented Dec 22, 2024

Currently, the polling mechanism only waits until a single assertion is true(i.e., a window state assertion). There could situations(like on Android), where the polling mechanism should wait until a set of assertions are true(i.e., assertions in addition to window state assertion).

For this, I want to propose a general purpose additional parameter assertion_test_method for wait_for_window(). Then, we could specify a set of assertions as a helper method, and pass that helper method to wait_for_window(). So that the polling mechanism would wait until a set of assertions are true instead of only waiting for a window state assertion.

Although, currently the prime use for this would be in the failing test, but I think this could become useful for eliminating intermittent failures in more tests.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of really minor tweaks inline, mostly aimed at optimising the overall test runtime. As written, they work fine, but they take longer than they need to.

The intermittent Android resize issue also needs a solution.

Comment on lines 424 to 427
# Wait for window animation before assertion.
await window1_probe.wait_for_window(
"All test windows are in WindowState.NORMAL", state=WindowState.NORMAL
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion should be after the window2.state assignment, so that both windows can be restored in parallel, rather than running strictly in series.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I have changed it.

Comment on lines 390 to 393
# Wait for window animation before assertion.
await window1_probe.wait_for_window(
"All test windows are in WindowState.NORMAL", state=WindowState.NORMAL
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion should be after the window2.state assignment, so that both windows can be restored in parallel, rather than running strictly in series. That will make the test marginally faster.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I have changed it.

await window_information["window_probe"].wait_for_window(
"App is in presentation mode", state=WindowState.PRESENTATION
)
assert app.in_presentation_mode
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion only needs to be executed once, not once per window. It needs to be after we've waited for all windows, so putting it after the for loop would make sense to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have put the assertion after the for loop.

@proneon267
Copy link
Contributor Author

Currently, the polling mechanism only waits until a single assertion is true(i.e., a window state assertion). There could situations(like on Android), where the polling mechanism should wait until a set of assertions are true(i.e., assertions in addition to window state assertion).

For this, I want to propose a general purpose additional parameter assertion_test_method for wait_for_window(). Then, we could specify a set of assertions as a helper method, and pass that helper method to wait_for_window(). So that the polling mechanism would wait until a set of assertions are true instead of only waiting for a window state assertion.

Although, currently the prime use for this would be in the failing test, but I think this could become useful for eliminating intermittent failures in more tests.

Can I implement this to resolve the Android intermittent failure?

@freakboy3742
Copy link
Member

Can I implement this to resolve the Android intermittent failure?

As I said previously - this isn't a situation where there is a generically configurable property that some tests might find useful to be able to pass in. No other platform requires this modification (as best as we can tell). It's a situation where, on Android, checking the window state isn't a sufficient validation that the window is in a FULLSCREEN state. So - modify the wait_for_window probe on Android so that it does guarantee that the window is in the FULLSCREEN state.

@proneon267
Copy link
Contributor Author

The native Android backend does report correctly when the window gas completed transition to FULLSCREEN. The test fails as the general purpose content_size property doesn't instantly get updated as soon as the window state changes to FULLSCREEN. This could be resolved if the polling mechanism could also wait until an additional assertion of content_size also became true, in addition to the window state assertion.

The reason for proposing to have the additional parameter is because content_size is a general purpose property available on all platforms, rather than being an Android backend specific property.

@proneon267 proneon267 force-pushed the polling_based_window_wait branch from 665e199 to 3a1a9ed Compare December 22, 2024 20:07
@proneon267
Copy link
Contributor Author

I also tried to fix the intermittent Android failure by adding a slight delay to the content_size probe property on the Android backend. Since, properties cannot be async and changing the content_size property into a method would churn a huge amount of tests, so I tried:

@property
    def content_size(self):
        if self.window.state in {WindowState.FULLSCREEN, WindowState.PRESENTATION}:
            # Wait to ensure that content size is updated according to the new state.
            asyncio.get_running_loop().run_until_complete(self.redraw(delay=0.1))
        return (
            self.root_view.getWidth() / self.scale_factor,
            self.root_view.getHeight() / self.scale_factor,
        )

But it didn't work and crashed the testbed. I also tried to do the same with new_event_loop() instead of get_running_loop(). But the result was a crash and an infinite loop due to a deadlock situation: https://github.com/beeware/toga/actions/runs/12457140060/job/34771394237. I tried to find any viable alternative, but couldn't. Therefore I had to implement the additional testing parameter for wait_for_window(). The major changes I have made are:

diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py
index 1a03342ba..8937b5d1c 100644
--- a/android/tests_backend/window.py
+++ b/android/tests_backend/window.py
@@ -23,9 +23,10 @@ class WindowProbe(BaseProbe, DialogsMixin):
         self,
         message,
         state=None,
+        assertion_test_method=None,
     ):
         await self.redraw(message, delay=0.1)
-        if state:
+        if state or assertion_test_method:
             timeout = 5
             polling_interval = 0.1
             exception = None
@@ -33,7 +34,10 @@ class WindowProbe(BaseProbe, DialogsMixin):
             start_time = loop.time()
             while (loop.time() - start_time) < timeout:
                 try:
-                    assert self.instantaneous_state == state
+                    if state:
+                        assert self.instantaneous_state == state
+                    if assertion_test_method:
+                        assertion_test_method()
                     return
                 except AssertionError as e:
                     exception = e
diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py
index cba9d1b2b..a8b49b98f 100644
--- a/testbed/tests/window/test_window.py
+++ b/testbed/tests/window/test_window.py
@@ -259,36 +259,41 @@ if toga.platform.current_platform in {"iOS", "android"}:
         assert main_window_probe.instantaneous_state == WindowState.NORMAL
         initial_content_size = main_window_probe.content_size
 
+        def assert_content_size_increase():
+            # At least one of the dimension should have increased.
+            assert (
+                main_window_probe.content_size[0] > initial_content_size[0]
+                or main_window_probe.content_size[1] > initial_content_size[1]
+            )
+
+        def assert_content_size_original():
+            assert main_window_probe.content_size == initial_content_size
+
         main_window.state = state
         # Wait for window animation before assertion.
         await main_window_probe.wait_for_window(
-            f"Main window is in {state}", state=state
+            f"Main window is in {state}",
+            state=state,
+            assertion_test_method=assert_content_size_increase,
         )
         assert main_window_probe.instantaneous_state == state
-        # At least one of the dimension should have increased.
-        assert (
-            main_window_probe.content_size[0] > initial_content_size[0]
-            or main_window_probe.content_size[1] > initial_content_size[1]
-        )
 
         main_window.state = state
         await main_window_probe.wait_for_window(
-            f"Main window is still in {state}", state=state
+            f"Main window is still in {state}",
+            state=state,
+            assertion_test_method=assert_content_size_increase,
         )
         assert main_window_probe.instantaneous_state == state
-        # At least one of the dimension should have increased.
-        assert (
-            main_window_probe.content_size[0] > initial_content_size[0]
-            or main_window_probe.content_size[1] > initial_content_size[1]
-        )
 
         main_window.state = WindowState.NORMAL
         # Wait for window animation before assertion.
         await main_window_probe.wait_for_window(
-            f"Main window is not in {state}", state=WindowState.NORMAL
+            f"Main window is not in {state}",
+            state=WindowState.NORMAL,
+            assertion_test_method=assert_content_size_original,
         )
         assert main_window_probe.instantaneous_state == WindowState.NORMAL
-        assert main_window_probe.content_size == initial_content_size
 
     @pytest.mark.parametrize(
         "state",

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also tried to fix the intermittent Android failure by adding a slight delay to the content_size probe property on the Android backend.

I've now told you twice that adding an assertion method parameter to wait_for_window is the wrong approach here.

Yes, "I'm waiting for content size" is a generic ask. But it's also a "generic" ask that is needed for exactly one case, on one platform.

You're proposing making the entire test harness more complex to support one platform in one case - and that case is "When moving to full screen on Android, waiting for the FULLSCREEN state alone isn't sufficient to know that the window is in a fully rendered state". That is - wait_for_window(FULLSCREEN) on Android doesn't fully reflect the window state. So... fix the problem there.

Comment on lines +35 to +41
try:
assert self.instantaneous_state == state
if state in {WindowState.FULLSCREEN, WindowState.PRESENTATION}:
# Add a slight delay to ensure window properties like
# `content_size` are updated according to the new state.
await self.redraw(delay=0.1)
return
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have added another assertion of assert content_size > initial_content_size on the android wait_for_window() probe method, like I have done on the cocoa backend for assert self.window._impl._pending_state_transition is None.

But, for content_size, we donot have access to the initial_content_size on the Android probe, so the content_size assertion cannot be done on the polling mechanism. Therefore, I have added a slight delay in order to ensure that the window properties like content_size are updated according to the new state.

I had also thought of adding a flag to the Android's LayoutListener, but I realized that there would no suitable place to reset the flag, and overall would cause more code churning.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be an acceptable solution?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - it's not ideal (as you've noted ideally we'd be polling for an observable condition), but it's at least as good as we had before.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That looks like everything - so we're good to land this. I guess time will tell whether it resolves the intermittent test failure issue.

Comment on lines +35 to +41
try:
assert self.instantaneous_state == state
if state in {WindowState.FULLSCREEN, WindowState.PRESENTATION}:
# Add a slight delay to ensure window properties like
# `content_size` are updated according to the new state.
await self.redraw(delay=0.1)
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - it's not ideal (as you've noted ideally we'd be polling for an observable condition), but it's at least as good as we had before.

@freakboy3742 freakboy3742 merged commit 832bba6 into beeware:main Dec 23, 2024
41 checks passed
@proneon267 proneon267 deleted the polling_based_window_wait branch December 23, 2024 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Testbed failure in test_window_state_rapid_assignment
2 participants