From 7ee008e150bc5bcf76082d726f719ee0fdfda982 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 02:37:39 -0600 Subject: 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 --- libs/plugin_python/tests/CMakeLists.txt | 4 + libs/plugin_python/tests/mocks/DummyFileTree.h | 34 +++++ libs/plugin_python/tests/mocks/MockOrganizer.h | 65 +++++++++ libs/plugin_python/tests/python/CMakeLists.txt | 75 ++++++++++ libs/plugin_python/tests/python/conftest.py | 12 ++ .../tests/python/test_argument_wrapper.cpp | 61 ++++++++ .../tests/python/test_argument_wrapper.py | 62 +++++++++ libs/plugin_python/tests/python/test_filetree.cpp | 22 +++ libs/plugin_python/tests/python/test_filetree.py | 86 ++++++++++++ .../plugin_python/tests/python/test_functional.cpp | 38 +++++ libs/plugin_python/tests/python/test_functional.py | 55 ++++++++ .../tests/python/test_guessed_string.cpp | 34 +++++ .../tests/python/test_guessed_string.py | 36 +++++ libs/plugin_python/tests/python/test_organizer.cpp | 46 ++++++ libs/plugin_python/tests/python/test_organizer.py | 14 ++ .../tests/python/test_path_wrappers.py | 45 ++++++ libs/plugin_python/tests/python/test_qt.cpp | 155 +++++++++++++++++++++ libs/plugin_python/tests/python/test_qt.py | 119 ++++++++++++++++ .../plugin_python/tests/python/test_qt_widgets.cpp | 85 +++++++++++ libs/plugin_python/tests/python/test_qt_widgets.py | 62 +++++++++ .../tests/python/test_shared_cpp_owner.cpp | 67 +++++++++ .../tests/python/test_shared_cpp_owner.py | 72 ++++++++++ libs/plugin_python/tests/runner/CMakeLists.txt | 65 +++++++++ .../tests/runner/plugins/dummy-diagnose.py | 37 +++++ .../tests/runner/plugins/dummy-filemapper.py | 32 +++++ .../tests/runner/plugins/dummy-game.py | 45 ++++++ .../tests/runner/plugins/dummy-installer.py | 46 ++++++ .../tests/runner/plugins/dummy-iplugin.py | 29 ++++ libs/plugin_python/tests/runner/test_diagnose.cpp | 61 ++++++++ .../plugin_python/tests/runner/test_filemapper.cpp | 64 +++++++++ libs/plugin_python/tests/runner/test_game.cpp | 28 ++++ libs/plugin_python/tests/runner/test_installer.cpp | 112 +++++++++++++++ libs/plugin_python/tests/runner/test_iplugin.cpp | 43 ++++++ libs/plugin_python/tests/runner/test_lifetime.cpp | 47 +++++++ 34 files changed, 1858 insertions(+) create mode 100644 libs/plugin_python/tests/CMakeLists.txt create mode 100644 libs/plugin_python/tests/mocks/DummyFileTree.h create mode 100644 libs/plugin_python/tests/mocks/MockOrganizer.h create mode 100644 libs/plugin_python/tests/python/CMakeLists.txt create mode 100644 libs/plugin_python/tests/python/conftest.py create mode 100644 libs/plugin_python/tests/python/test_argument_wrapper.cpp create mode 100644 libs/plugin_python/tests/python/test_argument_wrapper.py create mode 100644 libs/plugin_python/tests/python/test_filetree.cpp create mode 100644 libs/plugin_python/tests/python/test_filetree.py create mode 100644 libs/plugin_python/tests/python/test_functional.cpp create mode 100644 libs/plugin_python/tests/python/test_functional.py create mode 100644 libs/plugin_python/tests/python/test_guessed_string.cpp create mode 100644 libs/plugin_python/tests/python/test_guessed_string.py create mode 100644 libs/plugin_python/tests/python/test_organizer.cpp create mode 100644 libs/plugin_python/tests/python/test_organizer.py create mode 100644 libs/plugin_python/tests/python/test_path_wrappers.py create mode 100644 libs/plugin_python/tests/python/test_qt.cpp create mode 100644 libs/plugin_python/tests/python/test_qt.py create mode 100644 libs/plugin_python/tests/python/test_qt_widgets.cpp create mode 100644 libs/plugin_python/tests/python/test_qt_widgets.py create mode 100644 libs/plugin_python/tests/python/test_shared_cpp_owner.cpp create mode 100644 libs/plugin_python/tests/python/test_shared_cpp_owner.py create mode 100644 libs/plugin_python/tests/runner/CMakeLists.txt create mode 100644 libs/plugin_python/tests/runner/plugins/dummy-diagnose.py create mode 100644 libs/plugin_python/tests/runner/plugins/dummy-filemapper.py create mode 100644 libs/plugin_python/tests/runner/plugins/dummy-game.py create mode 100644 libs/plugin_python/tests/runner/plugins/dummy-installer.py create mode 100644 libs/plugin_python/tests/runner/plugins/dummy-iplugin.py create mode 100644 libs/plugin_python/tests/runner/test_diagnose.cpp create mode 100644 libs/plugin_python/tests/runner/test_filemapper.cpp create mode 100644 libs/plugin_python/tests/runner/test_game.cpp create mode 100644 libs/plugin_python/tests/runner/test_installer.cpp create mode 100644 libs/plugin_python/tests/runner/test_iplugin.cpp create mode 100644 libs/plugin_python/tests/runner/test_lifetime.cpp (limited to 'libs/plugin_python/tests') 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 + +// filetree implementation for testing purpose +// +class DummyFileTree : public MOBase::IFileTree { +public: + DummyFileTree(std::shared_ptr parent, QString name) + : FileTreeEntry(parent, name), IFileTree() + { + } + +protected: + std::shared_ptr makeDirectory(std::shared_ptr parent, + QString name) const override + { + return std::make_shared(parent, name); + } + + bool doPopulate(std::shared_ptr, + std::vector>&) const override + { + return true; + } + + std::shared_ptr doClone() const override + { + return std::make_shared(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 +#include + +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 &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 &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, findFileInfos, (const QString &path, const std::function &filter), (const, override)); + MOCK_METHOD(std::shared_ptr, 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, profile, (), (const, override)); + MOCK_METHOD(QStringList, profileNames, (), (const, override)); + MOCK_METHOD(std::shared_ptr, 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 &func), (override)); + MOCK_METHOD(bool, onAboutToRun, (const std::function &func), (override)); + MOCK_METHOD(bool, onFinishedRun, (const std::function &func), (override)); + MOCK_METHOD(void, refresh, (bool saveChanges), (override)); + MOCK_METHOD(MOBase::IPluginGame const *, managedGame, (), (const, override)); + MOCK_METHOD(bool, onUserInterfaceInitialized, (const std::function &), (override)); + MOCK_METHOD(bool, onNextRefresh, (const std::function&, bool), (override)); + MOCK_METHOD(bool, onProfileCreated, (const std::function&), (override)); + MOCK_METHOD(bool, onProfileRemoved, (const std::function&), (override)); + MOCK_METHOD(bool, onProfileRenamed, (const std::function&), (override)); + MOCK_METHOD(bool, onProfileChanged, (const std::function &), (override)); + MOCK_METHOD(bool, onPluginSettingChanged, (const std::function &), (override)); + MOCK_METHOD(bool, onPluginEnabled, (const std::function&), (override)); + MOCK_METHOD(bool, onPluginEnabled, (const QString&, const std::function&), (override)); + MOCK_METHOD(bool, onPluginDisabled, (const std::function&), (override)); + MOCK_METHOD(bool, onPluginDisabled, (const QString&, const std::function&), (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 $) + +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}\\;$;\ +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 +#include + +#include + +namespace mo2::python::detail { + + template <> + struct smart_variant_converter { + static std::string from(int const& value) { return std::to_string(value); } + }; + + template <> + struct smart_variant_converter { + 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; + +template +auto wrap(Fn&& fn) +{ + return mo2::python::wrap_arguments(std::forward(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 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 +#include + +#include + +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_MODULE(functional, m) +{ + m.def("fn_0_arg", [](std::function const& fn) { + return fn(); + }); + + m.def("fn_1_arg", [](std::function const& fn, int a) { + return fn(a); + }); + + m.def("fn_2_arg", [](std::function const& fn, int a, int b) { + return fn(a, b); + }); + + m.def("fn_0_or_1_arg", [](std::function const& fn) { + return fn(); + }); + + m.def("fn_0_or_1_arg", [](std::function const& fn) { + return fn(1); + }); + + m.def("fn_1_or_2_or_3_arg", [](std::function const& fn) { + return fn(1); + }); + + m.def("fn_1_or_2_or_3_arg", [](std::function const& fn) { + return fn(1, 2); + }); + + m.def("fn_1_or_2_or_3_arg", [](std::function 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 +#include + +#include + +using namespace MOBase; + +PYBIND11_MODULE(guessed_string, m) +{ + m.def("get_value", [](GuessedValue const& value) { + return value.operator const QString&(); + }); + m.def("get_variants", [](GuessedValue const& value) { + return value.variants(); + }); + + m.def("set_from_callback", + [](GuessedValue& value, + std::function&)> 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*)> const& fn) { + GuessedValue 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 + +#include +#include + +#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(); + 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(-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 + +#include + +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 const& map) { + QMap 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_(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 + +#include +#include + +namespace py = pybind11; +using namespace pybind11::literals; + +QMap 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_> + pyCustomWidget(m, "CustomWidget"); + pyCustomWidget + .def(py::init(), "name"_a, "parent"_a = (QWidget*)nullptr) + .def("set_parent_cpp", [](CustomWidget* w) { + w->setParent(s_Parent); + }); + py::qt::add_qt_delegate(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 + +#include +#include + +namespace py = pybind11; +using namespace pybind11::literals; + +class Base; +static std::unordered_map 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_ptr; + + py::class_>(m, "Base") + .def(py::init()) + .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 { + return std::make_shared(name); + }); + m.def("create_and_store", [](std::string const& name) { + base_ptr = std::make_shared(name); + return base_ptr; + }); + m.def("store", [](std::shared_ptr 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}\\;$\\;${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 + +#include +#include + +#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(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(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(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 + +#include +#include + +#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(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(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(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 + +#include + +#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(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 + +#include "MockOrganizer.h" +#include "pythonrunner.h" + +#include + +#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(objects[0]); + EXPECT_NE(plugin, nullptr); + + // basic tests + EXPECT_EQ(plugin->priority(), 10); + EXPECT_EQ(plugin->isManualInstaller(), false); + + GuessedValue name{"default name"}; + QString version = "1.0.0"; + int nexus_id = 12; + + // invalid tree + { + std::shared_ptr invalid_tree = + std::make_shared(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 valid_tree = + std::make_shared(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 complex_tree = + std::make_shared(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 +#include + +#include + +#include "MockOrganizer.h" +#include "pythonrunner.h" + +#include + +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(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 + +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); + } +} -- cgit v1.3.1