From a3e2273f12389e65a3d5d3b30aaaab51411400b0 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 1 Jul 2026 09:12:49 +0200 Subject: [PATCH 1/5] feat: create LSLReader and ConfigParser --- README.md | 25 ++-- cmake/Coverage.cmake | 4 +- cmake/Dependencies.cmake | 14 ++- include/config/ChannelConfig.hpp | 14 +++ include/config/ConfigParser.hpp | 16 +++ include/config/ExperimentConfig.hpp | 32 +++++ include/config/LSLConfig.hpp | 17 +++ include/lslreader/LSLReader.hpp | 33 ++++++ src/CMakeLists.txt | 12 +- src/config/CMakeLists.txt | 9 ++ src/config/ConfigParser.cpp | 173 ++++++++++++++++++++++++++++ src/lslreader/CMakeLists.txt | 14 +++ src/lslreader/LSLReader.cpp | 93 +++++++++++++++ 13 files changed, 436 insertions(+), 20 deletions(-) create mode 100644 include/config/ChannelConfig.hpp create mode 100644 include/config/ConfigParser.hpp create mode 100644 include/config/ExperimentConfig.hpp create mode 100644 include/config/LSLConfig.hpp create mode 100644 include/lslreader/LSLReader.hpp create mode 100644 src/config/CMakeLists.txt create mode 100644 src/config/ConfigParser.cpp create mode 100644 src/lslreader/CMakeLists.txt create mode 100644 src/lslreader/LSLReader.cpp diff --git a/README.md b/README.md index 88c9546..5d1b6be 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ EEG samples and the stimulus markers must share a **common clock domain** so tha | Language | C++20 | | Build system | CMake (≥ 3.25), Ninja-friendly | | Experiment file format | Protocol Buffers (proto3) — see `protoFiles/neuronide.proto` | +| Device config format | JSON (`config.json`) parsed with [nlohmann/json](https://github.com/nlohmann/json) | | EEG acquisition | [LSL — Lab Streaming Layer](https://github.com/sccn/liblsl) (`liblsl`) | | Rendering / windowing | SDL2 (+ SDL2_image), with vsync | | Inter-thread queues | [moodycamel ConcurrentQueue](https://github.com/cameron314/concurrentqueue) (lock-free) | @@ -44,8 +45,9 @@ EEG samples and the stimulus markers must share a **common clock domain** so tha | Testing | GoogleTest + CTest | | Tooling | clang-format, clang-tidy, gcovr (coverage) | -`liblsl`, `concurrentqueue`, and `googletest` are fetched automatically by CMake -(`FetchContent`). SDL2 and Protobuf are expected to be installed on the system. +`liblsl`, `concurrentqueue`, `nlohmann/json`, and `googletest` are fetched +automatically by CMake (`FetchContent`). SDL2 and Protobuf are expected to be +installed on the system. ## 3. Architecture @@ -198,9 +200,10 @@ stream rather than letting an exception terminate the process. | `ComponentRegistry` | Implemented | proto-type → factory, macro-based self-registration | | `specifiic components` | **Planned** | defined in `neuronide.proto`, not yet implemented in C++ | | `Renderer` | Implemented | SDL + vsync, marker timestamping | -| `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4) | +| `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4); driven by `LSLConfig` | +| `ConfigParser` | Implemented | `config.json` → `ExperimentConfig` (incl. `LSLConfig`), nlohmann/json | | `DataWriter` | Implemented | strategy-based; `CSVFormatStrategy` | -| `Runtime` orchestration | **Stub** | `Runtime::start()` currently only prints; wiring of Parser + the three threads is the next integration step | +| `Runtime` orchestration | **Stub** | parses `config.json` and starts `LSLReader`; wiring of Parser + the remaining threads is the next integration step | The class diagram in older docs is partly aspirational; the table above reflects the actual code. @@ -211,12 +214,14 @@ the actual code. Neuron-IDE-runtime/ # the C++ runtime (git repo) ├── README.md # this file, code context ├── CMakeLists.txt # top-level: deps, warnings, static analysis, coverage + ├── config.json # example device config (LSL stream, channels, montage) ├── cmake/ # Dependencies / CompilerWarnings / StaticAnalysis / Coverage ├── protoFiles/ │ ├── neuronide.proto # experiment file schema │ └── tests/ # .pbtxt fixtures + compiled .pb ├── include/ # public headers, mirrored by src/ │ ├── data_structures/ # EEGData, Marker, Context + │ ├── config/ # ConfigParser + ExperimentConfig / LSLConfig / ChannelConfig │ ├── parser/ # Parser │ ├── scene/ # Scene, SceneObject, components/ │ ├── renderer/ # Renderer @@ -249,9 +254,12 @@ sudo apt install cmake clang-format clang-tidy libsdl2-dev protobuf-compiler gco ```bash cmake -B build cmake --build build -./build/src/NeuronIDE # run the (currently stub) executable +./build/src/NeuronIDE config.json # parses the device config, starts LSLReader (stub run) ``` +`NeuronIDE` takes the path to a device `config.json` (defaults to `config.json` in +the working directory). + ### Tests ```bash @@ -300,10 +308,3 @@ protoc --encode=NeuronIDE.Scene protoFiles/neuronide.proto \ `feat(parser): create Parser class`. - **Types:** `feat`, `fix`, `style` (clang config), `test`, `ci` (`.github`). -## 10. Roadmap (next steps) - -1. Implement `Runtime` orchestration: `Parser → Scene`, then run `Renderer`, - `LSLReader`, and `DataWriter` concurrently and shut them down cleanly. -2. Implement the remaining components: `SpriteRenderer`, `TextRenderer`, - `ScriptComponent` (pybind11), each self-registering with `ComponentRegistry`. -3. Validate `LSLReader` end-to-end against a real EEG headset. diff --git a/cmake/Coverage.cmake b/cmake/Coverage.cmake index 4219d95..a510cd1 100644 --- a/cmake/Coverage.cmake +++ b/cmake/Coverage.cmake @@ -30,7 +30,9 @@ if(NEURON_IDE_ENABLE_COVERAGE) --exclude ".*protoFiles.*" --exclude ".*pb.*" --exclude ".*\\.hpp" - --fail-under-line 60 + --exclude-throw-branches + --exclude-unreachable-branches + --fail-under-line 90 --print-summary --html-details ${COVERAGE_DIR}/index.html --xml ${COVERAGE_DIR}/coverage.xml diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 8b0b7c8..d9f2c02 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -27,6 +27,14 @@ FetchContent_Declare( SYSTEM ) +# 4. nlohmann/json (header-only) - device config parsing +FetchContent_Declare( + nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz + SYSTEM +) +set(JSON_BuildTests OFF CACHE INTERNAL "") + # Suppress compiler warnings from third-party targets when compiling their source files set(BACKUP_C_FLAGS "${CMAKE_C_FLAGS}") set(BACKUP_CXX_FLAGS "${CMAKE_CXX_FLAGS}") @@ -38,14 +46,14 @@ elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /w") endif() -FetchContent_MakeAvailable(googletest liblsl concurrentqueue) +FetchContent_MakeAvailable(googletest liblsl concurrentqueue nlohmann_json) # Restore compiler flags for our own project code set(CMAKE_C_FLAGS "${BACKUP_C_FLAGS}") set(CMAKE_CXX_FLAGS "${BACKUP_CXX_FLAGS}") -# 4. SDL2 (System installed) +# 5. SDL2 (System installed) find_package(SDL2 REQUIRED) -# 5. Protobuf (System installed) +# 6. Protobuf (System installed) find_package(Protobuf REQUIRED) diff --git a/include/config/ChannelConfig.hpp b/include/config/ChannelConfig.hpp new file mode 100644 index 0000000..a888a55 --- /dev/null +++ b/include/config/ChannelConfig.hpp @@ -0,0 +1,14 @@ +#ifndef CHANNELCONFIG_HPP +#define CHANNELCONFIG_HPP + +#include + +// Description of a single EEG channel as declared in the device config file. +struct ChannelConfig { + int index = 0; + std::string label; + bool enabled = true; + std::string unit; +}; + +#endif // CHANNELCONFIG_HPP diff --git a/include/config/ConfigParser.hpp b/include/config/ConfigParser.hpp new file mode 100644 index 0000000..94173ef --- /dev/null +++ b/include/config/ConfigParser.hpp @@ -0,0 +1,16 @@ +#ifndef CONFIGPARSER_HPP +#define CONFIGPARSER_HPP + +#include +#include +#include + +class ConfigParser { + public: + ConfigParser() = default; + + static ExperimentConfig parse(const std::string& filePath); + static ExperimentConfig parseStream(std::istream& stream); +}; + +#endif // CONFIGPARSER_HPP diff --git a/include/config/ExperimentConfig.hpp b/include/config/ExperimentConfig.hpp new file mode 100644 index 0000000..0d32e6e --- /dev/null +++ b/include/config/ExperimentConfig.hpp @@ -0,0 +1,32 @@ +#ifndef EXPERIMENTCONFIG_HPP +#define EXPERIMENTCONFIG_HPP + +#include +#include + +struct ReferenceConfig { + std::string label; + std::string scheme; +}; + +struct GroundConfig { + std::string label; +}; + +struct ImpedanceConfig { + bool supported = false; + double thresholdKohm = 0.0; +}; + +struct ExperimentConfig { + std::string configVersion; + std::string deviceName; + std::string montageStandard; + LSLConfig lsl; + ReferenceConfig reference; + GroundConfig ground; + ImpedanceConfig impedance; + // TODO: DataWriterConfig writer; // EEG output file format strategy +}; + +#endif // EXPERIMENTCONFIG_HPP diff --git a/include/config/LSLConfig.hpp b/include/config/LSLConfig.hpp new file mode 100644 index 0000000..8d05725 --- /dev/null +++ b/include/config/LSLConfig.hpp @@ -0,0 +1,17 @@ +#ifndef LSLCONFIG_HPP +#define LSLCONFIG_HPP + +#include +#include +#include + +struct LSLConfig { + std::string name; // lsl_stream.name + std::string type; // lsl_stream.type + std::string sourceId; // lsl_stream.source_id + int expectedChannelCount = 0; + double expectedSampleRateHz = 0.0; + std::vector channels; +}; + +#endif // LSLCONFIG_HPP diff --git a/include/lslreader/LSLReader.hpp b/include/lslreader/LSLReader.hpp new file mode 100644 index 0000000..fc65925 --- /dev/null +++ b/include/lslreader/LSLReader.hpp @@ -0,0 +1,33 @@ +#ifndef LSLREADER_HPP +#define LSLREADER_HPP + +#include + +#include +#include +#include + +struct EEGData; + +class LSLReader { + public: + explicit LSLReader(LSLConfig config); + ~LSLReader(); + + LSLReader(const LSLReader&) = delete; + LSLReader& operator=(const LSLReader&) = delete; + LSLReader(LSLReader&&) = delete; + LSLReader& operator=(LSLReader&&) = delete; + + void start(std::shared_ptr> eegQueue); + void stop(); + + private: + void readLoop(const std::stop_token& stopToken); + + LSLConfig config; + std::shared_ptr> eegQueue; + std::jthread readerThread; +}; + +#endif // LSLREADER_HPP diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9d0e94c..c134744 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -7,17 +7,21 @@ target_link_libraries(neuronide_proto PUBLIC protobuf::libprotobuf) add_subdirectory(scene) add_subdirectory(parser) +add_subdirectory(config) add_subdirectory(renderer) +add_subdirectory(lslreader) add_subdirectory(datawriter) add_library(runtime_core STATIC Runtime.cpp ) target_include_directories(runtime_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../include) -target_link_libraries(runtime_core PUBLIC - scene - parser - renderer +target_link_libraries(runtime_core PUBLIC + scene + parser + config + renderer + lslreader datawriter ) diff --git a/src/config/CMakeLists.txt b/src/config/CMakeLists.txt new file mode 100644 index 0000000..e6fb0b6 --- /dev/null +++ b/src/config/CMakeLists.txt @@ -0,0 +1,9 @@ +add_library(config OBJECT + ConfigParser.cpp +) + +target_include_directories(config PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../include/config +) + +target_link_libraries(config PRIVATE nlohmann_json::nlohmann_json) diff --git a/src/config/ConfigParser.cpp b/src/config/ConfigParser.cpp new file mode 100644 index 0000000..a0a22b2 --- /dev/null +++ b/src/config/ConfigParser.cpp @@ -0,0 +1,173 @@ +#include "config/ConfigParser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using nlohmann::json; + +const json* requireMember(const json& obj, const char* key, std::string_view ctx) { + const auto member = obj.find(key); + if (member == obj.end()) { + throw std::invalid_argument("ConfigParser: missing '" + std::string(key) + "' in " + + std::string(ctx)); + } + return &(*member); +} + +template +T requireField(const json& obj, const char* key, std::string_view ctx) { + return requireMember(obj, key, ctx)->get(); +} + +void requireNonEmpty(const std::string& value, const char* field, std::string_view ctx) { + if (value.empty()) { + throw std::invalid_argument("ConfigParser: '" + std::string(field) + + "' must not be empty in " + std::string(ctx)); + } +} + + +std::vector buildChannels(const json& root, int expectedCount) { + const json& channelsJson = *requireMember(root, "channels", "config root"); + if (!channelsJson.is_array()) { + throw std::invalid_argument("ConfigParser: 'channels' must be an array"); + } + if (static_cast(channelsJson.size()) != expectedCount) { + throw std::invalid_argument("ConfigParser: channel count mismatch: 'channels' has " + + std::to_string(channelsJson.size()) + + " entries but expected_channel_count is " + + std::to_string(expectedCount)); + } + + std::vector channels; + channels.reserve(channelsJson.size()); + std::unordered_set seenIndices; + + for (const auto& entry : channelsJson) { + ChannelConfig channel; + channel.index = requireField(entry, "index", "channel"); + channel.label = requireField(entry, "label", "channel"); + channel.enabled = requireField(entry, "enabled", "channel"); + channel.unit = requireField(entry, "unit", "channel"); + + if (channel.index < 0 || channel.index >= expectedCount) { + throw std::invalid_argument("ConfigParser: channel index out of range: " + + std::to_string(channel.index)); + } + if (!seenIndices.insert(channel.index).second) { + throw std::invalid_argument("ConfigParser: duplicate channel index: " + + std::to_string(channel.index)); + } + + channels.push_back(std::move(channel)); + } + + return channels; +} + +LSLConfig buildLSLConfig(const json& root) { + const json& streamJson = *requireMember(root, "lsl_stream", "config root"); + + LSLConfig lsl; + lsl.name = requireField(streamJson, "name", "lsl_stream"); + lsl.type = requireField(streamJson, "type", "lsl_stream"); + lsl.sourceId = requireField(streamJson, "source_id", "lsl_stream"); + lsl.expectedChannelCount = + requireField(streamJson, "expected_channel_count", "lsl_stream"); + lsl.expectedSampleRateHz = + requireField(streamJson, "expected_sample_rate_hz", "lsl_stream"); + + requireNonEmpty(lsl.name, "name", "lsl_stream"); + requireNonEmpty(lsl.type, "type", "lsl_stream"); + requireNonEmpty(lsl.sourceId, "source_id", "lsl_stream"); + if (lsl.expectedChannelCount <= 0) { + throw std::invalid_argument("ConfigParser: 'expected_channel_count' must be positive"); + } + if (lsl.expectedSampleRateHz <= 0.0) { + throw std::invalid_argument("ConfigParser: 'expected_sample_rate_hz' must be positive"); + } + + lsl.channels = buildChannels(root, lsl.expectedChannelCount); + return lsl; +} + +ReferenceConfig buildReference(const json& root) { + ReferenceConfig reference; + if (root.contains("reference")) { + const json& ref = root.at("reference"); + reference.label = requireField(ref, "label", "reference"); + reference.scheme = requireField(ref, "scheme", "reference"); + } + return reference; +} + +GroundConfig buildGround(const json& root) { + GroundConfig ground; + if (root.contains("ground")) { + ground.label = requireField(root.at("ground"), "label", "ground"); + } + return ground; +} + +ImpedanceConfig buildImpedance(const json& root) { + ImpedanceConfig impedance; + if (root.contains("impedance_check")) { + const json& imp = root.at("impedance_check"); + impedance.supported = requireField(imp, "supported", "impedance_check"); + impedance.thresholdKohm = requireField(imp, "threshold_kohm", "impedance_check"); + } + return impedance; +} +} // namespace + +ExperimentConfig ConfigParser::parse(const std::string& filePath) { + std::ifstream file(filePath); + if (!file.is_open()) { + throw std::runtime_error("ConfigParser: cannot open file: " + filePath); + } + + try { + return parseStream(file); + } catch (const std::exception& e) { + throw std::runtime_error("ConfigParser: failed to parse file " + filePath + " - " + + e.what()); + } +} + +ExperimentConfig ConfigParser::parseStream(std::istream& stream) { + json root; + try { + root = json::parse(stream); + } catch (const json::parse_error& e) { + throw std::runtime_error(std::string("ConfigParser: invalid JSON: ") + e.what()); + } + + if (!root.is_object()) { + throw std::invalid_argument("ConfigParser: config root must be a JSON object"); + } + + try { + ExperimentConfig config; + config.configVersion = requireField(root, "config_version", "config root"); + config.deviceName = requireField(root, "device_name", "config root"); + config.montageStandard = requireField(root, "montage_standard", "config root"); + + requireNonEmpty(config.deviceName, "device_name", "config root"); + + config.lsl = buildLSLConfig(root); + config.reference = buildReference(root); + config.ground = buildGround(root); + config.impedance = buildImpedance(root); + + return config; + } catch (const json::type_error& e) { + throw std::invalid_argument(std::string("ConfigParser: field has wrong type: ") + e.what()); + } +} diff --git a/src/lslreader/CMakeLists.txt b/src/lslreader/CMakeLists.txt new file mode 100644 index 0000000..0f88626 --- /dev/null +++ b/src/lslreader/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(lslreader OBJECT + LSLReader.cpp +) + +target_include_directories(lslreader PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../include/lslreader + ${CMAKE_CURRENT_SOURCE_DIR}/../../include/data_structures +) + +target_link_libraries(lslreader PUBLIC + lsl + concurrentqueue + config +) diff --git a/src/lslreader/LSLReader.cpp b/src/lslreader/LSLReader.cpp new file mode 100644 index 0000000..6ef90af --- /dev/null +++ b/src/lslreader/LSLReader.cpp @@ -0,0 +1,93 @@ +#include "lslreader/LSLReader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "lsl_cpp.h" + +namespace { +constexpr double kResolveTimeout = 1.0; // seconds per resolve attempt +constexpr double kPullTimeout = 0.2; // seconds; bounds stop-token check latency +constexpr int kInletBufferSeconds = 360; // liblsl default inlet buffer length +constexpr double kSampleRateTolerance = 0.5; // Hz + +void validateStream(const lsl::stream_info& info, const LSLConfig& config) { + if (info.channel_count() != config.expectedChannelCount) { + throw std::runtime_error("LSLReader: stream '" + config.name + "' exposes " + + std::to_string(info.channel_count()) + + " channels but config expects " + + std::to_string(config.expectedChannelCount)); + } + + const double srate = info.nominal_srate(); + if (std::abs(srate - config.expectedSampleRateHz) > kSampleRateTolerance) { + throw std::runtime_error("LSLReader: stream '" + config.name + "' reports " + + std::to_string(srate) + " Hz but config expects " + + std::to_string(config.expectedSampleRateHz) + " Hz"); + } +} + +std::optional resolveStream(const LSLConfig& config, + const std::stop_token& stopToken) { + while (!stopToken.stop_requested()) { + std::vector results = + lsl::resolve_stream("name", config.name, 1, kResolveTimeout); + + if (!results.empty()) { + validateStream(results.front(), config); + return results.front(); + } + } + return std::nullopt; +} +} // namespace + +LSLReader::LSLReader(LSLConfig config) : config(std::move(config)) {} + +LSLReader::~LSLReader() { stop(); } + +void LSLReader::start(std::shared_ptr> eegQueue) { + stop(); + + this->eegQueue = std::move(eegQueue); + readerThread = std::jthread([this](const std::stop_token& stopToken) { + try { + readLoop(stopToken); + } catch (const std::exception& e) { + std::cerr << "LSLReader: fatal error, stopping acquisition: " << e.what() << "\n"; + } + }); +} + +void LSLReader::stop() { + if (readerThread.joinable()) { + readerThread.request_stop(); + } + if (readerThread.joinable()) { + readerThread.join(); + } +} + +void LSLReader::readLoop(const std::stop_token& stopToken) { + const std::optional info = resolveStream(config, stopToken); + if (!info.has_value()) { + return; + } + + lsl::stream_inlet inlet(*info, kInletBufferSeconds); + inlet.set_postprocessing(lsl::post_clocksync | lsl::post_dejitter | lsl::post_monotonize); + + while (!stopToken.stop_requested()) { + std::vector sample; + const double timestamp = inlet.pull_sample(sample, kPullTimeout); + if (timestamp != 0.0) { + eegQueue->enqueue(EEGData{timestamp, std::move(sample)}); + } + } +} From 061fde176b4bf31d6751f97f3468cc5b38f5a08a Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 1 Jul 2026 09:13:58 +0200 Subject: [PATCH 2/5] tests: add LSLReader tests and ConfigParser tests --- .../component_tests/dummy_component_test.cpp | 3 - tests/unit_tests/RendererTest.cpp | 130 ++++--- tests/unit_tests/config_parser_test.cpp | 318 ++++++++++++++++++ tests/unit_tests/dummy_unit_test.cpp | 3 - tests/unit_tests/lslreader_test.cpp | 177 ++++++++++ 5 files changed, 585 insertions(+), 46 deletions(-) delete mode 100644 tests/component_tests/dummy_component_test.cpp create mode 100644 tests/unit_tests/config_parser_test.cpp delete mode 100644 tests/unit_tests/dummy_unit_test.cpp create mode 100644 tests/unit_tests/lslreader_test.cpp diff --git a/tests/component_tests/dummy_component_test.cpp b/tests/component_tests/dummy_component_test.cpp deleted file mode 100644 index 233d9dd..0000000 --- a/tests/component_tests/dummy_component_test.cpp +++ /dev/null @@ -1,3 +0,0 @@ -#include - -TEST(DummyComponentTest, AlwaysPasses) { EXPECT_TRUE(true); } diff --git a/tests/unit_tests/RendererTest.cpp b/tests/unit_tests/RendererTest.cpp index 88da249..6f552c3 100644 --- a/tests/unit_tests/RendererTest.cpp +++ b/tests/unit_tests/RendererTest.cpp @@ -1,8 +1,10 @@ #include #include +#include #include #include +#include #include "data_structures/Context.hpp" #include "data_structures/Marker.hpp" @@ -16,6 +18,7 @@ constexpr int kDummySurfaceWidth = 10; constexpr int kDummySurfaceHeight = 10; constexpr int kDummySurfaceDepth = 32; constexpr uint32_t kDummySurfaceFlags = 0; +constexpr auto kRenderSpinWait = std::chrono::milliseconds(30); class CustomComponent : public Component { public: @@ -65,11 +68,29 @@ class MarkerComponent : public Component { std::shared_ptr stopSource; }; +class RendererTest : public ::testing::Test { + protected: + void SetUp() override { ASSERT_EQ(SDL_Init(SDL_INIT_EVENTS), 0); } + void TearDown() override { SDL_Quit(); } + + static std::shared_ptr makeSoftwareRenderer() { + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat( + kDummySurfaceFlags, kDummySurfaceWidth, kDummySurfaceHeight, kDummySurfaceDepth, + SDL_PIXELFORMAT_RGBA32); + SDL_Renderer* sdlRenderer = SDL_CreateSoftwareRenderer(surface); + return std::shared_ptr(sdlRenderer, [surface](SDL_Renderer* renderer) { + if (renderer != nullptr) { + SDL_DestroyRenderer(renderer); + } + if (surface != nullptr) { + SDL_FreeSurface(surface); + } + }); + } +}; } // namespace -TEST(RendererTest, RenderLoop_WhenComponentAdded_CallsUpdateExactlyOnceBeforeStop) { - ASSERT_EQ(SDL_Init(SDL_INIT_EVENTS), 0); - +TEST_F(RendererTest, RenderLoop_WhenComponentAdded_CallsUpdateExactlyOnceBeforeStop) { auto scene = std::make_shared(); auto obj = std::make_shared("obj"); @@ -80,34 +101,16 @@ TEST(RendererTest, RenderLoop_WhenComponentAdded_CallsUpdateExactlyOnceBeforeSto obj->addComponent(std::make_unique(obj, updates, renders, stop_source)); scene->addObject(obj); - SDL_Surface* surface = - SDL_CreateRGBSurfaceWithFormat(kDummySurfaceFlags, kDummySurfaceWidth, kDummySurfaceHeight, - kDummySurfaceDepth, SDL_PIXELFORMAT_RGBA32); - SDL_Renderer* sdlRenderer = SDL_CreateSoftwareRenderer(surface); - auto sharedRenderer = - std::shared_ptr(sdlRenderer, [surface](SDL_Renderer* renderer) { - if (renderer) { - SDL_DestroyRenderer(renderer); - } - if (surface) { - SDL_FreeSurface(surface); - } - }); - auto markerQueue = std::make_shared>(); - Renderer renderer(scene, sharedRenderer, markerQueue); + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); renderer.render(stop_source->get_token()); EXPECT_EQ(*updates, 1); EXPECT_EQ(*renders, 1); - - SDL_Quit(); } -TEST(RendererTest, RenderLoop_QueuesMarkersFromComponents) { - ASSERT_EQ(SDL_Init(SDL_INIT_EVENTS), 0); - +TEST_F(RendererTest, RenderLoop_QueuesMarkersFromComponents) { auto scene = std::make_shared(); auto obj = std::make_shared("obj"); auto stop_source = std::make_shared(); @@ -115,23 +118,9 @@ TEST(RendererTest, RenderLoop_QueuesMarkersFromComponents) { obj->addComponent(std::make_unique(obj, stop_source)); scene->addObject(obj); - SDL_Surface* surface = - SDL_CreateRGBSurfaceWithFormat(kDummySurfaceFlags, kDummySurfaceWidth, kDummySurfaceHeight, - kDummySurfaceDepth, SDL_PIXELFORMAT_RGBA32); - SDL_Renderer* sdlRenderer = SDL_CreateSoftwareRenderer(surface); - auto sharedRenderer = - std::shared_ptr(sdlRenderer, [surface](SDL_Renderer* renderer) { - if (renderer) { - SDL_DestroyRenderer(renderer); - } - if (surface) { - SDL_FreeSurface(surface); - } - }); - auto markerQueue = std::make_shared>(); - Renderer renderer(scene, sharedRenderer, markerQueue); + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); renderer.render(stop_source->get_token()); Marker marker; @@ -141,6 +130,67 @@ TEST(RendererTest, RenderLoop_QueuesMarkersFromComponents) { EXPECT_EQ(marker.eventName, "test_marker"); EXPECT_FALSE(markerQueue->try_dequeue(marker)); } +} + +TEST_F(RendererTest, RenderLoop_OnQuitEvent_ReturnsBeforeUpdating) { + auto scene = std::make_shared(); + auto obj = std::make_shared("obj"); + auto updates = std::make_shared>(0); + auto renders = std::make_shared>(0); + auto stop_source = std::make_shared(); + + obj->addComponent(std::make_unique(obj, updates, renders, stop_source)); + scene->addObject(obj); + + auto markerQueue = std::make_shared>(); + + SDL_Event quit; + quit.type = SDL_QUIT; + ASSERT_EQ(SDL_PushEvent(&quit), 1); + + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); + renderer.render(stop_source->get_token()); + + EXPECT_EQ(*updates, 0); + EXPECT_EQ(*renders, 0); +} - SDL_Quit(); -} \ No newline at end of file +TEST_F(RendererTest, RenderLoop_OnNonQuitEvent_ContinuesUpdating) { + auto scene = std::make_shared(); + auto obj = std::make_shared("obj"); + auto updates = std::make_shared>(0); + auto renders = std::make_shared>(0); + auto stop_source = std::make_shared(); + + obj->addComponent(std::make_unique(obj, updates, renders, stop_source)); + scene->addObject(obj); + + auto markerQueue = std::make_shared>(); + + SDL_Event userEvent; + userEvent.type = SDL_USEREVENT; + ASSERT_EQ(SDL_PushEvent(&userEvent), 1); + + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); + renderer.render(stop_source->get_token()); + + EXPECT_EQ(*updates, 1); + EXPECT_EQ(*renders, 1); +} + +TEST_F(RendererTest, RenderLoop_WhenSceneExpired_KeepsRunningWithoutCrashing) { + auto scene = std::make_shared(); + auto markerQueue = std::make_shared>(); + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); + + scene.reset(); + + std::stop_source stopSource; + std::thread worker([&renderer, &stopSource]() { renderer.render(stopSource.get_token()); }); + std::this_thread::sleep_for(kRenderSpinWait); + stopSource.request_stop(); + worker.join(); + + Marker marker; + EXPECT_FALSE(markerQueue->try_dequeue(marker)); +} diff --git a/tests/unit_tests/config_parser_test.cpp b/tests/unit_tests/config_parser_test.cpp new file mode 100644 index 0000000..638ecc5 --- /dev/null +++ b/tests/unit_tests/config_parser_test.cpp @@ -0,0 +1,318 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace fs = std::filesystem; + +constexpr int kExpectedChannelCount = 8; +constexpr double kExpectedSampleRate = 250.0; +constexpr double kImpedanceThreshold = 5.0; +constexpr double kDefaultImpedanceThreshold = 0.0; + +constexpr const char* kSampleConfig = R"json({ + "config_version": "1.0", + "device_name": "OpenBCI Cyton 8ch", + "montage_standard": "10-20", + "lsl_stream": { + "name": "obci_eeg1", + "type": "EEG", + "source_id": "cyton-a1b2c3", + "expected_channel_count": 8, + "expected_sample_rate_hz": 250 + }, + "reference": { "label": "linked_mastoids", "scheme": "physical" }, + "ground": { "label": "Fpz" }, + "channels": [ + { "index": 0, "label": "Fz", "enabled": true, "unit": "microvolts" }, + { "index": 1, "label": "Cz", "enabled": true, "unit": "microvolts" }, + { "index": 2, "label": "Pz", "enabled": true, "unit": "microvolts" }, + { "index": 3, "label": "Oz", "enabled": true, "unit": "microvolts" }, + { "index": 4, "label": "P3", "enabled": true, "unit": "microvolts" }, + { "index": 5, "label": "P4", "enabled": true, "unit": "microvolts" }, + { "index": 6, "label": "O1", "enabled": true, "unit": "microvolts" }, + { "index": 7, "label": "O2", "enabled": false, "unit": "microvolts" } + ], + "impedance_check": { "supported": true, "threshold_kohm": 5.0 } +})json"; + + +constexpr const char* kMinimalConfig = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] +})json"; + +ExperimentConfig parseString(const std::string& jsonText) { + std::istringstream stream(jsonText); + return ConfigParser::parseStream(stream); +} + +fs::path writeTempConfig(const std::string& content) { + const auto suffix = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + const fs::path path = fs::temp_directory_path() / ("neuronide_config_" + suffix + ".json"); + std::ofstream out(path); + out << content; + return path; +} +} // namespace + +TEST(ConfigParserTest, ParsesTopLevelMetadata) { + const ExperimentConfig config = parseString(kSampleConfig); + EXPECT_EQ(config.configVersion, "1.0"); + EXPECT_EQ(config.deviceName, "OpenBCI Cyton 8ch"); + EXPECT_EQ(config.montageStandard, "10-20"); +} + +TEST(ConfigParserTest, ParsesLslStreamFields) { + const ExperimentConfig config = parseString(kSampleConfig); + EXPECT_EQ(config.lsl.name, "obci_eeg1"); + EXPECT_EQ(config.lsl.type, "EEG"); + EXPECT_EQ(config.lsl.sourceId, "cyton-a1b2c3"); + EXPECT_EQ(config.lsl.expectedChannelCount, kExpectedChannelCount); + EXPECT_DOUBLE_EQ(config.lsl.expectedSampleRateHz, kExpectedSampleRate); +} + +TEST(ConfigParserTest, ParsesAllChannelsIncludingDisabled) { + const ExperimentConfig config = parseString(kSampleConfig); + ASSERT_EQ(config.lsl.channels.size(), static_cast(kExpectedChannelCount)); + + const auto& first = config.lsl.channels.front(); + EXPECT_EQ(first.index, 0); + EXPECT_EQ(first.label, "Fz"); + EXPECT_TRUE(first.enabled); + EXPECT_EQ(first.unit, "microvolts"); + + const auto& last = config.lsl.channels.back(); + EXPECT_EQ(last.label, "O2"); + EXPECT_FALSE(last.enabled); +} + +TEST(ConfigParserTest, ParsesReferenceGroundAndImpedance) { + const ExperimentConfig config = parseString(kSampleConfig); + EXPECT_EQ(config.reference.label, "linked_mastoids"); + EXPECT_EQ(config.reference.scheme, "physical"); + EXPECT_EQ(config.ground.label, "Fpz"); + EXPECT_TRUE(config.impedance.supported); + EXPECT_DOUBLE_EQ(config.impedance.thresholdKohm, kImpedanceThreshold); +} + +TEST(ConfigParserTest, MissingLslStreamThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "channels": [] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, EmptyStreamNameThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, ChannelCountMismatchThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 2, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, MalformedJsonThrows) { + EXPECT_THROW(parseString("{ this is not json"), std::runtime_error); +} + +TEST(ConfigParserTest, NonObjectRootThrows) { + EXPECT_THROW(parseString("[1, 2, 3]"), std::invalid_argument); +} + +TEST(ConfigParserTest, WrongFieldTypeThrows) { + EXPECT_THROW(parseString(R"json({ "config_version": "1.0", "device_name": 123 })json"), + std::invalid_argument); +} + +TEST(ConfigParserTest, EmptyStreamTypeThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, EmptySourceIdThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, NonPositiveChannelCountThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 0, "expected_sample_rate_hz": 250 + }, + "channels": [] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, NonPositiveSampleRateThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 0 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, ChannelsNotArrayThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": 5 + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, ChannelIndexOutOfRangeThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 5, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, NegativeChannelIndexThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": -1, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, DuplicateChannelIndexThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 2, "expected_sample_rate_hz": 250 + }, + "channels": [ + { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" }, + { "index": 0, "label": "Cz", "enabled": true, "unit": "uV" } + ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, MalformedReferenceThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "reference": { "label": 5, "scheme": "physical" } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, MalformedGroundThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "ground": { "label": 5 } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, OptionalSectionsDefaultWhenAbsent) { + const ExperimentConfig config = parseString(kMinimalConfig); + EXPECT_TRUE(config.reference.label.empty()); + EXPECT_TRUE(config.reference.scheme.empty()); + EXPECT_TRUE(config.ground.label.empty()); + EXPECT_FALSE(config.impedance.supported); + EXPECT_DOUBLE_EQ(config.impedance.thresholdKohm, kDefaultImpedanceThreshold); +} + +TEST(ConfigParserTest, ParsesFromFilePath) { + const fs::path path = writeTempConfig(kSampleConfig); + + const ExperimentConfig config = ConfigParser::parse(path.string()); + EXPECT_EQ(config.deviceName, "OpenBCI Cyton 8ch"); + EXPECT_EQ(config.lsl.name, "obci_eeg1"); + + fs::remove(path); +} + +TEST(ConfigParserTest, MissingFileThrows) { + EXPECT_THROW(ConfigParser::parse("/no/such/neuronide_config_file.json"), std::runtime_error); +} + +TEST(ConfigParserTest, InvalidFileContentThrows) { + const fs::path path = writeTempConfig("{ this is not json"); + + EXPECT_THROW(ConfigParser::parse(path.string()), std::runtime_error); + + fs::remove(path); +} diff --git a/tests/unit_tests/dummy_unit_test.cpp b/tests/unit_tests/dummy_unit_test.cpp deleted file mode 100644 index b7a05f3..0000000 --- a/tests/unit_tests/dummy_unit_test.cpp +++ /dev/null @@ -1,3 +0,0 @@ -#include - -TEST(DummyUnitTest, AlwaysPasses) { EXPECT_TRUE(true); } diff --git a/tests/unit_tests/lslreader_test.cpp b/tests/unit_tests/lslreader_test.cpp new file mode 100644 index 0000000..297ca1e --- /dev/null +++ b/tests/unit_tests/lslreader_test.cpp @@ -0,0 +1,177 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "lsl_cpp.h" + +namespace { +constexpr int kChannelCount = 4; +constexpr double kSampleRate = 250.0; +constexpr int kSamplesToPush = 20; +constexpr int kMismatchedChannels = kChannelCount + 1; +constexpr double kMismatchedSampleRate = 100.0; +constexpr auto kSubscribeWait = std::chrono::seconds(3); +constexpr auto kPushInterval = std::chrono::milliseconds(10); +constexpr auto kDrainWait = std::chrono::milliseconds(300); +constexpr auto kSubscribePoll = std::chrono::milliseconds(20); + +constexpr auto kValidationWait = std::chrono::milliseconds(1500); + +class ScopedStreamRedirect { + public: + ScopedStreamRedirect(std::ostream& stream, std::streambuf* buffer) + : stream(stream), previous(stream.rdbuf(buffer)) {} + ~ScopedStreamRedirect() { stream.rdbuf(previous); } + + ScopedStreamRedirect(const ScopedStreamRedirect&) = delete; + ScopedStreamRedirect& operator=(const ScopedStreamRedirect&) = delete; + ScopedStreamRedirect(ScopedStreamRedirect&&) = delete; + ScopedStreamRedirect& operator=(ScopedStreamRedirect&&) = delete; + + private: + std::ostream& stream; + std::streambuf* previous; +}; + +LSLConfig makeConfig() { + LSLConfig config; + config.name = "neuronide_test_stream"; + config.type = "EEG"; + config.sourceId = "neuronide-test-src"; + config.expectedChannelCount = kChannelCount; + config.expectedSampleRateHz = kSampleRate; + return config; +} + +std::vector makeSample() { + std::vector sample(kChannelCount); + for (int i = 0; i < kChannelCount; ++i) { + sample[i] = static_cast(i + 1); + } + return sample; +} + +lsl::stream_outlet makeOutletWithShape(const LSLConfig& config, int channelCount, + double sampleRate) { + const lsl::stream_info info(config.name, config.type, channelCount, sampleRate, + lsl::cf_double64, config.sourceId); + return lsl::stream_outlet(info); +} + +lsl::stream_outlet makeOutlet(const LSLConfig& config) { + return makeOutletWithShape(config, config.expectedChannelCount, config.expectedSampleRateHz); +} + +// Waits (bounded) for the reader's inlet to subscribe to the outlet. +bool waitForConsumer(lsl::stream_outlet& outlet) { + const auto deadline = std::chrono::steady_clock::now() + kSubscribeWait; + while (!outlet.have_consumers()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + std::this_thread::sleep_for(kSubscribePoll); + } + return true; +} + +void pushSamples(lsl::stream_outlet& outlet, const std::vector& sample, int count) { + for (int i = 0; i < count; ++i) { + outlet.push_sample(sample); + std::this_thread::sleep_for(kPushInterval); + } +} + +std::string runAndCaptureDiagnostics(const LSLConfig& config, int channelCount, double sampleRate) { + lsl::stream_outlet outlet = makeOutletWithShape(config, channelCount, sampleRate); + auto eegQueue = std::make_shared>(); + LSLReader reader(config); + + std::ostringstream captured; + { + const ScopedStreamRedirect redirect(std::cerr, captured.rdbuf()); + reader.start(eegQueue); + std::this_thread::sleep_for(kValidationWait); + reader.stop(); + } + + EEGData received; + EXPECT_FALSE(eegQueue->try_dequeue(received)) << "a rejected stream must yield no samples"; + return captured.str(); +} +} // namespace + +TEST(LSLReaderTest, ReadsSamplesFromStreamIntoQueue) { + const LSLConfig config = makeConfig(); + lsl::stream_outlet outlet = makeOutlet(config); + + auto eegQueue = std::make_shared>(); + + LSLReader reader(config); + reader.start(eegQueue); + + ASSERT_TRUE(waitForConsumer(outlet)) << "LSLReader did not subscribe (needs loopback)"; + + const std::vector sample = makeSample(); + pushSamples(outlet, sample, kSamplesToPush); + + std::this_thread::sleep_for(kDrainWait); + reader.stop(); + + EEGData received; + ASSERT_TRUE(eegQueue->try_dequeue(received)); + EXPECT_EQ(received.channels.size(), static_cast(kChannelCount)); + EXPECT_DOUBLE_EQ(received.channels.front(), sample.front()); + EXPECT_DOUBLE_EQ(received.channels.back(), sample.back()); + EXPECT_NE(received.timestamp, 0.0); +} + +TEST(LSLReaderTest, RejectsStreamWithMismatchedChannelCount) { + LSLConfig config = makeConfig(); + config.name = "neuronide_test_chan_mismatch"; + config.sourceId = "neuronide-test-chan"; + + const std::string log = runAndCaptureDiagnostics(config, kMismatchedChannels, kSampleRate); + EXPECT_NE(log.find("channels"), std::string::npos) + << "expected a channel-count rejection, got: " << log; +} + +TEST(LSLReaderTest, RejectsStreamWithMismatchedSampleRate) { + LSLConfig config = makeConfig(); + config.name = "neuronide_test_rate_mismatch"; + config.sourceId = "neuronide-test-rate"; + + const std::string log = runAndCaptureDiagnostics(config, kChannelCount, kMismatchedSampleRate); + EXPECT_NE(log.find("Hz"), std::string::npos) + << "expected a sample-rate rejection, got: " << log; +} + +TEST(LSLReaderTest, StopBeforeStreamResolvedExitsCleanly) { + LSLConfig config = makeConfig(); + config.name = "neuronide_test_absent_stream"; + config.sourceId = "neuronide-test-absent"; + + auto eegQueue = std::make_shared>(); + LSLReader reader(config); + + reader.start(eegQueue); + reader.stop(); + + EEGData received; + EXPECT_FALSE(eegQueue->try_dequeue(received)); +} + +TEST(LSLReaderTest, DestroyingUnstartedReaderIsSafe) { + EXPECT_NO_THROW({ const LSLReader reader(makeConfig()); }); +} From 550486e028d31d3b36ac9c493d2c2cb4ba0934dc Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 1 Jul 2026 09:27:11 +0200 Subject: [PATCH 3/5] fix: format code --- tests/unit_tests/config_parser_test.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/config_parser_test.cpp b/tests/unit_tests/config_parser_test.cpp index 638ecc5..f2110eb 100644 --- a/tests/unit_tests/config_parser_test.cpp +++ b/tests/unit_tests/config_parser_test.cpp @@ -44,7 +44,6 @@ constexpr const char* kSampleConfig = R"json({ "impedance_check": { "supported": true, "threshold_kohm": 5.0 } })json"; - constexpr const char* kMinimalConfig = R"json({ "config_version": "1.0", "device_name": "Dev", From d8127791cf107d731d419e83bdccb2e5d024543e Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 1 Jul 2026 09:30:44 +0200 Subject: [PATCH 4/5] fix: format code --- src/config/ConfigParser.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config/ConfigParser.cpp b/src/config/ConfigParser.cpp index a0a22b2..50c2ba3 100644 --- a/src/config/ConfigParser.cpp +++ b/src/config/ConfigParser.cpp @@ -33,7 +33,6 @@ void requireNonEmpty(const std::string& value, const char* field, std::string_vi } } - std::vector buildChannels(const json& root, int expectedCount) { const json& channelsJson = *requireMember(root, "channels", "config root"); if (!channelsJson.is_array()) { From a4cbee149bc058ccc8905ac67d4d498ff8c50021 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 1 Jul 2026 12:47:28 +0200 Subject: [PATCH 5/5] docs: update readme --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d1b6be..d78780b 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ stream rather than letting an exception terminate the process. | `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4); driven by `LSLConfig` | | `ConfigParser` | Implemented | `config.json` → `ExperimentConfig` (incl. `LSLConfig`), nlohmann/json | | `DataWriter` | Implemented | strategy-based; `CSVFormatStrategy` | -| `Runtime` orchestration | **Stub** | parses `config.json` and starts `LSLReader`; wiring of Parser + the remaining threads is the next integration step | +| `Runtime` orchestration | **Stub** | currently does nothing | The class diagram in older docs is partly aspirational; the table above reflects the actual code. @@ -214,7 +214,6 @@ the actual code. Neuron-IDE-runtime/ # the C++ runtime (git repo) ├── README.md # this file, code context ├── CMakeLists.txt # top-level: deps, warnings, static analysis, coverage - ├── config.json # example device config (LSL stream, channels, montage) ├── cmake/ # Dependencies / CompilerWarnings / StaticAnalysis / Coverage ├── protoFiles/ │ ├── neuronide.proto # experiment file schema @@ -254,7 +253,7 @@ sudo apt install cmake clang-format clang-tidy libsdl2-dev protobuf-compiler gco ```bash cmake -B build cmake --build build -./build/src/NeuronIDE config.json # parses the device config, starts LSLReader (stub run) +./build/src/NeuronIDE config.json experiment.neuroz # parses the device config, parses scene, starts LSLReader, DataWriter, Renderer. ``` `NeuronIDE` takes the path to a device `config.json` (defaults to `config.json` in