// Copyright (c) 2025 YAMS Contributors // SPDX-License-Identifier: GPL-3.0-or-later // // Unit tests for PluginManager component (PBI-090) // // Catch2 migration from GTest (yams-3s4 / yams-zns) #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 static int setenv(const char* name, const char* value, int /*overwrite*/) { return _putenv_s(name, value); } static void unsetenv(const char* name) { _putenv_s(name, ""); } #endif using namespace yams::daemon; namespace { struct PluginManagerFixture { std::filesystem::path tempDir; std::unique_ptr lifecycleFsm; std::unique_ptr stateComponent; DaemonConfig config; PluginManagerFixture() { tempDir = std::filesystem::temp_directory_path() / ("yams_pluginmgr_test_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); std::filesystem::create_directories(tempDir); lifecycleFsm = std::make_unique(); stateComponent = std::make_unique(); } ~PluginManagerFixture() { lifecycleFsm.reset(); stateComponent.reset(); std::error_code ec; std::filesystem::remove_all(tempDir, ec); } PluginManager::Dependencies makeDeps() { PluginManager::Dependencies deps; deps.config = &config; deps.state = stateComponent.get(); deps.lifecycleFsm = lifecycleFsm.get(); deps.dataDir = tempDir; deps.resolvePreferredModel = []() { return std::string("test-model"); }; return deps; } }; } // namespace TEST_CASE_METHOD(PluginManagerFixture, "PluginManager construction", "[daemon][components][plugin][catch2]") { auto deps = makeDeps(); SECTION("construction succeeds with valid dependencies") { PluginManager mgr(deps); CHECK(mgr.getName() == std::string("PluginManager")); } } TEST_CASE_METHOD(PluginManagerFixture, "PluginManager initialize/shutdown lifecycle", "[daemon][components][plugin][catch2]") { auto deps = makeDeps(); PluginManager mgr(deps); SECTION("initialize without plugins succeeds") { auto result = mgr.initialize(); CHECK(result.has_value()); } SECTION("shutdown is safe without initialize") { mgr.shutdown(); // No crash = success } SECTION("initialize then shutdown works") { REQUIRE(mgr.initialize().has_value()); mgr.shutdown(); // No crash = success } SECTION("double shutdown is safe") { REQUIRE(mgr.initialize().has_value()); mgr.shutdown(); mgr.shutdown(); // No crash = success } } TEST_CASE_METHOD(PluginManagerFixture, "PluginManager plugin host accessors", "[daemon][components][plugin][catch2]") { auto deps = makeDeps(); PluginManager mgr(deps); SECTION("plugin host is nullptr before init") { // May or may not be null depending on implementation auto host = mgr.getPluginHost(); // Just verify accessor doesn't crash (void)host; } SECTION("plugin loader is nullptr before init") { auto loader = mgr.getPluginLoader(); (void)loader; } SECTION("external plugin host is nullptr before init") { auto ext = mgr.getExternalPluginHost(); (void)ext; } } TEST_CASE_METHOD(PluginManagerFixture, "PluginManager trust list operations", "[daemon][components][plugin][catch2]") { auto deps = makeDeps(); PluginManager mgr(deps); SECTION("initial trust list may be empty") { auto list = mgr.trustList(); // Empty or contains defaults - both valid (void)list; } SECTION("trustAdd for valid path returns result") { auto result = mgr.trustAdd(tempDir / "plugin.so"); // Either success or failure is acceptable without init (void)result; } SECTION("trustRemove for non-existent path returns result") { auto result = mgr.trustRemove(tempDir / "nonexistent.so"); (void)result; } } TEST_CASE("PluginManager getName returns component name", "[daemon][components][plugin][catch2]") { DaemonLifecycleFsm fsm; StateComponent state; DaemonConfig config; PluginManager::Dependencies deps; deps.config = &config; deps.state = &state; deps.lifecycleFsm = &fsm; deps.resolvePreferredModel = []() { return std::string("test"); }; PluginManager mgr(deps); CHECK(std::string(mgr.getName()) == "PluginManager"); } namespace { struct ScopedEmbedBackend { std::string savedBackend; bool hadBackend{false}; std::string savedPreferred; bool hadPreferred{false}; explicit ScopedEmbedBackend(const char* backend) { if (const char* p = std::getenv("YAMS_EMBED_BACKEND")) { savedBackend = p; hadBackend = true; } if (const char* p = std::getenv("YAMS_PREFERRED_MODEL")) { savedPreferred = p; hadPreferred = true; } setenv("YAMS_EMBED_BACKEND", backend, 1); unsetenv("YAMS_PREFERRED_MODEL"); } ~ScopedEmbedBackend() { if (hadBackend) { setenv("YAMS_EMBED_BACKEND", savedBackend.c_str(), 1); } else { unsetenv("YAMS_EMBED_BACKEND"); } if (hadPreferred) { setenv("YAMS_PREFERRED_MODEL", savedPreferred.c_str(), 1); } else { unsetenv("YAMS_PREFERRED_MODEL"); } } }; class TaggedInProcessProvider : public IModelProvider { public: explicit TaggedInProcessProvider(std::string tag) : tag_(std::move(tag)) {} yams::Result> generateEmbedding(const std::string&) override { return std::vector{}; } yams::Result>> generateBatchEmbeddings(const std::vector&) override { return std::vector>{}; } yams::Result> generateEmbeddingFor(const std::string&, const std::string&) override { return std::vector{}; } yams::Result>> generateBatchEmbeddingsFor(const std::string&, const std::vector&) override { return std::vector>{}; } yams::Result loadModel(const std::string&) override { return {}; } yams::Result unloadModel(const std::string&) override { return {}; } bool isModelLoaded(const std::string&) const override { return false; } std::vector getLoadedModels() const override { return {}; } size_t getLoadedModelCount() const override { return 0; } yams::Result getModelInfo(const std::string&) const override { return yams::daemon::ModelInfo{}; } size_t getEmbeddingDim(const std::string&) const override { return 384; } std::shared_ptr getEmbeddingGenerator(const std::string&) override { return nullptr; } std::string getProviderName() const override { return tag_; } std::string getProviderVersion() const override { return "0.0.0-test"; } bool isAvailable() const override { return true; } bool isTrainingFree() const override { return true; } size_t getMemoryUsage() const override { return 0; } void releaseUnusedResources() override {} void shutdown() override {} private: std::string tag_; }; std::string makeUniqueProviderName(const char* prefix) { static std::atomic counter{0}; return std::string(prefix) + "_" + std::to_string(counter.fetch_add(1, std::memory_order_relaxed)); } } // namespace TEST_CASE_METHOD(PluginManagerFixture, "adoptModelProvider: prefers in-process backend when selected via env", "[daemon][components][plugin][catch2][simeon]") { const std::string backendName = makeUniqueProviderName("pm_test_backend"); const std::string expectedTag = backendName; ModelProviderFactoryRegistration reg{ backendName, [expectedTag]() -> std::unique_ptr { return std::make_unique(expectedTag); }}; ScopedEmbedBackend guard{backendName.c_str()}; auto deps = makeDeps(); PluginManager mgr(deps); REQUIRE(mgr.initialize().has_value()); auto result = mgr.adoptModelProvider(); REQUIRE(result.has_value()); REQUIRE(result.value()); auto provider = mgr.getModelProvider(); REQUIRE(provider != nullptr); CHECK(provider->getProviderName() == expectedTag); CHECK(provider->isTrainingFree()); CHECK(mgr.getEmbeddingModelName() == expectedTag); } TEST_CASE_METHOD( PluginManagerFixture, "adoptModelProvider: empty preferredName and backend=auto does not force in-process", "[daemon][components][plugin][catch2][simeon]") { ScopedEmbedBackend guard{"auto"}; auto deps = makeDeps(); PluginManager mgr(deps); REQUIRE(mgr.initialize().has_value()); auto result = mgr.adoptModelProvider(); REQUIRE(result.has_value()); CHECK_FALSE(result.value()); CHECK(mgr.getModelProvider() == nullptr); }