diff --git a/.gitignore b/.gitignore index d1b4e06..7382898 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ _deps/ .Trashes ehthumbs.db Thumbs.db + +# config files +*.json diff --git a/README.md b/README.md index 4c74db1..6254121 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ otherwise the same experiment stops being portable between labs. | 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` | +| | Output format for `DataWriter` (`output.format`) | Consequences of the split: @@ -227,12 +227,16 @@ way — `channels` is a top-level key, so it is a top-level `DeviceConfig` field { "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 } + "impedance_check": { "supported": true, "threshold_kohm": 5.0 }, + "output": { "format": "csv" } // -> DataFormatStrategyFactory, defaults to csv } ``` `config_version`, `device_name`, `montage_standard`, `lsl_stream` and `channels` -are required; `reference`, `ground` and `impedance_check` default when absent. +are required; `reference`, `ground`, `impedance_check` and `output` default when +absent. If `output` is present it must carry a non-empty `format`; whether that +format is *known* is decided by `DataFormatStrategyFactory` when the writer is +built, not by config validation. Channels with `"enabled": false` stay in the config (they document the cap) but are **not** acquired: `LSLReader` drops them from every sample. @@ -320,7 +324,8 @@ stay fatal — they are logged and the worker exits instead of retrying forever. | `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 | +| `DataFormatStrategyFactory` | Implemented | `output.format` → format strategy; unknown formats rejected | +| `Runtime` orchestration | Implemented | owns SDL session, parses both config files, wires the queues, drives the render loop, stops workers | The class diagram in older docs is partly aspirational; the table above reflects the actual code. diff --git a/include/Runtime.hpp b/include/Runtime.hpp index 838b4a6..f577e0c 100644 --- a/include/Runtime.hpp +++ b/include/Runtime.hpp @@ -1,18 +1,93 @@ #ifndef RUNTIME_HPP #define RUNTIME_HPP -#include +#include + +#include +#include +#include +#include +#include + +class Scene; +class LSLReader; +class DataWriter; +class Renderer; +struct EEGData; +struct Marker; + +// Tag matches the other forward declarations in include/ (Scene, SceneObject, +// Component). SDL declares it as a struct, so all four are technically +// mismatched; changing one alone trips -Wmismatched-tags. +class SDL_Renderer; + +struct RuntimePaths { + std::string config; // device config.json + std::string experiment; // serialized experiment scene (protobuf) + std::string outputDir = "."; // directory the recorded CSV is written to +}; class Runtime { public: - Runtime() = default; - ~Runtime() = default; + using RenderTargetFactory = + std::function(const std::string& windowTitle)>; + + explicit Runtime(const RuntimePaths& paths); + Runtime(const RuntimePaths& paths, const RenderTargetFactory& renderTargetFactory); + ~Runtime(); Runtime(const Runtime&) = delete; Runtime& operator=(const Runtime&) = delete; Runtime(Runtime&&) = delete; - Runtime& operator=(Runtime&&) = delete; - static void start(); + Runtime& operator=(Runtime&&) = delete; + + // Drives the experiment on the calling thread until the render loop ends + // (SDL_QUIT or requestStop), then stops the workers. Single-shot: the stop + // source is never re-armed, so a second run() returns immediately. + void run(); + + // Safe to call from any thread, including while run() is in progress. + void requestStop(); + + // Empty until run() has computed it. Not synchronized: read it before run() + // starts or after it has returned. + const std::string& outputPath() const noexcept { return outputFilePath; } + + static RenderTargetFactory defaultRenderTargetFactory(); + + private: + struct SdlSession { + SdlSession(); + ~SdlSession(); + + SdlSession(const SdlSession&) = delete; + SdlSession& operator=(const SdlSession&) = delete; + SdlSession(SdlSession&&) = delete; + SdlSession& operator=(SdlSession&&) = delete; + }; + + void startWorkers(const std::string& outputPath); + void shutdown(); + std::string makeOutputPath() const; + + RuntimePaths paths; + DeviceConfig config; + + SdlSession sdlSession; + + std::shared_ptr scene; + std::shared_ptr> eegQueue; + std::shared_ptr> markerQueue; + + std::shared_ptr sdlRenderer; + + std::unique_ptr lslReader; + std::unique_ptr dataWriter; + std::unique_ptr renderer; + + std::string outputExtension; + std::string outputFilePath; + std::stop_source stopSource; }; -#endif // RUNTIME_HPP \ No newline at end of file +#endif // RUNTIME_HPP diff --git a/include/config/DeviceConfig.hpp b/include/config/DeviceConfig.hpp index 256c5d0..ca7e741 100644 --- a/include/config/DeviceConfig.hpp +++ b/include/config/DeviceConfig.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -36,7 +37,7 @@ struct DeviceConfig { GroundConfig ground; // ground std::vector channels; // channels ImpedanceConfig impedance; // impedance_check - // TODO: DataWriterConfig writer; // EEG output file format strategy + OutputConfig output; // output // Checks every rule a device config must satisfy, including the cross-field // ones no single member can check (channel count matching the stream, unique diff --git a/include/config/OutputConfig.hpp b/include/config/OutputConfig.hpp new file mode 100644 index 0000000..3fdb527 --- /dev/null +++ b/include/config/OutputConfig.hpp @@ -0,0 +1,18 @@ +#ifndef OUTPUTCONFIG_HPP +#define OUTPUTCONFIG_HPP + +#include + +// How recorded data should be persisted. The `format` selects the DataWriter +// format strategy (see DataFormatStrategyFactory) and, through it, the output +// file extension. +struct OutputConfig { + std::string format = "csv"; + + // Throws std::invalid_argument on an empty format. Whether a non-empty + // format is *known* is DataFormatStrategyFactory's decision, so that check + // stays out of the config layer. + void validate() const; +}; + +#endif // OUTPUTCONFIG_HPP diff --git a/include/datawriter/CSVFormatStrategy.hpp b/include/datawriter/CSVFormatStrategy.hpp index fb76dbe..4a9efca 100644 --- a/include/datawriter/CSVFormatStrategy.hpp +++ b/include/datawriter/CSVFormatStrategy.hpp @@ -15,6 +15,8 @@ class CSVFormatStrategy : public IDataFormatStrategy { CSVFormatStrategy(CSVFormatStrategy&&) = delete; CSVFormatStrategy& operator=(CSVFormatStrategy&&) = delete; + std::string fileExtension() const override { return "csv"; } + void open(const std::string& filepath) override; void close() override; diff --git a/include/datawriter/DataFormatStrategyFactory.hpp b/include/datawriter/DataFormatStrategyFactory.hpp new file mode 100644 index 0000000..9699e62 --- /dev/null +++ b/include/datawriter/DataFormatStrategyFactory.hpp @@ -0,0 +1,18 @@ +#ifndef DATAFORMATSTRATEGYFACTORY_HPP +#define DATAFORMATSTRATEGYFACTORY_HPP + +#include +#include +#include + +// Builds the DataWriter format strategy selected by the config's output format. +// Adding a new output format means registering it here; nothing else in the +// runtime needs to change. +class DataFormatStrategyFactory { + public: + // Returns the strategy for the given format (case-insensitive), e.g. "csv". + // Throws std::invalid_argument if the format is unknown. + static std::unique_ptr create(const std::string& format); +}; + +#endif // DATAFORMATSTRATEGYFACTORY_HPP diff --git a/include/datawriter/IDataFormatStrategy.hpp b/include/datawriter/IDataFormatStrategy.hpp index c178e43..28992a7 100644 --- a/include/datawriter/IDataFormatStrategy.hpp +++ b/include/datawriter/IDataFormatStrategy.hpp @@ -16,6 +16,8 @@ class IDataFormatStrategy { IDataFormatStrategy(IDataFormatStrategy&&) = delete; IDataFormatStrategy& operator=(IDataFormatStrategy&&) = delete; + virtual std::string fileExtension() const = 0; + virtual void open(const std::string& filepath) = 0; virtual void close() = 0; diff --git a/src/Runtime.cpp b/src/Runtime.cpp index 200a3d5..2082d6b 100644 --- a/src/Runtime.cpp +++ b/src/Runtime.cpp @@ -1,3 +1,148 @@ +#include + #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kWindowWidth = 1280; +constexpr int kWindowHeight = 720; +constexpr const char* kDefaultTitle = "NeuronIDE"; +constexpr const char* kFallbackName = "experiment"; + +std::string sdlError(const char* what) { return std::string(what) + ": " + SDL_GetError(); } + +// The experiment name comes from an authored protobuf file and ends up in a file +// name, so anything that is not plainly safe becomes '_'. Left unfiltered, a name +// like "block 1/run" would resolve to a missing subdirectory (and "../x" would +// escape the output directory entirely). +std::string sanitizeForFileName(std::string name) { + if (name.empty()) { + return kFallbackName; + } + + std::replace_if( + name.begin(), name.end(), + [](unsigned char character) { + return std::isalnum(character) == 0 && character != '-' && character != '_'; + }, + '_'); + return name; +} +} // namespace + +Runtime::SdlSession::SdlSession() { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) != 0) { + throw std::runtime_error(sdlError("Runtime: SDL_Init failed")); + } +} + +Runtime::SdlSession::~SdlSession() { SDL_Quit(); } + +Runtime::RenderTargetFactory Runtime::defaultRenderTargetFactory() { + return [](const std::string& windowTitle) -> std::shared_ptr { + const std::string title = windowTitle.empty() ? kDefaultTitle : windowTitle; + + SDL_Window* window = + SDL_CreateWindow(title.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, + kWindowWidth, kWindowHeight, SDL_WINDOW_SHOWN); + if (window == nullptr) { + throw std::runtime_error(sdlError("Runtime: SDL_CreateWindow failed")); + } + + SDL_Renderer* renderer = + SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); + if (renderer == nullptr) { + SDL_DestroyWindow(window); + throw std::runtime_error(sdlError("Runtime: SDL_CreateRenderer failed")); + } + + return {renderer, [window](SDL_Renderer* target) { + if (target != nullptr) { + SDL_DestroyRenderer(target); + } + SDL_DestroyWindow(window); + }}; + }; +} + +Runtime::Runtime(const RuntimePaths& paths) : Runtime(paths, defaultRenderTargetFactory()) {} + +Runtime::Runtime(const RuntimePaths& paths, const RenderTargetFactory& renderTargetFactory) + : paths(paths), + config(ConfigParser::parse(paths.config)), + scene(Parser::parse(paths.experiment)), + eegQueue(std::make_shared>()), + markerQueue(std::make_shared>()) { + if (!renderTargetFactory) { + throw std::invalid_argument("Runtime: render target factory must not be null"); + } + + sdlRenderer = renderTargetFactory(scene->getExperimentName()); + if (!sdlRenderer) { + throw std::runtime_error("Runtime: render target factory returned no renderer"); + } + + auto formatStrategy = DataFormatStrategyFactory::create(config.output.format); + outputExtension = formatStrategy->fileExtension(); + + lslReader = std::make_unique(config); + dataWriter = std::make_unique(std::move(formatStrategy)); + renderer = std::make_unique(scene, sdlRenderer, markerQueue); +} + +Runtime::~Runtime() { shutdown(); } + +std::string Runtime::makeOutputPath() const { + const std::string name = sanitizeForFileName(scene->getExperimentName()); + + const auto now = std::chrono::system_clock::now().time_since_epoch(); + const auto epoch = std::chrono::duration_cast(now).count(); + + const std::filesystem::path fileName = + name + "_" + std::to_string(epoch) + "." + outputExtension; + return (std::filesystem::path(paths.outputDir) / fileName).string(); +} + +void Runtime::startWorkers(const std::string& outputPath) { + dataWriter->start(outputPath, eegQueue, markerQueue); + lslReader->start(eegQueue); +} + +void Runtime::run() { + outputFilePath = makeOutputPath(); + startWorkers(outputFilePath); + + renderer->render(stopSource.get_token()); + + shutdown(); +} + +void Runtime::requestStop() { stopSource.request_stop(); } + +void Runtime::shutdown() { + stopSource.request_stop(); -void Runtime::start() { std::cout << "Runtime started." << "\n"; } \ No newline at end of file + if (lslReader) { + lslReader->stop(); + } + if (dataWriter) { + dataWriter->stop(); + } +} diff --git a/src/config/ConfigParser.cpp b/src/config/ConfigParser.cpp index 11f93ef..5c43f5b 100644 --- a/src/config/ConfigParser.cpp +++ b/src/config/ConfigParser.cpp @@ -129,6 +129,14 @@ GroundConfig buildGround(const json& root) { return ground; } +OutputConfig buildOutput(const json& root) { + OutputConfig output; + if (root.contains("output")) { + output.format = requireField(root.at("output"), "format", "output"); + } + return output; +} + ImpedanceConfig buildImpedance(const json& root) { ImpedanceConfig impedance; if (root.contains("impedance_check")) { @@ -176,6 +184,7 @@ DeviceConfig ConfigParser::parseStream(std::istream& stream) { config.ground = buildGround(root); config.channels = buildChannels(root); config.impedance = buildImpedance(root); + config.output = buildOutput(root); // Mapping is done; the semantic rules belong to the types themselves. config.validate(); diff --git a/src/config/ConfigValidation.cpp b/src/config/ConfigValidation.cpp index ff44b99..1656af7 100644 --- a/src/config/ConfigValidation.cpp +++ b/src/config/ConfigValidation.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -59,11 +60,14 @@ void ImpedanceConfig::validate() const { } } +void OutputConfig::validate() const { requireNonEmpty(format, "format", "OutputConfig"); } + void DeviceConfig::validate() const { configVersion.validate(); requireNonEmpty(deviceName, "device_name", "DeviceConfig"); lsl.validate(); impedance.validate(); + output.validate(); if (static_cast(channels.size()) != lsl.expectedChannelCount) { throw std::invalid_argument("DeviceConfig: channel count mismatch: 'channels' has " + diff --git a/src/datawriter/CMakeLists.txt b/src/datawriter/CMakeLists.txt index c0b9322..b9a63e9 100644 --- a/src/datawriter/CMakeLists.txt +++ b/src/datawriter/CMakeLists.txt @@ -1,5 +1,6 @@ add_library(datawriter OBJECT CSVFormatStrategy.cpp + DataFormatStrategyFactory.cpp DataWriter.cpp ) diff --git a/src/datawriter/DataFormatStrategyFactory.cpp b/src/datawriter/DataFormatStrategyFactory.cpp new file mode 100644 index 0000000..d9560ea --- /dev/null +++ b/src/datawriter/DataFormatStrategyFactory.cpp @@ -0,0 +1,34 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char character) { return std::tolower(character); }); + return value; +} + +using StrategyCreator = std::function()>; + +const std::unordered_map& creators() { + static const std::unordered_map registry = { + {"csv", [] { return std::make_unique(); }}, + }; + return registry; +} +} // namespace + +std::unique_ptr DataFormatStrategyFactory::create(const std::string& format) { + const auto entry = creators().find(toLower(format)); + if (entry == creators().end()) { + throw std::invalid_argument("DataFormatStrategyFactory: unknown output format '" + format + + "'"); + } + return entry->second(); +} diff --git a/src/main.cpp b/src/main.cpp index 824bdc3..3dcb40c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,8 +1,42 @@ #include +#include +#include +#include +#include +#include + +namespace { +constexpr const char* kDefaultConfigPath = "config.json"; + +void printUsage(const char* program) { + std::cerr << "Usage: " << program << " [config.json] \n" + << " config.json defaults to '" << kDefaultConfigPath + << "' in the working directory.\n"; +} +} // namespace int main(int argc, char* argv[]) { - (void)argc; - (void)argv; - Runtime::start(); + const std::span args(argv, static_cast(argc)); + + RuntimePaths paths{.config = kDefaultConfigPath, .experiment = ""}; + + if (args.size() == 2) { + paths.experiment = args[1]; + } else if (args.size() >= 3) { + paths.config = args[1]; + paths.experiment = args[2]; + } else { + printUsage(args.empty() ? "NeuronIDE" : args[0]); + return 1; + } + + try { + Runtime runtime(paths); + runtime.run(); + } catch (const std::exception& e) { + std::cerr << "NeuronIDE: fatal: " << e.what() << "\n"; + return 1; + } + return 0; -} \ No newline at end of file +} diff --git a/tests/unit_tests/config_parser_test.cpp b/tests/unit_tests/config_parser_test.cpp index 279b5ff..a4db027 100644 --- a/tests/unit_tests/config_parser_test.cpp +++ b/tests/unit_tests/config_parser_test.cpp @@ -161,6 +161,36 @@ TEST(ConfigParserTest, ParsesReferenceGroundAndImpedance) { EXPECT_DOUBLE_EQ(config.impedance.thresholdKohm, kImpedanceThreshold); } +TEST(ConfigParserTest, ParsesOutputFormat) { + 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" } ], + "output": { "format": "csv" } + })json"; + EXPECT_EQ(parseString(jsonText).output.format, "csv"); +} + +TEST(ConfigParserTest, OutputFormatDefaultsToCsvWhenSectionAbsent) { + EXPECT_EQ(parseString(kMinimalConfig).output.format, "csv"); +} + +TEST(ConfigParserTest, MissingOutputFormatFieldThrows) { + 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" } ], + "output": { } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + TEST(ConfigParserTest, MissingLslStreamThrows) { const std::string jsonText = R"json({ "config_version": "1.0", diff --git a/tests/unit_tests/config_validation_test.cpp b/tests/unit_tests/config_validation_test.cpp index ec6167f..a84bc86 100644 --- a/tests/unit_tests/config_validation_test.cpp +++ b/tests/unit_tests/config_validation_test.cpp @@ -125,6 +125,23 @@ TEST(ConfigValidationTest, NegativeImpedanceThresholdThrows) { EXPECT_THROW(config.validate(), std::invalid_argument); } +TEST(ConfigValidationTest, EmptyOutputFormatThrows) { + DeviceConfig config = makeValidConfig(); + config.output.format.clear(); + + EXPECT_THROW(config.validate(), std::invalid_argument); +} + +TEST(ConfigValidationTest, UnknownOutputFormatIsNotAConfigRule) { + // Which formats exist is DataFormatStrategyFactory's knowledge, not the + // config layer's; an unknown-but-present format passes validation and is + // rejected when the strategy is built. + DeviceConfig config = makeValidConfig(); + config.output.format = "parquet"; + + EXPECT_NO_THROW(config.validate()); +} + TEST(ConfigValidationTest, NegativeVersionComponentThrows) { DeviceConfig config = makeValidConfig(); config.configVersion.minor = -1; diff --git a/tests/unit_tests/data_format_strategy_factory_test.cpp b/tests/unit_tests/data_format_strategy_factory_test.cpp new file mode 100644 index 0000000..040bb55 --- /dev/null +++ b/tests/unit_tests/data_format_strategy_factory_test.cpp @@ -0,0 +1,25 @@ +#include + +#include +#include +#include + +TEST(DataFormatStrategyFactoryTest, CreatesCsvStrategy) { + const auto strategy = DataFormatStrategyFactory::create("csv"); + ASSERT_NE(strategy, nullptr); + EXPECT_EQ(strategy->fileExtension(), "csv"); +} + +TEST(DataFormatStrategyFactoryTest, FormatMatchingIsCaseInsensitive) { + const auto strategy = DataFormatStrategyFactory::create("CSV"); + ASSERT_NE(strategy, nullptr); + EXPECT_EQ(strategy->fileExtension(), "csv"); +} + +TEST(DataFormatStrategyFactoryTest, UnknownFormatThrows) { + EXPECT_THROW(DataFormatStrategyFactory::create("parquet"), std::invalid_argument); +} + +TEST(DataFormatStrategyFactoryTest, EmptyFormatThrows) { + EXPECT_THROW(DataFormatStrategyFactory::create(""), std::invalid_argument); +} diff --git a/tests/unit_tests/runtime_test.cpp b/tests/unit_tests/runtime_test.cpp new file mode 100644 index 0000000..616184d --- /dev/null +++ b/tests/unit_tests/runtime_test.cpp @@ -0,0 +1,249 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils/ParserTestUtils.hpp" + +namespace { +namespace fs = std::filesystem; + +constexpr int kSurfaceSize = 10; +constexpr int kSurfaceDepth = 32; +constexpr auto kRenderSpinWait = std::chrono::milliseconds(50); + +constexpr const char* kValidConfig = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "runtime_test_stream", "type": "EEG", "source_id": "runtime-test-src", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] +})json"; + +// A software renderer backed by an in-memory surface: no window, GPU, or +// display required, so it runs anywhere (including headless CI). Injected in +// place of Runtime's production SDL window/vsync-renderer factory. +Runtime::RenderTargetFactory softwareRenderTargetFactory() { + return [](const std::string&) -> std::shared_ptr { + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat( + 0, kSurfaceSize, kSurfaceSize, kSurfaceDepth, SDL_PIXELFORMAT_RGBA32); + SDL_Renderer* renderer = SDL_CreateSoftwareRenderer(surface); + return {renderer, [surface](SDL_Renderer* target) { + if (target != nullptr) { + SDL_DestroyRenderer(target); + } + if (surface != nullptr) { + SDL_FreeSurface(surface); + } + }}; + }; +} + +void writeText(const fs::path& path, const std::string& content) { + std::ofstream out(path, std::ios::binary); + out << content; +} + +void writeExperimentFile(const fs::path& path, const NeuronIDE::Scene& scene) { + std::ofstream out(path, std::ios::binary); + scene.SerializeToOstream(&out); +} + +std::vector readAllLines(const fs::path& path) { + std::ifstream input(path); + std::vector lines; + std::string line; + while (std::getline(input, line)) { + lines.push_back(line); + } + return lines; +} + +// Owns the temp files that back a Runtime so a test never leaves artifacts on +// disk, and provides a valid config + experiment scene by default. +class RuntimeFixture : public ::testing::Test { + protected: + void SetUp() override { + // SdlSession initializes SDL_INIT_VIDEO; the dummy driver makes that + // succeed without a display. The software renderer is unaffected by it. + setenv("SDL_VIDEODRIVER", "dummy", 1); + + tempDir = fs::temp_directory_path() / + ("neuronide_runtime_" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + fs::create_directories(tempDir); + + writeText(tempDir / "config.json", kValidConfig); + writeExperimentFile(tempDir / "experiment.pb", utils::buildSimpleScene()); + } + + void TearDown() override { + std::error_code errorCode; + fs::remove_all(tempDir, errorCode); + } + + const fs::path& dir() const { return tempDir; } + + RuntimePaths paths() const { + return RuntimePaths{.config = (tempDir / "config.json").string(), + .experiment = (tempDir / "experiment.pb").string(), + .outputDir = tempDir.string()}; + } + + private: + fs::path tempDir; +}; +} // namespace + +TEST_F(RuntimeFixture, ConstructsWithValidInputs) { + EXPECT_NO_THROW({ const Runtime runtime(paths(), softwareRenderTargetFactory()); }); +} + +TEST_F(RuntimeFixture, OutputPathEmptyBeforeRun) { + const Runtime runtime(paths(), softwareRenderTargetFactory()); + EXPECT_TRUE(runtime.outputPath().empty()); +} + +TEST_F(RuntimeFixture, MissingConfigThrows) { + RuntimePaths badPaths = paths(); + badPaths.config = (dir() / "does_not_exist.json").string(); + + EXPECT_THROW(Runtime(badPaths, softwareRenderTargetFactory()), std::exception); +} + +TEST_F(RuntimeFixture, MissingExperimentThrows) { + RuntimePaths badPaths = paths(); + badPaths.experiment = (dir() / "does_not_exist.pb").string(); + + EXPECT_THROW(Runtime(badPaths, softwareRenderTargetFactory()), std::exception); +} + +TEST_F(RuntimeFixture, UnknownOutputFormatThrows) { + const std::string badFormatConfig = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "runtime_test_stream", "type": "EEG", "source_id": "runtime-test-src", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "output": { "format": "parquet" } + })json"; + writeText(dir() / "config.json", badFormatConfig); + + EXPECT_THROW(Runtime(paths(), softwareRenderTargetFactory()), std::invalid_argument); +} + +TEST_F(RuntimeFixture, NullRenderTargetFactoryThrows) { + EXPECT_THROW(Runtime(paths(), Runtime::RenderTargetFactory{}), std::invalid_argument); +} + +TEST_F(RuntimeFixture, FactoryReturningNullRendererThrows) { + const auto nullFactory = [](const std::string&) -> std::shared_ptr { + return nullptr; + }; + EXPECT_THROW(Runtime(paths(), nullFactory), std::runtime_error); +} + +TEST_F(RuntimeFixture, OutputFilenameDerivedFromExperimentNameAndDir) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + // Stop the render loop immediately so run() returns after a single pass. + runtime.requestStop(); + runtime.run(); + + const fs::path output = runtime.outputPath(); + EXPECT_EQ(output.parent_path(), dir()); + // Scene project name is "TestProject" (see ParserTestUtils::buildSimpleScene). + EXPECT_EQ(output.filename().string().rfind("TestProject_", 0), 0U); + EXPECT_EQ(output.extension(), ".csv"); +} + +TEST_F(RuntimeFixture, OutputFilenameSanitizesTheExperimentName) { + // An authored name is not a safe file name: unfiltered, "block 1/run" would + // point at a subdirectory that does not exist. + writeExperimentFile(dir() / "experiment.pb", + utils::buildSimpleScene({.projectName = "block 1/run"})); + + Runtime runtime(paths(), softwareRenderTargetFactory()); + runtime.requestStop(); + runtime.run(); + + const fs::path output = runtime.outputPath(); + EXPECT_EQ(output.parent_path(), dir()); + EXPECT_EQ(output.filename().string().rfind("block_1_run_", 0), 0U); + EXPECT_TRUE(fs::exists(output)); +} + +TEST_F(RuntimeFixture, RunWritesRecordingWithCsvHeader) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + runtime.requestStop(); + runtime.run(); + + const fs::path output = runtime.outputPath(); + ASSERT_TRUE(fs::exists(output)); + + const auto lines = readAllLines(output); + ASSERT_FALSE(lines.empty()); + EXPECT_EQ(lines.front(), "type,timestamp,payload"); +} + +TEST_F(RuntimeFixture, RunStopsOnQuitEvent) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + SDL_Event quit; + quit.type = SDL_QUIT; + ASSERT_EQ(SDL_PushEvent(&quit), 1); + + // Must return promptly on SDL_QUIT even though no stop was requested. + runtime.run(); + + EXPECT_TRUE(fs::exists(runtime.outputPath())); +} + +TEST_F(RuntimeFixture, RequestStopFromAnotherThreadEndsRun) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + // Ensure no stale SDL_QUIT from a previous test ends the loop early. + SDL_FlushEvent(SDL_QUIT); + + std::thread worker([&runtime] { runtime.run(); }); + std::this_thread::sleep_for(kRenderSpinWait); + runtime.requestStop(); + worker.join(); + + EXPECT_TRUE(fs::exists(runtime.outputPath())); +} + +// Covers the production SDL window + vsync renderer factory where the platform +// can provide an accelerated renderer; skipped on headless machines that can't. +TEST_F(RuntimeFixture, DefaultRenderTargetFactoryOnCapablePlatform) { + // Let SDL pick the best available driver instead of the forced dummy one, + // which cannot provide an accelerated renderer. + unsetenv("SDL_VIDEODRIVER"); + + try { + Runtime runtime(paths(), Runtime::defaultRenderTargetFactory()); + + SDL_Event quit; + quit.type = SDL_QUIT; + ASSERT_EQ(SDL_PushEvent(&quit), 1); + runtime.run(); + + EXPECT_TRUE(fs::exists(runtime.outputPath())); + } catch (const std::exception& e) { + GTEST_SKIP() << "No accelerated render target on this platform: " << e.what(); + } +}