626 lines
19 KiB
Python
626 lines
19 KiB
Python
import asyncio
|
|
import importlib.metadata
|
|
import sys
|
|
import webbrowser
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
import toga
|
|
from toga_dummy.utils import (
|
|
assert_action_not_performed,
|
|
assert_action_performed,
|
|
assert_action_performed_with,
|
|
)
|
|
|
|
EXPLICIT_FULL_APP_KWARGS = dict(
|
|
formal_name="Explicit App",
|
|
app_id="org.beeware.explicit-app",
|
|
app_name="override-app",
|
|
)
|
|
EXPLICIT_MIN_APP_KWARGS = dict(
|
|
formal_name="Explicit App",
|
|
app_id="org.beeware.explicit-app",
|
|
)
|
|
APP_METADATA = {
|
|
"Formal-Name": "Test App",
|
|
"App-ID": "org.beeware.test-app",
|
|
"Name": "test-app",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"kwargs, metadata, main_module, expected_formal_name, expected_app_id, "
|
|
"expected_app_name"
|
|
),
|
|
[
|
|
###########################################################################
|
|
# Invoking as python my_app.py, or as an interactive prompt
|
|
# This causes a main package of None
|
|
###########################################################################
|
|
# Explicit app properties, no metadata
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
None,
|
|
Mock(__package__=None),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
# Explicit app properties, but implied distribution name from app_id, no
|
|
# metadata
|
|
(
|
|
EXPLICIT_MIN_APP_KWARGS,
|
|
None,
|
|
Mock(__package__=None),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"explicit-app",
|
|
),
|
|
# No app properties, with metadata
|
|
(
|
|
dict(),
|
|
APP_METADATA,
|
|
Mock(__package__=None),
|
|
"Test App",
|
|
"org.beeware.test-app",
|
|
"toga",
|
|
),
|
|
# Explicit app properties, with metadata. Explicit values take precedence.
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
APP_METADATA,
|
|
Mock(__package__=None),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
###########################################################################
|
|
# Invoking as python -m my_app, where code is in my_app.py
|
|
# This causes a main module of ""
|
|
###########################################################################
|
|
# Explicit app properties, no metadata
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
None,
|
|
Mock(__package__=""),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
# Explicit app properties, but implied distribution name from app_id, no metadata
|
|
(
|
|
EXPLICIT_MIN_APP_KWARGS,
|
|
None,
|
|
Mock(__package__=""),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"explicit-app",
|
|
),
|
|
# No app properties, with metadata
|
|
(
|
|
dict(),
|
|
APP_METADATA,
|
|
Mock(__package__=""),
|
|
"Test App",
|
|
"org.beeware.test-app",
|
|
"toga",
|
|
),
|
|
# Explicit app properties, with metadata. Explicit values take precedence.
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
APP_METADATA,
|
|
Mock(__package__=""),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
###########################################################################
|
|
# Invoking as python -m my_app, where my_app is a folder with a __main__
|
|
# This causes a main module of "my_app"
|
|
###########################################################################
|
|
# Explicit app properties, no metadata
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
None,
|
|
Mock(__package__="my_app"),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
# Explicit app properties, but implied distribution name from __package__, no
|
|
# metadata
|
|
(
|
|
EXPLICIT_MIN_APP_KWARGS,
|
|
None,
|
|
Mock(__package__="my_app"),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"my_app",
|
|
),
|
|
# No app properties, with metadata
|
|
(
|
|
dict(),
|
|
APP_METADATA,
|
|
Mock(__package__="my_app"),
|
|
"Test App",
|
|
"org.beeware.test-app",
|
|
"my_app",
|
|
),
|
|
# Explicit app properties, with metadata. Explicit values take precedence.
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
APP_METADATA,
|
|
Mock(__package__="my_app"),
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
###########################################################################
|
|
# Invoking in a test harness, where there's no __main__
|
|
###########################################################################
|
|
# Explicit app properties, no metadata
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
None,
|
|
None,
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
# Explicit app properties, but implied distribution name from app_id, no metadata
|
|
(
|
|
EXPLICIT_MIN_APP_KWARGS,
|
|
None,
|
|
None,
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"explicit-app",
|
|
),
|
|
# No app properties, with metadata
|
|
(
|
|
dict(),
|
|
APP_METADATA,
|
|
None,
|
|
"Test App",
|
|
"org.beeware.test-app",
|
|
"toga",
|
|
),
|
|
# Explicit app properties, with metadata. Explicit values take precedence.
|
|
(
|
|
EXPLICIT_FULL_APP_KWARGS,
|
|
APP_METADATA,
|
|
None,
|
|
"Explicit App",
|
|
"org.beeware.explicit-app",
|
|
"override-app",
|
|
),
|
|
],
|
|
)
|
|
def test_create(
|
|
monkeypatch,
|
|
event_loop,
|
|
kwargs,
|
|
metadata,
|
|
main_module,
|
|
expected_formal_name,
|
|
expected_app_id,
|
|
expected_app_name,
|
|
):
|
|
"""A simple app can be created."""
|
|
# Monkeypatch the metadata retrieval function
|
|
if metadata:
|
|
metadata_mock = Mock(return_value=metadata)
|
|
else:
|
|
metadata_mock = Mock(
|
|
side_effect=importlib.metadata.PackageNotFoundError(expected_app_name)
|
|
)
|
|
monkeypatch.setattr(importlib.metadata, "metadata", metadata_mock)
|
|
|
|
# Monkeypatch the main module
|
|
if main_module is None:
|
|
try:
|
|
monkeypatch.delitem(sys.modules, "__main__")
|
|
except KeyError:
|
|
pass
|
|
else:
|
|
monkeypatch.setitem(sys.modules, "__main__", main_module)
|
|
|
|
app = toga.App(**kwargs)
|
|
|
|
assert app.formal_name == expected_formal_name
|
|
assert app.app_id == expected_app_id
|
|
assert app.app_name == expected_app_name
|
|
assert app.on_exit._raw is None
|
|
|
|
metadata_mock.assert_called_once_with(expected_app_name)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs, exc_type, message",
|
|
[
|
|
(dict(), RuntimeError, "Toga application must have a formal name"),
|
|
(
|
|
dict(formal_name="Something"),
|
|
RuntimeError,
|
|
"Toga application must have an app ID",
|
|
),
|
|
(
|
|
dict(windows=()),
|
|
ValueError,
|
|
"The `windows` constructor argument of toga.App has been removed",
|
|
),
|
|
],
|
|
)
|
|
def test_bad_app_creation(kwargs, exc_type, message):
|
|
"""Errors are raised."""
|
|
with pytest.raises(exc_type, match=message):
|
|
toga.App(**kwargs)
|
|
|
|
|
|
def test_app_metadata(monkeypatch, event_loop):
|
|
"""An app can load metadata from the .dist-info file."""
|
|
monkeypatch.setattr(
|
|
importlib.metadata,
|
|
"metadata",
|
|
Mock(
|
|
return_value={
|
|
"Formal-Name": "Metadata Name",
|
|
"Name": "metadata",
|
|
"App-ID": "org.beeware.metadata",
|
|
"Author": "Jane Developer",
|
|
"Version": "1.2.3",
|
|
"Home-page": "https://example.com/test-app",
|
|
"Summary": "A test app",
|
|
}
|
|
),
|
|
)
|
|
|
|
# We can't use the app fixture here, because we need the metadata to be loaded as
|
|
# part of app construction.
|
|
app = toga.App(
|
|
formal_name="Test App",
|
|
app_id="org.example.test-app",
|
|
)
|
|
|
|
assert app.author == "Jane Developer"
|
|
assert app.version == "1.2.3"
|
|
assert app.home_page == "https://example.com/test-app"
|
|
assert app.description == "A test app"
|
|
|
|
|
|
def test_explicit_app_metadata(monkeypatch, event_loop):
|
|
"""App metadata can be provided explicitly, overriding module-level metadata."""
|
|
monkeypatch.setattr(
|
|
importlib.metadata,
|
|
"metadata",
|
|
Mock(
|
|
return_value={
|
|
"Formal-Name": "Metadata Name",
|
|
"Name": "metadata",
|
|
"App-ID": "org.beeware.metadata",
|
|
"Author": "Alice Metadata",
|
|
"Version": "2.3.4",
|
|
"Home-page": "https://example.com/metadata",
|
|
"Summary": "Metadata description of app",
|
|
}
|
|
),
|
|
)
|
|
|
|
on_exit_handler = Mock()
|
|
|
|
app = toga.App(
|
|
formal_name="Test App",
|
|
app_id="org.example.test-app",
|
|
author="Jane Developer",
|
|
version="1.2.3",
|
|
home_page="https://example.com/test-app",
|
|
description="A test app",
|
|
on_exit=on_exit_handler,
|
|
)
|
|
|
|
assert app.author == "Jane Developer"
|
|
assert app.version == "1.2.3"
|
|
assert app.home_page == "https://example.com/test-app"
|
|
assert app.description == "A test app"
|
|
|
|
assert app.on_exit._raw == on_exit_handler
|
|
|
|
|
|
@pytest.mark.parametrize("construct", [True, False])
|
|
def test_icon_construction(construct, event_loop):
|
|
"""The app icon can be set during construction."""
|
|
if construct:
|
|
icon = toga.Icon("path/to/icon")
|
|
else:
|
|
icon = "path/to/icon"
|
|
|
|
app = toga.App(
|
|
formal_name="Test App",
|
|
app_id="org.example.test",
|
|
icon=icon,
|
|
)
|
|
assert isinstance(app.icon, toga.Icon)
|
|
assert app.icon.path == Path("path/to/icon")
|
|
|
|
|
|
@pytest.mark.parametrize("construct", [True, False])
|
|
def test_icon(app, construct):
|
|
"""The app icon can be changed."""
|
|
if construct:
|
|
icon = toga.Icon("path/to/icon")
|
|
else:
|
|
icon = "path/to/icon"
|
|
|
|
# Default icon matches distribution name
|
|
assert isinstance(app.icon, toga.Icon)
|
|
assert app.icon.path == Path("resources/test-app")
|
|
# During initial setup, the icon isn't explicitly set.
|
|
assert_action_not_performed(app, "set_icon")
|
|
|
|
# Change icon
|
|
app.icon = icon
|
|
assert isinstance(app.icon, toga.Icon)
|
|
assert app.icon.path == Path("path/to/icon")
|
|
assert_action_performed_with(app, "set_icon", icon=toga.Icon("path/to/icon"))
|
|
|
|
|
|
def test_current_window(app):
|
|
"""The current window can be set and changed."""
|
|
other_window = toga.Window()
|
|
|
|
# There are two windows - the main window, plus "other"
|
|
assert len(app.windows) == 2
|
|
assert_action_performed_with(app, "set_main_window", window=app.main_window)
|
|
|
|
# The initial current window is the main window
|
|
assert app.current_window == app.main_window
|
|
|
|
# Change the current window
|
|
app.current_window = other_window
|
|
assert app.current_window == other_window
|
|
assert_action_performed_with(app, "set_current_window", window=other_window)
|
|
|
|
|
|
def test_no_current_window(app):
|
|
"""If there's no current window, current_window reflects this."""
|
|
# If all the windows are deleted, and there's no main window (e.g., if it's a document app)
|
|
# there might be no current window.
|
|
app._main_window = None
|
|
|
|
# The current window evaluates as None
|
|
assert app.current_window is None
|
|
|
|
|
|
def test_full_screen(event_loop):
|
|
"""The app can be put into full screen mode."""
|
|
window1 = toga.Window()
|
|
window2 = toga.Window()
|
|
app = toga.App(formal_name="Test App", app_id="org.example.test")
|
|
|
|
assert not app.is_full_screen
|
|
|
|
# If we're not full screen, exiting full screen is a no-op
|
|
app.exit_full_screen()
|
|
assert_action_not_performed(app, "exit_full_screen")
|
|
|
|
# Enter full screen with 2 windows
|
|
app.set_full_screen(window2, app.main_window)
|
|
assert app.is_full_screen
|
|
assert_action_performed_with(
|
|
app, "enter_full_screen", windows=(window2, app.main_window)
|
|
)
|
|
|
|
# Change the screens that are full screen
|
|
app.set_full_screen(app.main_window, window1)
|
|
assert app.is_full_screen
|
|
assert_action_performed_with(
|
|
app, "enter_full_screen", windows=(app.main_window, window1)
|
|
)
|
|
|
|
# Exit full screen mode
|
|
app.exit_full_screen()
|
|
assert not app.is_full_screen
|
|
assert_action_performed_with(
|
|
app, "exit_full_screen", windows=(app.main_window, window1)
|
|
)
|
|
|
|
|
|
def test_set_empty_full_screen_window_list(event_loop):
|
|
"""Setting the full screen window list to [] is an explicit exit."""
|
|
app = toga.App(formal_name="Test App", app_id="org.example.test")
|
|
window1 = toga.Window()
|
|
window2 = toga.Window()
|
|
|
|
assert not app.is_full_screen
|
|
|
|
# Change the screens that are full screen
|
|
app.set_full_screen(window1, window2)
|
|
assert app.is_full_screen
|
|
assert_action_performed_with(app, "enter_full_screen", windows=(window1, window2))
|
|
|
|
# Exit full screen mode by setting no windows full screen
|
|
app.set_full_screen()
|
|
assert not app.is_full_screen
|
|
assert_action_performed_with(app, "exit_full_screen", windows=(window1, window2))
|
|
|
|
|
|
def test_show_hide_cursor(app):
|
|
"""The app cursor can be shown and hidden."""
|
|
app.hide_cursor()
|
|
assert_action_performed(app, "hide_cursor")
|
|
|
|
app.show_cursor()
|
|
assert_action_performed(app, "show_cursor")
|
|
|
|
|
|
def test_startup_method(event_loop):
|
|
"""If an app provides a startup method, it will be invoked during startup."""
|
|
startup = Mock()
|
|
app = toga.App(
|
|
formal_name="Test App",
|
|
app_id="org.example.test",
|
|
startup=startup,
|
|
)
|
|
|
|
startup.assert_called_once_with(app)
|
|
|
|
|
|
def test_startup_subclass(event_loop):
|
|
"""App can be subclassed."""
|
|
|
|
class SubclassedApp(toga.App):
|
|
def startup(self):
|
|
self.main_window = toga.MainWindow()
|
|
|
|
app = SubclassedApp(formal_name="Test App", app_id="org.example.test")
|
|
|
|
# The main window will exist, and will have the app's formal name.
|
|
assert app.main_window.title == "Test App"
|
|
|
|
|
|
def test_startup_subclass_no_main_window(event_loop):
|
|
"""If a subclassed app doesn't define a main window, an error is raised."""
|
|
|
|
class SubclassedApp(toga.App):
|
|
def startup(self):
|
|
pass
|
|
|
|
with pytest.raises(ValueError, match=r"Application does not have a main window."):
|
|
SubclassedApp(formal_name="Test App", app_id="org.example.test")
|
|
|
|
|
|
def test_about(app):
|
|
"""The about dialog for the app can be shown."""
|
|
app.about()
|
|
assert_action_performed(app, "show_about_dialog")
|
|
|
|
|
|
def test_visit_homepage(monkeypatch, event_loop):
|
|
"""The app's homepage can be opened."""
|
|
app = toga.App(
|
|
formal_name="Test App",
|
|
app_id="org.example.test",
|
|
home_page="https://example.com/test-app",
|
|
)
|
|
open_webbrowser = Mock()
|
|
monkeypatch.setattr(webbrowser, "open", open_webbrowser)
|
|
|
|
# The app has no homepage by default, so visit is a no-op
|
|
app.visit_homepage()
|
|
|
|
open_webbrowser.assert_called_once_with("https://example.com/test-app")
|
|
|
|
|
|
def test_no_homepage(monkeypatch, app):
|
|
"""If the app doesn't have a home page, visit_homepage is a no-op."""
|
|
open_webbrowser = Mock()
|
|
monkeypatch.setattr(webbrowser, "open", open_webbrowser)
|
|
|
|
# The app has no homepage by default, so visit is a no-op
|
|
app.visit_homepage()
|
|
|
|
open_webbrowser.assert_not_called()
|
|
|
|
|
|
def test_beep(app):
|
|
"""The machine can go Bing!"""
|
|
app.beep()
|
|
assert_action_performed(app, "beep")
|
|
|
|
|
|
def test_exit_direct(app):
|
|
"""An app can be exited directly."""
|
|
on_exit_handler = Mock(return_value=True)
|
|
app.on_exit = on_exit_handler
|
|
|
|
# Exit the app directly
|
|
app.exit()
|
|
|
|
# App has been exited, but the exit handler has *not* been invoked.
|
|
assert_action_performed(app, "exit")
|
|
on_exit_handler.assert_not_called()
|
|
|
|
|
|
def test_exit_no_handler(app):
|
|
"""An app without an exit handler can be exited."""
|
|
# Exit the app
|
|
app._impl.simulate_exit()
|
|
|
|
# Window has been exited, and is no longer in the app's list of windows.
|
|
assert_action_performed(app, "exit")
|
|
|
|
|
|
def test_exit_successful_handler(app):
|
|
"""An app with a successful exit handler can be exited."""
|
|
on_exit_handler = Mock(return_value=True)
|
|
app.on_exit = on_exit_handler
|
|
|
|
# Close the app
|
|
app._impl.simulate_exit()
|
|
|
|
# App has been exited
|
|
assert_action_performed(app, "exit")
|
|
on_exit_handler.assert_called_once_with(app)
|
|
|
|
|
|
def test_exit_rejected_handler(app):
|
|
"""An app can have a exit handler that rejects the exit."""
|
|
on_exit_handler = Mock(return_value=False)
|
|
app.on_exit = on_exit_handler
|
|
|
|
# Close the window
|
|
app._impl.simulate_exit()
|
|
|
|
# App has been *not* exited
|
|
assert_action_not_performed(app, "exit")
|
|
on_exit_handler.assert_called_once_with(app)
|
|
|
|
|
|
def test_loop(app, event_loop):
|
|
"""The main thread's event loop can be accessed."""
|
|
assert isinstance(app.loop, asyncio.AbstractEventLoop)
|
|
assert app.loop is event_loop
|
|
|
|
|
|
def test_background_task(app):
|
|
"""A background task can be queued."""
|
|
canary = Mock()
|
|
|
|
async def background(app, **kwargs):
|
|
canary()
|
|
|
|
app.add_background_task(background)
|
|
|
|
# Create an async task that we can use to start the event loop for a short time.
|
|
async def waiter():
|
|
await asyncio.sleep(0.1)
|
|
|
|
app.loop.run_until_complete(waiter())
|
|
|
|
# Once the loop has executed, the background task should have executed as well.
|
|
canary.assert_called_once()
|
|
|
|
|
|
def test_deprecated_id(event_loop):
|
|
"""The deprecated `id` constructor argument is ignored, and the property of the same
|
|
name is redirected to `app_id`"""
|
|
id_warning = r"App.id is deprecated.* Use app_id instead"
|
|
with pytest.warns(DeprecationWarning, match=id_warning):
|
|
app = toga.App("Test App", "org.example.test", id="test_app_id")
|
|
|
|
assert app.app_id == "org.example.test"
|
|
with pytest.warns(DeprecationWarning, match=id_warning):
|
|
assert app.id == "org.example.test"
|
|
|
|
|
|
def test_deprecated_name(event_loop):
|
|
"""The deprecated `name` property is redirected to `formal_name`"""
|
|
name_warning = r"App.name is deprecated. Use formal_name instead"
|
|
app = toga.App("Test App", "org.example.test")
|
|
|
|
assert app.formal_name == "Test App"
|
|
with pytest.warns(DeprecationWarning, match=name_warning):
|
|
assert app.name == "Test App"
|