diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 746dfa3aed..0c0283dec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,6 @@ jobs: run: | tox -e py-core cd core - python -m coverage xml mv .coverage .coverage.${{ matrix.platform }}.${{ matrix.python-version }} - name: Store coverage data uses: actions/upload-artifact@v3 @@ -104,13 +103,6 @@ jobs: name: core-coverage-data path: "core/.coverage.*" if-no-files-found: error - - name: Upload coverage data to CodeCov - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: core/coverage.xml - flags: unittests - fail_ci_if_error: true core-coverage: name: Combine & check core coverage. @@ -190,3 +182,58 @@ jobs: TOGA_INSTALL_COMMAND: 'bash -c "pip install ../{core,dummy,${{ matrix.backend }}}/dist/*.whl"' run: | tox -e py-${{ matrix.backend }} + + testbed: + needs: core + strategy: + fail-fast: false + matrix: + backend: ["macOS", "windows", "linux", "android", "iOS"] + include: + - pre-command: + briefcase-run-prefix: + briefcase-run-args: + + - backend: macOS + runs-on: macos-12 + + - backend: linux + runs-on: ubuntu-latest + pre-command: "sudo apt-get update -y && sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config" + briefcase-run-prefix: 'xvfb-run -a -s "-screen 0 2048x1536x24"' + + - backend: windows + runs-on: windows-latest + + - backend: iOS + runs-on: macos-12 + briefcase-run-args: ' -d "iPhone SE (3rd generation)"' + + - backend: android + runs-on: macos-12 + briefcase-run-args: " -d '{\"avd\":\"beePhone\"}' --Xemulator=-no-window --Xemulator=-no-snapshot --Xemulator=-no-audio --Xemulator=-no-boot-anim --shutdown-on-exit" + + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v3.1.0 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v4.3.0 + with: + python-version: "3.X" + - name: Install dependencies + run: | + ${{ matrix.pre-command }} + # Use the development version of Briefcase + pip install git+https://github.com/beeware/briefcase.git + - name: Test App + run: | + cd testbed + ${{ matrix.briefcase-run-prefix }} briefcase run ${{ matrix.backend }} --test ${{ matrix.briefcase-run-args }} + - uses: actions/upload-artifact@v3 + name: Upload logs + if: failure() + with: + name: testbed-failure-logs-${{ matrix.backend }} + path: testbed/logs/* diff --git a/android/MANIFEST.in b/android/MANIFEST.in index 126da665ed..96861fad80 100644 --- a/android/MANIFEST.in +++ b/android/MANIFEST.in @@ -3,3 +3,4 @@ include LICENSE include README.rst include tox.ini recursive-include tests *.py +recursive-include tests_backend *.py diff --git a/android/tests_backend/__init__.py b/android/tests_backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/tests_backend/widgets/__init__.py b/android/tests_backend/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py new file mode 100644 index 0000000000..bfd934d472 --- /dev/null +++ b/android/tests_backend/widgets/base.py @@ -0,0 +1,43 @@ +from pytest import skip + + +class SimpleProbe: + def __init__(self, widget): + self.native = widget._impl.native + assert isinstance(self.native, self.native_class) + + def assert_container(self, container): + container_native = container._impl.native + for i in range(container_native.getChildCount()): + child = container_native.getChildAt(i) + if child is self.native: + break + else: + raise AssertionError(f"cannot find {self.native} in {container_native}") + + @property + def enabled(self): + return self.native.isEnabled() + + @property + def background_color(self): + skip("not implemented: background_color") + + @property + def color(self): + skip("not implemented: color") + + @property + def hidden(self): + skip("not implemented: hidden") + + @property + def width(self): + return self.native.getWidth() + + @property + def height(self): + return self.native.getHeight() + + def press(self): + self.native.performClick() diff --git a/android/tests_backend/widgets/button.py b/android/tests_backend/widgets/button.py new file mode 100644 index 0000000000..d707f8374d --- /dev/null +++ b/android/tests_backend/widgets/button.py @@ -0,0 +1,8 @@ +from java import jclass + +from .label import LabelProbe + + +# On Android, a Button is just a TextView with a state-dependent background image. +class ButtonProbe(LabelProbe): + native_class = jclass("android.widget.Button") diff --git a/android/tests_backend/widgets/label.py b/android/tests_backend/widgets/label.py new file mode 100644 index 0000000000..553f52aa59 --- /dev/null +++ b/android/tests_backend/widgets/label.py @@ -0,0 +1,16 @@ +from java import jclass + +from .base import SimpleProbe +from .properties import toga_color + + +class LabelProbe(SimpleProbe): + native_class = jclass("android.widget.TextView") + + @property + def color(self): + return toga_color(self.native.getCurrentTextColor()) + + @property + def text(self): + return str(self.native.getText()) diff --git a/android/tests_backend/widgets/properties.py b/android/tests_backend/widgets/properties.py new file mode 100644 index 0000000000..95a56903dd --- /dev/null +++ b/android/tests_backend/widgets/properties.py @@ -0,0 +1,15 @@ +from java import jint + +from android.graphics import Color +from toga.colors import rgba + + +def toga_color(color_int): + # Select the `int` overloads rather than the `long` ones. + color_int = jint(color_int) + return rgba( + Color.red(color_int), + Color.green(color_int), + Color.blue(color_int), + Color.alpha(color_int) / 255, + ) diff --git a/android/tests_backend/widgets/slider.py b/android/tests_backend/widgets/slider.py new file mode 100644 index 0000000000..fefa45c154 --- /dev/null +++ b/android/tests_backend/widgets/slider.py @@ -0,0 +1,24 @@ +from java import jclass + +from android.os import Build + +from .base import SimpleProbe + + +class SliderProbe(SimpleProbe): + native_class = jclass("android.widget.SeekBar") + + @property + def position(self): + return (self.native.getProgress() - self._min) / (self._max - self._min) + + def change(self, position): + self.native.setProgress(self._min + round(position * (self._max - self._min))) + + @property + def _min(self): + return 0 if (Build.VERSION.SDK_INT < 26) else self.native.getMin() + + @property + def _max(self): + return self.native.getMax() diff --git a/changes/1687.misc.rst b/changes/1687.misc.rst new file mode 100644 index 0000000000..66c7d93862 --- /dev/null +++ b/changes/1687.misc.rst @@ -0,0 +1 @@ +Added a backend test app. diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 7dc5204253..7a2d5d8764 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -110,8 +110,10 @@ def open_document(self, fileURL): pass def main_loop(self): - # Main loop is a no-op on iOS; the app loop is integrated with the - # main iOS event loop. + # Main loop is non-blocking on iOS. The app loop is integrated with the + # main iOS event loop, so this call will return; however, it will leave + # the app in a state such that asyncio events will be scheduled on the + # iOS event loop. self.loop.run_forever_cooperatively(lifecycle=iOSLifecycle()) def set_main_window(self, window): diff --git a/pyproject.toml b/pyproject.toml index 90f22ecd40..9ffe9d28a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ known_first_party = [ "toga_dummy", "toga_gtk", "toga_iOS", + "toga_test", "toga_web", "toga_winforms", ] diff --git a/testbed/.gitignore b/testbed/.gitignore new file mode 100644 index 0000000000..34661cf5b9 --- /dev/null +++ b/testbed/.gitignore @@ -0,0 +1,69 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# OSX useful to ignore +*.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# IntelliJ Idea family of suites +.idea +*.iml +## File-based project format: +*.ipr +*.iws +## mpeltonen/sbt-idea plugin +.idea_modules/ + +# Briefcase build directories +iOS/ +macOS/ +windows/ +android/ +linux/ +django/ + +# Briefcase log files +logs/ diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml new file mode 100644 index 0000000000..7bbe9ce98e --- /dev/null +++ b/testbed/pyproject.toml @@ -0,0 +1,90 @@ +# This project was generated using template: https://github.com/beeware/briefcase-template and branch: v0.3.12 +[tool.briefcase] +project_name = "Toga Testbed" +bundle = "org.beeware.toga" +version = "0.0.1" +url = "https://beeware.org" +license = "BSD license" +author = 'Tiberius Yak' +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.testbed] +formal_name = "Toga Testbed" +description = "A testbed for Toga visual tests" +icon = "src/testbed/resources/testbed" +sources = [ + 'src/testbed', +] +test_sources = [ + 'tests', +] +requires = [ + '../core', +] +test_requires = [ + 'pytest==7.2.0', + 'pytest-asyncio==0.20.2', +] + + +[tool.briefcase.app.testbed.macOS] +requires = [ + '../cocoa', + 'std-nslog~=1.0.0' +] + +[tool.briefcase.app.testbed.linux] +requires = [ + '../gtk', +] + +[tool.briefcase.app.testbed.linux.appimage] +system_requires = [ + 'gir1.2-webkit-3.0', + 'libcairo2-dev', + 'libgirepository1.0-dev', + 'libgtk-3-dev', + 'libpango1.0-dev', + 'librsvg2-dev', + 'libwebkitgtk-3.0-0', +] +linuxdeploy_plugins = [ + 'DEPLOY_GTK_VERSION=3 gtk', +] + +[tool.briefcase.app.testbed.linux.flatpak] +flatpak_runtime = 'org.gnome.Platform' +flatpak_runtime_version = '42' +flatpak_sdk = 'org.gnome.Sdk' + +[tool.briefcase.app.testbed.windows] +test_sources = [ + '../winforms/tests_backend', +] +requires = [ + '../winforms', +] + +# Mobile deployments +[tool.briefcase.app.testbed.iOS] +requires = [ + '../iOS', + 'std-nslog~=1.0.0' +] + +[tool.briefcase.app.testbed.android] +test_sources = [ + '../android/tests_backend', +] +requires = [ + '../android' +] + +# TODO: replace with extractPackages +build_gradle_extra_content = "android.defaultConfig.python.pyc.src false" + +[tool.briefcase.app.testbed.web] +requires = [ + '../web' +] +style_framework = "Bootstrap v4.6" diff --git a/testbed/src/testbed/__init__.py b/testbed/src/testbed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/src/testbed/__main__.py b/testbed/src/testbed/__main__.py new file mode 100644 index 0000000000..7c4c4d4546 --- /dev/null +++ b/testbed/src/testbed/__main__.py @@ -0,0 +1,4 @@ +from testbed.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py new file mode 100644 index 0000000000..02f0f9499e --- /dev/null +++ b/testbed/src/testbed/app.py @@ -0,0 +1,27 @@ +import toga + + +class Testbed(toga.App): + def startup(self): + self.main_window = toga.MainWindow(title=self.formal_name) + self.main_window.content = toga.Box( + children=[ + toga.Label("Did you forget to use --test?"), + ] + ) + self.main_window.show() + + # FIXME: workaround for https://github.com/beeware/rubicon-objc/issues/228 + if toga.platform.current_platform == "iOS": + import asyncio + + async def heartbeat(*args, **kwargs): + while True: + await asyncio.sleep(0.0001) + + self.add_background_task(heartbeat) + # END FIXME + + +def main(): + return Testbed(app_name="testbed") diff --git a/testbed/src/testbed/resources/__init__.py b/testbed/src/testbed/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/src/testbed/resources/testbed-1024.png b/testbed/src/testbed/resources/testbed-1024.png new file mode 100644 index 0000000000..d0ece53f8d Binary files /dev/null and b/testbed/src/testbed/resources/testbed-1024.png differ diff --git a/testbed/src/testbed/resources/testbed-120.png b/testbed/src/testbed/resources/testbed-120.png new file mode 100644 index 0000000000..fe725aaaf7 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-120.png differ diff --git a/testbed/src/testbed/resources/testbed-152.png b/testbed/src/testbed/resources/testbed-152.png new file mode 100644 index 0000000000..3293486604 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-152.png differ diff --git a/testbed/src/testbed/resources/testbed-167.png b/testbed/src/testbed/resources/testbed-167.png new file mode 100644 index 0000000000..48046cdd62 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-167.png differ diff --git a/testbed/src/testbed/resources/testbed-180.png b/testbed/src/testbed/resources/testbed-180.png new file mode 100644 index 0000000000..dd1233d701 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-180.png differ diff --git a/testbed/src/testbed/resources/testbed-20.png b/testbed/src/testbed/resources/testbed-20.png new file mode 100644 index 0000000000..35d1383fcb Binary files /dev/null and b/testbed/src/testbed/resources/testbed-20.png differ diff --git a/testbed/src/testbed/resources/testbed-29.png b/testbed/src/testbed/resources/testbed-29.png new file mode 100644 index 0000000000..b199573108 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-29.png differ diff --git a/testbed/src/testbed/resources/testbed-40.png b/testbed/src/testbed/resources/testbed-40.png new file mode 100644 index 0000000000..248f415f1a Binary files /dev/null and b/testbed/src/testbed/resources/testbed-40.png differ diff --git a/testbed/src/testbed/resources/testbed-58.png b/testbed/src/testbed/resources/testbed-58.png new file mode 100644 index 0000000000..aaad2800ad Binary files /dev/null and b/testbed/src/testbed/resources/testbed-58.png differ diff --git a/testbed/src/testbed/resources/testbed-60.png b/testbed/src/testbed/resources/testbed-60.png new file mode 100644 index 0000000000..686ae8f49a Binary files /dev/null and b/testbed/src/testbed/resources/testbed-60.png differ diff --git a/testbed/src/testbed/resources/testbed-76.png b/testbed/src/testbed/resources/testbed-76.png new file mode 100644 index 0000000000..2a6ca25939 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-76.png differ diff --git a/testbed/src/testbed/resources/testbed-80.png b/testbed/src/testbed/resources/testbed-80.png new file mode 100644 index 0000000000..f25442602a Binary files /dev/null and b/testbed/src/testbed/resources/testbed-80.png differ diff --git a/testbed/src/testbed/resources/testbed-87.png b/testbed/src/testbed/resources/testbed-87.png new file mode 100644 index 0000000000..af46ea995f Binary files /dev/null and b/testbed/src/testbed/resources/testbed-87.png differ diff --git a/testbed/src/testbed/resources/testbed-round-144.png b/testbed/src/testbed/resources/testbed-round-144.png new file mode 100644 index 0000000000..99bdd5abd6 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-round-144.png differ diff --git a/testbed/src/testbed/resources/testbed-round-192.png b/testbed/src/testbed/resources/testbed-round-192.png new file mode 100644 index 0000000000..2c69657d04 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-round-192.png differ diff --git a/testbed/src/testbed/resources/testbed-round-48.png b/testbed/src/testbed/resources/testbed-round-48.png new file mode 100644 index 0000000000..aa27b58e52 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-round-48.png differ diff --git a/testbed/src/testbed/resources/testbed-round-72.png b/testbed/src/testbed/resources/testbed-round-72.png new file mode 100644 index 0000000000..8489be821e Binary files /dev/null and b/testbed/src/testbed/resources/testbed-round-72.png differ diff --git a/testbed/src/testbed/resources/testbed-round-96.png b/testbed/src/testbed/resources/testbed-round-96.png new file mode 100644 index 0000000000..effcd8e391 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-round-96.png differ diff --git a/testbed/src/testbed/resources/testbed-square-144.png b/testbed/src/testbed/resources/testbed-square-144.png new file mode 100644 index 0000000000..3577821858 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-square-144.png differ diff --git a/testbed/src/testbed/resources/testbed-square-192.png b/testbed/src/testbed/resources/testbed-square-192.png new file mode 100644 index 0000000000..1a254076e8 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-square-192.png differ diff --git a/testbed/src/testbed/resources/testbed-square-48.png b/testbed/src/testbed/resources/testbed-square-48.png new file mode 100644 index 0000000000..70689ed130 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-square-48.png differ diff --git a/testbed/src/testbed/resources/testbed-square-72.png b/testbed/src/testbed/resources/testbed-square-72.png new file mode 100644 index 0000000000..509ce382f0 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-square-72.png differ diff --git a/testbed/src/testbed/resources/testbed-square-96.png b/testbed/src/testbed/resources/testbed-square-96.png new file mode 100644 index 0000000000..a1489805e0 Binary files /dev/null and b/testbed/src/testbed/resources/testbed-square-96.png differ diff --git a/testbed/src/testbed/resources/testbed.icns b/testbed/src/testbed/resources/testbed.icns new file mode 100644 index 0000000000..64cac52111 Binary files /dev/null and b/testbed/src/testbed/resources/testbed.icns differ diff --git a/testbed/src/testbed/resources/testbed.ico b/testbed/src/testbed/resources/testbed.ico new file mode 100644 index 0000000000..aa3492f61b Binary files /dev/null and b/testbed/src/testbed/resources/testbed.ico differ diff --git a/testbed/src/testbed/resources/testbed.png b/testbed/src/testbed/resources/testbed.png new file mode 100644 index 0000000000..d52899e4c8 Binary files /dev/null and b/testbed/src/testbed/resources/testbed.png differ diff --git a/testbed/tests/__init__.py b/testbed/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/assertions.py b/testbed/tests/assertions.py new file mode 100644 index 0000000000..bea59e6f1f --- /dev/null +++ b/testbed/tests/assertions.py @@ -0,0 +1,16 @@ +from pytest import approx + + +# This could be generalized in future to accept syntax like: +# * assert_set_get(obj, name, pytest.approx(value)) - for floating point values +# * assert_set_get(obj, name, set_value, get_value) - where the two values are different +def assert_set_get(obj, name, value): + """Calls a setter, then asserts that the same value is returned by the getter.""" + setattr(obj, name, value) + assert getattr(obj, name) == value + + +def assert_color(actual, expected): + for component in ["r", "g", "b"]: + assert getattr(actual, component) == getattr(expected, component) + assert actual.a == approx(expected.a, abs=(1 / 255)) diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py new file mode 100644 index 0000000000..4808a614c1 --- /dev/null +++ b/testbed/tests/conftest.py @@ -0,0 +1,57 @@ +import asyncio +import inspect +from dataclasses import dataclass + +from pytest import fixture + +import toga + + +@fixture(scope="session") +def app(): + return toga.App.app + + +@fixture(scope="session") +def main_window(app): + return app.main_window + + +# Controls the event loop used by pytest-asyncio. +@fixture(scope="session") +def event_loop(app): + return ProxyEventLoop(app._impl.loop) + + +# Proxy which forwards all tasks to another event loop in a thread-safe manner. It +# implements only the methods used by pytest-asyncio. +@dataclass +class ProxyEventLoop(asyncio.AbstractEventLoop): + loop: object + + # Used by ensure_future. + def create_task(self, coro): + return ProxyTask(coro) + + def run_until_complete(self, future): + if inspect.iscoroutine(future): + coro = future + elif isinstance(future, ProxyTask): + coro = future.coro + else: + raise TypeError(f"Future type {type(future)} is not currently supported") + return asyncio.run_coroutine_threadsafe(coro, self.loop).result() + + def close(self): + pass + + +@dataclass +class ProxyTask: + coro: object + + # Used by ensure_future. + _source_traceback = None + + def done(self): + return False diff --git a/testbed/tests/data.py b/testbed/tests/data.py new file mode 100644 index 0000000000..a3a52cf3d9 --- /dev/null +++ b/testbed/tests/data.py @@ -0,0 +1,25 @@ +from toga.colors import rgba + +# TODO: add non-ASCII strings. +TEXTS = ["", " ", "a", "ab", "abc", "hello world", "hello\nworld"] + + +# TODO: include None +COLORS = [ + rgba(r, g, b, a) + for r, g, b in [ + # Black, gray, white, + (0, 0, 0), + (1, 1, 1), + (10, 10, 10), + (128, 128, 128), + (245, 245, 245), + (254, 254, 254), + (255, 255, 255), + # Primaries + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + ] + for a in [0.0, 0.01, 0.1, 0.5, 0.9, 0.99, 1.0] +] diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py new file mode 100644 index 0000000000..20c9c1b1b9 --- /dev/null +++ b/testbed/tests/testbed.py @@ -0,0 +1,61 @@ +import os +import sys +import tempfile +from functools import partial +from pathlib import Path +from threading import Thread + +import pytest + +from testbed.app import main + + +def run_tests(app): + project_path = Path(__file__).parent.parent + os.chdir(project_path) + + # TODO: replace with extractPackages. + if hasattr(sys, "getandroidapilevel"): + import tests + + chaquopy_extract_package(tests) + + pytest.main( + [ + # Output formatting + "-vv", + "--no-header", + "--tb=native", + "-rP", # Show stdout from all tests, even if they passed. + "--color=no", + # Run all async tests and fixtures using pytest-asyncio. + "--asyncio-mode=auto", + # Override the cache directory to be somewhere known writable + "-o", + f"cache_dir={tempfile.gettempdir()}/.pytest_cache", + project_path / "tests", + ] + ) + app.add_background_task(lambda app, **kwargs: app.exit()) + + +def chaquopy_extract_package(pkg): + finder = pkg.__loader__.finder + for path in pkg.__path__: + chaquopy_extract_dir(finder, finder.zip_path(path)) + + +def chaquopy_extract_dir(finder, zip_dir): + for filename in finder.listdir(zip_dir): + zip_path = f"{zip_dir}/{filename}" + if finder.isdir(zip_path): + chaquopy_extract_dir(finder, zip_path) + else: + finder.extract_if_changed(zip_path) + + +if __name__ == "__main__": + app = main() + thread = Thread(target=partial(run_tests, app)) + app.add_background_task(lambda app, *kwargs: thread.start()) + app.main_loop() diff --git a/testbed/tests/widgets/__init__.py b/testbed/tests/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py new file mode 100644 index 0000000000..9390336efb --- /dev/null +++ b/testbed/tests/widgets/conftest.py @@ -0,0 +1,22 @@ +from pytest import fixture + +import toga + +from .probe import get_probe + + +@fixture +async def widget(): + raise NotImplementedError("test modules must define a `widget` fixture") + + +@fixture +async def probe(main_window, widget): + box = toga.Box(children=[widget]) + main_window.content = box + + probe = get_probe(widget) + probe.assert_container(box) + yield probe + + main_window.content = toga.Box() diff --git a/testbed/tests/widgets/probe.py b/testbed/tests/widgets/probe.py new file mode 100644 index 0000000000..a1cf52118a --- /dev/null +++ b/testbed/tests/widgets/probe.py @@ -0,0 +1,12 @@ +from importlib import import_module + +from pytest import skip + + +def get_probe(widget): + name = type(widget).__name__ + try: + module = import_module(f"tests_backend.widgets.{name.lower()}") + except ModuleNotFoundError: + skip(f"No probe module for {name}") + return getattr(module, f"{name}Probe")(widget) diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py new file mode 100644 index 0000000000..64e6f9117b --- /dev/null +++ b/testbed/tests/widgets/properties.py @@ -0,0 +1,20 @@ +from ..assertions import assert_color, assert_set_get +from ..data import COLORS, TEXTS + + +async def test_text(widget, probe): + for text in TEXTS: + assert_set_get(widget, "text", text) + assert probe.text == text + + +async def test_color(widget, probe): + for color in COLORS: + widget.style.color = color + assert_color(probe.color, color) + + +async def test_background_color(widget, probe): + for color in COLORS: + widget.style.background_color = color + assert_color(probe.background_color, color) diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py new file mode 100644 index 0000000000..6d3f0dff25 --- /dev/null +++ b/testbed/tests/widgets/test_button.py @@ -0,0 +1,26 @@ +from unittest.mock import Mock + +from pytest import fixture + +import toga + +from .properties import ( # noqa: F401 + test_background_color, + test_color, + test_text, +) + + +@fixture +async def widget(): + return toga.Button("") + + +async def test_press(widget, probe): + handler = Mock() + # TODO: can't use assert_set_get, because getattr returns the wrapped handler, which + # is an implementation detail that we shouldn't expose. + # https://github.com/beeware/toga/pull/804 may be relevant. + setattr(widget, "on_press", handler) + probe.press() + handler.assert_called_once_with(widget) diff --git a/testbed/tests/widgets/test_label.py b/testbed/tests/widgets/test_label.py new file mode 100644 index 0000000000..334e40faf9 --- /dev/null +++ b/testbed/tests/widgets/test_label.py @@ -0,0 +1,37 @@ +from pytest import approx, fixture, mark + +import toga + +from .properties import ( # noqa: F401 + test_background_color, + test_color, + test_text, +) + + +@fixture +async def widget(): + return toga.Label("") + + +# TODO: a `width` test, for any widget whose width depends on its text. +@mark.skip("changing text does not trigger a refresh (#1289)") +async def test_multiline(widget, probe): + def make_lines(n): + return "\n".join(f"line{i}" for i in range(n)) + + widget.text = make_lines(1) + # TODO: Android at least will need an `await` after each text change, to give the + # native layout a chance to update. + line_height = probe.height + + widget.text = make_lines(2) + assert probe.height == approx(line_height * 2, rel=0.1) + line_spacing = probe.height - (line_height * 2) + + for n in range(3, 10): + widget.text = make_lines(n) + assert probe.height == approx( + (line_height * n) + (line_spacing * (n - 1)), + rel=0.1, + ) diff --git a/testbed/tests/widgets/test_slider.py b/testbed/tests/widgets/test_slider.py new file mode 100644 index 0000000000..44b40a67e6 --- /dev/null +++ b/testbed/tests/widgets/test_slider.py @@ -0,0 +1,73 @@ +from unittest.mock import Mock + +from pytest import approx, fixture, mark + +import toga +from toga.platform import current_platform + +POSITIONS = [0, 0.01, 0.1, 0.5, 0.9, 0.99, 1] +SCALES = [0.01, 0.1, 1, 10, 100000] + +# How accurate the position must be in continuous mode. +ACCURACY = 0.001 + + +@fixture +async def widget(on_change): + return toga.Slider(on_change=on_change) + + +@fixture +def on_change(): + return Mock() + + +@mark.skipif(current_platform == "android", reason="value is 0.0") +async def test_init(widget, probe, on_change): + assert widget.value == 0.5 + assert widget.range == (0, 1) + assert probe.position == 0.5 + on_change.assert_not_called() + + +@mark.skipif(current_platform == "windows", reason="on_change called 2 times") +@mark.skipif(current_platform == "android", reason="position is 0.0") +async def test_value(widget, probe, on_change): + for scale in SCALES: + widget.range = (0, scale) + for position in POSITIONS: + on_change.mock_calls.clear() + widget.value = position * scale + assert probe.position == approx(position, abs=ACCURACY) + on_change.assert_called_once_with(widget) + + +@mark.skipif( + current_platform in ["android", "windows"], reason="on_change called 0 times" +) +async def test_change(widget, probe, on_change): + for scale in SCALES: + widget.range = (0, scale) + for position in POSITIONS: + on_change.mock_calls.clear() + probe.change(position) + assert widget.value == approx(position * scale, abs=(ACCURACY * scale)) + on_change.assert_called_once_with(widget) + + +@mark.skipif(current_platform == "windows", reason="value does not remain constant") +@mark.skipif(current_platform == "android", reason="value is 0.0") +async def test_min(widget, probe): + for min in POSITIONS[:4]: + widget.range = (min, 1) + assert widget.value == 0.5 + assert probe.position == approx((0.5 - min) / (1 - min), abs=ACCURACY) + + +@mark.skipif(current_platform == "windows", reason="value does not remain constant") +@mark.skipif(current_platform == "android", reason="value is 0.0") +async def test_max(widget, probe): + for max in POSITIONS[-4:]: + widget.range = (0, max) + assert widget.value == 0.5 + assert probe.position == approx(0.5 / max, abs=ACCURACY) diff --git a/tox.ini b/tox.ini index a3da155e0d..3a89921100 100644 --- a/tox.ini +++ b/tox.ini @@ -54,9 +54,9 @@ commands = [testenv:package] skip_install = True deps = - check_manifest - build - twine + check_manifest==0.48 + build==0.9.0 + twine==4.0.2 commands = check-manifest -v {posargs} python -m build {posargs} diff --git a/winforms/MANIFEST.in b/winforms/MANIFEST.in index 126da665ed..96861fad80 100644 --- a/winforms/MANIFEST.in +++ b/winforms/MANIFEST.in @@ -3,3 +3,4 @@ include LICENSE include README.rst include tox.ini recursive-include tests *.py +recursive-include tests_backend *.py diff --git a/winforms/tests_backend/__init__.py b/winforms/tests_backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/winforms/tests_backend/widgets/__init__.py b/winforms/tests_backend/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py new file mode 100644 index 0000000000..5ab8afae78 --- /dev/null +++ b/winforms/tests_backend/widgets/base.py @@ -0,0 +1,44 @@ +from System import EventArgs, Object + +from .properties import toga_color + + +class SimpleProbe: + def __init__(self, widget): + self.native = widget._impl.native + assert isinstance(self.native, self.native_class) + + def assert_container(self, container): + container_native = container._impl.native + for control in container_native.Controls: + if Object.ReferenceEquals(control, self.native): + break + else: + raise ValueError(f"cannot find {self.native} in {container_native}") + + @property + def enabled(self): + return self.native.Enabled + + @property + def background_color(self): + return toga_color(self.native.BackColor) + + @property + def color(self): + return toga_color(self.native.ForeColor) + + @property + def hidden(self): + return not self.native.Visible + + @property + def width(self): + return self.native.Width + + @property + def height(self): + return self.native.Height + + def press(self): + self.native.OnClick(EventArgs.Empty) diff --git a/winforms/tests_backend/widgets/button.py b/winforms/tests_backend/widgets/button.py new file mode 100644 index 0000000000..b04221b842 --- /dev/null +++ b/winforms/tests_backend/widgets/button.py @@ -0,0 +1,11 @@ +import System.Windows.Forms + +from .base import SimpleProbe + + +class ButtonProbe(SimpleProbe): + native_class = System.Windows.Forms.Button + + @property + def text(self): + return self.native.Text diff --git a/winforms/tests_backend/widgets/label.py b/winforms/tests_backend/widgets/label.py new file mode 100644 index 0000000000..f7ae735b8c --- /dev/null +++ b/winforms/tests_backend/widgets/label.py @@ -0,0 +1,11 @@ +import System.Windows.Forms + +from .base import SimpleProbe + + +class LabelProbe(SimpleProbe): + native_class = System.Windows.Forms.Label + + @property + def text(self): + return self.native.Text diff --git a/winforms/tests_backend/widgets/properties.py b/winforms/tests_backend/widgets/properties.py new file mode 100644 index 0000000000..f0eba27506 --- /dev/null +++ b/winforms/tests_backend/widgets/properties.py @@ -0,0 +1,5 @@ +from toga.colors import rgba + + +def toga_color(color): + return rgba(color.R, color.G, color.B, color.A / 255) diff --git a/winforms/tests_backend/widgets/slider.py b/winforms/tests_backend/widgets/slider.py new file mode 100644 index 0000000000..c1258159d8 --- /dev/null +++ b/winforms/tests_backend/widgets/slider.py @@ -0,0 +1,22 @@ +import System.Windows.Forms + +from .base import SimpleProbe + + +class SliderProbe(SimpleProbe): + native_class = System.Windows.Forms.TrackBar + + @property + def position(self): + return (self.native.Value - self._min) / (self._max - self._min) + + def change(self, position): + self.native.Value = self._min + round(position * (self._max - self._min)) + + @property + def _min(self): + return self.native.Minimum + + @property + def _max(self): + return self.native.Maximum