aboutsummaryrefslogtreecommitdiff
path: root/libs/plugin_python/tests
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/plugin_python/tests
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/plugin_python/tests')
-rw-r--r--libs/plugin_python/tests/CMakeLists.txt4
-rw-r--r--libs/plugin_python/tests/mocks/DummyFileTree.h34
-rw-r--r--libs/plugin_python/tests/mocks/MockOrganizer.h65
-rw-r--r--libs/plugin_python/tests/python/CMakeLists.txt75
-rw-r--r--libs/plugin_python/tests/python/conftest.py12
-rw-r--r--libs/plugin_python/tests/python/test_argument_wrapper.cpp61
-rw-r--r--libs/plugin_python/tests/python/test_argument_wrapper.py62
-rw-r--r--libs/plugin_python/tests/python/test_filetree.cpp22
-rw-r--r--libs/plugin_python/tests/python/test_filetree.py86
-rw-r--r--libs/plugin_python/tests/python/test_functional.cpp38
-rw-r--r--libs/plugin_python/tests/python/test_functional.py55
-rw-r--r--libs/plugin_python/tests/python/test_guessed_string.cpp34
-rw-r--r--libs/plugin_python/tests/python/test_guessed_string.py36
-rw-r--r--libs/plugin_python/tests/python/test_organizer.cpp46
-rw-r--r--libs/plugin_python/tests/python/test_organizer.py14
-rw-r--r--libs/plugin_python/tests/python/test_path_wrappers.py45
-rw-r--r--libs/plugin_python/tests/python/test_qt.cpp155
-rw-r--r--libs/plugin_python/tests/python/test_qt.py119
-rw-r--r--libs/plugin_python/tests/python/test_qt_widgets.cpp85
-rw-r--r--libs/plugin_python/tests/python/test_qt_widgets.py62
-rw-r--r--libs/plugin_python/tests/python/test_shared_cpp_owner.cpp67
-rw-r--r--libs/plugin_python/tests/python/test_shared_cpp_owner.py72
-rw-r--r--libs/plugin_python/tests/runner/CMakeLists.txt65
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-diagnose.py37
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-filemapper.py32
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-game.py45
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-installer.py46
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-iplugin.py29
-rw-r--r--libs/plugin_python/tests/runner/test_diagnose.cpp61
-rw-r--r--libs/plugin_python/tests/runner/test_filemapper.cpp64
-rw-r--r--libs/plugin_python/tests/runner/test_game.cpp28
-rw-r--r--libs/plugin_python/tests/runner/test_installer.cpp112
-rw-r--r--libs/plugin_python/tests/runner/test_iplugin.cpp43
-rw-r--r--libs/plugin_python/tests/runner/test_lifetime.cpp47
34 files changed, 1858 insertions, 0 deletions
diff --git a/libs/plugin_python/tests/CMakeLists.txt b/libs/plugin_python/tests/CMakeLists.txt
new file mode 100644
index 0000000..0141184
--- /dev/null
+++ b/libs/plugin_python/tests/CMakeLists.txt
@@ -0,0 +1,4 @@
+cmake_minimum_required(VERSION 3.16)
+
+add_subdirectory(python)
+add_subdirectory(runner)
diff --git a/libs/plugin_python/tests/mocks/DummyFileTree.h b/libs/plugin_python/tests/mocks/DummyFileTree.h
new file mode 100644
index 0000000..cf81691
--- /dev/null
+++ b/libs/plugin_python/tests/mocks/DummyFileTree.h
@@ -0,0 +1,34 @@
+#ifndef DUMMY_TREE_H
+#define DUMMY_TREE_H
+
+#include <uibase/ifiletree.h>
+
+// filetree implementation for testing purpose
+//
+class DummyFileTree : public MOBase::IFileTree {
+public:
+ DummyFileTree(std::shared_ptr<const IFileTree> parent, QString name)
+ : FileTreeEntry(parent, name), IFileTree()
+ {
+ }
+
+protected:
+ std::shared_ptr<IFileTree> makeDirectory(std::shared_ptr<const IFileTree> parent,
+ QString name) const override
+ {
+ return std::make_shared<DummyFileTree>(parent, name);
+ }
+
+ bool doPopulate(std::shared_ptr<const IFileTree>,
+ std::vector<std::shared_ptr<FileTreeEntry>>&) const override
+ {
+ return true;
+ }
+
+ std::shared_ptr<IFileTree> doClone() const override
+ {
+ return std::make_shared<DummyFileTree>(nullptr, name());
+ }
+};
+
+#endif
diff --git a/libs/plugin_python/tests/mocks/MockOrganizer.h b/libs/plugin_python/tests/mocks/MockOrganizer.h
new file mode 100644
index 0000000..3ccfa3c
--- /dev/null
+++ b/libs/plugin_python/tests/mocks/MockOrganizer.h
@@ -0,0 +1,65 @@
+#include <gmock/gmock.h>
+#include <uibase/imoinfo.h>
+
+using namespace MOBase;
+
+class MockOrganizer : public IOrganizer {
+public:
+ // clang-format off
+ MOCK_METHOD(IModRepositoryBridge*, createNexusBridge, (), (const, override));
+ MOCK_METHOD(QString, instanceName, (), (const, override));
+ MOCK_METHOD(QString, profileName, (), (const, override));
+ MOCK_METHOD(QString, profilePath, (), (const, override));
+ MOCK_METHOD(QString, downloadsPath, (), (const, override));
+ MOCK_METHOD(QString, overwritePath, (), (const, override));
+ MOCK_METHOD(QString, basePath, (), (const, override));
+ MOCK_METHOD(QString, modsPath, (), (const, override));
+ MOCK_METHOD(VersionInfo, appVersion, (), (const, override));
+ MOCK_METHOD(Version, version, (), (const, override));
+ MOCK_METHOD(IModInterface*, createMod, (GuessedValue<QString> &name), (override));
+ MOCK_METHOD(IPluginGame*, getGame, (const QString &gameName), (const, override));
+ MOCK_METHOD(void, modDataChanged, (IModInterface *mod), (override));
+ MOCK_METHOD(QVariant, pluginSetting, (const QString &pluginName, const QString &key), (const, override));
+ MOCK_METHOD(void, setPluginSetting, (const QString &pluginName, const QString &key, const QVariant &value), (override));
+ MOCK_METHOD(bool, isPluginEnabled, (const QString& pluginName), (const, override));
+ MOCK_METHOD(bool, isPluginEnabled, (IPlugin *plugin), (const, override));
+ MOCK_METHOD(QVariant, persistent, (const QString &pluginName, const QString &key, const QVariant &def), (const, override));
+ MOCK_METHOD(void, setPersistent, (const QString &pluginName, const QString &key, const QVariant &value, bool sync), (override));
+ MOCK_METHOD(QString, pluginDataPath, (), (const, override));
+ MOCK_METHOD(IModInterface*, installMod, (const QString &fileName, const QString &nameSuggestion), (override));
+ MOCK_METHOD(QString, resolvePath, (const QString &fileName), (const, override));
+ MOCK_METHOD(QStringList, listDirectories, (const QString &directoryName), (const, override));
+ MOCK_METHOD(QStringList, findFiles, (const QString &path, const std::function<bool(const QString&)> &filter), (const, override));
+ MOCK_METHOD(QStringList, findFiles, (const QString &path, const QStringList &filter), (const, override));
+ MOCK_METHOD(QStringList, getFileOrigins, (const QString &fileName) ,(const, override));
+ MOCK_METHOD(QList<FileInfo>, findFileInfos, (const QString &path, const std::function<bool(const FileInfo&)> &filter), (const, override));
+ MOCK_METHOD(std::shared_ptr<const IFileTree>, virtualFileTree, (), (const, override));
+ MOCK_METHOD(MOBase::IInstanceManager*, instanceManager, (), (const, override));
+ MOCK_METHOD(MOBase::IDownloadManager*, downloadManager, (), (const, override));
+ MOCK_METHOD(MOBase::IPluginList*, pluginList, (), (const, override));
+ MOCK_METHOD(MOBase::IModList*, modList, (), (const, override));
+ MOCK_METHOD(MOBase::IExecutablesList*, executablesList, (), (const, override));
+ MOCK_METHOD(std::shared_ptr<MOBase::IProfile>, profile, (), (const, override));
+ MOCK_METHOD(QStringList, profileNames, (), (const, override));
+ MOCK_METHOD(std::shared_ptr<const MOBase::IProfile>, getProfile, (const QString& name), (const, override));
+ MOCK_METHOD(MOBase::IGameFeatures*, gameFeatures, (), (const, override));
+ MOCK_METHOD(HANDLE, startApplication, (const QString &executable, const QStringList &args, const QString &cwd, const QString &profile, const QString &forcedCustomOverwrite, bool ignoreCustomOverwrite), (override));
+ MOCK_METHOD(bool, waitForApplication, (HANDLE handle, bool refresh, LPDWORD exitCode), (const, override));
+ MOCK_METHOD(bool, onAboutToRun, (const std::function<bool(const QString&)> &func), (override));
+ MOCK_METHOD(bool, onAboutToRun, (const std::function<bool(const QString&, const QDir&, const QString&)> &func), (override));
+ MOCK_METHOD(bool, onFinishedRun, (const std::function<void(const QString&, unsigned int)> &func), (override));
+ MOCK_METHOD(void, refresh, (bool saveChanges), (override));
+ MOCK_METHOD(MOBase::IPluginGame const *, managedGame, (), (const, override));
+ MOCK_METHOD(bool, onUserInterfaceInitialized, (const std::function<void (QMainWindow *)> &), (override));
+ MOCK_METHOD(bool, onNextRefresh, (const std::function<void()>&, bool), (override));
+ MOCK_METHOD(bool, onProfileCreated, (const std::function<void(MOBase::IProfile*)>&), (override));
+ MOCK_METHOD(bool, onProfileRemoved, (const std::function<void(const QString&)>&), (override));
+ MOCK_METHOD(bool, onProfileRenamed, (const std::function<void(MOBase::IProfile*, QString const&, QString const&)>&), (override));
+ MOCK_METHOD(bool, onProfileChanged, (const std::function<void (MOBase::IProfile*, MOBase::IProfile*)> &), (override));
+ MOCK_METHOD(bool, onPluginSettingChanged, (const std::function<void (const QString &,const QString &,const QVariant &,const QVariant &)> &), (override));
+ MOCK_METHOD(bool, onPluginEnabled, (const std::function<void(const MOBase::IPlugin*)>&), (override));
+ MOCK_METHOD(bool, onPluginEnabled, (const QString&, const std::function<void()>&), (override));
+ MOCK_METHOD(bool, onPluginDisabled, (const std::function<void(const MOBase::IPlugin*)>&), (override));
+ MOCK_METHOD(bool, onPluginDisabled, (const QString&, const std::function<void()>&), (override));
+ // clang-format on
+};
diff --git a/libs/plugin_python/tests/python/CMakeLists.txt b/libs/plugin_python/tests/python/CMakeLists.txt
new file mode 100644
index 0000000..f473373
--- /dev/null
+++ b/libs/plugin_python/tests/python/CMakeLists.txt
@@ -0,0 +1,75 @@
+cmake_minimum_required(VERSION 3.16)
+
+# pytest
+cmake_policy(SET CMP0144 NEW)
+
+find_package(mo2-uibase CONFIG REQUIRED)
+find_package(GTest REQUIRED)
+
+set(PYLIB_DIR ${CMAKE_CURRENT_BINARY_DIR}/pylibs)
+
+set(UIBASE_PATH $<TARGET_FILE_DIR:mo2::uibase>)
+
+add_custom_target(python-tests)
+target_sources(python-tests
+ PRIVATE
+ conftest.py
+ test_argument_wrapper.py
+ test_filetree.py
+ test_functional.py
+ test_guessed_string.py
+ test_organizer.py
+ test_path_wrappers.py
+ test_qt_widgets.py
+ test_qt.py
+ test_shared_cpp_owner.py
+)
+
+add_test(NAME pytest
+ COMMAND ${CMAKE_CURRENT_BINARY_DIR}/pylibs/bin/pytest.exe ${CMAKE_CURRENT_SOURCE_DIR} -s
+)
+
+set_tests_properties(pytest
+ PROPERTIES
+ DEPENDS python-tests
+ ENVIRONMENT_MODIFICATION
+ "PYTHONPATH=set:${PYLIB_DIR}\\;$<TARGET_FILE_DIR:mobase>;\
+UIBASE_PATH=set:${UIBASE_PATH}"
+)
+
+mo2_python_pip_install(python-tests
+ DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/pylibs
+ PACKAGES pytest
+ PyQt${MO2_QT_VERSION_MAJOR}==${MO2_PYQT_VERSION}
+ PyQt${MO2_QT_VERSION_MAJOR}-Qt${MO2_QT_VERSION_MAJOR}==${MO2_QT_VERSION})
+add_dependencies(python-tests mobase)
+set_target_properties(python-tests PROPERTIES FOLDER tests/python)
+
+file(GLOB test_files CONFIGURE_DEPENDS "test_*.cpp")
+foreach (test_file ${test_files})
+ get_filename_component(target ${test_file} NAME_WLE)
+
+ string(REPLACE "test_" "" pymodule ${target})
+ pybind11_add_module(${target} EXCLUDE_FROM_ALL THIN_LTO ${test_file})
+ set_target_properties(${target}
+ PROPERTIES
+ CXX_STANDARD 23
+ OUTPUT_NAME ${pymodule}
+ FOLDER tests/python
+ LIBRARY_OUTPUT_DIRECTORY "${PYLIB_DIR}/mobase_tests")
+
+ if(DEFINED CMAKE_CONFIGURATION_TYPES)
+ foreach(config ${CMAKE_CONFIGURATION_TYPES})
+ string(TOUPPER ${config} config)
+ set_target_properties(${target} PROPERTIES
+ LIBRARY_OUTPUT_DIRECTORY_${config} "${PYLIB_DIR}/mobase_tests")
+ endforeach()
+ endif()
+
+ target_link_libraries(${target} PRIVATE
+ mo2::uibase Qt6::Core Qt6::Widgets pybind11::qt pybind11::utils GTest::gmock)
+
+ target_include_directories(${target}
+ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../mocks)
+ add_dependencies(python-tests ${target})
+endforeach()
diff --git a/libs/plugin_python/tests/python/conftest.py b/libs/plugin_python/tests/python/conftest.py
new file mode 100644
index 0000000..99e7800
--- /dev/null
+++ b/libs/plugin_python/tests/python/conftest.py
@@ -0,0 +1,12 @@
+import os
+import sys
+
+from PyQt6.QtWidgets import QApplication
+
+
+def pytest_configure():
+ global app
+
+ os.add_dll_directory(str(os.getenv("UIBASE_PATH")))
+
+ app = QApplication(sys.argv)
diff --git a/libs/plugin_python/tests/python/test_argument_wrapper.cpp b/libs/plugin_python/tests/python/test_argument_wrapper.cpp
new file mode 100644
index 0000000..e87f516
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_argument_wrapper.cpp
@@ -0,0 +1,61 @@
+#include "pybind11_utils/smart_variant_wrapper.h"
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+#include <string>
+
+namespace mo2::python::detail {
+
+ template <>
+ struct smart_variant_converter<std::string> {
+ static std::string from(int const& value) { return std::to_string(value); }
+ };
+
+ template <>
+ struct smart_variant_converter<int> {
+ static int from(std::string const& value) { return std::stoi(value); }
+ };
+
+} // namespace mo2::python::detail
+
+// wrapper that can be constructed from
+using Wrapper = mo2::python::smart_variant<int, std::string>;
+
+template <std::size_t... Is, class Fn>
+auto wrap(Fn&& fn)
+{
+ return mo2::python::wrap_arguments<Wrapper, Is...>(std::forward<Fn>(fn));
+}
+
+std::string fn1(std::string const& value)
+{
+ return value + "-" + value;
+}
+
+int fn2(int value)
+{
+ return value * 2;
+}
+
+std::string fn3(int value, std::vector<int> values, std::string const& name)
+{
+ return name + "-" + std::to_string(value + values.size());
+}
+
+PYBIND11_MODULE(argument_wrapper, m)
+{
+ m.def("fn1_raw", &fn1);
+ m.def("fn1_wrap", wrap(&fn1));
+ m.def("fn1_wrap_0", wrap<0>(&fn1));
+
+ m.def("fn2_raw", &fn2);
+ m.def("fn2_wrap", wrap(&fn2));
+ m.def("fn2_wrap_0", wrap<0>(&fn2));
+
+ m.def("fn3_raw", &fn3);
+ m.def("fn3_wrap", wrap(&fn3));
+ m.def("fn3_wrap_0", wrap<0>(&fn3));
+ m.def("fn3_wrap_2", wrap<2>(&fn3));
+ m.def("fn3_wrap_0_2", wrap<0, 2>(&fn3));
+}
diff --git a/libs/plugin_python/tests/python/test_argument_wrapper.py b/libs/plugin_python/tests/python/test_argument_wrapper.py
new file mode 100644
index 0000000..08b4a1b
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_argument_wrapper.py
@@ -0,0 +1,62 @@
+import pytest
+
+import mobase_tests.argument_wrapper as m
+
+
+def test_argument_wrapper_fn1():
+ assert m.fn1_raw("hello") == "hello-hello"
+
+ with pytest.raises(TypeError):
+ m.fn1_raw(1) # pyright: ignore[reportArgumentType]
+
+ assert m.fn1_wrap("hello") == "hello-hello"
+ assert m.fn1_wrap(32) == "32-32"
+
+ assert m.fn1_wrap_0("world") == "world-world"
+ assert m.fn1_wrap_0(45) == "45-45"
+
+
+def test_argument_wrapper_fn2():
+ assert m.fn2_raw(33) == 66
+
+ with pytest.raises(TypeError):
+ m.fn2_raw("12") # pyright: ignore[reportArgumentType]
+
+ assert m.fn2_wrap("15") == 30
+ assert m.fn2_wrap(32) == 64
+
+ assert m.fn2_wrap_0("-15") == -30
+ assert m.fn2_wrap_0(45) == 90
+
+
+def test_argument_wrapper_fn3():
+ assert m.fn3_raw(33, [], "hello") == "hello-33"
+ assert m.fn3_raw(33, [1, 2], "hello") == "hello-35"
+
+ with pytest.raises(TypeError):
+ m.fn3_raw("12", [], "hello") # pyright: ignore[reportArgumentType]
+
+ with pytest.raises(TypeError):
+ m.fn3_raw(36, [], 136) # pyright: ignore[reportArgumentType]
+
+ assert m.fn3_wrap(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap("15", [0], "woot") == "woot-16"
+ assert m.fn3_wrap(17, [], 33) == "33-17"
+ assert m.fn3_wrap("15", [], 44) == "44-15"
+
+ assert m.fn3_wrap_0_2(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap_0_2("15", [0], "woot") == "woot-16"
+ assert m.fn3_wrap_0_2(17, [], 33) == "33-17"
+ assert m.fn3_wrap_0_2("15", [], 44) == "44-15"
+
+ assert m.fn3_wrap_0(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap_0("15", [], "w00t") == "w00t-15"
+
+ with pytest.raises(TypeError):
+ m.fn3_wrap_0(14, [], 12) # pyright: ignore[reportArgumentType]
+
+ assert m.fn3_wrap_2(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap_2(15, [], 18) == "18-15"
+
+ with pytest.raises(TypeError):
+ m.fn3_wrap_2("14", [], 12) # pyright: ignore[reportArgumentType]
diff --git a/libs/plugin_python/tests/python/test_filetree.cpp b/libs/plugin_python/tests/python/test_filetree.cpp
new file mode 100644
index 0000000..cce43cf
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_filetree.cpp
@@ -0,0 +1,22 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/functional.h>
+#include <pybind11/pybind11.h>
+
+#include <uibase/ifiletree.h>
+
+using namespace MOBase;
+
+PYBIND11_MODULE(filetree, m)
+{
+ // test the FileTypes stuff
+ m.def("is_file", [](IFileTree::FileTypes const& t) {
+ return t.testFlag(IFileTree::FILE);
+ });
+ m.def("is_directory", [](IFileTree::FileTypes const& t) {
+ return t.testFlag(IFileTree::DIRECTORY);
+ });
+ m.def("value", [](IFileTree::FileTypes const& t) {
+ return t.toInt();
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_filetree.py b/libs/plugin_python/tests/python/test_filetree.py
new file mode 100644
index 0000000..0322faa
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_filetree.py
@@ -0,0 +1,86 @@
+from typing import TypeAlias, cast
+
+import mobase
+
+import mobase_tests.filetree as m
+
+
+def test_filetype():
+ FT = mobase.FileTreeEntry.FileTypes
+
+ assert mobase.FileTreeEntry.FILE == FT.FILE
+ assert m.is_file(FT.FILE)
+ assert not m.is_directory(FT.FILE)
+ assert m.value(FT.FILE) == FT.FILE.value
+
+ assert mobase.FileTreeEntry.DIRECTORY == FT.DIRECTORY
+ assert not m.is_file(FT.DIRECTORY)
+ assert m.is_directory(FT.DIRECTORY)
+ assert m.value(FT.DIRECTORY) == FT.DIRECTORY.value
+
+ assert mobase.FileTreeEntry.FILE_OR_DIRECTORY == FT.FILE_OR_DIRECTORY
+ assert m.is_file(FT.FILE_OR_DIRECTORY)
+ assert m.is_directory(FT.FILE_OR_DIRECTORY)
+ assert m.value(FT.FILE_OR_DIRECTORY) == FT.FILE_OR_DIRECTORY.value
+
+ assert FT.FILE_OR_DIRECTORY == FT.FILE | FT.DIRECTORY
+ assert m.is_file(FT.FILE | FT.DIRECTORY)
+ assert m.is_directory(FT.FILE | FT.DIRECTORY)
+ assert m.value(FT.FILE | FT.DIRECTORY) == (FT.FILE.value | FT.DIRECTORY.value)
+
+ assert m.is_file(FT.FILE_OR_DIRECTORY & FT.FILE)
+ assert not m.is_directory(FT.FILE_OR_DIRECTORY & FT.FILE)
+
+ assert not m.is_file(FT.FILE_OR_DIRECTORY & FT.DIRECTORY)
+ assert m.is_directory(FT.FILE_OR_DIRECTORY & FT.DIRECTORY)
+
+ assert m.is_file(FT.FILE_OR_DIRECTORY & ~FT.DIRECTORY)
+ assert not m.is_directory(FT.FILE_OR_DIRECTORY & ~FT.DIRECTORY)
+
+
+_tree_values: TypeAlias = list["str | tuple[str, _tree_values]"]
+
+
+def make_tree(
+ values: _tree_values, root: mobase.IFileTree | None = None
+) -> mobase.IFileTree:
+ if root is None:
+ root = cast(mobase.IFileTree, mobase.private.makeTree()) # type: ignore
+
+ for value in values:
+ if isinstance(value, str):
+ root.addFile(value)
+ else:
+ sub_tree = root.addDirectory(value[0])
+ make_tree(value[1], sub_tree)
+
+ return root
+
+
+def test_walk():
+ tree = make_tree(
+ [("a", []), ("b", ["u", "v"]), "c.x", "d.y", ("e", [("q", ["c.t", ("p", [])])])]
+ )
+
+ assert {"a", "b", "b/u", "b/v", "c.x", "d.y", "e", "e/q", "e/q/c.t", "e/q/p"} == {
+ e.path("/") for e in tree.walk()
+ }
+
+ entries: list[str] = []
+ for e in tree.walk():
+ if e.name() == "e":
+ break
+ entries.append(e.path("/"))
+ assert {"a", "b", "b/u", "b/v"} == set(entries)
+
+
+def test_glob():
+ tree = make_tree(
+ [("a", []), ("b", ["u", "v"]), "c.x", "d.y", ("e", [("q", ["c.t", ("p", [])])])]
+ )
+
+ assert {"a", "b", "b/u", "b/v", "c.x", "d.y", "e", "e/q", "e/q/c.t", "e/q/p"} == {
+ e.path("/") for e in tree.glob("**/*")
+ }
+
+ assert {"d.y"} == {e.path("/") for e in tree.glob("**/*.y")}
diff --git a/libs/plugin_python/tests/python/test_functional.cpp b/libs/plugin_python/tests/python/test_functional.cpp
new file mode 100644
index 0000000..4858516
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_functional.cpp
@@ -0,0 +1,38 @@
+#include "pybind11_utils/functional.h"
+
+#include <pybind11/pybind11.h>
+
+PYBIND11_MODULE(functional, m)
+{
+ m.def("fn_0_arg", [](std::function<int()> const& fn) {
+ return fn();
+ });
+
+ m.def("fn_1_arg", [](std::function<int(int)> const& fn, int a) {
+ return fn(a);
+ });
+
+ m.def("fn_2_arg", [](std::function<int(int, int)> const& fn, int a, int b) {
+ return fn(a, b);
+ });
+
+ m.def("fn_0_or_1_arg", [](std::function<int()> const& fn) {
+ return fn();
+ });
+
+ m.def("fn_0_or_1_arg", [](std::function<int(int)> const& fn) {
+ return fn(1);
+ });
+
+ m.def("fn_1_or_2_or_3_arg", [](std::function<int(int)> const& fn) {
+ return fn(1);
+ });
+
+ m.def("fn_1_or_2_or_3_arg", [](std::function<int(int, int)> const& fn) {
+ return fn(1, 2);
+ });
+
+ m.def("fn_1_or_2_or_3_arg", [](std::function<int(int, int, int)> const& fn) {
+ return fn(1, 2, 3);
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_functional.py b/libs/plugin_python/tests/python/test_functional.py
new file mode 100644
index 0000000..f8da087
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_functional.py
@@ -0,0 +1,55 @@
+from typing import Any
+
+import pytest
+
+import mobase_tests.functional as m
+
+
+def test_guessed_string():
+ # available functions:
+ # - fn_0_arg, fn_1_arg, fn_2_arg
+ # - fn_0_or_1_arg, fn_1_or_2_or_3_arg
+
+ def no_args() -> int:
+ return 0
+
+ def len_of_args(*args: int) -> int:
+ return len(args)
+
+ def len_of_args_tweaked(x: int, *args: int, **kwargs: Any) -> int:
+ return x + len(args)
+
+ def sum_of_args(*args: int) -> int:
+ return sum(args)
+
+ assert m.fn_0_arg(lambda: 0) == 0
+ assert m.fn_0_arg(lambda: 5) == 5
+ assert m.fn_0_arg(lambda x=2: x) == 2
+ assert m.fn_0_arg(len_of_args) == 0
+
+ assert m.fn_1_arg(lambda x: x, 4) == 4
+ assert m.fn_1_arg(sum_of_args, 8) == 8
+ assert m.fn_1_arg(lambda x=2, y=4: x + y, 3) == 7
+ assert m.fn_1_arg(len_of_args_tweaked, 5) == 5
+
+ assert m.fn_2_arg(lambda x, y: x * y, 4, 5) == 20
+ assert m.fn_2_arg(lambda x, y=3: x * y, 4, 2) == 8
+ assert m.fn_2_arg(sum_of_args, 8, 9) == 17
+ assert m.fn_2_arg(lambda x=2, y=4: x + y, 3, 3) == 6
+ assert m.fn_2_arg(len_of_args_tweaked, 5, 2) == 6
+
+ assert m.fn_0_or_1_arg(lambda: 3) == 3
+ assert m.fn_0_or_1_arg(lambda x: x) == 1
+
+ # the 0 arg is bound first, both are possible, the 0 arg is chosen
+ assert m.fn_0_or_1_arg(lambda x=2: x) == 2
+
+ with pytest.raises(TypeError):
+ m.fn_1_or_2_or_3_arg(no_args) # pyright: ignore[reportArgumentType, reportCallIssue]
+
+ assert m.fn_1_or_2_or_3_arg(lambda x=4: x) == 1
+ assert m.fn_1_or_2_or_3_arg(lambda x, y: x + y) == 3 # 1 + 2
+ assert m.fn_1_or_2_or_3_arg(lambda x, y, z: x * y * z) == 6 # 1 * 2 * 3
+
+ # the 1 arg is bound first
+ assert m.fn_1_or_2_or_3_arg(sum_of_args) == 1
diff --git a/libs/plugin_python/tests/python/test_guessed_string.cpp b/libs/plugin_python/tests/python/test_guessed_string.cpp
new file mode 100644
index 0000000..5e76773
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_guessed_string.cpp
@@ -0,0 +1,34 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/functional.h>
+#include <pybind11/pybind11.h>
+
+#include <uibase/guessedvalue.h>
+
+using namespace MOBase;
+
+PYBIND11_MODULE(guessed_string, m)
+{
+ m.def("get_value", [](GuessedValue<QString> const& value) {
+ return value.operator const QString&();
+ });
+ m.def("get_variants", [](GuessedValue<QString> const& value) {
+ return value.variants();
+ });
+
+ m.def("set_from_callback",
+ [](GuessedValue<QString>& value,
+ std::function<void(GuessedValue<QString>&)> const& fn) {
+ fn(value);
+ });
+
+ // note: the function needs to take the guessed string by pointer if constructed
+ // from C++, this is to be taken into account when calling Python function (cf.
+ // installers)
+ m.def("get_from_callback",
+ [](std::function<void(GuessedValue<QString>*)> const& fn) {
+ GuessedValue<QString> value;
+ fn(&value);
+ return (QString)value;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_guessed_string.py b/libs/plugin_python/tests/python/test_guessed_string.py
new file mode 100644
index 0000000..204e1f1
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_guessed_string.py
@@ -0,0 +1,36 @@
+import mobase
+
+import mobase_tests.guessed_string as m
+
+
+def test_guessed_string():
+ # empty string
+ gs = mobase.GuessedString()
+
+ assert len(gs.variants()) == 0
+ assert not gs.variants()
+ assert str(gs) == ""
+
+ # automatic conversion from string
+ assert m.get_value("test") == "test"
+ assert m.get_variants("test") == {"test"}
+
+ # update
+ gs = mobase.GuessedString("fallback", mobase.GuessQuality.FALLBACK)
+ assert str(gs) == "fallback"
+
+ gs.update("good", mobase.GuessQuality.GOOD)
+ assert str(gs) == "good"
+ assert gs.variants() == {"fallback", "good"}
+
+ # back-and-forth
+ gs = mobase.GuessedString()
+ assert str(gs) == ""
+
+ def _update(gs: mobase.GuessedString):
+ gs.update("test")
+
+ m.set_from_callback(gs, _update)
+ assert str(gs) == "test"
+
+ assert m.get_from_callback(_update) == "test"
diff --git a/libs/plugin_python/tests/python/test_organizer.cpp b/libs/plugin_python/tests/python/test_organizer.cpp
new file mode 100644
index 0000000..ea54c0d
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_organizer.cpp
@@ -0,0 +1,46 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/pybind11.h>
+
+#include <QMap>
+#include <QWidget>
+
+#include "MockOrganizer.h"
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+using ::testing::NiceMock;
+
+PYBIND11_MODULE(organizer, m)
+{
+ using ::testing::_;
+ using ::testing::Eq;
+ using ::testing::Return;
+
+ m.def("organizer", []() -> IOrganizer* {
+ MockOrganizer* mock = new NiceMock<MockOrganizer>();
+ ON_CALL(*mock, profileName).WillByDefault([&mock]() {
+ return "profile";
+ });
+
+ const auto handle = (HANDLE)std::uintptr_t{4654};
+ ON_CALL(*mock, startApplication)
+ .WillByDefault([handle](const auto& name, auto&&... args) {
+ return name == "valid.exe" ? handle : INVALID_HANDLE_VALUE;
+ });
+ ON_CALL(*mock, waitForApplication)
+ .WillByDefault([&mock, original_handle = handle](HANDLE handle, bool,
+ LPDWORD exitCode) {
+ if (handle == original_handle) {
+ *exitCode = 0;
+ return true;
+ }
+ else {
+ *exitCode = static_cast<DWORD>(-1);
+ return false;
+ }
+ });
+
+ return mock;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_organizer.py b/libs/plugin_python/tests/python/test_organizer.py
new file mode 100644
index 0000000..3ac42d2
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_organizer.py
@@ -0,0 +1,14 @@
+import pytest
+
+import mobase
+
+m = pytest.importorskip("mobase_tests.organizer")
+
+
+def test_getters():
+ o: mobase.IOrganizer = m.organizer()
+ assert o.profileName() == "profile"
+ assert o.startApplication("valid.exe") == 4654
+ assert o.startApplication("invalid.exe") == mobase.INVALID_HANDLE_VALUE
+ assert o.waitForApplication(42) == (False, -1)
+ assert o.waitForApplication(4654) == (True, 0)
diff --git a/libs/plugin_python/tests/python/test_path_wrappers.py b/libs/plugin_python/tests/python/test_path_wrappers.py
new file mode 100644
index 0000000..5a0d701
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_path_wrappers.py
@@ -0,0 +1,45 @@
+import sys
+from pathlib import Path
+
+import pytest
+from PyQt6.QtCore import QDir, QFileInfo
+
+import mobase
+
+
+def test_filepath_wrappers():
+ # TBC that this works everywhere
+ version = ".".join(map(str, sys.version_info[:3]))
+
+ # from string, ok
+ assert mobase.getProductVersion(sys.executable) == version
+
+ # from path, ok
+ assert mobase.getProductVersion(Path(sys.executable)) == version
+
+ # from QDir, ko
+ with pytest.raises(TypeError):
+ mobase.getProductVersion(QDir(sys.executable)) # pyright: ignore[reportArgumentType]
+
+
+def test_executableinfo():
+ info = mobase.ExecutableInfo("exe", QFileInfo(sys.executable))
+ assert info.binary() == QFileInfo(sys.executable)
+
+ info = mobase.ExecutableInfo("exe", sys.executable)
+ assert info.binary() == QFileInfo(sys.executable)
+
+ info = mobase.ExecutableInfo("exe", Path(sys.executable))
+ assert info.binary() == QFileInfo(sys.executable)
+
+ info.withWorkingDirectory(Path(__file__).parent)
+ assert info.workingDirectory() == QFileInfo(__file__).dir()
+
+ info.withWorkingDirectory(".")
+ assert info.workingDirectory() == QDir(".")
+
+ info.withWorkingDirectory(Path("."))
+ assert info.workingDirectory() == QDir(".")
+
+ info.withWorkingDirectory(".")
+ assert info.workingDirectory() == QDir(".")
diff --git a/libs/plugin_python/tests/python/test_qt.cpp b/libs/plugin_python/tests/python/test_qt.cpp
new file mode 100644
index 0000000..5ef1f00
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt.cpp
@@ -0,0 +1,155 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/pybind11.h>
+
+#include <tuple>
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+
+PYBIND11_MODULE(qt, m)
+{
+ // QString
+
+ m.def("create_qstring_with_emoji", []() {
+ return QString::fromUtf16(u"\U0001F600");
+ });
+
+ m.def("consume_qstring_with_emoji", [](QString const& qstring) {
+ return qstring.length();
+ });
+
+ m.def("qstring_to_stdstring", [](QString const& qstring) {
+ return qstring.toStdString();
+ });
+
+ m.def("stdstring_to_qstring", [](std::string const& string) {
+ return QString::fromStdString(string);
+ });
+ m.def("qstring_to_int", [](QString const& qstring) {
+ return qstring.toInt();
+ });
+ m.def("int_to_qstring", [](int value) {
+ return QString::number(value);
+ });
+
+ // QStringList
+
+ m.def("qstringlist_join", [](QStringList const& values, QString const& sep) {
+ return values.join(sep);
+ });
+
+ m.def("qstringlist_at", [](QStringList const& values, int index) {
+ return values.at(index);
+ });
+
+ // QMap
+
+ m.def("qmap_to_length", [](QMap<QString, QString> const& map) {
+ QMap<QString, int> res;
+ for (auto it = map.begin(); it != map.end(); ++it) {
+ res[it.key()] = it.value().size();
+ }
+ return res;
+ });
+
+ // QDateTime
+
+ m.def(
+ "datetime_from_string",
+ [](QString const& date, Qt::DateFormat format) {
+ return QDateTime::fromString(date, format);
+ },
+ "string"_a, "format"_a = Qt::DateFormat::ISODate);
+
+ m.def(
+ "datetime_to_string",
+ [](QDateTime const& datetime, Qt::DateFormat format) {
+ return datetime.toString(format);
+ },
+ "datetime"_a, "format"_a = Qt::DateFormat::ISODate);
+
+ // QVariant
+
+ m.def("qvariant_from_none", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::UnknownType,
+ variant.isValid());
+ });
+ m.def("qvariant_from_int", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::Int, variant.toInt());
+ });
+ m.def("qvariant_from_bool", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::Bool, variant.toBool());
+ });
+ m.def("qvariant_from_str", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::QString,
+ variant.toString());
+ });
+ m.def("qvariant_from_list", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::QVariantList,
+ variant.toList());
+ });
+ m.def("qvariant_from_map", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::QVariantMap,
+ variant.toMap());
+ });
+
+ m.def("qvariant_none", []() {
+ return QVariant();
+ });
+ m.def("qvariant_int", []() {
+ return QVariant(42);
+ });
+ m.def("qvariant_bool", []() {
+ return QVariant(true);
+ });
+ m.def("qvariant_str", []() {
+ return QVariant("hello world");
+ });
+ m.def("qvariant_list", [] {
+ QVariantMap subMap;
+ subMap["bar"] = 42;
+ subMap["moo"] = QVariantList{44, true};
+ QVariantList list;
+ list.push_back(33);
+ list.push_back(QVariantList{4, "foo"});
+ list.push_back(false);
+ list.push_back("hello");
+ list.push_back(QVariant());
+ list.push_back(subMap);
+ list.push_back(45);
+ return QVariant(list);
+ });
+ m.def("qvariant_map", []() {
+ QVariantMap map;
+ map["bar"] = 42;
+ map["moo"] = true;
+ map["baz"] = "world hello";
+ return map;
+ });
+
+ // QFlags
+
+ enum SimpleEnum { Value0 = 0x1, Value1 = 0x2, Value2 = 0x4 };
+ Q_DECLARE_FLAGS(SimpleEnumFlags, SimpleEnum);
+
+ py::enum_<SimpleEnum>(m, "SimpleEnum", py::arithmetic())
+ .value("Value0", Value0)
+ .value("Value1", Value1)
+ .value("Value2", Value2);
+
+ m.def("qflags_explode", [](SimpleEnumFlags const& flags) {
+ return std::make_tuple(flags.toInt(), flags.testFlag(Value0),
+ flags.testFlag(Value1), flags.testFlag(Value2));
+ });
+ m.def("qflags_create", [](bool v0, bool v1, bool v2) {
+ SimpleEnumFlags r;
+ if (v0)
+ r.setFlag(Value0);
+ if (v1)
+ r.setFlag(Value1);
+ if (v2)
+ r.setFlag(Value2);
+ return r;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_qt.py b/libs/plugin_python/tests/python/test_qt.py
new file mode 100644
index 0000000..991e33c
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt.py
@@ -0,0 +1,119 @@
+from PyQt6.QtCore import QDateTime, Qt
+
+import mobase_tests.qt as m
+
+
+def test_qstring():
+ assert m.qstring_to_stdstring("test") == "test"
+ assert m.stdstring_to_qstring("test") == "test"
+
+ assert m.qstring_to_stdstring("éàüö") == "éàüö"
+ assert m.stdstring_to_qstring("éàüö") == "éàüö"
+ assert m.qstring_to_stdstring("خالد") == "خالد"
+ assert m.qstring_to_stdstring("🌎") == "🌎"
+
+ assert m.qstring_to_int("2") == 2
+ assert m.int_to_qstring(2) == "2"
+
+ emoji = m.create_qstring_with_emoji()
+
+ assert emoji.encode("utf-16be", "surrogatepass") == b"\xd8\x3d\xde\x00"
+ assert m.consume_qstring_with_emoji(emoji) == 2
+
+ assert m.consume_qstring_with_emoji("🌎") == 2
+
+
+def test_qstringlist():
+ assert m.qstringlist_join([""], "--") == ""
+ assert m.qstringlist_join(["a", "b"], "") == "ab"
+ assert m.qstringlist_join(["x", "y"], ";") == "x;y"
+
+ assert m.qstringlist_at(["x", "y"], 0) == "x"
+ assert m.qstringlist_at(["x", "y"], 1) == "y"
+
+
+def test_qmap():
+ assert m.qmap_to_length({"t1": "abc", "t2": "o", "t3": ""}) == {
+ "t1": 3,
+ "t2": 1,
+ "t3": 0,
+ }
+
+
+def test_qdatetime():
+ assert m.datetime_from_string("2022-03-01") == QDateTime(2022, 3, 1, 0, 0)
+
+ date = QDateTime(1995, 5, 20, 0, 0)
+ assert (
+ m.datetime_from_string(
+ date.toString(Qt.DateFormat.TextDate), Qt.DateFormat.TextDate
+ )
+ == date
+ )
+
+ assert m.datetime_to_string(date) == "1995-05-20T00:00:00"
+ assert m.datetime_to_string(date, Qt.DateFormat.TextDate) == date.toString(
+ Qt.DateFormat.TextDate
+ )
+
+
+def test_qvariant():
+ # Python -> C++
+
+ assert m.qvariant_from_none(None) == (True, False)
+
+ assert m.qvariant_from_int(-52) == (True, -52)
+ assert m.qvariant_from_int(0) == (True, 0)
+ assert m.qvariant_from_int(33) == (True, 33)
+
+ assert m.qvariant_from_bool(True) == (True, True)
+ assert m.qvariant_from_bool(False) == (True, False)
+
+ assert m.qvariant_from_str("a string") == (True, "a string")
+
+ assert m.qvariant_from_list([]) == (True, [])
+ assert m.qvariant_from_list([1, "hello", False]) == (True, [1, "hello", False])
+
+ assert m.qvariant_from_map({"a": 33, "b": False, "c": ["a", "b"]}) == (
+ True,
+ {"a": 33, "b": False, "c": ["a", "b"]},
+ )
+
+ # C++ -> Python (see .cpp file for the value)
+
+ assert m.qvariant_none() is None
+ assert m.qvariant_int() == 42
+ assert m.qvariant_bool() is True
+ assert m.qvariant_str() == "hello world"
+
+ assert m.qvariant_map() == {"baz": "world hello", "bar": 42, "moo": True}
+
+ assert m.qvariant_list() == [
+ 33,
+ [4, "foo"],
+ False,
+ "hello",
+ None,
+ {"bar": 42, "moo": [44, True]},
+ 45,
+ ]
+
+
+def test_qflags():
+ v0, v1, v2 = m.SimpleEnum.Value0, m.SimpleEnum.Value1, m.SimpleEnum.Value2
+
+ assert m.qflags_explode(v0 | v1) == (0x3, True, True, False)
+ assert m.qflags_explode(v0 | v2) == (0x5, True, False, True)
+ assert m.qflags_explode(0) == (0, False, False, False)
+
+ assert not (m.qflags_create(False, False, False) & v0)
+ assert not (m.qflags_create(False, False, False) & v1)
+ assert not (m.qflags_create(False, False, False) & v2)
+
+ assert m.qflags_create(True, False, False) & v0
+ assert m.qflags_create(True, True, False) & v0
+ assert m.qflags_create(True, True, False) & v1
+ assert not (m.qflags_create(True, True, False) & v2)
+
+ assert m.qflags_create(True, False, False) | v0 == v0
+ assert m.qflags_create(True, False, False) | v0 | v2 == v0 | v2
diff --git a/libs/plugin_python/tests/python/test_qt_widgets.cpp b/libs/plugin_python/tests/python/test_qt_widgets.cpp
new file mode 100644
index 0000000..6d8a901
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt_widgets.cpp
@@ -0,0 +1,85 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/pybind11.h>
+
+#include <QMap>
+#include <QWidget>
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+
+QMap<QString, QWidget*> s_Widgets;
+QWidget* s_Parent;
+
+class CustomWidget : public QWidget {
+public:
+ CustomWidget(QString const& name, QWidget* parent = nullptr) : QWidget(parent)
+ {
+ s_Widgets[name] = this;
+ setProperty("name", name);
+ }
+
+ ~CustomWidget() { s_Widgets.remove(property("name").toString()); }
+};
+
+class PyCustomWidget : public CustomWidget {
+public:
+ using CustomWidget::CustomWidget;
+ int heightForWidth(int value) const
+ {
+ PYBIND11_OVERRIDE(int, CustomWidget, heightForWidth, value);
+ }
+};
+
+PYBIND11_MODULE(qt_widgets, m)
+{
+ s_Parent = new QWidget();
+
+ py::class_<CustomWidget, PyCustomWidget, py::qt::qobject_holder<CustomWidget>>
+ pyCustomWidget(m, "CustomWidget");
+ pyCustomWidget
+ .def(py::init<QString, QWidget*>(), "name"_a, "parent"_a = (QWidget*)nullptr)
+ .def("set_parent_cpp", [](CustomWidget* w) {
+ w->setParent(s_Parent);
+ });
+ py::qt::add_qt_delegate<QWidget>(pyCustomWidget, "_widget");
+
+ m.def("is_alive", [](QString const& name) {
+ return s_Widgets.contains(name);
+ });
+
+ m.def("get", [](QString const& name) {
+ return s_Widgets.contains(name) ? s_Widgets[name] : nullptr;
+ });
+
+ m.def("set_parent", [](QWidget* widget) {
+ widget->setParent(s_Parent);
+ });
+
+ m.def("is_owned_cpp", [](QString const& name) {
+ return s_Widgets.contains(name) && s_Widgets[name]->parent() == s_Parent;
+ });
+
+ m.def("make_widget_own_cpp", [](QString const& name) -> QWidget* {
+ return new CustomWidget(name, s_Parent);
+ });
+
+ m.def(
+ "make_widget_own_py",
+ [](QString const& name) -> QWidget* {
+ return new CustomWidget(name);
+ },
+ py::return_value_policy::take_ownership);
+
+ // simply passing the widget gives the ownership of the Python object to C++
+ m.def("send_to_cpp", [](QString const& name, QWidget* widget) {
+ widget->setProperty("name", name);
+ widget->setParent(s_Parent);
+ s_Widgets[name] = widget;
+ });
+
+ m.def("heightForWidth", [](QString const& name, int value) {
+ return s_Widgets.contains(name) ? s_Widgets[name]->heightForWidth(value)
+ : -1024;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_qt_widgets.py b/libs/plugin_python/tests/python/test_qt_widgets.py
new file mode 100644
index 0000000..057f34e
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt_widgets.py
@@ -0,0 +1,62 @@
+from PyQt6.QtWidgets import QWidget
+
+import mobase_tests.qt_widgets as m
+
+
+class PyWidget(QWidget):
+ def heightForWidth(self, a0: int) -> int:
+ return a0 * 3 + 4
+
+
+class PyCustomWidget(m.CustomWidget):
+ def __init__(self, name: str):
+ super().__init__(name)
+
+ def heightForWidth(self, value: int) -> int:
+ return value * 6 - 5
+
+
+def test_qt_widget():
+ # own cpp
+ w = m.make_widget_own_cpp("w1")
+ assert m.is_alive("w1")
+ assert m.is_owned_cpp("w1")
+
+ del w
+ assert m.is_alive("w1")
+
+ # own py
+ w = m.make_widget_own_py("w2")
+ assert m.is_alive("w2")
+ assert not m.is_owned_cpp("w2")
+
+ del w
+ assert not m.is_alive("w2")
+
+ # transfer to C++
+ w = PyWidget()
+ m.send_to_cpp("w3", w)
+
+ # delete the reference w - this should NOT delete the underlying object since it
+ # was transferred to C++
+ del w
+ assert m.is_alive("w3")
+ assert m.is_owned_cpp("w3")
+
+ # if the Python object is dead (BAD!), this will crash horrible
+ assert m.heightForWidth("w3", 4) == 4 * 3 + 4
+
+ # CustomWidget as a qholder, so the construction itself transfers the ownership
+ # to C++
+ w = PyCustomWidget("w4")
+ w.set_parent_cpp()
+ assert m.is_alive("w4")
+ assert m.heightForWidth("w4", 7) == 6 * 7 - 5
+ assert w.heightForWidth(7) == 6 * 7 - 5
+
+ # can call function not defined and not bound through the delegate
+ assert not w.hasHeightForWidth()
+
+ del w
+ assert m.is_alive("w4")
+ assert m.heightForWidth("w4", 7) == 6 * 7 - 5
diff --git a/libs/plugin_python/tests/python/test_shared_cpp_owner.cpp b/libs/plugin_python/tests/python/test_shared_cpp_owner.cpp
new file mode 100644
index 0000000..f24cafd
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_shared_cpp_owner.cpp
@@ -0,0 +1,67 @@
+#include "pybind11_utils/shared_cpp_owner.h"
+
+#include <pybind11/pybind11.h>
+
+#include <tuple>
+#include <unordered_map>
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+
+class Base;
+static std::unordered_map<std::string, Base*> bases;
+
+class Base {
+ std::string name_;
+
+public:
+ Base(std::string const& name) : name_{name} { bases[name] = this; }
+ virtual std::string fn() const = 0;
+ virtual ~Base() { bases.erase(name_); }
+};
+
+MO2_PYBIND11_SHARED_CPP_HOLDER(Base);
+
+class CppBase : public Base {
+public:
+ using Base::Base;
+ std::string fn() const override { return "CppBase::fn()"; }
+};
+
+class PyBase : public Base {
+public:
+ using Base::Base;
+ std::string fn() const override { PYBIND11_OVERRIDE_PURE(std::string, Base, fn, ); }
+};
+
+PYBIND11_MODULE(shared_cpp_owner, m)
+{
+ static std::shared_ptr<Base> base_ptr;
+
+ py::class_<Base, PyBase, std::shared_ptr<Base>>(m, "Base")
+ .def(py::init<std::string>())
+ .def("fn", &Base::fn);
+
+ m.def("is_alive", [](std::string const& name) {
+ return bases.find(name) != bases.end();
+ });
+
+ m.def("create", [](std::string const& name) -> std::shared_ptr<Base> {
+ return std::make_shared<CppBase>(name);
+ });
+ m.def("create_and_store", [](std::string const& name) {
+ base_ptr = std::make_shared<CppBase>(name);
+ return base_ptr;
+ });
+ m.def("store", [](std::shared_ptr<Base> ptr) {
+ base_ptr = ptr;
+ });
+ m.def("clear", []() {
+ base_ptr.reset();
+ });
+
+ m.def("call_fn", [](std::string const& name) {
+ auto it = bases.find(name);
+ return it != bases.end() ? it->second->fn() : "";
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_shared_cpp_owner.py b/libs/plugin_python/tests/python/test_shared_cpp_owner.py
new file mode 100644
index 0000000..0bcecf7
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_shared_cpp_owner.py
@@ -0,0 +1,72 @@
+import mobase_tests.shared_cpp_owner as m
+
+
+class PyBase(m.Base):
+ def __init__(self, name: str, value: int):
+ super().__init__(name)
+ self.value = value
+
+ def fn(self):
+ return f"PyBase.fn({self.value})"
+
+
+def test_shared_cpp_owner_1():
+ # create from C++, owned by Python
+
+ # create from C++
+ p = m.create("tmp")
+ assert m.is_alive("tmp")
+
+ # should delete since it's not owner by C++
+ del p
+ assert not m.is_alive("tmp")
+
+
+def test_shared_cpp_owner_2():
+ # create from C++, owned by C++ (and Python)
+
+ # create from C++
+ p = m.create_and_store("tmp")
+ assert m.is_alive("tmp")
+
+ # should not delete since it's owned by both C++ and Python
+ del p
+ assert m.is_alive("tmp")
+
+ # clear from C++ should free it
+ m.clear()
+ assert not m.is_alive("tmp")
+
+
+def test_shared_cpp_owner_3():
+ # create from Python, owned by Python
+
+ p = PyBase("foo", 1)
+ assert m.is_alive("foo")
+ assert m.call_fn("foo") == "PyBase.fn(1)"
+
+ del p
+ assert not m.is_alive("foo")
+
+
+def test_shared_cpp_owner_4():
+ # create from Python, owned by C++
+
+ p = PyBase("foo", 2)
+ assert m.is_alive("foo")
+
+ # send to C++
+ m.store(p)
+ assert m.is_alive("foo")
+ assert m.call_fn("foo") == "PyBase.fn(2)"
+
+ # delete in Python, should still be alived
+ del p
+ assert m.is_alive("foo")
+
+ # should still be able to call fn()
+ assert m.call_fn("foo") == "PyBase.fn(2)"
+
+ # clear in C++, should kill Python
+ m.clear()
+ assert not m.is_alive("foo")
diff --git a/libs/plugin_python/tests/runner/CMakeLists.txt b/libs/plugin_python/tests/runner/CMakeLists.txt
new file mode 100644
index 0000000..ea17438
--- /dev/null
+++ b/libs/plugin_python/tests/runner/CMakeLists.txt
@@ -0,0 +1,65 @@
+cmake_minimum_required(VERSION 3.22)
+
+# setting-up the tests for the runner is a bit complex because we need a tons of
+# things
+
+# first we configure the tests as with other tests
+add_executable(runner-tests EXCLUDE_FROM_ALL)
+mo2_default_source_group()
+mo2_target_sources(runner-tests
+ FOLDER src
+ PRIVATE
+ test_diagnose.cpp
+ test_filemapper.cpp
+ test_game.cpp
+ test_installer.cpp
+ test_iplugin.cpp
+ test_lifetime.cpp
+)
+mo2_target_sources(runner-tests
+ FOLDER src/mocks
+ PRIVATE
+ ../mocks/DummyFileTree.h
+ ../mocks/MockOrganizer.h
+)
+mo2_target_sources(runner-tests
+ FOLDER src/plugins
+ PRIVATE
+ plugins/dummy-diagnose.py
+ plugins/dummy-filemapper.py
+ plugins/dummy-game.py
+ plugins/dummy-installer.py
+ plugins/dummy-iplugin.py
+)
+mo2_configure_tests(runner-tests NO_SOURCES WARNINGS 4)
+
+set_target_properties(runner-tests PROPERTIES FOLDER tests/runner)
+
+# link to runner
+target_link_libraries(runner-tests PUBLIC runner)
+
+# linking to Python - this is not required to get proper linking but required so that
+# CMake generator variables will lookup appropriate DLLs for Python and update PATH
+# accordingly thanks to mo2_configure_tests
+target_link_libraries(runner-tests PUBLIC Python::Python)
+
+# add mocks
+target_include_directories(runner-tests
+ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../mocks)
+
+set(PYLIB_DIR ${CMAKE_CURRENT_BINARY_DIR}/pylibs)
+mo2_python_pip_install(runner-tests
+ DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/pylibs
+ PACKAGES
+ pytest
+ PyQt${MO2_QT_VERSION_MAJOR}==${MO2_PYQT_VERSION}
+ PyQt${MO2_QT_VERSION_MAJOR}-Qt${MO2_QT_VERSION_MAJOR}==${MO2_QT_VERSION})
+
+add_dependencies(runner-tests mobase)
+
+set(PYTHONPATH "${PYLIB_DIR}\\;$<TARGET_FILE_DIR:mobase>\\;${Python_DLL_DIR}\\;${Python_LIB_DIR}")
+
+set_tests_properties(${runner-tests_gtests}
+ PROPERTIES
+ ENVIRONMENT "PLUGIN_DIR=${CMAKE_CURRENT_SOURCE_DIR}/plugins;PYTHONPATH=${PYTHONPATH}"
+)
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-diagnose.py b/libs/plugin_python/tests/runner/plugins/dummy-diagnose.py
new file mode 100644
index 0000000..6f25ef0
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-diagnose.py
@@ -0,0 +1,37 @@
+import mobase
+
+
+class DummyDiagnose(mobase.IPluginDiagnose):
+ def activeProblems(self) -> list[int]:
+ return [1, 2]
+
+ def shortDescription(self, key: int) -> str:
+ return f"short-{key}"
+
+ def fullDescription(self, key: int) -> str:
+ return f"long-{key}"
+
+ def hasGuidedFix(self, key: int) -> bool:
+ return key == 1
+
+
+class DummyDiagnoseAndGame(mobase.IPluginDiagnose, mobase.IPluginGame):
+ def __init__(self):
+ mobase.IPluginDiagnose.__init__(self)
+ mobase.IPluginGame.__init__(self)
+
+ def activeProblems(self) -> list[int]:
+ return [5, 7]
+
+ def shortDescription(self, key: int) -> str:
+ return f"short-{key}"
+
+ def fullDescription(self, key: int) -> str:
+ return f"long-{key}"
+
+ def hasGuidedFix(self, key: int) -> bool:
+ return key == 7
+
+
+def createPlugins() -> list[mobase.IPlugin]:
+ return [DummyDiagnose(), DummyDiagnoseAndGame()] # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-filemapper.py b/libs/plugin_python/tests/runner/plugins/dummy-filemapper.py
new file mode 100644
index 0000000..1993b55
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-filemapper.py
@@ -0,0 +1,32 @@
+import mobase
+
+
+class DummyFileMapper(mobase.IPluginFileMapper):
+ def mappings(self) -> list[mobase.Mapping]:
+ return [
+ mobase.Mapping(
+ source="the source", destination="the destination", is_directory=False
+ ),
+ mobase.Mapping(
+ source="the other source",
+ destination="the other destination",
+ is_directory=True,
+ ),
+ ]
+
+
+class DummyFileMapperAndGame(mobase.IPluginFileMapper, mobase.IPluginGame):
+ def __init__(self):
+ mobase.IPluginFileMapper.__init__(self)
+ mobase.IPluginGame.__init__(self)
+
+ def mappings(self) -> list[mobase.Mapping]:
+ return [
+ mobase.Mapping(
+ source="the source", destination="the destination", is_directory=False
+ ),
+ ]
+
+
+def createPlugins() -> list[mobase.IPlugin]:
+ return [DummyFileMapper(), DummyFileMapperAndGame()] # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-game.py b/libs/plugin_python/tests/runner/plugins/dummy-game.py
new file mode 100644
index 0000000..e17c9b8
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-game.py
@@ -0,0 +1,45 @@
+from collections.abc import Sequence
+
+from PyQt6.QtWidgets import QWidget
+
+import mobase
+
+
+class DummyModDataChecker(mobase.ModDataChecker):
+ def dataLooksValid(
+ self, filetree: mobase.IFileTree
+ ) -> mobase.ModDataChecker.CheckReturn:
+ return mobase.ModDataChecker.VALID
+
+ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
+ return filetree
+
+
+class DummySaveGameInfo(mobase.SaveGameInfo):
+ def getMissingAssets(self, save: mobase.ISaveGame) -> dict[str, Sequence[str]]:
+ return {}
+
+ def getSaveGameWidget(self, parent: QWidget) -> mobase.ISaveGameInfoWidget | None:
+ return None
+
+
+# we do not implement everything since this will do fine if we do not call anything
+# from the C++ side
+#
+class DummyGame(mobase.IPluginGame):
+ _features: dict[type, object]
+
+ def __init__(self):
+ super().__init__()
+
+ self._features = {
+ mobase.ModDataChecker: DummyModDataChecker(),
+ mobase.SaveGameInfo: DummySaveGameInfo(),
+ }
+
+ def _featureList(self) -> dict[type, object]:
+ return self._features
+
+
+def createPlugin() -> mobase.IPlugin:
+ return DummyGame() # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-installer.py b/libs/plugin_python/tests/runner/plugins/dummy-installer.py
new file mode 100644
index 0000000..3c4e67e
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-installer.py
@@ -0,0 +1,46 @@
+# -*- encoding: utf-8 -*-
+
+from typing import Union, cast
+
+import mobase
+
+
+# we do not implement everything since this will do fine if we do not call anything
+# from the C++ side
+#
+class DummyInstaller(mobase.IPluginInstallerSimple):
+ def isManualInstaller(self) -> bool:
+ return False
+
+ def priority(self) -> int:
+ return 10
+
+ def isArchiveSupported(self, tree: mobase.IFileTree) -> bool:
+ return tree.find("needed-file.txt") is not None
+
+ def install(
+ self,
+ name: mobase.GuessedString,
+ tree: mobase.IFileTree,
+ version: str,
+ nexus_id: int,
+ ) -> Union[
+ mobase.InstallResult,
+ mobase.IFileTree,
+ tuple[mobase.InstallResult, mobase.IFileTree, str, int],
+ ]:
+ if tree.find("needed-file.txt") is None:
+ return mobase.InstallResult.FAILED
+
+ if tree.find("extra-file.txt"):
+ name.update("new name")
+ new_tree = tree.createOrphanTree()
+ new_tree.move(tree, "subtree")
+ cast(mobase.IFileTree, new_tree.find("subtree")).remove("extra-file.txt")
+ return new_tree
+
+ return (mobase.InstallResult.NOT_ATTEMPTED, tree, "2.4.5", 33)
+
+
+def createPlugin() -> mobase.IPlugin:
+ return DummyInstaller() # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-iplugin.py b/libs/plugin_python/tests/runner/plugins/dummy-iplugin.py
new file mode 100644
index 0000000..eaaf6c0
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-iplugin.py
@@ -0,0 +1,29 @@
+import mobase
+
+
+class DummyPlugin(mobase.IPlugin):
+ def init(self, organizer: mobase.IOrganizer) -> bool:
+ return True
+
+ def author(self) -> str:
+ return "The Author"
+
+ def name(self) -> str:
+ return "The Name"
+
+ def description(self) -> str:
+ return "The Description"
+
+ def version(self) -> mobase.VersionInfo:
+ return mobase.VersionInfo("1.3.0")
+
+ def settings(self) -> list[mobase.PluginSetting]:
+ return [
+ mobase.PluginSetting(
+ "a setting", "the setting description", default_value=12
+ )
+ ]
+
+
+def createPlugin() -> mobase.IPlugin:
+ return DummyPlugin()
diff --git a/libs/plugin_python/tests/runner/test_diagnose.cpp b/libs/plugin_python/tests/runner/test_diagnose.cpp
new file mode 100644
index 0000000..467c9cb
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_diagnose.cpp
@@ -0,0 +1,61 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include <uibase/iplugindiagnose.h>
+#include <uibase/iplugingame.h>
+
+#include "MockOrganizer.h"
+
+using namespace MOBase;
+
+using ::testing::ElementsAre;
+
+TEST(IPluginDiagnose, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-diagnose.py");
+ EXPECT_EQ(objects.size(), 3);
+
+ // load the first IPluginDiagnose
+ {
+ IPluginDiagnose* plugin = qobject_cast<IPluginDiagnose*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ ASSERT_THAT(plugin->activeProblems(), ElementsAre(1, 2));
+ EXPECT_EQ(plugin->shortDescription(1), "short-1");
+ EXPECT_EQ(plugin->fullDescription(1), "long-1");
+ EXPECT_TRUE(plugin->hasGuidedFix(1));
+ EXPECT_EQ(plugin->shortDescription(2), "short-2");
+ EXPECT_EQ(plugin->fullDescription(2), "long-2");
+ EXPECT_FALSE(plugin->hasGuidedFix(2));
+ }
+
+ // load the second one (this is cast before IPluginGame so should be before)
+ {
+ IPluginDiagnose* plugin = qobject_cast<IPluginDiagnose*>(objects[1]);
+ EXPECT_NE(plugin, nullptr);
+
+ ASSERT_THAT(plugin->activeProblems(), ElementsAre(5, 7));
+ EXPECT_EQ(plugin->shortDescription(5), "short-5");
+ EXPECT_EQ(plugin->fullDescription(5), "long-5");
+ EXPECT_FALSE(plugin->hasGuidedFix(5));
+ EXPECT_EQ(plugin->shortDescription(7), "short-7");
+ EXPECT_EQ(plugin->fullDescription(7), "long-7");
+ EXPECT_TRUE(plugin->hasGuidedFix(7));
+ }
+
+ // load the game plugin
+ {
+ IPluginGame* plugin = qobject_cast<IPluginGame*>(objects[2]);
+ EXPECT_NE(plugin, nullptr);
+ }
+}
diff --git a/libs/plugin_python/tests/runner/test_filemapper.cpp b/libs/plugin_python/tests/runner/test_filemapper.cpp
new file mode 100644
index 0000000..5044283
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_filemapper.cpp
@@ -0,0 +1,64 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include <uibase/ipluginfilemapper.h>
+#include <uibase/iplugingame.h>
+
+#include "MockOrganizer.h"
+
+using namespace MOBase;
+
+TEST(IPluginFileMapper, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-filemapper.py");
+ EXPECT_EQ(objects.size(), 3);
+
+ // load the first IPluginFileMapper
+ {
+ IPluginFileMapper* plugin = qobject_cast<IPluginFileMapper*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ const auto m = plugin->mappings();
+ EXPECT_EQ(m.size(), 2);
+
+ EXPECT_EQ(m[0].source, "the source");
+ EXPECT_EQ(m[0].destination, "the destination");
+ EXPECT_EQ(m[0].isDirectory, false);
+ EXPECT_EQ(m[0].createTarget, false);
+
+ EXPECT_EQ(m[1].source, "the other source");
+ EXPECT_EQ(m[1].destination, "the other destination");
+ EXPECT_EQ(m[1].isDirectory, true);
+ EXPECT_EQ(m[1].createTarget, false);
+ }
+
+ // load the second one (this is cast before IPluginGame so should be before)
+ {
+ IPluginFileMapper* plugin = qobject_cast<IPluginFileMapper*>(objects[1]);
+ EXPECT_NE(plugin, nullptr);
+
+ const auto m = plugin->mappings();
+ EXPECT_EQ(m.size(), 1);
+
+ EXPECT_EQ(m[0].source, "the source");
+ EXPECT_EQ(m[0].destination, "the destination");
+ EXPECT_EQ(m[0].isDirectory, false);
+ EXPECT_EQ(m[0].createTarget, false);
+ }
+
+ // load the game plugin
+ {
+ IPluginGame* plugin = qobject_cast<IPluginGame*>(objects[2]);
+ EXPECT_NE(plugin, nullptr);
+ }
+}
diff --git a/libs/plugin_python/tests/runner/test_game.cpp b/libs/plugin_python/tests/runner/test_game.cpp
new file mode 100644
index 0000000..0d1d718
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_game.cpp
@@ -0,0 +1,28 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include <uibase/iplugingame.h>
+
+#include "MockOrganizer.h"
+
+using namespace MOBase;
+
+TEST(IPluginGame, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-game.py");
+ EXPECT_EQ(objects.size(), 1);
+
+ // load the IPlugin
+ IPluginGame* plugin = qobject_cast<IPluginGame*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+}
diff --git a/libs/plugin_python/tests/runner/test_installer.cpp b/libs/plugin_python/tests/runner/test_installer.cpp
new file mode 100644
index 0000000..349c904
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_installer.cpp
@@ -0,0 +1,112 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include <uibase/iplugininstallersimple.h>
+
+#include "MockOrganizer.h"
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include "DummyFileTree.h"
+
+using namespace MOBase;
+
+TEST(IPluginInstaller, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-installer.py");
+ EXPECT_EQ(objects.size(), 1);
+
+ // load the IPlugin
+ IPluginInstallerSimple* plugin = qobject_cast<IPluginInstallerSimple*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ // basic tests
+ EXPECT_EQ(plugin->priority(), 10);
+ EXPECT_EQ(plugin->isManualInstaller(), false);
+
+ GuessedValue<QString> name{"default name"};
+ QString version = "1.0.0";
+ int nexus_id = 12;
+
+ // invalid tree
+ {
+ std::shared_ptr<IFileTree> invalid_tree =
+ std::make_shared<DummyFileTree>(nullptr, "root");
+ invalid_tree->addFile("some file");
+ invalid_tree->addDirectory("some directory")->addFile("some other file");
+ EXPECT_EQ(plugin->isArchiveSupported(invalid_tree), false);
+ EXPECT_EQ(plugin->install(name, invalid_tree, version, nexus_id),
+ IPluginInstaller::RESULT_FAILED);
+
+ // extra arguments stay the same
+ EXPECT_EQ(name, "default name");
+ EXPECT_EQ(version, "1.0.0");
+ EXPECT_EQ(nexus_id, 12);
+ }
+
+ // valid simple tree
+ {
+ std::shared_ptr<IFileTree> valid_tree =
+ std::make_shared<DummyFileTree>(nullptr, "root");
+ const auto initial_tree = valid_tree;
+ valid_tree->addFile("needed-file.txt");
+ valid_tree->addFile("some file");
+ valid_tree->addDirectory("some directory")->addFile("some other file");
+
+ // valid tree
+ EXPECT_EQ(plugin->isArchiveSupported(valid_tree), true);
+ EXPECT_EQ(plugin->install(name, valid_tree, version, nexus_id),
+ IPluginInstaller::RESULT_NOTATTEMPTED);
+
+ // name is not modified
+ EXPECT_EQ(name, "default name");
+
+ // tree is not modified
+ EXPECT_EQ(valid_tree, initial_tree);
+
+ // version and ID are modified
+ EXPECT_EQ(version, "2.4.5");
+ EXPECT_EQ(nexus_id, 33);
+ }
+
+ // "complex" tree
+ {
+ std::shared_ptr<IFileTree> complex_tree =
+ std::make_shared<DummyFileTree>(nullptr, "root");
+ const auto initial_tree = complex_tree;
+ const auto needed_file = complex_tree->addFile("needed-file.txt");
+ const auto extra_file = complex_tree->addFile("extra-file.txt");
+ const auto some_file = complex_tree->addFile("some file");
+ const auto some_directory = complex_tree->addDirectory("some directory");
+ some_directory->addFile("some other file");
+
+ // valid tree
+ EXPECT_EQ(plugin->isArchiveSupported(complex_tree), true);
+ EXPECT_EQ(plugin->install(name, complex_tree, version, nexus_id),
+ IPluginInstaller::RESULT_SUCCESS);
+
+ // name is modified
+ EXPECT_EQ(name, "new name");
+
+ // tree is modified
+ EXPECT_EQ(complex_tree->findDirectory("subtree")->find("needed-file.txt"),
+ needed_file);
+ EXPECT_EQ(
+ complex_tree->findDirectory("subtree")->findDirectory("extra-file.txt"),
+ nullptr);
+ EXPECT_EQ(
+ complex_tree->findDirectory("subtree")->findDirectory("some directory"),
+ some_directory);
+
+ // version and ID are not modified (from previous call)
+ EXPECT_EQ(version, "2.4.5");
+ EXPECT_EQ(nexus_id, 33);
+ }
+}
diff --git a/libs/plugin_python/tests/runner/test_iplugin.cpp b/libs/plugin_python/tests/runner/test_iplugin.cpp
new file mode 100644
index 0000000..27c4829
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_iplugin.cpp
@@ -0,0 +1,43 @@
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include <uibase/iplugin.h>
+
+#include "MockOrganizer.h"
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+using namespace MOBase;
+
+TEST(IPlugin, Basic)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-iplugin.py");
+ EXPECT_EQ(objects.size(), 1);
+
+ // load the IPlugin
+ const IPlugin* plugin = qobject_cast<IPlugin*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ EXPECT_EQ(plugin->author(), "The Author");
+ EXPECT_EQ(plugin->name(), "The Name");
+ EXPECT_EQ(plugin->version(), VersionInfo(1, 3, 0));
+ EXPECT_EQ(plugin->description(), "The Description");
+
+ // settings
+ const auto settings = plugin->settings();
+ EXPECT_EQ(settings.size(), 1);
+ EXPECT_EQ(settings[0].key, "a setting");
+ EXPECT_EQ(settings[0].description, "the setting description");
+ EXPECT_EQ(settings[0].defaultValue.userType(), QMetaType::Type::Int);
+ EXPECT_EQ(settings[0].defaultValue.toInt(), 12);
+
+ // no translation, no custom implementation -> name()
+ EXPECT_EQ(plugin->localizedName(), "The Name");
+}
diff --git a/libs/plugin_python/tests/runner/test_lifetime.cpp b/libs/plugin_python/tests/runner/test_lifetime.cpp
new file mode 100644
index 0000000..bf057d3
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_lifetime.cpp
@@ -0,0 +1,47 @@
+#include "gtest/gtest.h"
+
+#include "MockOrganizer.h"
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+TEST(Lifetime, Plugins)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ {
+ const auto objects = runner->load(plugins_folder + "/dummy-iplugin.py");
+
+ // we found one plugin
+ EXPECT_EQ(objects.size(), 1);
+
+ // check that deleting the object actually destroys it
+ bool destroyed = false;
+ QObject::connect(objects[0], &QObject::destroyed, [&destroyed]() {
+ destroyed = true;
+ });
+ delete objects[0];
+ EXPECT_EQ(destroyed, true);
+ }
+
+ // same things but with a parent
+ {
+ QObject* dummy_parent = new QObject();
+ const auto objects = runner->load(plugins_folder + "/dummy-iplugin.py");
+
+ // we found one plugin
+ EXPECT_EQ(objects.size(), 1);
+ objects[0]->setParent(dummy_parent);
+
+ // check that deleting the object actually destroys it
+ bool destroyed = false;
+ QObject::connect(objects[0], &QObject::destroyed, [&destroyed]() {
+ destroyed = true;
+ });
+ delete dummy_parent;
+ EXPECT_EQ(destroyed, true);
+ }
+}