Files
textual/tests/test_animation.py
Darren Burns 34fb596c56 Test flakiness investigation and attempted fixes ❄ (#3498)
* Modifying two flaky animation tests, hopefully removing flakiness :)

* Make switch_mode return an AwaitMount

* Fix event issue

* Add AwaitComplete - a more generalised optionally awaitable object

* Use AwaitComplete in Tabs.remove_tab() and update tests accordingly.

* Update TabbedContent to use AwaitComplete instead of AwaitTabbedContent

* Simplifying - dont use optional awaitables where not required

* Update variable name

* Update a comment

* Add watcher for cursor blink to ensure when blink is switched off, the cursor immediately becomes visible. Ensure we turn of cursor blink inside the input suggetions snapshot test.

* Fix cursor blink reactive and disable cursor blink in the command palette snapshot test

* More progress

* Reworking AwaitComplete

* Some more work on tabs flakiness/race-conditions

* Ensure active tab is set correctly

* Simplify next tab assignment

* Simplify removing tabs logic

* Make button animation duration configurable; Switch off button animation in snapshot test

* Remove a flawed test

* Add awaits in some tests

* Docstrings

* Make active_effect_duration an instance attribute

* Fix a Tabs crash

* Await the tree reload when the path changes in DirectoryTree

* Change AwaitComplete _instances class attr to a set from a list

* Make AwaitComplete generic, AwaitComplete._wait_all is now private, and exposes timeout parameter

* Actually make AwaitComplete instances a set, not a list

* Update CHANGELOG.md regarding flaky-test adjacent changes, AwaitComplete, etc..

* Remove whitespace

* Use list() instead of useless comprehension, remove unused import

* Ensure loading indicator _start_time is initialised correctly

* Switch from time.sleep to asyncio.sleep in a notifications test, rework numbers to try and prevent flakiness

* Resolve deadlock by awaiting event on the event loop instead of in the message pump

* Renaming for clarity

* Debugging for remove_tab test flakiness

* Running all tests

* Updating snapshots

* Remove debugging prints

* Fix broken docstring, remove unused import

* Rename variable to make it clearer

* Add missing return type annotation

* Update src/textual/widgets/_tabbed_content.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update src/textual/widgets/_tabbed_content.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update src/textual/widgets/_tabs.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Scroll datatable cursor after refresh

* Add comment explaining use of call_after_refresh when scrolling data table cursor into view

* Add repr to AwaitComplete (auto-repr_

* Remove use of generics from AwaitComplete

* Update changelog and improve docstring

* Add a missing parameter from DirectoryTree.reset_node docstring.

Signed-off-by: Darren Burns <darrenb900@gmail.com>

* Improve docstring in DirectoryTree

Signed-off-by: Darren Burns <darrenb900@gmail.com>

* Rename parameter coroutine to coroutines in await_complete.py, since it's a variable length param.

---------

Signed-off-by: Darren Burns <darrenb900@gmail.com>
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
2023-10-25 14:41:02 +01:00

214 lines
7.1 KiB
Python

from time import perf_counter
from textual.app import App, ComposeResult
from textual.reactive import var
from textual.widgets import Static
class AnimApp(App):
CSS = """
#foo {
height: 1;
}
"""
def compose(self) -> ComposeResult:
yield Static("foo", id="foo")
async def test_animate_height() -> None:
"""Test animating styles.height works."""
# Styles.height is a scalar, which makes it more complicated to animate
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
assert static.size.height == 1
assert static.styles.height.value == 1
static.styles.animate("height", 100, duration=0.5, easing="linear")
start = perf_counter()
# Wait for the animation to finished
await pilot.wait_for_animation()
elapsed = perf_counter() - start
# Check that the full time has elapsed
assert elapsed >= 0.5
# Check the height reached the maximum
assert static.styles.height.value == 100
async def test_scheduling_animation() -> None:
"""Test that scheduling an animation works."""
app = AnimApp()
delay = 0.1
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
styles.background = "black"
styles.animate("background", "white", delay=delay, duration=0)
# Still black immediately after call, animation hasn't started yet due to `delay`
assert styles.background.rgb == (0, 0, 0)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (255, 255, 255)
async def test_wait_for_current_animations() -> None:
"""Test that we can wait only for the current animations taking place."""
app = AnimApp()
delay = 10
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
styles.animate("height", 100, duration=0.1)
start = perf_counter()
styles.animate("height", 200, duration=0.1, delay=delay)
# Wait for the first animation to finish
await pilot.wait_for_animation()
elapsed = perf_counter() - start
assert elapsed < (delay / 2)
async def test_wait_for_current_and_scheduled_animations() -> None:
"""Test that we can wait for current and scheduled animations."""
app = AnimApp()
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
start = perf_counter()
styles.animate("height", 50, duration=0.01)
styles.animate("background", "black", duration=0.01, delay=0.05)
await pilot.wait_for_scheduled_animations()
elapsed = perf_counter() - start
assert elapsed >= 0.06
assert styles.background.rgb == (0, 0, 0)
async def test_reverse_animations() -> None:
"""Test that you can create reverse animations.
Regression test for #1372 https://github.com/Textualize/textual/issues/1372
"""
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
styles = static.styles
# Starting point.
styles.background = "black"
assert styles.background.rgb == (0, 0, 0)
# First, make sure we can go from black to white and back, step by step.
styles.animate("background", "white", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (255, 255, 255)
styles.animate("background", "black", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if creating both at once.
styles.animate("background", "white", duration=0.01)
styles.animate("background", "black", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (0, 0, 0)
async def test_schedule_reverse_animations() -> None:
"""Test that you can schedule reverse animations.
Regression test for #1372 https://github.com/Textualize/textual/issues/1372
"""
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
styles = static.styles
# Starting point.
styles.background = "black"
assert styles.background.rgb == (0, 0, 0)
# First, make sure we can go from black to white and back, step by step.
styles.animate("background", "white", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (255, 255, 255)
styles.animate("background", "black", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if scheduling both at once.
styles.animate("background", "white", delay=0.025, duration=0.05)
# While the black -> white animation runs, start the white -> black animation.
styles.animate("background", "black", delay=0.05, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0)
class CancelAnimWidget(Static):
counter: var[float] = var(23)
class CancelAnimApp(App[None]):
counter: var[float] = var(23)
def compose(self) -> ComposeResult:
yield CancelAnimWidget()
async def test_cancel_app_animation() -> None:
"""It should be possible to cancel a running app animation."""
async with CancelAnimApp().run_test() as pilot:
pilot.app.animate("counter", value=0, final_value=1000, duration=60)
await pilot.pause()
assert pilot.app.animator.is_being_animated(pilot.app, "counter")
await pilot.app.stop_animation("counter")
assert not pilot.app.animator.is_being_animated(pilot.app, "counter")
async def test_cancel_app_non_animation() -> None:
"""It should be possible to attempt to cancel a non-running app animation."""
async with CancelAnimApp().run_test() as pilot:
assert not pilot.app.animator.is_being_animated(pilot.app, "counter")
await pilot.app.stop_animation("counter")
assert not pilot.app.animator.is_being_animated(pilot.app, "counter")
async def test_cancel_widget_animation() -> None:
"""It should be possible to cancel a running widget animation."""
async with CancelAnimApp().run_test() as pilot:
widget = pilot.app.query_one(CancelAnimWidget)
widget.animate("counter", value=0, final_value=1000, duration=60)
await pilot.pause()
assert pilot.app.animator.is_being_animated(widget, "counter")
await widget.stop_animation("counter")
assert not pilot.app.animator.is_being_animated(widget, "counter")
async def test_cancel_widget_non_animation() -> None:
"""It should be possible to attempt to cancel a non-running widget animation."""
async with CancelAnimApp().run_test() as pilot:
widget = pilot.app.query_one(CancelAnimWidget)
assert not pilot.app.animator.is_being_animated(widget, "counter")
await widget.stop_animation("counter")
assert not pilot.app.animator.is_being_animated(widget, "counter")