Repository: ImperialCollegeLondon/guikit (language: python)

Issue:
Create the contents of plugins.py
The software should be extended via plugins without the need of modifying a single comma of the existing code. The plugin system has two parts:

- A way of registering or enabling the plugins to be discovered (eg. automatic registration via sub-clasing), setting a specific API that those plugins should fulfil.
- A way of listing and loading the discovered plugins on demand.


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 392ac63..3d08736 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,16 +1,14 @@
 repos:
-  - repo: https://github.com/timothycrosley/isort
-    rev: "5.4.2"
-    hooks:
-      - id: isort
-        additional_dependencies: [toml]
   - repo: https://github.com/pre-commit/pre-commit-hooks
     rev: v2.2.1
     hooks:
-      - id: flake8
       - id: check-merge-conflict
       - id: debug-statements
   - repo: https://github.com/psf/black
     rev: "20.8b1"
     hooks:
-      - id: black
\ No newline at end of file
+      - id: black
+  - repo: https://github.com/pycqa/flake8
+    rev: "3.9.2"
+    hooks:
+      - id: flake8
\ No newline at end of file
diff --git a/myapp/__main__.py b/myapp/__main__.py
index f3cba01..d73b20d 100644
--- a/myapp/__main__.py
+++ b/myapp/__main__.py
@@ -1,4 +1,17 @@
 """
 The entry point of the program. It launches the main application - i.e. it starts
-whatever represents de mainloop in the chosen GUI toolkit.
+whatever represents the mainloop in the chosen GUI toolkit.
 """
+import wx
+
+from . import APP_NAME
+from .core import MainApp
+from .plugins import collect_builtin_extensions
+
+app = MainApp(
+    title=APP_NAME,
+    plugins_list=collect_builtin_extensions(),
+    notebook_layout=True,
+    tab_style=wx.NB_TOP,
+)
+app.MainLoop()
diff --git a/myapp/core.py b/myapp/core.py
index 9182d1f..528c918 100644
--- a/myapp/core.py
+++ b/myapp/core.py
@@ -1,8 +1,220 @@
 """
 Contains the definition of the top window of the program, including the menu bar, the
 toolbar (if any), the central widget, bottom progress bar and status area.
+
 In particular, the progress bar and the status area are the kind of things that the
 plugins will be importing - or at least wanting to update - regularly. They are defined
-as singletons such that even though an instance is created once, such an instance can
-be access directly from the class anywhere else where you import the class.
+as global aobject singletons such that even though an instance is created once, such an
+instance can be access directly from the class anywhere else where you import the class.
 """
+from __future__ import annotations
+
+import itertools
+from typing import List, Optional
+
+import wx
+
+from .plugins import KNOWN_PLUGINS, MenuTool, PluginBase, load_plugins
+
+
+class StatusBar(wx.StatusBar):
+    """
+    Singleton class to manage the top level status and progress bar.
+
+    It provides two fields:
+        - field 0: Automatic display of menu and tools descriptions as well as custom
+            status messages
+        - field 1: Holds the progress bar. Do not use it for displaying text.
+
+    Use 'StatusBar().SomeMethod` to manage the status bar messages and properties. See
+    'wx.StatusBar' for more information about the options available.
+
+    Use `StatusBar().progress_bar.SomeMethod` to manage the progress bar properties.
+    See 'wx.Gauge' for more information about the options available.
+    """
+
+    _instance: Optional[StatusBar] = None
+
+    def __new__(cls, *args, **kwargs):
+        if cls._instance is None:
+            cls._instance = wx.StatusBar.__new__(cls)
+        return cls._instance
+
+    def __init__(self, *args, progress_bar_width: int = 150, **kwargs):
+        super(StatusBar, self).__init__(*args, **kwargs)
+        self.SetFieldsCount(2, (-1, progress_bar_width))
+        self.progress_bar = wx.Gauge(
+            self,
+            wx.ID_ANY,
+            size=(progress_bar_width, 10),
+            style=wx.GA_HORIZONTAL | wx.GA_SMOOTH,
+        )
+        sizer = wx.BoxSizer(wx.HORIZONTAL)
+        sizer.Add(wx.StaticText(self, -1, ""), 1, wx.ALL)
+        sizer.Add(self.progress_bar, 0, wx.ALL, 10)
+        self.SetSizer(sizer)
+        self.Layout()
+
+    def SetStatusWidths(self, width: int):
+        """Ensures that progress bar is resized if fields are resized.
+
+        Args:
+            width: Width of the progress bar.
+        """
+        super(StatusBar, self).SetStatusWidths((-1, width))
+        self.progress_bar.SetSize(self.GetStatusWidth(1), 10)
+
+
+class MainWindow(wx.Frame):
+    def __init__(
+        self,
+        parent,
+        title: str,
+        notebook_layout: bool = True,
+        tab_style: int = wx.NB_TOP,
+    ):
+        super(MainWindow, self).__init__(parent, title=title)
+        self.notebook_layout = notebook_layout
+        self.tab_style = tab_style
+        self.SetStatusBar(StatusBar(self))
+
+    def populate_window(self):
+        """Adds menu items, tools and other widgets in plugins to the main window."""
+        if self.notebook_layout:
+            self._make_notebook(self.tab_style)
+        else:
+            self._make_central_widget()
+        self._make_toolbar()
+        self._make_menubar()
+
+    def _make_menubar(self) -> None:
+        """Create the menu bar from the entries provided by the widgets."""
+        # Collecting the menu entries
+        entries = itertools.chain.from_iterable(
+            [view().menu_entries() for view in KNOWN_PLUGINS]
+        )
+
+        # Creating the menus
+        menus = dict()
+        for entry in entries:
+            if entry.menu not in menus:
+                menus[entry.menu] = wx.Menu()
+
+            menu_entry = menus[entry.menu].Append(
+                entry.id, entry.text, entry.description, entry.kind
+            )
+
+            if entry.callback is not None:
+                self.Bind(wx.EVT_MENU, entry.callback, menu_entry)
+
+        # Creating the menubar.
+        menu_bar = wx.MenuBar()
+        for name, item in menus.items():
+            menu_bar.Append(item, name)
+
+        # Adding the MenuBar to the Frame content.
+        self.SetMenuBar(menu_bar)
+
+    def _make_toolbar(self):
+        """Create the tool bar from the entries provided by the widgets."""
+        # Collect tools
+        tools = itertools.chain.from_iterable(
+            [view().toolbar_items() for view in KNOWN_PLUGINS]
+        )
+
+        # Including the tools
+        toolbar = self.CreateToolBar()
+        for tool in tools:
+            item = toolbar.AddTool(
+                tool.id, tool.text, tool.bitmap, tool.short_help, tool.kind
+            )
+
+            if tool.callback is not None:
+                self.Bind(wx.EVT_MENU, tool.callback, item)
+
+        toolbar.Realize()
+
+    def _make_notebook(self, tab_style: int = wx.NB_TOP) -> None:
+        """Create the central widget of the window as a notebook.
+
+        A notebook is created as the central widget and any other view provided by the
+        plugins is added as new page. Finally, the first page is selected.
+
+        Args:
+            tab_style: integer indicating the position of the tabs. Valid values (OS
+                dependent) are wx.NB_TOP, wx.NB_LEFT, wx.NB_RIGHT, wx.NB_BOTTOM,
+                wx.NB_FIXEDWIDTH, wx.NB_MULTILINE and wx.NB_NOPAGETHEME.
+        """
+        self.notebook = wx.Notebook(self, style=tab_style)
+
+        # Collect tabs
+        tabs = itertools.chain.from_iterable(
+            [view().tabs(self.notebook) for view in KNOWN_PLUGINS]
+        )
+
+        # Add tabs to notebook
+        for tab in tabs:
+            self.notebook.AddPage(*tab)
+
+        if self.notebook.PageCount > 0:
+            self.notebook.SetSelection(0)
+
+    def _make_central_widget(self) -> None:
+        """Create the central widget of the window.
+
+        Such a central widget is taken from the plugins. Exactly one central widget
+        needs to be provided between all plugins.
+
+        Raises:
+            ValueError: If the number of central widgets found is not 1.
+        """
+        widget = [
+            v for v in [view().central(self) for view in KNOWN_PLUGINS] if v is not None
+        ]
+
+        if (n := len(widget)) != 1:
+            raise ValueError(
+                f"Exactly 1 central widget needs to be provided. {n} given."
+            )
+
+
+class BuiltInActions(PluginBase):
+    """Actions and features builtin with the core window."""
+
+    def menu_entries(self) -> List[MenuTool]:
+        return [
+            MenuTool(
+                menu="File",
+                id=wx.ID_EXIT,
+                text="Exit",
+                description="Terminate application",
+            ),
+        ]
+
+
+class MainApp(wx.App):
+    def __init__(
+        self,
+        *args,
+        title: str,
+        plugins_list: Optional[List[str]] = None,
+        notebook_layout: bool = True,
+        tab_style: int = wx.NB_TOP,
+        **kwargs,
+    ):
+        self.title = title
+        self.plugins_list = plugins_list if plugins_list is not None else []
+        self.notebook_layout = notebook_layout
+        self.tab_style = tab_style
+        super(MainApp, self).__init__(*args, **kwargs)
+
+    def OnInit(self) -> bool:
+        self.SetAppName(self.title)
+        window = MainWindow(None, self.title, self.notebook_layout, self.tab_style)
+        self.SetTopWindow(window)
+
+        load_plugins(self.plugins_list)
+        window.populate_window()
+        window.Show(True)
+
+        return True
diff --git a/myapp/extensions/about_dialog.py b/myapp/extensions/about_dialog.py
new file mode 100644
index 0000000..f39e9ed
--- /dev/null
+++ b/myapp/extensions/about_dialog.py
@@ -0,0 +1,29 @@
+from typing import List
+
+import wx
+
+from ..plugins import MenuTool, PluginBase
+
+
+class AboutDialog(PluginBase):
+    def menu_entries(self) -> List[MenuTool]:
+        return [
+            MenuTool(
+                menu="About",
+                id=wx.ID_ABOUT,
+                text="About",
+                description="Some info about this app",
+                callback=self.dialog,
+            ),
+        ]
+
+    @staticmethod
+    def dialog(event):
+        """A message dialog box with an OK button.
+
+        wx.OK is a standard ID in wxWidgets.
+        """
+        #
+        dlg = wx.MessageDialog(None, "A GUI example", "About the GUI", wx.OK)
+        dlg.ShowModal()  # Show it
+        dlg.Destroy()  # finally destroy it when finished.
diff --git a/myapp/extensions/myplugin/__init__.py b/myapp/extensions/myplugin/__init__.py
deleted file mode 100644
index 57874bd..0000000
--- a/myapp/extensions/myplugin/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-One particular plugin should be completly independent of other plugins. It exploits the
-machinery implemented in the files above to run. It uses the [Model-View-Presenter
-architectural pattern](https://zenodo.org/record/4724519) to separate the different
-components.
-"""
diff --git a/myapp/extensions/myplugin/model.py b/myapp/extensions/myplugin/model.py
deleted file mode 100644
index 31265a4..0000000
--- a/myapp/extensions/myplugin/model.py
+++ /dev/null
@@ -1,4 +0,0 @@
-"""
-Contains the bussiness logic of the plugin. Obviously, it might consist of many more
-files. It must be wholly GUI toolkit independent.
-"""
diff --git a/myapp/extensions/myplugin/presenter.py b/myapp/extensions/myplugin/presenter.py
deleted file mode 100644
index 1101c64..0000000
--- a/myapp/extensions/myplugin/presenter.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-The one who glues everything together. It will contain the functions that the plugin
-system needs to load this plugin, to provide the content for the menu bar, a notebook
-to the tab - if that is the preferred layout -, etc., as well as linking the actions
-triggered in the view with execution pipelines of the model and back.
-"""
diff --git a/myapp/extensions/myplugin/view.py b/myapp/extensions/myplugin/view.py
deleted file mode 100644
index f9ba9ec..0000000
--- a/myapp/extensions/myplugin/view.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""
-The definition of the GUI elements of the plugin. It should restrict itself to set out
-the content and layout as well as dealing the purely GUI-related calls. Any other call,
-possbly involving the data structures, should be derived to the Presenter.
-"""
diff --git a/myapp/plugins.py b/myapp/plugins.py
index 4c0f6de..4147970 100644
--- a/myapp/plugins.py
+++ b/myapp/plugins.py
@@ -1,3 +1,178 @@
 """
 Contains all the machinery to register and load plugins.
 """
+from __future__ import annotations
+
+import importlib
+import sys
+from abc import ABC
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, List, Optional, Type
+
+import wx
+
+from .logging import logger
+
+KNOWN_PLUGINS: List[Type[PluginBase]] = []
+"""List of plugins registered as subclasses of PluginBase."""
+
+
+@dataclass
+class MenuTool:
+    """Compiles the information required to add menu and toolbar items."""
+
+    menu: str = ""
+    id: int = wx.ID_ANY
+    text: str = ""
+    description: str = ""
+    short_help: str = ""
+    callback: Optional[Callable[[wx.Event], None]] = None
+    kind: wx.ItemKind = wx.ITEM_NORMAL
+    bitmap: Optional[wx.Bitmap] = None
+
+
+@dataclass
+class Tab:
+    """Compiles the information required to define add tabs."""
+
+    page: wx.Window
+    text: str
+    select: bool = False
+    imageId: int = wx.NO_IMAGE
+
+
+class PluginBase(ABC):
+    """
+    Base class that defines the required API that all the views of the different plugins
+    will need to provide.
+    """
+
+    def __init_subclass__(cls: Type[PluginBase]):
+        if cls not in KNOWN_PLUGINS:
+            KNOWN_PLUGINS.append(cls)
+
+    def menu_entries(self) -> List[MenuTool]:
+        """Return a list of menu entries provided by this plugin.
+
+        The items of the list must be MenuTool objects.
+
+        Returns:
+            A list of MenuTool objects provided by this plugin to be added to the
+            application menu bar.
+        """
+        return []
+
+    def toolbar_items(self) -> List[MenuTool]:
+        """Return a list of toolbar items provided by this plugin.
+
+        The items of the list must be MenuTool objects.
+
+        Returns:
+            A list of MenuTool objects provided by this plugin to be added to the
+            application toolbar.
+        """
+        return []
+
+    def tabs(self, parent=None) -> List[Tab]:
+        """Create and return the list of tabs provided by this plugin.
+
+        The elements of the list must be Tab objects.
+
+        Args:
+            parent: The application notebook, to be used as parent during the creation
+            of the tabs.
+
+        Returns:
+            A list of Tab objects provided by this plugin to be added to the application
+            notebook.
+        """
+        return []
+
+    def central(self, parent=None) -> Optional[wx.Window]:
+        """Central widget provided by this plugin.
+
+        Args:
+            parent: The main window, to be used as parent during the creation of the
+            widget.
+
+        Returns:
+            None (if no central widget provided) or any object derived from
+            wx.MainWindow (pretty much, any widget).
+        """
+        return None
+
+
+def collect_plugins(
+    path: Path, package: Optional[str] = None, add_to_path: bool = False
+) -> List[str]:
+    """Collects the plugins from the given location.
+
+    This function collects the modules and packages available in chosen location. If
+    they are not directly importable by Python (i.e. they are not in the path), either
+    the name of the containing package should be supplied or the add_to_path flag must
+    be set to True.
+
+    Eg. If path = "/some/location/with/tools/mytool", and "mytool" is an importable
+    package with "import mytool", plugins within "mytool" folder will be included with
+    no extra arguments.
+
+    If "mytool" is not importable but "tools" is, i.e. 'import tools.mytool' works,
+    then you should add "package='tools'".
+
+    If neither "mytool" nor anything else in its path is importable, then the path can
+    be added to the module's import path, sys.path, so the plugins within can be
+    imported as "import plugin".
+
+    Args:
+        path: Directory to explore.
+        package: Package in which the directory is contained.
+        add_to_path: If the directory should be added to the import path.
+
+    Returns:
+        List of modules and packages to be imported in the form
+        "package.subpackage.plugin", where "plugin" can be a module (a python file) or
+        a package itself (a folder containing a __init__.py file).
+    """
+    if add_to_path:
+        sys.path.insert(1, str(path))
+        pkg = ""
+    else:
+        pkg = f"{path.stem}." if package is None else f"{package}.{path.stem}."
+
+    plugin_names = []
+    for p in path.glob("*.py"):
+        if not p.stem.startswith("__"):
+            plugin_names.append(f"{pkg}{p.stem}")
+
+    for p in path.glob("*/"):
+        if p.is_dir() and (p / "__init__.py").exists():
+            plugin_names.append(f"{pkg}{p.stem}")
+
+    return plugin_names
+
+
+def collect_builtin_extensions():
+    """Search for plugins to be loaded in the "extensions" subfolder.
+
+    These plugins can be single modules (simple_plugin.py) or more complex modules
+    defined as packages (i.e. they are folders with a __init__.py file).
+
+    Returns:
+        A list of plugins names to be loaded.
+    """
+    extensions = Path(__file__).parent / "extensions"
+    return collect_plugins(extensions, Path(__file__).parent.stem)
+
+
+def load_plugins(plugin_list: List[str]):
+    """Loads the plugins of the list.
+
+    Args:
+        plugin_list: A list of plugins to be loaded.
+    """
+    for plugin in plugin_list:
+        try:
+            importlib.import_module(plugin)
+        except ModuleNotFoundError as err:
+            logger.warning(f"Plugin '{err.name}' could not be loaded. {err}")
diff --git a/setup.cfg b/setup.cfg
index 5305555..709913e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -46,7 +46,7 @@ test = pytest
 addopts =
 	-v --flake8 --mypy -p no:warnings
 	--cov=myapp  --cov-report=html:htmlcov/coverage
-	--doctest-modules
+	--doctest-modules --ignore=myapp/__main__.py
 
 [pycodestyle]
 max-line-length = 88
@@ -63,3 +63,7 @@ strict_optional = False
 
 [mypy-setup]
 ignore_errors = True
+
+[tool.isort]
+multi_line_output = 3
+include_trailing_comma = true
\ No newline at end of file


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..97e0f14
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,57 @@
+import wx
+import pytest
+
+
+@pytest.fixture()
+def window():
+    app = wx.App()
+    frame = wx.Frame(None)
+    yield frame
+    wx.CallAfter(frame.Close)
+    app.MainLoop()
+
+
+@pytest.fixture()
+def main_window():
+    from myapp.core import MainWindow
+
+    app = wx.App()
+    frame = MainWindow(None, "My App")
+    yield frame
+    wx.CallAfter(frame.Close)
+    app.MainLoop()
+
+
+@pytest.fixture()
+def plugin():
+    import wx
+    from typing import List
+    from myapp.plugins import PluginBase, MenuTool
+
+    class ExamplePlugin(PluginBase):
+        def central(self, parent=None) -> wx.Window:
+            return wx.TextCtrl(parent, style=wx.TE_MULTILINE)
+
+        def toolbar_items(self) -> List[MenuTool]:
+            return [
+                MenuTool(
+                    id=wx.ID_STOP,
+                    text="Exit",
+                    short_help="Terminate application",
+                    bitmap=wx.ArtProvider.GetBitmap(
+                        wx.ART_QUIT, wx.ART_TOOLBAR, wx.Size(50, 50)
+                    ),
+                ),
+            ]
+
+    return ExamplePlugin
+
+
+@pytest.fixture()
+def empty_plugin():
+    from myapp.plugins import PluginBase
+
+    class EmptyPlugin(PluginBase):
+        pass
+
+    return EmptyPlugin
diff --git a/tests/test_core.py b/tests/test_core.py
index e69de29..a36a3b7 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -0,0 +1,91 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+
+class TestStatusBar:
+    def test_set_status_widths(self, window):
+        import wx
+        from myapp.core import StatusBar
+
+        bar = StatusBar(window)
+        assert isinstance(bar.progress_bar, wx.Gauge)
+
+        expected = 300
+        bar.SetStatusWidths(expected)
+        assert bar.GetStatusWidth(1) == expected
+        assert bar.progress_bar.GetSize()[0] == expected
+
+
+class TestMainWindow:
+    def test_populate_window(self, main_window):
+        main_window._make_notebook = MagicMock()
+        main_window._make_toolbar = MagicMock()
+        main_window._make_menubar = MagicMock()
+        main_window._make_central_widget = MagicMock()
+
+        main_window.populate_window()
+        main_window._make_menubar.assert_called_once()
+        main_window._make_toolbar.assert_called_once()
+        main_window._make_notebook.assert_called_once()
+        main_window._make_central_widget.assert_not_called()
+
+        main_window.notebook_layout = False
+        main_window.populate_window()
+        main_window._make_central_widget.assert_called_once()
+
+    def test__make_menubar(self, main_window):
+        main_window._make_menubar()
+        menus = main_window.GetMenuBar().GetMenus()
+        assert len(menus) > 0
+        assert menus[0][1] == "File"
+
+    def test__make_toolbar(self, main_window):
+        main_window._make_toolbar()
+        assert main_window.GetToolBar().GetToolsCount() == 0
+
+    def test__make_notebook(self, main_window):
+        import wx
+
+        main_window._make_notebook()
+        assert isinstance(main_window.notebook, wx.Notebook)
+        assert main_window.notebook.PageCount == 0
+
+    def test__make_central_widget(self, main_window):
+        import wx
+        from myapp.plugins import PluginBase
+
+        with pytest.raises(ValueError):
+            main_window._make_central_widget()
+
+        class SomePlugin(PluginBase):
+            def central(self, parent):
+                return wx.TextCtrl(parent, style=wx.TE_MULTILINE)
+
+        main_window._make_central_widget()
+
+        class SomeOtherPlugin(PluginBase):
+            def central(self, parent):
+                return wx.TextCtrl(parent, style=wx.TE_MULTILINE)
+
+        with pytest.raises(ValueError):
+            main_window._make_central_widget()
+
+
+class TestBuiltInActions:
+    def test_menu_entries(self):
+        from myapp.core import BuiltInActions
+        from myapp.plugins import MenuTool
+
+        entries = BuiltInActions().menu_entries()
+        assert len(entries) > 0
+        assert all([isinstance(item, MenuTool) for item in entries])
+        assert entries[0].menu == "File"
+
+
+class TestMainApp:
+    def test_on_init(self):
+        from myapp.core import MainApp, MainWindow
+
+        app = MainApp(title="Some App")
+        assert isinstance(app.GetTopWindow(), MainWindow)
diff --git a/tests/test_logging.py b/tests/test_logging.py
index ac05a8c..34ed10d 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -1,7 +1,6 @@
 def test_logger(caplog):
     from myapp.logging import app_dir, logger
 
-    print("hi")
     msg = "Some function executed."
 
     def some_func():
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index e69de29..ad47875 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -0,0 +1,61 @@
+from unittest.mock import patch, MagicMock
+
+
+class TestPluginBase:
+    def test_plugin_registration(self, plugin):
+        from myapp.plugins import KNOWN_PLUGINS
+
+        assert plugin in KNOWN_PLUGINS
+
+    def test_menu_entries(self, empty_plugin):
+        """Dummy tests to check default outputs"""
+        assert empty_plugin().menu_entries() == []
+
+    def test_toolbar_items(self, empty_plugin):
+        """Dummy tests to check default outputs"""
+        assert empty_plugin().toolbar_items() == []
+
+    def test_tabs(self, empty_plugin):
+        """Dummy tests to check default outputs"""
+        assert empty_plugin().tabs() == []
+
+    def test_central(self, empty_plugin):
+        """Dummy tests to check default outputs"""
+        assert empty_plugin().central() is None
+
+
+def test_collect_plugins():
+    from pathlib import Path
+    from myapp.plugins import collect_plugins
+
+    this_file = Path(__file__)
+    plugins = collect_plugins(this_file.parent)
+    assert f"{this_file.parent.stem}.{this_file.stem}" in plugins
+
+    plugins = collect_plugins(this_file.parent, package="some_package")
+    assert f"some_package.{this_file.parent.stem}.{this_file.stem}" in plugins
+
+    plugins = collect_plugins(this_file.parent, add_to_path=True)
+    assert f"{this_file.stem}" in plugins
+
+
+def test_collect_builtin_extensions():
+    with patch("myapp.plugins.collect_plugins", MagicMock()):
+        from myapp.plugins import collect_builtin_extensions, collect_plugins
+
+        collect_builtin_extensions()
+        collect_plugins.assert_called_once()
+
+
+def test_load_plugins(caplog):
+    with patch("importlib.import_module", MagicMock()):
+        from importlib import import_module
+        from myapp.plugins import collect_builtin_extensions, load_plugins
+
+        plugins = collect_builtin_extensions()
+        load_plugins(plugins)
+        import_module.assert_called()
+
+    plugins.append("Wrong plugin")
+    load_plugins(plugins)
+    assert "Plugin 'Wrong plugin' could not be loaded." in caplog.messages[-1]