diff --git a/changes/2088.misc.rst b/changes/2088.misc.rst new file mode 100644 index 0000000000..bc08496d82 --- /dev/null +++ b/changes/2088.misc.rst @@ -0,0 +1 @@ +Test garbage collection of widgets on all platforms. diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index f74d40507b..ad0a9d085f 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -19,18 +19,18 @@ # Use this for widgets or tests which are not supported on some platforms, but could be # supported in the future. -def skip_on_platforms(*platforms): +def skip_on_platforms(*platforms, reason=None): current_platform = toga.platform.current_platform if current_platform in platforms: - skip(f"not yet implemented on {current_platform}") + skip(reason or f"not yet implemented on {current_platform}") # Use this for widgets or tests which are not supported on some platforms, and will not # be supported in the foreseeable future. -def xfail_on_platforms(*platforms): +def xfail_on_platforms(*platforms, reason=None): current_platform = toga.platform.current_platform if current_platform in platforms: - skip(f"not applicable on {current_platform}") + skip(reason or f"not applicable on {current_platform}") @fixture(autouse=True) diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index db6a90c3ea..0035a1bfe2 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -1,3 +1,5 @@ +import gc +import weakref from unittest.mock import Mock from pytest import fixture @@ -5,6 +7,7 @@ import toga from toga.style.pack import TOP +from ..conftest import skip_on_platforms, xfail_on_platforms from .probe import get_probe @@ -78,3 +81,31 @@ def verify_focus_handlers(): def verify_vertical_alignment(): """The widget's default vertical alignment""" return TOP + + +def build_cleanup_test( + widget_constructor, args=None, kwargs=None, skip_platforms=(), xfail_platforms=() +): + async def test_cleanup(): + nonlocal args, kwargs + + skip_on_platforms(*skip_platforms) + xfail_on_platforms(*xfail_platforms, reason="Leaks memory") + + if args is None: + args = () + + if kwargs is None: + kwargs = {} + + widget = widget_constructor(*args, **kwargs) + ref = weakref.ref(widget) + + # Args or kwargs may hold a backref to the widget itself, for example if they + # are widget content. Ensure that they are deleted before garbage collection. + del widget, args, kwargs + gc.collect() + + assert ref() is None + + return test_cleanup diff --git a/testbed/tests/widgets/test_activityindicator.py b/testbed/tests/widgets/test_activityindicator.py index 15e97cc720..f4f912c2af 100644 --- a/testbed/tests/widgets/test_activityindicator.py +++ b/testbed/tests/widgets/test_activityindicator.py @@ -3,6 +3,7 @@ import toga from ..conftest import skip_on_platforms +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enable_noop, test_focus_noop, @@ -15,6 +16,11 @@ async def widget(): return toga.ActivityIndicator() +test_cleanup = build_cleanup_test( + toga.ActivityIndicator, skip_platforms=("android", "windows") +) + + async def test_start_stop(widget, probe): "The activity indicator can be started and stopped" # Widget should be initially stopped diff --git a/testbed/tests/widgets/test_box.py b/testbed/tests/widgets/test_box.py index 0959a27db8..13370d2b53 100644 --- a/testbed/tests/widgets/test_box.py +++ b/testbed/tests/widgets/test_box.py @@ -3,6 +3,7 @@ import toga from toga.style import Pack +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -15,3 +16,6 @@ @pytest.fixture async def widget(): return toga.Box(style=Pack(width=100, height=200)) + + +test_cleanup = build_cleanup_test(toga.Box, xfail_platforms=("iOS",)) diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index 33004fde8d..05025eb308 100644 --- a/testbed/tests/widgets/test_button.py +++ b/testbed/tests/widgets/test_button.py @@ -7,6 +7,7 @@ from ..assertions import assert_color from ..data import TEXTS +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -31,6 +32,11 @@ async def widget(): return toga.Button("Hello") +test_cleanup = build_cleanup_test( + toga.Button, args=("Hello",), skip_platforms=("android",) +) + + async def test_text(widget, probe): "The text displayed on a button can be changed" initial_height = probe.height diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index a5c4e8c4b9..aa967e4618 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -21,6 +21,7 @@ from toga.fonts import BOLD from toga.style.pack import SYSTEM, Pack +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -108,6 +109,9 @@ def assert_pixel(image, x, y, color): assert image.getpixel((x, y)) == color +test_cleanup = build_cleanup_test(toga.Canvas, xfail_platforms=("android",)) + + async def test_resize(widget, probe, on_resize_handler): "Resizing the widget causes on-resize events" # Make the canvas visible against window background. diff --git a/testbed/tests/widgets/test_dateinput.py b/testbed/tests/widgets/test_dateinput.py index d05158f8d5..82d827dc22 100644 --- a/testbed/tests/widgets/test_dateinput.py +++ b/testbed/tests/widgets/test_dateinput.py @@ -6,6 +6,7 @@ import toga from ..conftest import skip_on_platforms +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -82,6 +83,13 @@ async def widget(): return toga.DateInput() +test_cleanup = build_cleanup_test( + toga.DateInput, + skip_platforms=("macOS", "iOS", "linux"), + xfail_platforms=("android",), +) + + async def test_init(): "Properties can be set in the constructor" skip_on_platforms("macOS", "iOS", "linux") diff --git a/testbed/tests/widgets/test_detailedlist.py b/testbed/tests/widgets/test_detailedlist.py index d729333af0..6916de669f 100644 --- a/testbed/tests/widgets/test_detailedlist.py +++ b/testbed/tests/widgets/test_detailedlist.py @@ -6,6 +6,7 @@ from toga.sources import ListSource from toga.style.pack import Pack +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enable_noop, test_flex_widget_size, @@ -74,6 +75,11 @@ async def widget( ) +test_cleanup = build_cleanup_test( + toga.DetailedList, xfail_platforms=("android", "linux") +) + + async def test_scroll(widget, probe): """The detailedList can be scrolled""" diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index d5287b2cfe..848aadbe33 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -4,6 +4,7 @@ from toga.constants import Direction from toga.style.pack import COLUMN, ROW +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enable_noop, test_focus_noop, @@ -15,6 +16,9 @@ async def widget(): return toga.Divider() +test_cleanup = build_cleanup_test(toga.Divider, xfail_platforms=("iOS",)) + + async def test_directions(widget, probe): "The divider has the right size as direction is changed" # Widget should be initially horizontal. diff --git a/testbed/tests/widgets/test_imageview.py b/testbed/tests/widgets/test_imageview.py index 8610095e88..fda5e8f089 100644 --- a/testbed/tests/widgets/test_imageview.py +++ b/testbed/tests/widgets/test_imageview.py @@ -3,6 +3,7 @@ import toga from toga.style.pack import COLUMN, ROW +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -17,6 +18,11 @@ async def widget(): return toga.ImageView(image="resources/sample.png") +test_cleanup = build_cleanup_test( + toga.ImageView, kwargs={"image": "resources/sample.png"}, xfail_platforms=("iOS",) +) + + async def test_implicit_size(widget, probe, container_probe): """If the image view size is implicit, the image provides flexible size hints.""" diff --git a/testbed/tests/widgets/test_label.py b/testbed/tests/widgets/test_label.py index f0de6bb265..aac296e18c 100644 --- a/testbed/tests/widgets/test_label.py +++ b/testbed/tests/widgets/test_label.py @@ -2,6 +2,7 @@ import toga +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -24,6 +25,9 @@ async def widget(): return toga.Label("hello, this is a label") +test_cleanup = build_cleanup_test(toga.Label, args=("hello, this is a label",)) + + async def test_multiline(widget, probe): """If the label contains multiline text, it resizes vertically.""" diff --git a/testbed/tests/widgets/test_mapview.py b/testbed/tests/widgets/test_mapview.py index 0a80dfeacc..0a15c93040 100644 --- a/testbed/tests/widgets/test_mapview.py +++ b/testbed/tests/widgets/test_mapview.py @@ -8,6 +8,7 @@ import toga from toga.style import Pack +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_flex_widget_size, ) @@ -57,6 +58,9 @@ async def widget(on_select): toga.App.app._gc_protector.append(widget) +test_cleanup = build_cleanup_test(toga.MapView, xfail_platforms=("android",)) + + # The next two tests fail about 75% of the time in the macOS x86_64 CI configuration. # The failure mode appears to be that the widget *exists*, but doesn't respond to # changes in location or zoom. I've been unable to reproduce this in actual testing on diff --git a/testbed/tests/widgets/test_multilinetextinput.py b/testbed/tests/widgets/test_multilinetextinput.py index 06ec4c3811..d979c2709b 100644 --- a/testbed/tests/widgets/test_multilinetextinput.py +++ b/testbed/tests/widgets/test_multilinetextinput.py @@ -3,6 +3,7 @@ import toga from toga.style import Pack +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -42,6 +43,11 @@ def verify_font_sizes(): return False, False +test_cleanup = build_cleanup_test( + toga.MultilineTextInput, xfail_platforms=("android", "linux") +) + + async def test_scroll_position(widget, probe): "The widget can be programmatically scrolled." # The document initially fits within the visible area. diff --git a/testbed/tests/widgets/test_numberinput.py b/testbed/tests/widgets/test_numberinput.py index 2415ff342f..10551a0425 100644 --- a/testbed/tests/widgets/test_numberinput.py +++ b/testbed/tests/widgets/test_numberinput.py @@ -6,6 +6,7 @@ import toga from ..conftest import skip_on_platforms +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -41,6 +42,9 @@ def verify_focus_handlers(): return False +test_cleanup = build_cleanup_test(toga.NumberInput, xfail_platforms=("android",)) + + async def test_on_change_handler(widget, probe): "The on_change handler is triggered when the user types." widget.step = "0.01" diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index a09b569fd0..906e97f216 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -6,6 +6,7 @@ from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE, SEAGREEN from toga.style.pack import Pack +from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 test_enable_noop, @@ -71,6 +72,14 @@ async def widget(content1, content2, content3, on_select_handler): ) +test_cleanup = build_cleanup_test( + # Pass a function here to prevent init of toga.Box() in a different thread than + # toga.OptionContainer. This would raise a runtime error on Windows. + lambda: toga.OptionContainer(content=[("Tab 1", toga.Box())]), + xfail_platforms=("android", "iOS", "linux"), +) + + async def test_select_tab( widget, probe, diff --git a/testbed/tests/widgets/test_passwordinput.py b/testbed/tests/widgets/test_passwordinput.py index d5b9acc05e..e12509fc91 100644 --- a/testbed/tests/widgets/test_passwordinput.py +++ b/testbed/tests/widgets/test_passwordinput.py @@ -2,6 +2,7 @@ import toga +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -44,6 +45,9 @@ def verify_font_sizes(): return False, True +test_cleanup = build_cleanup_test(toga.PasswordInput, xfail_platforms=("android",)) + + async def test_value_hidden(widget, probe): "Value should always be hidden in a PasswordInput" assert probe.value_hidden diff --git a/testbed/tests/widgets/test_progressbar.py b/testbed/tests/widgets/test_progressbar.py index a08e005b16..57b57dcf79 100644 --- a/testbed/tests/widgets/test_progressbar.py +++ b/testbed/tests/widgets/test_progressbar.py @@ -2,6 +2,7 @@ import toga +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enable_noop, test_flex_horizontal_widget_size, @@ -19,6 +20,9 @@ async def widget(): return toga.ProgressBar(max=100, value=5) +test_cleanup = build_cleanup_test(toga.ProgressBar) + + async def test_start_stop_determinate(widget, probe): "A determinate progress bar can be started and stopped" # Widget should be initially stopped and determinate diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index c71ee7e84c..68b13434c4 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -7,6 +7,7 @@ from toga.colors import CORNFLOWERBLUE, REBECCAPURPLE, TRANSPARENT from toga.style.pack import COLUMN, ROW, Pack +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -72,6 +73,14 @@ async def widget(content, on_scroll): ) +test_cleanup = build_cleanup_test( + # Pass a function here to prevent init of toga.Box() in a different thread than + # toga.ScrollContainer. This would raise a runtime error on Windows. + lambda: toga.ScrollContainer(content=toga.Box()), + xfail_platforms=("android", "iOS", "linux"), +) + + async def test_clear_content(widget, probe, small_content): "Widget content can be cleared and reset" assert probe.document_width == probe.width - probe.scrollbar_inset diff --git a/testbed/tests/widgets/test_selection.py b/testbed/tests/widgets/test_selection.py index 876ceaa153..41e511078c 100644 --- a/testbed/tests/widgets/test_selection.py +++ b/testbed/tests/widgets/test_selection.py @@ -6,6 +6,7 @@ from toga.constants import CENTER from toga.sources import ListSource +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -51,6 +52,13 @@ def verify_vertical_alignment(): return CENTER +test_cleanup = build_cleanup_test( + toga.Selection, + kwargs={"items": ["first", "second", "third"]}, + xfail_platforms=("android", "iOS", "windows"), +) + + async def test_item_titles(widget, probe): """The selection is able to build display titles from a range of data types""" on_change_handler = Mock() diff --git a/testbed/tests/widgets/test_slider.py b/testbed/tests/widgets/test_slider.py index 41ff1029b1..d8cecda2b7 100644 --- a/testbed/tests/widgets/test_slider.py +++ b/testbed/tests/widgets/test_slider.py @@ -7,6 +7,7 @@ import toga from ..assertions import assert_set_get +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enabled, test_flex_horizontal_widget_size, @@ -41,6 +42,9 @@ def on_change(widget): return handler +test_cleanup = build_cleanup_test(toga.Slider, xfail_platforms=("android",)) + + async def test_init(widget, probe): assert widget.value == 0.5 assert widget.min == 0 diff --git a/testbed/tests/widgets/test_splitcontainer.py b/testbed/tests/widgets/test_splitcontainer.py index 69aad102b1..47ce59a54c 100644 --- a/testbed/tests/widgets/test_splitcontainer.py +++ b/testbed/tests/widgets/test_splitcontainer.py @@ -6,7 +6,8 @@ from toga.constants import Direction from toga.style.pack import Pack -from ..conftest import xfail_on_platforms +from ..conftest import skip_on_platforms +from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 test_enable_noop, @@ -56,10 +57,18 @@ async def content3_probe(content3): @pytest.fixture async def widget(content1, content2): - xfail_on_platforms("android", "iOS") + skip_on_platforms("android", "iOS") return toga.SplitContainer(content=[content1, content2], style=Pack(flex=1)) +test_cleanup = build_cleanup_test( + # Pass a function here to prevent init of toga.Box() in a different thread than + # toga.SplitContainer. This would raise a runtime error on Windows. + lambda: toga.SplitContainer(content=[toga.Box(), toga.Box()]), + skip_platforms=("android", "iOS"), +) + + async def test_set_content( widget, probe, diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index eadded169d..fe115ac7e8 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -5,6 +5,7 @@ import toga from ..data import TEXTS +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_color, test_color_reset, @@ -27,6 +28,11 @@ async def widget(): return toga.Switch("Hello") +test_cleanup = build_cleanup_test( + toga.Switch, args=("Hello",), xfail_platforms=("android", "iOS", "linux") +) + + async def test_text(widget, probe): "The text displayed on a switch can be changed" initial_height = probe.height diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 91e684268c..3056faa3cb 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -8,6 +8,7 @@ from toga.style.pack import Pack from ..conftest import skip_on_platforms +from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 test_background_color, @@ -111,6 +112,14 @@ async def multiselect_probe(main_window, multiselect_widget): main_window.content = old_content +test_cleanup = build_cleanup_test( + toga.Table, + kwargs={"headings": ["A", "B", "C"]}, + skip_platforms=("iOS",), + xfail_platforms=("linux",), +) + + async def test_scroll(widget, probe): """The table can be scrolled""" diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 049d23acb3..f1ea09de5a 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -6,6 +6,7 @@ from toga.constants import CENTER from ..data import TEXTS +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -51,6 +52,9 @@ async def placeholder(request, widget): widget.placeholder = request.param +test_cleanup = build_cleanup_test(toga.TextInput, xfail_platforms=("android",)) + + async def test_value_not_hidden(widget, probe): "Value should always be visible in a regular TextInput" assert not probe.value_hidden diff --git a/testbed/tests/widgets/test_timeinput.py b/testbed/tests/widgets/test_timeinput.py index a15ff14229..057ba202ff 100644 --- a/testbed/tests/widgets/test_timeinput.py +++ b/testbed/tests/widgets/test_timeinput.py @@ -6,6 +6,7 @@ import toga from ..conftest import skip_on_platforms +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -78,6 +79,13 @@ async def widget(): return toga.TimeInput() +test_cleanup = build_cleanup_test( + toga.TimeInput, + skip_platforms=("macOS", "iOS", "linux"), + xfail_platforms=("android",), +) + + async def test_init(normalize): "Properties can be set in the constructor" skip_on_platforms("macOS", "iOS", "linux") diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 272011bf55..fd00838062 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -8,6 +8,7 @@ from toga.style.pack import Pack from ..conftest import skip_on_platforms +from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 test_background_color, @@ -164,6 +165,18 @@ async def multiselect_probe(main_window, multiselect_widget): main_window.content = old_content +test_cleanup = build_cleanup_test( + toga.Tree, + kwargs={"headings": ["A", "B", "C"]}, + skip_platforms=( + "iOS", + "android", + "windows", + ), + xfail_platforms=("linux",), +) + + async def test_select(widget, probe, source, on_select_handler): """Rows can be selected""" # Initial selection is empty diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 13f90dab4b..6591e47b9f 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -9,6 +9,7 @@ import toga from toga.style import Pack +from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_flex_widget_size, test_focus, @@ -114,6 +115,9 @@ async def widget(on_load): toga.App.app._gc_protector.append(widget) +test_cleanup = build_cleanup_test(toga.WebView, xfail_platforms=("linux",)) + + async def test_set_url(widget, probe, on_load): """The URL can be set.""" widget.url = "https://github.com/beeware"