From a3e2273f12389e65a3d5d3b30aaaab51411400b0 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 1 Jul 2026 09:12:49 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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 From 03a8dd60f8b9e38764fcd62f0ee80e15e85671b0 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 4 Aug 2026 18:48:28 +0200 Subject: [PATCH 6/7] feat: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename ExperimentConfig to DeviceConfig: it describes the acquisition hardware, not the experiment, which lives in the protobuf files - move `channels` to the top level of DeviceConfig so the struct mirrors config.json 1:1, like every other field already does - LSLReader: create the inlet with liblsl's recover flag off and catch lsl::lost_error to re-resolve and re-validate a dropped stream, as README §6 already claimed. With recover on, lost_error is never thrown, so the documented behaviour could not have held - LSLReader: take a DeviceConfig and forward only the channels the config enables, in declaration order; reject a config that enables none - ConfigParser: validate config_version (MAJOR.MINOR) before any other field, rejecting an unsupported major and unparseable versions - README: document the protobuf/JSON config boundary and the schema versioning rules (§5) Note: I decided to leave all static classes as they are, and created a seperate issue to refactor them into namespaces --- README.md | 110 +++++++++++++-- include/config/ConfigParser.hpp | 10 +- include/config/ConfigVersion.hpp | 21 +++ include/config/DeviceConfig.hpp | 39 +++++ include/config/ExperimentConfig.hpp | 32 ----- include/config/LSLConfig.hpp | 16 +-- include/lslreader/LSLReader.hpp | 17 ++- src/config/ConfigParser.cpp | 63 ++++++++- src/lslreader/LSLReader.cpp | 105 ++++++++++++-- tests/unit_tests/config_parser_test.cpp | 87 ++++++++++-- tests/unit_tests/lslreader_test.cpp | 180 ++++++++++++++++++++---- 11 files changed, 563 insertions(+), 117 deletions(-) create mode 100644 include/config/ConfigVersion.hpp create mode 100644 include/config/DeviceConfig.hpp delete mode 100644 include/config/ExperimentConfig.hpp diff --git a/README.md b/README.md index d78780b..c6bea4f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ flowchart LR `lsl::local_clock()` right after `SDL_RenderPresent` and pushes `Marker`s onto `markerQueue`. - **LSLReader** resolves and subscribes to the EEG LSL stream and continuously - pushes `EEGData` samples onto `eegQueue`. + pushes `EEGData` samples onto `eegQueue`. It forwards **only the channels the + device config enables** (see [§5 Configuration boundary](#5-configuration-boundary-protobuf-vs-json)), + in config declaration order. - **DataWriter** drains both queues and writes them to disk via a pluggable formatting strategy (currently CSV). @@ -143,7 +145,7 @@ the parser does not need to know about concrete component classes. ```cpp struct EEGData { // one EEG sample double timestamp; // in the local_clock() domain (see §4) - std::vector channels; + std::vector channels; // enabled channels only, in config order (see §5) }; struct Marker { // one experiment event @@ -171,7 +173,88 @@ are already mapped into the local `lsl::local_clock()` domain — the same clock Renderer uses. This is the single most important correctness property of the data path. -## 5. Thread lifecycle conventions +## 5. Configuration boundary: protobuf vs JSON + +The runtime is fed by **two** config inputs and they are not interchangeable. +The split is deliberate and should be respected when adding new settings, +otherwise the same experiment stops being portable between labs. + +> **The rule:** the **protobuf experiment file** describes *what the experiment +> does*; the **device `config.json`** describes *what the hardware is*. +> A new field belongs in protobuf if changing it changes the experiment's meaning +> for analysis, and in JSON if it only changes how this particular machine +> acquires or stores the data. + +| Goes in the protobuf experiment file (`.neuroz`) | Goes in the device config (`config.json`) | +| --------------------------------------------------------- | ------------------------------------------------------------------ | +| Experiment name, scene objects, transforms, visibility | Device name, montage standard (`10-20`, …) | +| Components and their parameters (e.g. blink frequency) | LSL stream identity: `name`, `type`, `source_id` | +| Stimulus timing, trial structure, marker/event names | Expected stream shape: channel count, sample rate | +| Anything the editor authors and versions with the study | Channel table: index, label, enabled, unit | +| | Reference / ground electrodes, impedance check thresholds | +| | *(planned)* output file format for `DataWriter` | + +Consequences of the split: + +- The same experiment file runs on a different cap by swapping only + `config.json` — no re-export from the editor. +- The runtime can validate the incoming LSL stream (channel count, sample rate) + **before** the experiment starts, because expectations are declared per device. +- Electrode-level knowledge (which channel is `Oz`, which are enabled) lives in + one place, so `LSLReader` and, later, `DataWriter` agree on channel order. + +The JSON is parsed **1:1** into `DeviceConfig`: every JSON key maps onto exactly +one field, and nesting in the file is the nesting in the struct. Keep it that +way — `channels` is a top-level key, so it is a top-level `DeviceConfig` field +(not tucked under `lsl`), even though `LSLReader` is its main consumer. + +```jsonc +{ + "config_version": "1.0", // "MAJOR.MINOR", checked first (see below) + "device_name": "OpenBCI Cyton 8ch", + "montage_standard": "10-20", + "lsl_stream": { // -> DeviceConfig::lsl (LSLConfig) + "name": "obci_eeg1", + "type": "EEG", + "source_id": "cyton-a1b2c3", + "expected_channel_count": 8, // must equal channels.size() + "expected_sample_rate_hz": 250 + }, + "reference": { "label": "linked_mastoids", "scheme": "physical" }, + "ground": { "label": "Fpz" }, + "channels": [ // -> DeviceConfig::channels + { "index": 0, "label": "Fz", "enabled": true, "unit": "microvolts" }, + { "index": 1, "label": "Oz", "enabled": false, "unit": "microvolts" } + // ... one entry per expected_channel_count, indices unique and in range + ], + "impedance_check": { "supported": true, "threshold_kohm": 5.0 } +} +``` + +`config_version`, `device_name`, `montage_standard`, `lsl_stream` and `channels` +are required; `reference`, `ground` and `impedance_check` default when absent. +Channels with `"enabled": false` stay in the config (they document the cap) but +are **not** acquired: `LSLReader` drops them from every sample. + +### Schema versioning + +`config_version` is `"MAJOR.MINOR"` and is the **first** thing `ConfigParser` +validates — on an unsupported schema every later complaint would be a misleading +"missing field" message instead of "your config is newer than this runtime". + +- **MAJOR** — breaking change: a field moved, was renamed, or changed meaning. + A runtime rejects any major other than `ConfigParser::kSupportedConfigMajor`. +- **MINOR** — additive, backward-compatible change: new optional keys. Any minor + of the supported major is accepted, and unknown keys are ignored, so a `1.7` + file still runs on a runtime that only knows `1.0`. +- A version that cannot be compared (`"1"`, `"v1"`, `"1.2.3"`, empty) is rejected + rather than assumed — an unparseable version is worse than none. + +Bump MINOR when adding optional keys, MAJOR when moving or renaming any existing +one, and raise `kSupportedConfigMajor` in the same commit that lands the breaking +parser change. + +## 6. Thread lifecycle conventions Threads use C++20 `std::jthread` + `std::stop_token` for cooperative cancellation. Two ownership patterns are in use: @@ -188,9 +271,14 @@ Two ownership patterns are in use: `start()` returns immediately instead of blocking the runtime while waiting for the cap), uses a **blocking pull with a finite timeout** (no busy-wait, low latency, periodic stop-token checks), and catches `lsl::lost_error` to re-resolve a dropped -stream rather than letting an exception terminate the process. +stream rather than letting an exception terminate the process. Recovery is +deliberately *ours*: the inlet is created with liblsl's `recover` flag **off**, so +a lost cap surfaces as `lsl::lost_error` instead of being silently reconnected, and +the re-resolved stream is re-validated (channel count, sample rate) before +acquisition continues. Config errors (mismatched stream shape, no enabled channels) +stay fatal — they are logged and the worker exits instead of retrying forever. -## 6. Implementation status +## 7. Implementation status | Area / class | Status | Notes | | --------------------------- | ------------- | ------------------------------------------------------------ | @@ -200,15 +288,15 @@ 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); driven by `LSLConfig` | -| `ConfigParser` | Implemented | `config.json` → `ExperimentConfig` (incl. `LSLConfig`), nlohmann/json | +| `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4); driven by `DeviceConfig`, enabled channels only, re-resolves lost streams | +| `ConfigParser` | Implemented | `config.json` → `DeviceConfig` (1:1 mapping, major-version checked, see §5), nlohmann/json | | `DataWriter` | Implemented | strategy-based; `CSVFormatStrategy` | | `Runtime` orchestration | **Stub** | currently does nothing | The class diagram in older docs is partly aspirational; the table above reflects the actual code. -## 7. Repository layout +## 8. Repository layout ``` Neuron-IDE-runtime/ # the C++ runtime (git repo) @@ -220,7 +308,7 @@ Neuron-IDE-runtime/ # the C++ runtime (git repo) │ └── tests/ # .pbtxt fixtures + compiled .pb ├── include/ # public headers, mirrored by src/ │ ├── data_structures/ # EEGData, Marker, Context - │ ├── config/ # ConfigParser + ExperimentConfig / LSLConfig / ChannelConfig + │ ├── config/ # ConfigParser + DeviceConfig / LSLConfig / ChannelConfig / ConfigVersion │ ├── parser/ # Parser │ ├── scene/ # Scene, SceneObject, components/ │ ├── renderer/ # Renderer @@ -236,7 +324,7 @@ Neuron-IDE-runtime/ # the C++ runtime (git repo) Each `src//` builds a static library; `runtime_core` links them together and the `NeuronIDE` executable links `runtime_core`. -## 8. Build, test, and tooling +## 9. Build, test, and tooling All commands are run from the `Neuron-IDE-runtime/` directory. @@ -300,7 +388,7 @@ protoc --encode=NeuronIDE.Scene protoFiles/neuronide.proto \ < protoFiles/tests/test_scene.pbtxt > protoFiles/tests/test_scene.pb ``` -## 9. Contribution conventions +## 10. Contribution conventions - **Branches:** `/`, e.g. `feat/setup-project`. - **Commits:** `(optional scope): description`, e.g. diff --git a/include/config/ConfigParser.hpp b/include/config/ConfigParser.hpp index 94173ef..8b1bf30 100644 --- a/include/config/ConfigParser.hpp +++ b/include/config/ConfigParser.hpp @@ -1,16 +1,20 @@ #ifndef CONFIGPARSER_HPP #define CONFIGPARSER_HPP -#include +#include #include #include class ConfigParser { public: + // Schema major version this runtime understands. Configs declaring another + // major are rejected; any minor of this major is accepted (see README §5). + static constexpr int kSupportedConfigMajor = 1; + ConfigParser() = default; - static ExperimentConfig parse(const std::string& filePath); - static ExperimentConfig parseStream(std::istream& stream); + static DeviceConfig parse(const std::string& filePath); + static DeviceConfig parseStream(std::istream& stream); }; #endif // CONFIGPARSER_HPP diff --git a/include/config/ConfigVersion.hpp b/include/config/ConfigVersion.hpp new file mode 100644 index 0000000..34d5c9c --- /dev/null +++ b/include/config/ConfigVersion.hpp @@ -0,0 +1,21 @@ +#ifndef CONFIGVERSION_HPP +#define CONFIGVERSION_HPP + +#include + +// Schema version of a device config file, written as "MAJOR.MINOR". +// MAJOR changes are breaking (fields moved, renamed or removed) and are rejected +// by a runtime built for another major; MINOR changes are additive and +// backward-compatible, so any minor of a supported major is accepted. +struct ConfigVersion { + int major = 0; + int minor = 0; + + bool operator==(const ConfigVersion&) const = default; + + [[nodiscard]] std::string toString() const { + return std::to_string(major) + "." + std::to_string(minor); + } +}; + +#endif // CONFIGVERSION_HPP diff --git a/include/config/DeviceConfig.hpp b/include/config/DeviceConfig.hpp new file mode 100644 index 0000000..358cd1b --- /dev/null +++ b/include/config/DeviceConfig.hpp @@ -0,0 +1,39 @@ +#ifndef DEVICECONFIG_HPP +#define DEVICECONFIG_HPP + +#include +#include +#include +#include +#include + +struct ReferenceConfig { + std::string label; + std::string scheme; +}; + +struct GroundConfig { + std::string label; +}; + +struct ImpedanceConfig { + bool supported = false; + double thresholdKohm = 0.0; +}; + +// Describes the acquisition hardware (the cap and its LSL stream), not the +// experiment - experiment content lives in the protobuf file. Mirrors +// `config.json` 1:1: every JSON key maps onto exactly one field below. +struct DeviceConfig { + ConfigVersion configVersion; // config_version + std::string deviceName; // device_name + std::string montageStandard; // montage_standard + LSLConfig lsl; // lsl_stream + ReferenceConfig reference; // reference + GroundConfig ground; // ground + std::vector channels; // channels + ImpedanceConfig impedance; // impedance_check + // TODO: DataWriterConfig writer; // EEG output file format strategy +}; + +#endif // DEVICECONFIG_HPP diff --git a/include/config/ExperimentConfig.hpp b/include/config/ExperimentConfig.hpp deleted file mode 100644 index 0d32e6e..0000000 --- a/include/config/ExperimentConfig.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#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 index 8d05725..e479151 100644 --- a/include/config/LSLConfig.hpp +++ b/include/config/LSLConfig.hpp @@ -1,17 +1,17 @@ #ifndef LSLCONFIG_HPP #define LSLCONFIG_HPP -#include #include -#include +// Identity and expected shape of the device's LSL stream (`lsl_stream` in +// `config.json`). The channel table lives next to this in DeviceConfig, +// mirroring the top-level `channels` key of the JSON file. 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; + 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; }; #endif // LSLCONFIG_HPP diff --git a/include/lslreader/LSLReader.hpp b/include/lslreader/LSLReader.hpp index fc65925..770f311 100644 --- a/include/lslreader/LSLReader.hpp +++ b/include/lslreader/LSLReader.hpp @@ -3,15 +3,23 @@ #include -#include +#include +#include #include #include +#include struct EEGData; +// Acquires the device's LSL stream on its own worker thread and pushes samples +// onto eegQueue. Only the channels the device config marks as enabled are +// forwarded; each pushed EEGData holds those channels in config declaration +// order, so its values line up with the enabled entries of DeviceConfig::channels. class LSLReader { public: - explicit LSLReader(LSLConfig config); + // Throws std::invalid_argument if the config enables no channels or an + // enabled channel index is outside the expected channel count. + explicit LSLReader(DeviceConfig deviceConfig); ~LSLReader(); LSLReader(const LSLReader&) = delete; @@ -25,7 +33,10 @@ class LSLReader { private: void readLoop(const std::stop_token& stopToken); - LSLConfig config; + DeviceConfig config; + // Sample offsets to forward, in config declaration order. + std::vector enabledChannelIndices; + bool forwardsWholeSample = false; std::shared_ptr> eegQueue; std::jthread readerThread; }; diff --git a/src/config/ConfigParser.cpp b/src/config/ConfigParser.cpp index 50c2ba3..f81263c 100644 --- a/src/config/ConfigParser.cpp +++ b/src/config/ConfigParser.cpp @@ -1,10 +1,15 @@ #include "config/ConfigParser.hpp" +#include +#include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -33,6 +38,50 @@ void requireNonEmpty(const std::string& value, const char* field, std::string_vi } } +bool toUnsigned(std::string_view text, int& out) { + const bool digitsOnly = + !text.empty() && std::all_of(text.begin(), text.end(), [](unsigned char character) { + return std::isdigit(character) != 0; + }); + if (!digitsOnly) { + return false; + } + + const char* const first = text.data(); + const char* const last = text.data() + text.size(); + const auto [parsed, code] = std::from_chars(first, last, out); + return code == std::errc{} && parsed == last; +} + +// "MAJOR.MINOR" -> ConfigVersion. Anything else ("1", "v1", "1.2.3", "") is +// rejected: a version that cannot be compared is worse than no version at all. +ConfigVersion parseConfigVersion(const std::string& text) { + const std::size_t dot = text.find('.'); + ConfigVersion version; + + if (dot == std::string::npos || + !toUnsigned(std::string_view(text).substr(0, dot), version.major) || + !toUnsigned(std::string_view(text).substr(dot + 1), version.minor)) { + throw std::invalid_argument( + "ConfigParser: 'config_version' must be \"MAJOR.MINOR\", got \"" + text + "\""); + } + return version; +} + +// Checked before anything else is parsed: on an unsupported schema every later +// error would be a misleading missing/renamed field complaint. +ConfigVersion requireSupportedVersion(const json& root) { + const ConfigVersion version = + parseConfigVersion(requireField(root, "config_version", "config root")); + + if (version.major != ConfigParser::kSupportedConfigMajor) { + throw std::invalid_argument("ConfigParser: config_version " + version.toString() + + " is not supported by this runtime (supports " + + std::to_string(ConfigParser::kSupportedConfigMajor) + ".x)"); + } + return version; +} + std::vector buildChannels(const json& root, int expectedCount) { const json& channelsJson = *requireMember(root, "channels", "config root"); if (!channelsJson.is_array()) { @@ -71,7 +120,7 @@ std::vector buildChannels(const json& root, int expectedCount) { return channels; } -LSLConfig buildLSLConfig(const json& root) { +LSLConfig buildLSLStream(const json& root) { const json& streamJson = *requireMember(root, "lsl_stream", "config root"); LSLConfig lsl; @@ -93,7 +142,6 @@ LSLConfig buildLSLConfig(const json& root) { throw std::invalid_argument("ConfigParser: 'expected_sample_rate_hz' must be positive"); } - lsl.channels = buildChannels(root, lsl.expectedChannelCount); return lsl; } @@ -126,7 +174,7 @@ ImpedanceConfig buildImpedance(const json& root) { } } // namespace -ExperimentConfig ConfigParser::parse(const std::string& filePath) { +DeviceConfig ConfigParser::parse(const std::string& filePath) { std::ifstream file(filePath); if (!file.is_open()) { throw std::runtime_error("ConfigParser: cannot open file: " + filePath); @@ -140,7 +188,7 @@ ExperimentConfig ConfigParser::parse(const std::string& filePath) { } } -ExperimentConfig ConfigParser::parseStream(std::istream& stream) { +DeviceConfig ConfigParser::parseStream(std::istream& stream) { json root; try { root = json::parse(stream); @@ -153,16 +201,17 @@ ExperimentConfig ConfigParser::parseStream(std::istream& stream) { } try { - ExperimentConfig config; - config.configVersion = requireField(root, "config_version", "config root"); + DeviceConfig config; + config.configVersion = requireSupportedVersion(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.lsl = buildLSLStream(root); config.reference = buildReference(root); config.ground = buildGround(root); + config.channels = buildChannels(root, config.lsl.expectedChannelCount); config.impedance = buildImpedance(root); return config; diff --git a/src/lslreader/LSLReader.cpp b/src/lslreader/LSLReader.cpp index 6ef90af..0dfd74a 100644 --- a/src/lslreader/LSLReader.cpp +++ b/src/lslreader/LSLReader.cpp @@ -1,6 +1,7 @@ #include "lslreader/LSLReader.hpp" #include +#include #include #include #include @@ -12,11 +13,65 @@ #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 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 int kSenderChunkLength = 0; // 0: the sender decides chunk granularity +// Disables liblsl's silent recovery so a dropped stream surfaces as lsl::lost_error +// and is re-resolved (and re-validated) here instead. +constexpr bool kRecoverSilently = false; constexpr double kSampleRateTolerance = 0.5; // Hz +// Offsets of the enabled channels within a pulled sample, in the order the +// config declares them. +std::vector selectEnabledChannels(const DeviceConfig& config) { + std::vector indices; + indices.reserve(config.channels.size()); + + for (const ChannelConfig& channel : config.channels) { + if (!channel.enabled) { + continue; + } + if (channel.index < 0 || channel.index >= config.lsl.expectedChannelCount) { + throw std::invalid_argument("LSLReader: channel '" + channel.label + "' has index " + + std::to_string(channel.index) + + " outside the expected channel count " + + std::to_string(config.lsl.expectedChannelCount)); + } + indices.push_back(static_cast(channel.index)); + } + + if (indices.empty()) { + throw std::invalid_argument("LSLReader: config for stream '" + config.lsl.name + + "' enables no channels"); + } + return indices; +} + +// True when the enabled channels are the whole sample in stream order, so it can +// be forwarded without copying. +bool coversWholeSample(const std::vector& indices, int expectedChannelCount) { + if (indices.size() != static_cast(expectedChannelCount)) { + return false; + } + for (std::size_t position = 0; position < indices.size(); ++position) { + if (indices[position] != position) { + return false; + } + } + return true; +} + +std::vector pickChannels(const std::vector& sample, + const std::vector& indices) { + std::vector selected; + selected.reserve(indices.size()); + for (const std::size_t index : indices) { + selected.push_back(sample[index]); + } + return selected; +} + 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 " + @@ -48,7 +103,11 @@ std::optional resolveStream(const LSLConfig& config, } } // namespace -LSLReader::LSLReader(LSLConfig config) : config(std::move(config)) {} +LSLReader::LSLReader(DeviceConfig deviceConfig) + : config(std::move(deviceConfig)), + enabledChannelIndices(selectEnabledChannels(config)), + forwardsWholeSample( + coversWholeSample(enabledChannelIndices, config.lsl.expectedChannelCount)) {} LSLReader::~LSLReader() { stop(); } @@ -75,19 +134,35 @@ void LSLReader::stop() { } void LSLReader::readLoop(const std::stop_token& stopToken) { - const std::optional info = resolveStream(config, stopToken); - if (!info.has_value()) { - return; - } + while (!stopToken.stop_requested()) { + const std::optional info = resolveStream(config.lsl, stopToken); + if (!info.has_value()) { + return; // stop requested while waiting for the cap + } - lsl::stream_inlet inlet(*info, kInletBufferSeconds); - inlet.set_postprocessing(lsl::post_clocksync | lsl::post_dejitter | lsl::post_monotonize); + try { + lsl::stream_inlet inlet(*info, kInletBufferSeconds, kSenderChunkLength, + kRecoverSilently); + 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)}); + std::vector sample; + while (!stopToken.stop_requested()) { + const double timestamp = inlet.pull_sample(sample, kPullTimeout); + if (timestamp == 0.0) { + continue; + } + + if (forwardsWholeSample) { + eegQueue->enqueue(EEGData{timestamp, std::move(sample)}); + } else { + eegQueue->enqueue( + EEGData{timestamp, pickChannels(sample, enabledChannelIndices)}); + } + } + } catch (const lsl::lost_error& e) { + std::cerr << "LSLReader: stream '" << config.lsl.name << "' lost (" << e.what() + << "), re-resolving\n"; } } } diff --git a/tests/unit_tests/config_parser_test.cpp b/tests/unit_tests/config_parser_test.cpp index f2110eb..ef33544 100644 --- a/tests/unit_tests/config_parser_test.cpp +++ b/tests/unit_tests/config_parser_test.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include #include #include #include @@ -55,11 +55,31 @@ constexpr const char* kMinimalConfig = R"json({ "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] })json"; -ExperimentConfig parseString(const std::string& jsonText) { +DeviceConfig parseString(const std::string& jsonText) { std::istringstream stream(jsonText); return ConfigParser::parseStream(stream); } +// A minimal valid config carrying the given raw config_version value. +std::string withVersion(const std::string& version) { + return R"json({ + "config_version": ")json" + + version + R"json(", + "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"; +} + +void expectVersionRejected(const std::string& version) { + EXPECT_THROW(parseString(withVersion(version)), std::invalid_argument) + << "accepted malformed config_version: \"" << version << "\""; +} + 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"); @@ -70,14 +90,46 @@ fs::path writeTempConfig(const std::string& content) { } // namespace TEST(ConfigParserTest, ParsesTopLevelMetadata) { - const ExperimentConfig config = parseString(kSampleConfig); - EXPECT_EQ(config.configVersion, "1.0"); + const DeviceConfig config = parseString(kSampleConfig); + EXPECT_EQ(config.configVersion.major, ConfigParser::kSupportedConfigMajor); + EXPECT_EQ(config.configVersion.minor, 0); EXPECT_EQ(config.deviceName, "OpenBCI Cyton 8ch"); EXPECT_EQ(config.montageStandard, "10-20"); } +TEST(ConfigParserTest, AcceptsNewerMinorOfSupportedMajor) { + const DeviceConfig config = parseString(withVersion("1.7")); + EXPECT_EQ(config.configVersion, (ConfigVersion{ConfigParser::kSupportedConfigMajor, 7})); +} + +TEST(ConfigParserTest, UnsupportedMajorVersionThrows) { + EXPECT_THROW(parseString(withVersion("2.0")), std::invalid_argument); + EXPECT_THROW(parseString(withVersion("0.9")), std::invalid_argument); +} + +TEST(ConfigParserTest, MalformedVersionThrows) { + for (const char* version : {"", "1", "v1", "1.", ".0", "1.2.3", "1.x", "-1.0", " 1.0"}) { + expectVersionRejected(version); + } +} + +TEST(ConfigParserTest, UnsupportedVersionIsReportedBeforeOtherFieldErrors) { + // A future schema would fail on every renamed field; the version must be the + // error the user sees. + const std::string jsonText = R"json({ "config_version": "2.0" })json"; + + try { + parseString(jsonText); + FAIL() << "expected an unsupported-version error"; + } catch (const std::invalid_argument& e) { + const std::string message = e.what(); + EXPECT_NE(message.find("config_version"), std::string::npos) << message; + EXPECT_NE(message.find("2.0"), std::string::npos) << message; + } +} + TEST(ConfigParserTest, ParsesLslStreamFields) { - const ExperimentConfig config = parseString(kSampleConfig); + const DeviceConfig config = parseString(kSampleConfig); EXPECT_EQ(config.lsl.name, "obci_eeg1"); EXPECT_EQ(config.lsl.type, "EEG"); EXPECT_EQ(config.lsl.sourceId, "cyton-a1b2c3"); @@ -86,22 +138,22 @@ TEST(ConfigParserTest, ParsesLslStreamFields) { } TEST(ConfigParserTest, ParsesAllChannelsIncludingDisabled) { - const ExperimentConfig config = parseString(kSampleConfig); - ASSERT_EQ(config.lsl.channels.size(), static_cast(kExpectedChannelCount)); + const DeviceConfig config = parseString(kSampleConfig); + ASSERT_EQ(config.channels.size(), static_cast(kExpectedChannelCount)); - const auto& first = config.lsl.channels.front(); + const auto& first = config.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(); + const auto& last = config.channels.back(); EXPECT_EQ(last.label, "O2"); EXPECT_FALSE(last.enabled); } TEST(ConfigParserTest, ParsesReferenceGroundAndImpedance) { - const ExperimentConfig config = parseString(kSampleConfig); + const DeviceConfig config = parseString(kSampleConfig); EXPECT_EQ(config.reference.label, "linked_mastoids"); EXPECT_EQ(config.reference.scheme, "physical"); EXPECT_EQ(config.ground.label, "Fpz"); @@ -119,6 +171,17 @@ TEST(ConfigParserTest, MissingLslStreamThrows) { EXPECT_THROW(parseString(jsonText), std::invalid_argument); } +TEST(ConfigParserTest, MissingChannelsThrows) { + 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 + } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + TEST(ConfigParserTest, EmptyStreamNameThrows) { const std::string jsonText = R"json({ "config_version": "1.0", @@ -286,7 +349,7 @@ TEST(ConfigParserTest, MalformedGroundThrows) { } TEST(ConfigParserTest, OptionalSectionsDefaultWhenAbsent) { - const ExperimentConfig config = parseString(kMinimalConfig); + const DeviceConfig config = parseString(kMinimalConfig); EXPECT_TRUE(config.reference.label.empty()); EXPECT_TRUE(config.reference.scheme.empty()); EXPECT_TRUE(config.ground.label.empty()); @@ -297,7 +360,7 @@ TEST(ConfigParserTest, OptionalSectionsDefaultWhenAbsent) { TEST(ConfigParserTest, ParsesFromFilePath) { const fs::path path = writeTempConfig(kSampleConfig); - const ExperimentConfig config = ConfigParser::parse(path.string()); + const DeviceConfig config = ConfigParser::parse(path.string()); EXPECT_EQ(config.deviceName, "OpenBCI Cyton 8ch"); EXPECT_EQ(config.lsl.name, "obci_eeg1"); diff --git a/tests/unit_tests/lslreader_test.cpp b/tests/unit_tests/lslreader_test.cpp index 297ca1e..bc029bd 100644 --- a/tests/unit_tests/lslreader_test.cpp +++ b/tests/unit_tests/lslreader_test.cpp @@ -2,13 +2,15 @@ #include #include -#include +#include +#include #include #include #include #include #include #include +#include #include #include #include @@ -23,9 +25,11 @@ constexpr int kSamplesToPush = 20; constexpr int kMismatchedChannels = kChannelCount + 1; constexpr double kMismatchedSampleRate = 100.0; constexpr auto kSubscribeWait = std::chrono::seconds(3); +constexpr auto kRecoveryWait = std::chrono::seconds(10); constexpr auto kPushInterval = std::chrono::milliseconds(10); constexpr auto kDrainWait = std::chrono::milliseconds(300); constexpr auto kSubscribePoll = std::chrono::milliseconds(20); +constexpr double kRecoveryValue = 42.0; constexpr auto kValidationWait = std::chrono::milliseconds(1500); @@ -45,13 +49,31 @@ class ScopedStreamRedirect { 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; +// All channels enabled, declared in stream order. +std::vector makeChannels() { + std::vector channels; + channels.reserve(kChannelCount); + for (int i = 0; i < kChannelCount; ++i) { + ChannelConfig channel; + channel.index = i; + channel.label = "ch" + std::to_string(i); + channel.enabled = true; + channel.unit = "microvolts"; + channels.push_back(channel); + } + return channels; +} + +DeviceConfig makeConfig() { + DeviceConfig config; + config.configVersion = ConfigVersion{1, 0}; + config.deviceName = "NeuronIDE test device"; + config.lsl.name = "neuronide_test_stream"; + config.lsl.type = "EEG"; + config.lsl.sourceId = "neuronide-test-src"; + config.lsl.expectedChannelCount = kChannelCount; + config.lsl.expectedSampleRateHz = kSampleRate; + config.channels = makeChannels(); return config; } @@ -63,20 +85,22 @@ std::vector makeSample() { return sample; } -lsl::stream_outlet makeOutletWithShape(const LSLConfig& config, int channelCount, +lsl::stream_outlet makeOutletWithShape(const DeviceConfig& config, int channelCount, double sampleRate) { - const lsl::stream_info info(config.name, config.type, channelCount, sampleRate, - lsl::cf_double64, config.sourceId); + const lsl::stream_info info(config.lsl.name, config.lsl.type, channelCount, sampleRate, + lsl::cf_double64, config.lsl.sourceId); return lsl::stream_outlet(info); } -lsl::stream_outlet makeOutlet(const LSLConfig& config) { - return makeOutletWithShape(config, config.expectedChannelCount, config.expectedSampleRateHz); +lsl::stream_outlet makeOutlet(const DeviceConfig& config) { + return makeOutletWithShape(config, config.lsl.expectedChannelCount, + config.lsl.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; +bool waitForConsumer(lsl::stream_outlet& outlet, + std::chrono::steady_clock::duration timeout = kSubscribeWait) { + const auto deadline = std::chrono::steady_clock::now() + timeout; while (!outlet.have_consumers()) { if (std::chrono::steady_clock::now() >= deadline) { return false; @@ -93,7 +117,8 @@ void pushSamples(lsl::stream_outlet& outlet, const std::vector& sample, } } -std::string runAndCaptureDiagnostics(const LSLConfig& config, int channelCount, double sampleRate) { +std::string runAndCaptureDiagnostics(const DeviceConfig& config, int channelCount, + double sampleRate) { lsl::stream_outlet outlet = makeOutletWithShape(config, channelCount, sampleRate); auto eegQueue = std::make_shared>(); LSLReader reader(config); @@ -110,10 +135,32 @@ std::string runAndCaptureDiagnostics(const LSLConfig& config, int channelCount, EXPECT_FALSE(eegQueue->try_dequeue(received)) << "a rejected stream must yield no samples"; return captured.str(); } + +// Keeps pushing a marked sample until one of them comes back through the queue, +// so the check does not depend on when exactly the inlet reconnects. Samples +// from before the drop carry other values and are discarded. +bool pushUntilReceived(lsl::stream_outlet& outlet, moodycamel::ConcurrentQueue& eegQueue, + double value, std::chrono::steady_clock::duration timeout) { + const std::vector sample(kChannelCount, value); + const auto deadline = std::chrono::steady_clock::now() + timeout; + + while (std::chrono::steady_clock::now() < deadline) { + outlet.push_sample(sample); + std::this_thread::sleep_for(kPushInterval); + + EEGData received; + while (eegQueue.try_dequeue(received)) { + if (!received.channels.empty() && received.channels.front() == value) { + return true; + } + } + } + return false; +} } // namespace TEST(LSLReaderTest, ReadsSamplesFromStreamIntoQueue) { - const LSLConfig config = makeConfig(); + const DeviceConfig config = makeConfig(); lsl::stream_outlet outlet = makeOutlet(config); auto eegQueue = std::make_shared>(); @@ -137,10 +184,91 @@ TEST(LSLReaderTest, ReadsSamplesFromStreamIntoQueue) { EXPECT_NE(received.timestamp, 0.0); } +TEST(LSLReaderTest, ForwardsOnlyChannelsEnabledInConfig) { + DeviceConfig config = makeConfig(); + config.lsl.name = "neuronide_test_channel_filter"; + config.lsl.sourceId = "neuronide-test-filter"; + config.channels[1].enabled = false; + config.channels[2].enabled = false; + + 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(); // { 1, 2, 3, 4 } + pushSamples(outlet, sample, kSamplesToPush); + + std::this_thread::sleep_for(kDrainWait); + reader.stop(); + + EEGData received; + ASSERT_TRUE(eegQueue->try_dequeue(received)); + ASSERT_EQ(received.channels.size(), 2U) << "disabled channels must not be forwarded"; + EXPECT_DOUBLE_EQ(received.channels[0], sample[0]); + EXPECT_DOUBLE_EQ(received.channels[1], sample[3]); +} + +TEST(LSLReaderTest, ConfigWithoutEnabledChannelsThrows) { + DeviceConfig config = makeConfig(); + for (ChannelConfig& channel : config.channels) { + channel.enabled = false; + } + + EXPECT_THROW({ const LSLReader reader(config); }, std::invalid_argument); +} + +TEST(LSLReaderTest, ChannelIndexOutsideExpectedCountThrows) { + DeviceConfig config = makeConfig(); + config.channels.back().index = kChannelCount; + + EXPECT_THROW({ const LSLReader reader(config); }, std::invalid_argument); +} + +TEST(LSLReaderTest, ReResolvesStreamAfterItIsLost) { + DeviceConfig config = makeConfig(); + config.lsl.name = "neuronide_test_lost_stream"; + config.lsl.sourceId = "neuronide-test-lost"; + + auto eegQueue = std::make_shared>(); + LSLReader reader(config); + + std::ostringstream captured; + { + const ScopedStreamRedirect redirect(std::cerr, captured.rdbuf()); + + { + lsl::stream_outlet outlet = makeOutlet(config); + reader.start(eegQueue); + ASSERT_TRUE(waitForConsumer(outlet, kRecoveryWait)) + << "LSLReader did not subscribe (needs loopback)"; + pushSamples(outlet, makeSample(), kSamplesToPush); + } // outlet gone: the inlet's pull must raise lsl::lost_error + + lsl::stream_outlet revived = makeOutlet(config); + ASSERT_TRUE(waitForConsumer(revived, kRecoveryWait)) + << "LSLReader did not re-resolve the stream after it was lost"; + + EXPECT_TRUE(pushUntilReceived(revived, *eegQueue, kRecoveryValue, kRecoveryWait)) + << "no samples arrived after the stream came back"; + + reader.stop(); + } + + const std::string log = captured.str(); + EXPECT_NE(log.find("lost"), std::string::npos) + << "the drop should surface as lsl::lost_error and be re-resolved, got: " << log; + EXPECT_EQ(log.find("fatal error"), std::string::npos) + << "a dropped stream must not stop acquisition, got: " << log; +} + TEST(LSLReaderTest, RejectsStreamWithMismatchedChannelCount) { - LSLConfig config = makeConfig(); - config.name = "neuronide_test_chan_mismatch"; - config.sourceId = "neuronide-test-chan"; + DeviceConfig config = makeConfig(); + config.lsl.name = "neuronide_test_chan_mismatch"; + config.lsl.sourceId = "neuronide-test-chan"; const std::string log = runAndCaptureDiagnostics(config, kMismatchedChannels, kSampleRate); EXPECT_NE(log.find("channels"), std::string::npos) @@ -148,9 +276,9 @@ TEST(LSLReaderTest, RejectsStreamWithMismatchedChannelCount) { } TEST(LSLReaderTest, RejectsStreamWithMismatchedSampleRate) { - LSLConfig config = makeConfig(); - config.name = "neuronide_test_rate_mismatch"; - config.sourceId = "neuronide-test-rate"; + DeviceConfig config = makeConfig(); + config.lsl.name = "neuronide_test_rate_mismatch"; + config.lsl.sourceId = "neuronide-test-rate"; const std::string log = runAndCaptureDiagnostics(config, kChannelCount, kMismatchedSampleRate); EXPECT_NE(log.find("Hz"), std::string::npos) @@ -158,9 +286,9 @@ TEST(LSLReaderTest, RejectsStreamWithMismatchedSampleRate) { } TEST(LSLReaderTest, StopBeforeStreamResolvedExitsCleanly) { - LSLConfig config = makeConfig(); - config.name = "neuronide_test_absent_stream"; - config.sourceId = "neuronide-test-absent"; + DeviceConfig config = makeConfig(); + config.lsl.name = "neuronide_test_absent_stream"; + config.lsl.sourceId = "neuronide-test-absent"; auto eegQueue = std::make_shared>(); LSLReader reader(config); From 7b48043966580d2089c1c6ed42751c1b4b2d302c Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 4 Aug 2026 19:21:43 +0200 Subject: [PATCH 7/7] feat: add json config validation --- README.md | 29 ++++ include/config/ChannelConfig.hpp | 5 + include/config/ConfigVersion.hpp | 4 + include/config/DeviceConfig.hpp | 12 ++ include/config/LSLConfig.hpp | 4 + include/lslreader/LSLReader.hpp | 4 +- src/config/CMakeLists.txt | 1 + src/config/ConfigParser.cpp | 53 ++------ src/config/ConfigValidation.cpp | 88 ++++++++++++ src/lslreader/LSLReader.cpp | 21 ++- tests/unit_tests/config_parser_test.cpp | 105 +-------------- tests/unit_tests/config_validation_test.cpp | 141 ++++++++++++++++++++ 12 files changed, 307 insertions(+), 160 deletions(-) create mode 100644 src/config/ConfigValidation.cpp create mode 100644 tests/unit_tests/config_validation_test.cpp diff --git a/README.md b/README.md index c6bea4f..4c74db1 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,34 @@ are required; `reference`, `ground` and `impedance_check` default when absent. Channels with `"enabled": false` stay in the config (they document the cap) but are **not** acquired: `LSLReader` drops them from every sample. +### Validation contract + +Mapping and validation are separate jobs. `ConfigParser` only turns JSON into +structs — presence of keys, types, array shape. Every *semantic* rule (non-empty +stream identity, positive rate, channel count matching the stream, unique +in-range indices) lives on the config types themselves as `validate()`, because +none of those rules are about JSON and they must hold for any producer: + +```cpp +DeviceConfig config = ConfigParser::parse("config.json"); // already validated +``` +```cpp +DeviceConfig config; // hand-assembled: no guarantees +config.lsl.name = ...; +config.validate(); // throws std::invalid_argument on the first broken rule +``` + +**A `DeviceConfig` returned by `ConfigParser` has passed `validate()`. One you +assemble yourself has not** — call it before handing the config to a consumer. +`LSLReader` validates in its constructor rather than trusting its caller, since +it indexes into raw samples with the configured channel offsets. + +Rules that need more context than the config carries stay with the consumer, not +in `validate()`: "at least one channel is enabled" is an `LSLReader` precondition +(a fully disabled cap is a valid *config*, just nothing to acquire), and +"stream shape matches the live LSL stream" can only be checked against a resolved +stream at runtime. + ### Schema versioning `config_version` is `"MAJOR.MINOR"` and is the **first** thing `ConfigParser` @@ -290,6 +318,7 @@ stay fatal — they are logged and the worker exits instead of retrying forever. | `Renderer` | Implemented | SDL + vsync, marker timestamping | | `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4); driven by `DeviceConfig`, enabled channels only, re-resolves lost streams | | `ConfigParser` | Implemented | `config.json` → `DeviceConfig` (1:1 mapping, major-version checked, see §5), nlohmann/json | +| Config `validate()` | Implemented | semantic rules on the config types themselves, independent of JSON (see §5) | | `DataWriter` | Implemented | strategy-based; `CSVFormatStrategy` | | `Runtime` orchestration | **Stub** | currently does nothing | diff --git a/include/config/ChannelConfig.hpp b/include/config/ChannelConfig.hpp index a888a55..9882de7 100644 --- a/include/config/ChannelConfig.hpp +++ b/include/config/ChannelConfig.hpp @@ -9,6 +9,11 @@ struct ChannelConfig { std::string label; bool enabled = true; std::string unit; + + // Throws std::invalid_argument if this channel breaks its own invariants. + // Rules that need the stream shape (index within range, uniqueness) belong + // to DeviceConfig::validate. + void validate() const; }; #endif // CHANNELCONFIG_HPP diff --git a/include/config/ConfigVersion.hpp b/include/config/ConfigVersion.hpp index 34d5c9c..8b8ce73 100644 --- a/include/config/ConfigVersion.hpp +++ b/include/config/ConfigVersion.hpp @@ -13,6 +13,10 @@ struct ConfigVersion { bool operator==(const ConfigVersion&) const = default; + // Throws std::invalid_argument on a version that cannot be compared. + // Whether a valid version is *supported* is ConfigParser's decision. + void validate() const; + [[nodiscard]] std::string toString() const { return std::to_string(major) + "." + std::to_string(minor); } diff --git a/include/config/DeviceConfig.hpp b/include/config/DeviceConfig.hpp index 358cd1b..256c5d0 100644 --- a/include/config/DeviceConfig.hpp +++ b/include/config/DeviceConfig.hpp @@ -19,6 +19,9 @@ struct GroundConfig { struct ImpedanceConfig { bool supported = false; double thresholdKohm = 0.0; + + // Throws std::invalid_argument on a negative threshold. + void validate() const; }; // Describes the acquisition hardware (the cap and its LSL stream), not the @@ -34,6 +37,15 @@ struct DeviceConfig { std::vector channels; // channels ImpedanceConfig impedance; // impedance_check // TODO: DataWriterConfig writer; // EEG output file format strategy + + // Checks every rule a device config must satisfy, including the cross-field + // ones no single member can check (channel count matching the stream, unique + // in-range indices), and throws std::invalid_argument on the first failure. + // + // A DeviceConfig returned by ConfigParser has already passed this. One + // assembled by hand has not - call it before handing the config to a + // consumer such as LSLReader. + void validate() const; }; #endif // DEVICECONFIG_HPP diff --git a/include/config/LSLConfig.hpp b/include/config/LSLConfig.hpp index e479151..1a187ff 100644 --- a/include/config/LSLConfig.hpp +++ b/include/config/LSLConfig.hpp @@ -12,6 +12,10 @@ struct LSLConfig { std::string sourceId; // lsl_stream.source_id int expectedChannelCount = 0; double expectedSampleRateHz = 0.0; + + // Throws std::invalid_argument if the stream cannot be resolved or checked + // against with these values. + void validate() const; }; #endif // LSLCONFIG_HPP diff --git a/include/lslreader/LSLReader.hpp b/include/lslreader/LSLReader.hpp index 770f311..c289c78 100644 --- a/include/lslreader/LSLReader.hpp +++ b/include/lslreader/LSLReader.hpp @@ -17,8 +17,8 @@ struct EEGData; // order, so its values line up with the enabled entries of DeviceConfig::channels. class LSLReader { public: - // Throws std::invalid_argument if the config enables no channels or an - // enabled channel index is outside the expected channel count. + // Validates the config (see DeviceConfig::validate) and throws + // std::invalid_argument if it is invalid or enables no channels. explicit LSLReader(DeviceConfig deviceConfig); ~LSLReader(); diff --git a/src/config/CMakeLists.txt b/src/config/CMakeLists.txt index e6fb0b6..d2ccecf 100644 --- a/src/config/CMakeLists.txt +++ b/src/config/CMakeLists.txt @@ -1,5 +1,6 @@ add_library(config OBJECT ConfigParser.cpp + ConfigValidation.cpp ) target_include_directories(config PUBLIC diff --git a/src/config/ConfigParser.cpp b/src/config/ConfigParser.cpp index f81263c..11f93ef 100644 --- a/src/config/ConfigParser.cpp +++ b/src/config/ConfigParser.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -31,13 +30,6 @@ 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)); - } -} - bool toUnsigned(std::string_view text, int& out) { const bool digitsOnly = !text.empty() && std::all_of(text.begin(), text.end(), [](unsigned char character) { @@ -82,21 +74,14 @@ ConfigVersion requireSupportedVersion(const json& root) { return version; } -std::vector buildChannels(const json& root, int expectedCount) { +std::vector buildChannels(const json& root) { 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; @@ -105,15 +90,6 @@ std::vector buildChannels(const json& root, int expectedCount) { 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)); } @@ -132,16 +108,6 @@ LSLConfig buildLSLStream(const json& root) { 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"); - } - return lsl; } @@ -205,15 +171,14 @@ DeviceConfig ConfigParser::parseStream(std::istream& stream) { config.configVersion = requireSupportedVersion(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 = buildLSLStream(root); - config.reference = buildReference(root); - config.ground = buildGround(root); - config.channels = buildChannels(root, config.lsl.expectedChannelCount); - config.impedance = buildImpedance(root); - + config.lsl = buildLSLStream(root); + config.reference = buildReference(root); + config.ground = buildGround(root); + config.channels = buildChannels(root); + config.impedance = buildImpedance(root); + + // Mapping is done; the semantic rules belong to the types themselves. + config.validate(); 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/config/ConfigValidation.cpp b/src/config/ConfigValidation.cpp new file mode 100644 index 0000000..ff44b99 --- /dev/null +++ b/src/config/ConfigValidation.cpp @@ -0,0 +1,88 @@ +// Semantic rules for the device config types. These are deliberately separate +// from ConfigParser: none of them are about JSON, so they hold for any producer +// of a config, not just the file parser. +// +// Field names in the messages are the JSON keys, since that is what a user edits +// (the structs mirror config.json 1:1 - see README §5). + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +void requireNonEmpty(const std::string& value, const char* field, std::string_view owner) { + if (value.empty()) { + throw std::invalid_argument(std::string(owner) + ": '" + std::string(field) + + "' must not be empty"); + } +} +} // namespace + +void ConfigVersion::validate() const { + if (major < 0 || minor < 0) { + throw std::invalid_argument("ConfigVersion: version components must not be negative, got " + + toString()); + } +} + +void ChannelConfig::validate() const { + if (index < 0) { + throw std::invalid_argument("ChannelConfig: channel '" + label + "' has negative index " + + std::to_string(index)); + } +} + +void LSLConfig::validate() const { + requireNonEmpty(name, "name", "LSLConfig"); + requireNonEmpty(type, "type", "LSLConfig"); + requireNonEmpty(sourceId, "source_id", "LSLConfig"); + + if (expectedChannelCount <= 0) { + throw std::invalid_argument("LSLConfig: 'expected_channel_count' must be positive, got " + + std::to_string(expectedChannelCount)); + } + if (expectedSampleRateHz <= 0.0) { + throw std::invalid_argument("LSLConfig: 'expected_sample_rate_hz' must be positive, got " + + std::to_string(expectedSampleRateHz)); + } +} + +void ImpedanceConfig::validate() const { + if (thresholdKohm < 0.0) { + throw std::invalid_argument("ImpedanceConfig: 'threshold_kohm' must not be negative, got " + + std::to_string(thresholdKohm)); + } +} + +void DeviceConfig::validate() const { + configVersion.validate(); + requireNonEmpty(deviceName, "device_name", "DeviceConfig"); + lsl.validate(); + impedance.validate(); + + if (static_cast(channels.size()) != lsl.expectedChannelCount) { + throw std::invalid_argument("DeviceConfig: channel count mismatch: 'channels' has " + + std::to_string(channels.size()) + + " entries but 'expected_channel_count' is " + + std::to_string(lsl.expectedChannelCount)); + } + + std::unordered_set seenIndices; + for (const ChannelConfig& channel : channels) { + channel.validate(); + + if (channel.index >= lsl.expectedChannelCount) { + throw std::invalid_argument("DeviceConfig: channel index out of range: " + + std::to_string(channel.index)); + } + if (!seenIndices.insert(channel.index).second) { + throw std::invalid_argument("DeviceConfig: duplicate channel index: " + + std::to_string(channel.index)); + } + } +} diff --git a/src/lslreader/LSLReader.cpp b/src/lslreader/LSLReader.cpp index 0dfd74a..8775397 100644 --- a/src/lslreader/LSLReader.cpp +++ b/src/lslreader/LSLReader.cpp @@ -23,22 +23,19 @@ constexpr bool kRecoverSilently = false; constexpr double kSampleRateTolerance = 0.5; // Hz // Offsets of the enabled channels within a pulled sample, in the order the -// config declares them. -std::vector selectEnabledChannels(const DeviceConfig& config) { +// config declares them. Validates the config first: the reader indexes into raw +// samples with these offsets, so it cannot take the config's invariants on +// trust, and a hand-built DeviceConfig has not been through ConfigParser. +std::vector validatedChannelOffsets(const DeviceConfig& config) { + config.validate(); + std::vector indices; indices.reserve(config.channels.size()); for (const ChannelConfig& channel : config.channels) { - if (!channel.enabled) { - continue; - } - if (channel.index < 0 || channel.index >= config.lsl.expectedChannelCount) { - throw std::invalid_argument("LSLReader: channel '" + channel.label + "' has index " + - std::to_string(channel.index) + - " outside the expected channel count " + - std::to_string(config.lsl.expectedChannelCount)); + if (channel.enabled) { + indices.push_back(static_cast(channel.index)); } - indices.push_back(static_cast(channel.index)); } if (indices.empty()) { @@ -105,7 +102,7 @@ std::optional resolveStream(const LSLConfig& config, LSLReader::LSLReader(DeviceConfig deviceConfig) : config(std::move(deviceConfig)), - enabledChannelIndices(selectEnabledChannels(config)), + enabledChannelIndices(validatedChannelOffsets(config)), forwardsWholeSample( coversWholeSample(enabledChannelIndices, config.lsl.expectedChannelCount)) {} diff --git a/tests/unit_tests/config_parser_test.cpp b/tests/unit_tests/config_parser_test.cpp index ef33544..279b5ff 100644 --- a/tests/unit_tests/config_parser_test.cpp +++ b/tests/unit_tests/config_parser_test.cpp @@ -182,27 +182,15 @@ TEST(ConfigParserTest, MissingChannelsThrows) { EXPECT_THROW(parseString(jsonText), std::invalid_argument); } -TEST(ConfigParserTest, EmptyStreamNameThrows) { +// The individual rules live in config_validation_test.cpp; this only pins that +// the parser runs them on what it produced. +TEST(ConfigParserTest, SemanticallyInvalidConfigIsRejected) { 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" } ] @@ -223,54 +211,6 @@ TEST(ConfigParserTest, WrongFieldTypeThrows) { 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", @@ -283,45 +223,6 @@ TEST(ConfigParserTest, ChannelsNotArrayThrows) { 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", diff --git a/tests/unit_tests/config_validation_test.cpp b/tests/unit_tests/config_validation_test.cpp new file mode 100644 index 0000000..ec6167f --- /dev/null +++ b/tests/unit_tests/config_validation_test.cpp @@ -0,0 +1,141 @@ +// Rules that hold for any device config, whatever produced it. These build the +// structs directly - no JSON - so they test the rule and not the parser. + +#include + +#include +#include +#include +#include +#include + +namespace { +constexpr int kChannelCount = 2; +constexpr double kSampleRate = 250.0; + +ChannelConfig makeChannel(int index, const std::string& label) { + ChannelConfig channel; + channel.index = index; + channel.label = label; + channel.enabled = true; + channel.unit = "microvolts"; + return channel; +} + +DeviceConfig makeValidConfig() { + DeviceConfig config; + config.configVersion = ConfigVersion{1, 0}; + config.deviceName = "OpenBCI Cyton 8ch"; + config.montageStandard = "10-20"; + config.lsl.name = "obci_eeg1"; + config.lsl.type = "EEG"; + config.lsl.sourceId = "cyton-a1b2c3"; + config.lsl.expectedChannelCount = kChannelCount; + config.lsl.expectedSampleRateHz = kSampleRate; + config.channels = {makeChannel(0, "Fz"), makeChannel(1, "Cz")}; + return config; +} +} // namespace + +TEST(ConfigValidationTest, AcceptsAValidConfig) { EXPECT_NO_THROW(makeValidConfig().validate()); } + +TEST(ConfigValidationTest, AcceptsConfigWithDisabledChannels) { + DeviceConfig config = makeValidConfig(); + config.channels[1].enabled = false; + + EXPECT_NO_THROW(config.validate()); +} + +TEST(ConfigValidationTest, EmptyDeviceNameThrows) { + DeviceConfig config = makeValidConfig(); + config.deviceName.clear(); + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, EmptyStreamNameThrows) { + DeviceConfig config = makeValidConfig(); + config.lsl.name.clear(); + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, EmptyStreamTypeThrows) { + DeviceConfig config = makeValidConfig(); + config.lsl.type.clear(); + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, EmptySourceIdThrows) { + DeviceConfig config = makeValidConfig(); + config.lsl.sourceId.clear(); + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, NonPositiveChannelCountThrows) { + DeviceConfig config = makeValidConfig(); + config.lsl.expectedChannelCount = 0; + config.channels.clear(); + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, NonPositiveSampleRateThrows) { + DeviceConfig config = makeValidConfig(); + config.lsl.expectedSampleRateHz = 0.0; + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, ChannelCountMismatchThrows) { + DeviceConfig config = makeValidConfig(); + config.channels.pop_back(); + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, NegativeChannelIndexThrows) { + DeviceConfig config = makeValidConfig(); + config.channels[0].index = -1; + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, ChannelIndexOutOfRangeThrows) { + DeviceConfig config = makeValidConfig(); + config.channels[1].index = kChannelCount; + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, DuplicateChannelIndexThrows) { + DeviceConfig config = makeValidConfig(); + config.channels[1].index = config.channels[0].index; + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, NegativeImpedanceThresholdThrows) { + DeviceConfig config = makeValidConfig(); + config.impedance.supported = true; + config.impedance.thresholdKohm = -1.0; + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, NegativeVersionComponentThrows) { + DeviceConfig config = makeValidConfig(); + config.configVersion.minor = -1; + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, ChannelValidatesItsOwnInvariantsOnly) { + // An index beyond the stream's channel count is not something a channel can + // judge alone - only DeviceConfig knows the expected count. + const ChannelConfig channel = makeChannel(999, "Fz"); + + EXPECT_NO_THROW(channel.validate()); +}