diff --git a/.gitignore b/.gitignore index b582844..0c6ec0e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ !/docs !/include !/src +!/tests # Include files !/.gitignore !/CMakeLists.txt - +!/Makefile diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a530e6..86dffa8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,10 +3,52 @@ project(magic_scrolls) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +find_package(SQLite3 REQUIRED) + file(GLOB_RECURSE sources "${CMAKE_SOURCE_DIR}/src/*.cpp") add_executable(magic_scrolls ${sources}) target_include_directories(magic_scrolls PUBLIC "${CMAKE_SOURCE_DIR}/include") +target_link_libraries(magic_scrolls PRIVATE SQLite::SQLite3) +# ------- +enable_testing() + +add_executable(event_bus_test + tests/engine/event_bus_test.cpp + src/engine/core/event_bus.cpp +) +add_executable(db_provider_test + tests/engine/db_provider_test.cpp + src/engine/core/engine.cpp + src/engine/core/engine_db_provider.cpp +) +add_executable(engine_test + tests/engine/engine_test.cpp + src/engine/core/engine.cpp + src/engine/core/engine_db_provider.cpp +) + +target_include_directories(event_bus_test PUBLIC "${CMAKE_SOURCE_DIR}/include") +target_link_libraries(event_bus_test PRIVATE SQLite::SQLite3) + +target_include_directories(db_provider_test PUBLIC "${CMAKE_SOURCE_DIR}/include") +target_link_libraries(db_provider_test PRIVATE SQLite::SQLite3) + +target_include_directories(engine_test PUBLIC "${CMAKE_SOURCE_DIR}/include") +target_link_libraries(engine_test PRIVATE SQLite::SQLite3) + +add_test( + NAME "Event Bus" + COMMAND event_bus_test +) +add_test( + NAME "Database Provider" + COMMAND db_provider_test +) +add_test( + NAME "Engine" + COMMAND engine_test +) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..343456b --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.PHONY: build test + +build: + cmake -B build + cmake --build build + +test: + ctest --test-dir build --output-on-failure --progress diff --git a/include/engine/core/engine.hpp b/include/engine/core/engine.hpp index 5f7b102..e6b0284 100644 --- a/include/engine/core/engine.hpp +++ b/include/engine/core/engine.hpp @@ -1,17 +1,20 @@ #ifndef __MCS_ENGINE_HPP__ #define __MCS_ENGINE_HPP__ -#include "engine/core/engine_module.hpp" -#include "engine/core/engine_module_api.hpp" +#include +#include +#include #include #include #include /// Magic Srolls engine core -class MscEngine : public IMscEngineModuleAPI { +class MscEngine : public IMscEngineModuleAPI +{ private: /// Loaded modules list std::vector> _modules; + MscEventBus _bus; public: /// Actual version @@ -19,8 +22,11 @@ public: MscEngine(std::vector> modules); - UNIMPL getBus() const override; - UNIMPL getLoadedModules() const override; + MscEventBus &getBus() override; + std::unordered_map getLoadedModules() const override; + MscEngineModule &getModule(const std::string &name) override; + std::unique_ptr createDBProvider() override; + std::unique_ptr createDBProvider(const std::string &filepath) override; }; #endif // __MCS_ENGINE_HPP__ diff --git a/include/engine/core/engine_bus.hpp b/include/engine/core/engine_bus.hpp new file mode 100644 index 0000000..e3820bf --- /dev/null +++ b/include/engine/core/engine_bus.hpp @@ -0,0 +1,82 @@ +#ifndef __MSC_ENGINE_BUS_HPP__ +#define __MSC_ENGINE_BUS_HPP__ + +#include +#include +#include +#include +#include +#include + +/// @brief Event base class +class MscEvent +{ +public: + virtual ~MscEvent() = default; + + /// @brief Get event name + virtual std::string getName() const = 0; + + /// @brief Is current event is canceled + bool isCanceled() const + { + return canceled; + } + + /// @brief Cancel current event + void cancel() + { + canceled = true; + } + +private: + bool canceled = false; +}; + +/// @brief Engine event bus +class MscEventBus +{ +public: + /// @brief Event handler alias + using Listener = std::function; + + /// @brief Subscription identifier alias + using SubscriptionId = std::uint64_t; + + /// @brief Emit event + /// @param event event context + void publish(MscEvent &event); + + /// @brief Subscribe to specified event + /// @param name event name + /// @param listener event handler + /// @returns subscription identifier to make it possitble to remove listener + SubscriptionId subscribe(const std::string &name, Listener listener); + + /// @brief Subscribe to specified event and execute once on publish + /// @param name event name + /// @param listener event handler + SubscriptionId subscribeOnce(const std::string &name, Listener listener); + + /// @brief Unsubscribe listener by its own id + /// @param id subscription id + /// @return `true` if listener has been deleted, otherwise `false` + bool unsubscribe(SubscriptionId id); + +private: + using ListenerWithId = struct + { + SubscriptionId id; + Listener listener; + bool once; + }; + + std::unordered_map> _listeners; + std::unordered_map _listenersPaths; + std::uint64_t _lastId = 0; + std::mutex _listenersMutex; + + SubscriptionId _subscribe(const std::string &name, Listener listener, bool once); +}; + +#endif // __MSC_ENGINE_BUS_HPP__ diff --git a/include/engine/core/engine_db_provider.hpp b/include/engine/core/engine_db_provider.hpp new file mode 100644 index 0000000..a3a936d --- /dev/null +++ b/include/engine/core/engine_db_provider.hpp @@ -0,0 +1,30 @@ +#ifndef __MSC_ENGINE_DB_PROVIDER_HPP__ +#define __MSC_ENGINE_DB_PROVIDER_HPP__ + +#include +#include + +/// @brief Engine database provider +class MscEngineDBProvider +{ +public: + ~MscEngineDBProvider(); + + /// @brief Create inmemory database + MscEngineDBProvider(); + + /// @brief Create file database + explicit MscEngineDBProvider(const std::string &filepath); + + /// @brief Execute SQL statement + /// @param sql statement + void execute(const std::string &sql); + + MscEngineDBProvider(const MscEngineDBProvider &) = delete; + MscEngineDBProvider &operator=(const MscEngineDBProvider &) = delete; + +private: + sqlite3 *_db = nullptr; +}; + +#endif // __MSC_ENGINE_DB_PROVIDER_HPP__ diff --git a/include/engine/core/engine_module.hpp b/include/engine/core/engine_module.hpp index e421a58..19978a6 100644 --- a/include/engine/core/engine_module.hpp +++ b/include/engine/core/engine_module.hpp @@ -2,12 +2,14 @@ #define __MCS_ENGINE_MODULE_HPP__ #include "engine/utils/rev_range.hpp" +#include "engine/core/engine_module_api.hpp" #include #include #include /// Engine module base class -class MscEngineModule { +class MscEngineModule +{ public: virtual ~MscEngineModule() = default; @@ -22,7 +24,7 @@ public: getDependencies() const = 0; /// Module initialization phase - virtual void onInitialize(); + virtual void onInitialize(IMscEngineModuleAPI &engine) = 0; }; #endif // __MCS_ENGINE_MODULE_HPP__ diff --git a/include/engine/core/engine_module_api.hpp b/include/engine/core/engine_module_api.hpp index c5e9b39..616bd9b 100644 --- a/include/engine/core/engine_module_api.hpp +++ b/include/engine/core/engine_module_api.hpp @@ -1,17 +1,34 @@ #ifndef __MSC_ENGINE_MODULE_API_HPP__ #define __MSC_ENGINE_MODULE_API_HPP__ -typedef void UNIMPL; +#include +#include +#include +#include +#include +#include -class IMscEngineModuleAPI { +class MscEngineModule; + +class IMscEngineModuleAPI +{ public: virtual ~IMscEngineModuleAPI() = default; - /// Get access to engine event bus - virtual UNIMPL getBus() const = 0; + /// @brief Get access to engine event bus + virtual MscEventBus &getBus() = 0; - /// Get access to loaded module list - virtual UNIMPL getLoadedModules() const = 0; + /// @brief Get access to loaded module list + virtual std::unordered_map getLoadedModules() const = 0; + + /// @brief Access module by its name + virtual MscEngineModule &getModule(const std::string &name) = 0; + + /// @brief Create database provider with in-memory database + virtual std::unique_ptr createDBProvider() = 0; + + /// @brief Create database provider with file database + virtual std::unique_ptr createDBProvider(const std::string &filepath) = 0; }; #endif // __MSC_ENGINE_MODULE_API_HPP__ diff --git a/include/engine/modules/test1/test1.hpp b/include/engine/modules/test1/test1.hpp index 77411b8..444540f 100644 --- a/include/engine/modules/test1/test1.hpp +++ b/include/engine/modules/test1/test1.hpp @@ -2,9 +2,11 @@ #define __MSC_TEST_1_MODULE_HPP__ #include "engine/core/engine_module.hpp" +#include "engine/core/engine_module_api.hpp" #include -class Test1Module : public MscEngineModule { +class Test1Module : public MscEngineModule +{ public: ~Test1Module() = default; @@ -13,7 +15,7 @@ public: const std::unordered_map & getDependencies() const override; - void onInitialize() override; + void onInitialize(IMscEngineModuleAPI &engine) override; }; #endif // __MSC_TEST_1_MODULE_HPP__ diff --git a/include/engine/utils/rev_range.hpp b/include/engine/utils/rev_range.hpp index 8167f56..736ef09 100644 --- a/include/engine/utils/rev_range.hpp +++ b/include/engine/utils/rev_range.hpp @@ -2,12 +2,14 @@ #define __MSC_REV_RANGE_HPP__ #include -struct MscRevisionRange { +struct MscRevisionRange +{ uint16_t minRevision; uint16_t maxRevision; /// Check provided revision to be in range - bool isSatisfied(uint16_t rev) const { + bool isSatisfied(uint16_t rev) const + { return rev >= this->minRevision && rev <= this->maxRevision; } }; diff --git a/src/engine/core/engine.cpp b/src/engine/core/engine.cpp index 53ddee4..95f8ff6 100644 --- a/src/engine/core/engine.cpp +++ b/src/engine/core/engine.cpp @@ -8,33 +8,40 @@ uint16_t MscEngine::REV = 0; -MscEngine::MscEngine(std::vector> modules) { +MscEngine::MscEngine(std::vector> modules) +{ std::unordered_map registry; - for (const auto &module : modules) { + for (const auto &module : modules) + { if (!module) continue; auto [it, inserted] = registry.try_emplace(module->getName(), module->getRev()); - if (!inserted) { + if (!inserted) + { throw std::runtime_error("Duplicate module detected: " + module->getName()); } } - for (const auto &module : modules) { + for (const auto &module : modules) + { if (!module) continue; - for (const auto &[dep_name, range] : module->getDependencies()) { + for (const auto &[dep_name, range] : module->getDependencies()) + { auto it = registry.find(dep_name); - if (it == registry.end()) { + if (it == registry.end()) + { throw std::runtime_error("Module " + module->getName() + " requires missing module: " + dep_name); } int current_rev = it->second; - if (!range.isSatisfied(current_rev)) { + if (!range.isSatisfied(current_rev)) + { throw std::runtime_error( "Module " + module->getName() + " requires " + dep_name + " in range [" + std::to_string(range.minRevision) + " <-> " + @@ -47,8 +54,43 @@ MscEngine::MscEngine(std::vector> modules) { _modules = std::move(modules); } -UNIMPL MscEngine::getBus() const { throw std::runtime_error("Unimplemented"); } +MscEventBus &MscEngine::getBus() +{ + return _bus; +} -UNIMPL MscEngine::getLoadedModules() const { - throw std::runtime_error("Unimplemented"); +std::unordered_map MscEngine::getLoadedModules() const +{ + std::unordered_map result; + for (const auto &module : _modules) + { + if (!module) + continue; + + auto mod = module.get(); + result[mod->getName()] = mod->getRev(); + } + + return result; +} + +MscEngineModule &MscEngine::getModule(const std::string &name) +{ + for (auto &module : _modules) + { + if (module.get()->getName() == name) + return *module.get(); + } + + throw std::runtime_error("Module not found: " + name); +} + +std::unique_ptr MscEngine::createDBProvider() +{ + return std::make_unique(); +} + +std::unique_ptr MscEngine::createDBProvider(const std::string &filepath) +{ + return std::make_unique(filepath); } diff --git a/src/engine/core/engine_db_provider.cpp b/src/engine/core/engine_db_provider.cpp new file mode 100644 index 0000000..e95d292 --- /dev/null +++ b/src/engine/core/engine_db_provider.cpp @@ -0,0 +1,45 @@ +#include +#include + +MscEngineDBProvider::MscEngineDBProvider() : MscEngineDBProvider(":memory:") +{ +} + +MscEngineDBProvider::MscEngineDBProvider(const std::string &filepath) +{ + const int result = sqlite3_open(filepath.c_str(), &_db); + if (result != SQLITE_OK) + { + const std::string message = _db != nullptr + ? sqlite3_errmsg(_db) + : "unknown SQLite error"; + + if (_db != nullptr) + { + sqlite3_close(_db); + _db = nullptr; + } + + throw std::runtime_error("Failed to open SQLite database: " + message); + } +} + +MscEngineDBProvider::~MscEngineDBProvider() +{ + if (_db != nullptr) + sqlite3_close(_db); +} + +void MscEngineDBProvider::execute(const std::string &sql) +{ + char *errorMessage = nullptr; + const int result = sqlite3_exec(_db, sql.c_str(), nullptr, nullptr, &errorMessage); + + if (result == SQLITE_OK) + return; + + std::string message(errorMessage); + sqlite3_free(errorMessage); + + throw std::runtime_error("Failed to execute SQL: " + message); +} diff --git a/src/engine/core/event_bus.cpp b/src/engine/core/event_bus.cpp new file mode 100644 index 0000000..a7a8a52 --- /dev/null +++ b/src/engine/core/event_bus.cpp @@ -0,0 +1,80 @@ +#include +#include +#include + +void MscEventBus::publish(MscEvent &event) +{ + std::vector listeners; + + { + std::lock_guard lock(_listenersMutex); + auto it = _listeners.find(event.getName()); + if (it == _listeners.end()) + return; + + for (const auto &entry : it->second) + listeners.push_back(entry); + } + + for (const auto &entry : listeners) + { + if (event.isCanceled()) + break; + + entry.listener(event); + if (entry.once) + unsubscribe(entry.id); + } +} + +MscEventBus::SubscriptionId MscEventBus::_subscribe(const std::string &name, MscEventBus::Listener listener, bool once) +{ + std::lock_guard lock(_listenersMutex); + + const SubscriptionId id = ++_lastId; + auto listenerWithId = ListenerWithId{ + .id = id, + .listener = std::move(listener), + .once = once}; + + _listeners[name].push_back(std::move(listenerWithId)); + _listenersPaths[id] = name; + + return id; +} + +MscEventBus::SubscriptionId MscEventBus::subscribe(const std::string &name, Listener listener) +{ + return _subscribe(name, std::move(listener), false); +} + +MscEventBus::SubscriptionId MscEventBus::subscribeOnce(const std::string &name, Listener listener) +{ + return _subscribe(name, std::move(listener), true); +} + +bool MscEventBus::unsubscribe(SubscriptionId id) +{ + std::lock_guard lock(_listenersMutex); + + auto it = _listenersPaths.find(id); + if (it == _listenersPaths.end()) + return false; + + auto &listeners = _listeners.at(it->second); + for (std::size_t i = 0; i < listeners.size(); i++) + { + if (listeners.at(i).id != id) + continue; + + listeners.erase(listeners.begin() + i); + _listenersPaths.erase(id); + + if (listeners.empty()) + _listeners.erase(it->second); + + return true; + } + + return false; +} \ No newline at end of file diff --git a/src/engine/modules/test1/test1.hpp b/src/engine/modules/test1/test1.hpp index e071cf3..cac5322 100644 --- a/src/engine/modules/test1/test1.hpp +++ b/src/engine/modules/test1/test1.hpp @@ -1,4 +1,5 @@ #include "engine/modules/test1/test1.hpp" +#include "engine/core/engine_module_api.hpp" #include "engine/utils/rev_range.hpp" #include #include @@ -9,9 +10,13 @@ std::string Test1Module::getName() const { return "test1"; } uint16_t Test1Module::getRev() const { return 1; } const std::unordered_map & -Test1Module::getDependencies() const { +Test1Module::getDependencies() const +{ static const std::unordered_map deps{}; return deps; } -void Test1Module::onInitialize() {} +void Test1Module::onInitialize(IMscEngineModuleAPI &engine) +{ + (void)(engine); +} diff --git a/src/main.cpp b/src/main.cpp index 3156943..d4a84cc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,7 +5,8 @@ #include #include -int main(int argc, char **argv) { +int main(int argc, char **argv) +{ auto engine = std::make_unique(std::vector>{ std::make_shared()}); diff --git a/tests/engine/db_provider_test.cpp b/tests/engine/db_provider_test.cpp new file mode 100644 index 0000000..c085da1 --- /dev/null +++ b/tests/engine/db_provider_test.cpp @@ -0,0 +1,62 @@ +#include +#include +#include +#include +#include + +/// @brief Проверка на создание экземпляра +void testProviderCreation() +{ + MscEngine engine({}); + + auto dbp1 = engine.createDBProvider(); + assert(dbp1.get() != nullptr); +} + +/// @brief Проверка на создание экземпляра с некорректным путём файла +void testIncorrectFilePath() +{ + MscEngine engine({}); + + try + { + engine.createDBProvider("/i/am/an/incorrect/path/to/file.txt"); + assert(false); + } + catch (const std::runtime_error &error) + { + } +} + +/// @brief Проверка на успешное выполнение корректного выражения +void testValidStatement() +{ + MscEngine engine({}); + auto dbp = engine.createDBProvider(); + dbp.get()->execute("SELECT 'hello, world' AS message;"); +} + +void testInvalidStatement() +{ + MscEngine engine({}); + auto dbp = engine.createDBProvider(); + + try + { + dbp.get()->execute("SELECT * FROM nonexistentdatabase ABC"); + assert(false); + } + catch (const std::runtime_error &error) + { + } +} + +int main() +{ + testProviderCreation(); + testIncorrectFilePath(); + testValidStatement(); + testInvalidStatement(); + + return 0; +} diff --git a/tests/engine/engine_test.cpp b/tests/engine/engine_test.cpp new file mode 100644 index 0000000..8aadfc0 --- /dev/null +++ b/tests/engine/engine_test.cpp @@ -0,0 +1,149 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class FirstTestModuleR1 : public MscEngineModule +{ +public: + std::string getName() const override + { + return "first_tmod"; + }; + + std::uint16_t getRev() const override + { + return 1; + } + + const std::unordered_map &getDependencies() const override + { + static const std::unordered_map deps{}; + return deps; + } + + void onInitialize(IMscEngineModuleAPI &engine) override {} +}; + +class FirstTestModuleR2 : public MscEngineModule +{ +public: + std::string getName() const override + { + return "first_tmod"; + }; + + std::uint16_t getRev() const override + { + return 2; + } + + const std::unordered_map &getDependencies() const override + { + static const std::unordered_map deps{}; + return deps; + } + + void onInitialize(IMscEngineModuleAPI &engine) override {} +}; + +class SecondTestModuleE1 : public MscEngineModule +{ +public: + std::string getName() const override + { + return "second_tmod"; + } + + std::uint16_t getRev() const override + { + return 1; + } + + const std::unordered_map &getDependencies() const override + { + static const std::unordered_map deps{ + {"first_tmod", MscRevisionRange{1, 1}}}; + return deps; + } + + void onInitialize(IMscEngineModuleAPI &engine) override {} +}; + +class SecondTestModuleE2 : public MscEngineModule +{ +public: + std::string getName() const override + { + return "second_tmod"; + } + + std::uint16_t getRev() const override + { + return 1; + } + + const std::unordered_map &getDependencies() const override + { + static const std::unordered_map deps{ + {"first_tmod", MscRevisionRange{1, 2}}}; + return deps; + } + + void onInitialize(IMscEngineModuleAPI &engine) override {} +}; + +void testSatisfiedModules() +{ + MscEngine engine({std::make_shared(), + std::make_shared()}); +} + +void testDuplicatedModules() +{ + try + { + MscEngine engine({ + std::make_shared(), + std::make_shared(), + }); + assert(false); + } + catch (const std::runtime_error &error) + { + } +} + +void testUnsatisfiedModules() +{ + try + { + MscEngine engine({std::make_shared(), + std::make_shared()}); + assert(false); + } + catch (const std::runtime_error &error) + { + } +} + +void testSatisfiedMultiversionModule() +{ + MscEngine engine({std::make_shared(), + std::make_shared()}); +} + +int main() +{ + testSatisfiedModules(); + testDuplicatedModules(); + testUnsatisfiedModules(); + testSatisfiedMultiversionModule(); + + return 0; +} diff --git a/tests/engine/event_bus_test.cpp b/tests/engine/event_bus_test.cpp new file mode 100644 index 0000000..ec0fec0 --- /dev/null +++ b/tests/engine/event_bus_test.cpp @@ -0,0 +1,76 @@ +#include +#include + +const std::string testEventName = "TestEvent"; +int calls = 0; + +class TestEvent : public MscEvent +{ +public: + std::string getName() const override + { + return testEventName; + } +}; + +/// @brief Проверка на работу pub-sub +void testEventPubSub() +{ + calls = 0; + MscEventBus bus; + TestEvent event; + + bus.subscribe(testEventName, [&](MscEvent &ev) + { + calls++; + + auto *testEvent = dynamic_cast(&ev); + assert(testEvent != nullptr); }); + + bus.publish(event); + assert(calls == 1); +} + +/// @brief Проверка на работу +void testEventOnce() +{ + calls = 0; + MscEventBus bus; + TestEvent event; + + bus.subscribeOnce(testEventName, [&](MscEvent &) + { calls++; }); + + bus.publish(event); + bus.publish(event); + assert(calls == 1); +} + +/// @brief Проверка работы отписки +void testUnsubscribe() +{ + calls = 0; + MscEventBus bus; + TestEvent event; + + auto id = bus.subscribe(testEventName, [&](MscEvent &) + { calls++; }); + + bus.publish(event); + + assert(bus.unsubscribe(id) == true); + assert(bus.unsubscribe(id) == false); + + bus.publish(event); + + assert(calls == 1); +} + +int main() +{ + testEventPubSub(); + testEventOnce(); + testUnsubscribe(); + + return 0; +}