aboutsummaryrefslogtreecommitdiff
path: root/libs/plugin_python/tests/python
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/python
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/python')
-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
19 files changed, 1146 insertions, 0 deletions
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")