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

test: [wip] mock tests #510

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ repos:
- id: black

- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.118
rev: v0.0.125
hooks:
- id: ruff
args: ["--fix"]
Expand Down
21 changes: 11 additions & 10 deletions src/magicgui/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ class Application:
_backend: BaseApplicationBackend
_instance: Application | None = None

def __init__(self, backend_name: str | None = None):
self._use(backend_name)
def __init__(self, backend_name: str | BaseApplicationBackend | None = None):
if isinstance(backend_name, str) or not backend_name:
self._use(backend_name)
else:
self._backend = backend_name
self._backend_module = import_module(backend_name.__module__)

@property
def backend_name(self) -> str:
Expand Down Expand Up @@ -140,16 +144,13 @@ def _use_app(backend_name: Optional[str] = None):
# If we already have a default_app, raise error or return
current = Application._instance
if current is not None:
if backend_name:
names = current.backend_name.lower().replace("(", " ").strip(") ")
_nm = [n for n in names.split(" ") if n]
if backend_name.lower() not in _nm:
raise RuntimeError(
f"Can only select a backend once, already using {_nm}."
)
else:
if not backend_name:
return current # Current backend matches backend_name

names = current.backend_name.lower().replace("(", " ").strip(") ")
_nm = [n for n in names.split(" ") if n]
if backend_name.lower() not in _nm:
raise RuntimeError(f"Can only select a backend once, already using {_nm}.")
# Create default app
Application._instance = Application(backend_name)
return Application._instance
Expand Down
2 changes: 1 addition & 1 deletion src/magicgui/tqdm.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def __init__(self, iterable: Iterable | None = None, *args, **kwargs) -> None:
return

# no-op status printer, required for older tqdm compat
self.sp = lambda x: None # noqa: E731
self.sp = lambda x: None
if self.disable:
return

Expand Down
3 changes: 3 additions & 0 deletions src/magicgui/widgets/bases/_value_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def __init__(

def _post_init(self):
super()._post_init()
# Note that it is the responsibility of the backend to emit the changed signal
self._widget._mgui_bind_change_callback(self._on_value_change)

def _on_value_change(self, value=None):
Expand Down Expand Up @@ -79,6 +80,8 @@ def value(self):

@value.setter
def value(self, value):
# value_changed will be emitted indirectly by the backend calling our
# _on_value_change method, which we connected in _post_init
return self._widget._mgui_set_value(value)

def __repr__(self) -> str:
Expand Down
14 changes: 9 additions & 5 deletions src/magicgui/widgets/bases/_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from psygnal import Signal

from magicgui._type_resolution import resolve_single_type
from magicgui.application import use_app
from magicgui.application import Application, use_app
from magicgui.widgets import protocols

BUILDING_DOCS = sys.argv[-2:] == ["build", "docs"]
Expand Down Expand Up @@ -99,7 +99,7 @@ def __init__(
_prot = _prot.__name__
prot = getattr(protocols, _prot.replace("protocols.", ""))
protocols.assert_protocol(widget_type, prot)
self.__magicgui_app__ = use_app()
self.__magicgui_app__: Application = use_app()
assert self.__magicgui_app__.native
if isinstance(parent, Widget):
parent = parent.native
Expand Down Expand Up @@ -212,8 +212,15 @@ def parent(self) -> Widget:

@parent.setter
def parent(self, value: Widget):
# note that it's up to the backend to actually set the parent
# which should trigger a call to _emit_parent because of the
# self._widget._mgui_bind_parent_change_callback(self._emit_parent)
# in the constructor.
self._widget._mgui_set_parent(value)

def _emit_parent(self, *_):
self.parent_changed.emit(self.parent)

@property
def widget_type(self) -> str:
"""Return type of widget."""
Expand Down Expand Up @@ -391,9 +398,6 @@ def _repr_png_(self) -> Optional[bytes]:
return file_obj.read()
return None

def _emit_parent(self, *_):
self.parent_changed.emit(self.parent)

def _ipython_display_(self, *args, **kwargs):
if hasattr(self.native, "_ipython_display_"):
return self.native._ipython_display_(*args, **kwargs)
Expand Down
22 changes: 20 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from unittest.mock import MagicMock, PropertyMock, create_autospec, patch

import pytest

from magicgui.application import use_app
from magicgui.application import Application, use_app
from magicgui.widgets.protocols import BaseApplicationBackend


@pytest.fixture(scope="session")
Expand All @@ -11,9 +14,24 @@ def qapp():
# for now, the only backend is qt, and pytest-qt's qapp provides some nice pre-post
# test cleanup that prevents some segfaults. Once we start testing multiple backends
# this will need to change.
@pytest.fixture(autouse=True, scope="function")
@pytest.fixture(scope="function")
def always_qapp(qapp):
yield qapp
for w in qapp.topLevelWidgets():
w.close()
w.deleteLater()


@pytest.fixture
def mock_app():
MockAppBackend: MagicMock = create_autospec(BaseApplicationBackend, spec_set=True)
mock_app = Application(MockAppBackend)

backend_module = MagicMock()
p = PropertyMock()
setattr(type(backend_module), "some name", p)
setattr(mock_app, "_prop", p)

mock_app._backend_module = backend_module
with patch.object(Application, "_instance", mock_app):
yield mock_app
29 changes: 24 additions & 5 deletions tests/test_application.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
from magicgui import use_app
from magicgui.application import APPLICATION_NAME
from magicgui.application import use_app


def test_app_name():
app = use_app("qt")
assert app.native.applicationName() == APPLICATION_NAME
def test_mock_app(mock_app):
app = use_app()
backend = mock_app._backend

assert app is mock_app

app.backend_name
backend._mgui_get_backend_name.assert_called_once()

app.get_obj("some name")
mock_app._prop.assert_called_once()

with app:
backend._mgui_get_native_app.assert_called_once()
backend._mgui_start_timer.assert_called_once()
backend._mgui_run.assert_called_once()
backend._mgui_stop_timer.assert_called_once()

app.process_events()
backend._mgui_process_events.assert_called_once()

app.quit()
backend._mgui_quit.assert_called_once()
2 changes: 1 addition & 1 deletion tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def test_widget_options():

def test_nested_forward_refs():

resolved = resolve_single_type(Optional['List["numpy.ndarray"]']) # noqa
resolved = resolve_single_type(Optional['List["numpy.ndarray"]'])

from typing import List

Expand Down
149 changes: 149 additions & 0 deletions tests/test_widget_bases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# type: ignore
from __future__ import annotations

import enum
from typing import ForwardRef, TypeVar
from unittest.mock import Mock, create_autospec

from magicgui import widgets

W = TypeVar("W", bound=widgets.Widget)


def _mock_widget(WidgetType: type[W], **kwargs) -> W:
"""Create a mock widget with the given spec."""
from magicgui.widgets import protocols

_proto = WidgetType.__annotations__.get("_widget", None)
if _proto is None:
raise TypeError(f"Cannot mock {WidgetType} without a _widget annotation")
elif isinstance(_proto, (ForwardRef, str)):
if isinstance(_proto, str):
_proto = ForwardRef(_proto)
try:
_proto = _proto._evaluate({"protocols": protocols}, None, frozenset())
except TypeError:
_proto = _proto._evaluate({"protocols": protocols}, None)
backend_mock = create_autospec(_proto, spec_set=True)
widget = WidgetType(widget_type=backend_mock, **kwargs)
backend_mock.assert_called_once_with(parent=None)
return widget


def test_base_widgtet_protocol(mock_app):
widget = _mock_widget(widgets.Widget)

assert widget.__magicgui_app__ is mock_app
mock = widget._widget

mock._mgui_get_native_widget.assert_called_once()
assert widget.native._magic_widget is widget

mock._mgui_set_tooltip.assert_called_once_with(None)
mock._mgui_set_enabled.assert_called_once_with(True)
mock._mgui_bind_parent_change_callback.assert_called_once()

assert {"enabled", "visible"}.issubset(set(widget.options))
mock._mgui_get_enabled.assert_called_once()
mock._mgui_get_visible.assert_called_once()

for attr in (
"width",
"height",
"min_width",
"min_height",
"max_width",
"max_height",
):
getattr(widget, attr)
getattr(mock, f"_mgui_get_{attr}").assert_called_once()
setattr(widget, attr, 1)
getattr(mock, f"_mgui_set_{attr}").assert_called_once_with(1)

widget.show(run=True)
mock._mgui_set_visible.assert_called_once_with(True)
mock_app._backend._mgui_run.assert_called_once()

# shown context
mock._mgui_set_visible.reset_mock()
assert mock_app._backend._mgui_get_native_app.call_count == 1
assert mock_app._backend._mgui_run.call_count == 1
with widget.shown():
mock._mgui_set_visible.assert_called_with(True)
assert mock_app._backend._mgui_get_native_app.call_count == 2
assert mock_app._backend._mgui_run.call_count == 2

widget.hide()
mock._mgui_set_visible.assert_called_with(False)

widget.close()
mock._mgui_close_widget.assert_called_once()

widget.render()
mock._mgui_render.assert_called_once()


def test_base_widget_events(mock_app):
widget = _mock_widget(widgets.Widget)
widget._widget._mgui_set_parent.side_effect = widget._emit_parent

mock = Mock()
widget.label_changed.connect(mock)
widget.label = "new_label"
mock.assert_called_once_with("new_label")

mock.reset_mock()
widget.parent_changed.connect(mock)
widget.parent = "new_parent"
mock.assert_called_once()


def test_value_widget_protocol(mock_app):
widget = _mock_widget(widgets.bases.ValueWidget, value=1)
widget._widget._mgui_set_value.assert_called_once_with(1)

widget.value
assert widget._widget._mgui_get_value.call_count == 1
widget.get_value()
assert widget._widget._mgui_get_value.call_count == 2

widget.value = 2
widget._widget._mgui_set_value.assert_called_with(2)


def test_value_widget_bind(mock_app):
widget = _mock_widget(widgets.bases.ValueWidget)

mock = Mock()
mock.return_value = 3
widget.bind(mock)
mock.assert_not_called()
assert widget.value == 3
mock.assert_called_once_with(widget)


def test_value_widget_events(mock_app):
widget = _mock_widget(widgets.bases.ValueWidget, value=1)
widget._widget._mgui_set_value.side_effect = widget._on_value_change

change_mock = Mock()
widget.changed.connect(change_mock)

widget.value = 2
change_mock.assert_called_with(2)


def test_categorical_widget_events(mock_app):
class E(enum.Enum):
a = 1
b = 2

widget = _mock_widget(widgets.bases.CategoricalWidget, choices=E)
widget._widget._mgui_get_choices.return_value = (("a", E.a), ("b", E.b))
widget._widget._mgui_set_value.side_effect = widget._on_value_change

change_mock = Mock()
widget.changed.connect(change_mock)

widget.value = E.b
change_mock.assert_called_with(E.b)