diff --git a/BUILD.md b/BUILD.md index c3973b9..99741fe 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,6 +2,25 @@ This file includes quick start recipes. To see general principles, look [here](https://github.com/labstreaminglayer/labstreaminglayer/blob/master/doc/BUILD.md). +## Stream selection regression check + +With a GUI build configured, build and run the standalone selection check: + +```sh +cmake --build build --target teststreamselection --config Release +./build/teststreamselection +``` + +For a Visual Studio build, run `build\Release\teststreamselection.exe` instead. +This target uses the application's Qt and liblsl dependencies and is built only +when requested. It runs the actual GUI selection code offscreen with temporary +configuration files and synthetic outlets in a private LSL session. Local stream +discovery must be available. A failed check returns a nonzero exit code. + +The checks cover stream identities after query selection and refresh, missing +stream deselection and watchlist input, and disappearance/reappearance without +switching source identities. They do not start a recording or validate XDF contents. + ## Windows - CMake - Visual Studio 2017 diff --git a/CMakeLists.txt b/CMakeLists.txt index a7d904e..d2da3cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -198,6 +198,20 @@ if(LABRECORDER_BUILD_GUI) LSL::lsl ) + # Build explicitly with --target teststreamselection; uses the actual GUI code. + add_executable(teststreamselection EXCLUDE_FROM_ALL + src/test_stream_selection.cpp + src/mainwindow.cpp + src/mainwindow.h + src/mainwindow.ui + src/recording.cpp + src/tcpinterface.cpp + src/tcpinterface.h + ) + target_link_libraries(teststreamselection PRIVATE + xdfwriter Qt6::Core Qt6::Widgets Qt6::Network Threads::Threads LSL::lsl + ) + # macOS bundle configuration if(APPLE) set(MACOSX_BUNDLE_GUI_IDENTIFIER "com.labstreaminglayer.${PROJECT_NAME}") diff --git a/README.md b/README.md index 8664d72..bdbb2cf 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,20 @@ If you check the box to EnableRCS then LabRecorder exposes some rudimentary cont Currently supported commands include: * `select all` * `select none` -* `start` +* `select ` - checks streams matching an LSL resolver predicate such as `name='BioSemi'`, `type='EEG'`, or `name='BioSemi' and hostname='LabPC1'`. +* `start` - starts recording the current stream selection; returns an error if no streams are selected. * `stop` * `update` * `filename ...` +Commands respond with `OK`, `WARNING ...`, or `ERROR ...`. + +Query selection is additive: use `select none` before `select ` to select +only its matches. For a stream with a nonempty source ID, that identity is retained +during a session if it disappears, so a different source with the same display name +is not selected or recorded in its place. Unchecked missing streams are not watched +for recording. + `filename` is followed by a series of space-delimited options enclosed in curly braces. e.g. {root:C:\root_data_dir} * `root` - Sets the root data directory. * `template` - sets the File Name / Template. Will unselect BIDS option. May contain wildcards. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index be82724..c69eb26 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -40,7 +40,7 @@ MainWindow::MainWindow(QWidget *parent, const char *config_file) connect(ui->refreshButton, &QPushButton::clicked, this, &MainWindow::refreshStreams); connect(ui->selectAllButton, &QPushButton::clicked, this, &MainWindow::selectAllStreams); connect(ui->selectNoneButton, &QPushButton::clicked, this, &MainWindow::selectNoStreams); - connect(ui->startButton, &QPushButton::clicked, this, &MainWindow::startRecording); + connect(ui->startButton, &QPushButton::clicked, this, [this]() { startRecording(); }); connect(ui->stopButton, &QPushButton::clicked, this, &MainWindow::stopRecording); connect(ui->actionAbout, &QAction::triggered, this, [this]() { QString infostr = QStringLiteral("LSL library version: ") + @@ -125,6 +125,7 @@ void MainWindow::blockSelected(const QString &block) { void MainWindow::load_config(QString filename) { qInfo() << "loading config file " << QDir::toNativeSeparators(filename); + updateStreamSelectionFromUi(); bool auto_start = false; try { QSettings pt(QDir::cleanPath(filename), QSettings::Format::IniFormat); @@ -133,11 +134,10 @@ void MainWindow::load_config(QString filename) { // required streams // ---------------------------- auto required = pt.value("RequiredStreams").toStringList(); -#if QT_VERSION >= QT_VERSION_CHECK(5,14,0) - missingStreams = QSet(required.begin(), required.end()); -#else - missingStreams = required.toSet(); -#endif + required.removeDuplicates(); + missingStreams.clear(); + for (const auto &name : required) + missingStreams.append(MissingStreamItem{name, true, std::nullopt}); // ---------------------------- // online sync streams @@ -263,22 +263,25 @@ void MainWindow::load_config(QString filename) { } catch (std::exception &e) { qWarning() << "Problem parsing config file: " << e.what(); } // std::cout << "refreshing streams ..." <rootEdit->text())); if (!ui->check_bids->isChecked()) settings.setValue("PathTemplate", QDir::cleanPath(ui->lineEdit_template->text())); - // Build QStringList from missingStreams and knownStreams that are missing. - QStringList requiredStreams = missingStreams.values(); + // Save only the selected streams, whether currently available or missing. + QStringList requiredStreams = selectedMissingStreams(); for (auto &k : knownStreams) { if (k.checked) { requiredStreams.append(k.listName()); } } - qInfo() << missingStreams; + qInfo() << selectedMissingStreams(); settings.setValue("RequiredStreams", requiredStreams); // Stub. } @@ -287,6 +290,99 @@ QString info_to_listName(const lsl::stream_info& info) { return QString::fromStdString(info.name() + " (" + info.hostname() + ")"); } +bool MissingStreamItem::matches(const lsl::stream_info &info) const { + return lastKnown ? lastKnown->matches(info) : label == info_to_listName(info); +} + +// XPath string literals do not use backslash escaping. Handle metadata containing quotes. +static std::string queryLiteral(const std::string &value) { + if (value.find('\'') == std::string::npos) return "'" + value + "'"; + if (value.find('"') == std::string::npos) return "\"" + value + "\""; + std::string result = "concat("; + size_t start = 0, quote; + while ((quote = value.find('\'', start)) != std::string::npos) { + result += "'" + value.substr(start, quote - start) + "',\"'\","; + start = quote + 1; + } + return result + "'" + value.substr(start) + "')"; +} + +QStringList MainWindow::selectedMissingStreams() const { + QStringList selected; + for (const auto &missing : missingStreams) { + if (missing.checked) selected.append(missing.label); + } + return selected; +} + +std::vector MainWindow::selectedMissingStreamQueries() const { + std::vector queries; + const QRegularExpression re("(.+)\\s+\\((\\S+)\\)"); + for (const auto &missing : missingStreams) { + if (!missing.checked) continue; + std::string query; + if (missing.lastKnown) { + const auto &stream = *missing.lastKnown; + query = "name=" + queryLiteral(stream.name) + " and type=" + queryLiteral(stream.type) + + " and source_id=" + queryLiteral(stream.id); + if (stream.id.empty()) + query += " and hostname=" + queryLiteral(stream.host) + + " and session_id=" + queryLiteral(stream.sessionId); + } else { + // Preserve the name/host semantics of configured RequiredStreams entries. + const auto match = re.match(missing.label); + const QString name = match.hasMatch() ? match.captured(1) : missing.label; + query = "name=" + queryLiteral(name.toStdString()); + if (match.hasMatch() && match.captured(2).size() > 1) + query += " and hostname=" + queryLiteral(match.captured(2).toStdString()); + } + queries.push_back(query); + } + return queries; +} + +void MainWindow::updateStreamSelectionFromUi() { + for (int i = 0; i < ui->streamList->count(); i++) { + QListWidgetItem *item = ui->streamList->item(i); + bool ok = false; + int knownIndex = item->data(Qt::UserRole).toInt(&ok); + if (ok && knownIndex >= 0 && knownIndex < knownStreams.count()) + knownStreams[knownIndex].checked = item->checkState() == Qt::Checked; + else if (ok && knownIndex < 0 && -knownIndex <= missingStreams.count()) + missingStreams[-knownIndex - 1].checked = item->checkState() == Qt::Checked; + } +} + +void MainWindow::rebuildStreamList() { + const QBrush good_brush(QColor(0, 128, 0)), bad_brush(QColor(255, 0, 0)); + ui->streamList->clear(); + for (int i = 0; i < missingStreams.count(); ++i) { + const auto &missing = missingStreams[i]; + auto *item = new QListWidgetItem(missing.label, ui->streamList); + item->setData(Qt::UserRole, -i - 1); + item->setCheckState(missing.checked ? Qt::Checked : Qt::Unchecked); + if (missing.lastKnown) + item->setToolTip(QString("Type: %1\nSource ID: %2") + .arg(QString::fromStdString(missing.lastKnown->type), + QString::fromStdString(missing.lastKnown->id))); + item->setForeground(bad_brush); + ui->streamList->addItem(item); + } + for (int i = 0; i < knownStreams.count(); i++) { + const auto &k = knownStreams[i]; + auto *item = new QListWidgetItem(k.listName(), ui->streamList); + item->setData(Qt::UserRole, i); + item->setCheckState(k.checked ? Qt::Checked : Qt::Unchecked); + item->setForeground(good_brush); + item->setToolTip(QString("Name: %1\nType: %2\nSource ID: %3\nHostname: %4") + .arg(QString::fromStdString(k.name), + QString::fromStdString(k.type), + QString::fromStdString(k.id), + QString::fromStdString(k.host))); + ui->streamList->addItem(item); + } +} + /** * @brief MainWindow::refreshStreams Find streams, generate a list of missing streams * and fill the UI streamlist. @@ -294,28 +390,28 @@ QString info_to_listName(const lsl::stream_info& info) { */ std::vector MainWindow::refreshStreams() { const std::vector resolvedStreams = lsl::resolve_streams(1.0); + updateStreamSelectionFromUi(); // For each item in resolvedStreams, ignore if already in knownStreams, otherwise add to knownStreams. - // if in missingStreams then also mark it as required (--> checked by default) and remove from missingStreams. + // Carry over a missing item's selection when the stream becomes available. for (const auto& s : resolvedStreams) { bool known = false; for (auto &k : knownStreams) { - known |= s.name() == k.name && s.type() == k.type && s.source_id() == k.id; + if (k.matches(s)) { + k.updateInfo(s); + known = true; + break; + } } if (!known) { - bool found = missingStreams.contains(info_to_listName(s)); - knownStreams << StreamItem(s.name(), s.type(), s.source_id(), s.hostname(), found); - if (found) { missingStreams.remove(info_to_listName(s)); } - } - } - // For each item in knownStreams, update its checked status from GUI. (only works for streams found on a previous refresh) - // Because we search by name + host, entries aren't guaranteed to be unique, so checking one entry with matching name and host checks them all. - for (auto &k : knownStreams) { - QList foundItems = ui->streamList->findItems(k.listName(), Qt::MatchCaseSensitive); - if (foundItems.count() > 0) { bool checked = false; - for (auto &fi : foundItems) { checked |= fi->checkState() == Qt::Checked; } - k.checked = checked; + for (int i = missingStreams.count() - 1; i >= 0; --i) { + if (missingStreams[i].matches(s)) { + checked |= missingStreams[i].checked; + missingStreams.removeAt(i); + } + } + knownStreams << StreamItem(s, checked); } } // For each item in knownStreams; if it is not resolved then drop it. If it was checked then add back to missingStreams. @@ -326,11 +422,11 @@ std::vector MainWindow::refreshStreams() { size_t r_ind = 0; while (!resolved && r_ind < resolvedStreams.size()) { const lsl::stream_info r = resolvedStreams[r_ind]; - resolved |= (r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id); + resolved |= k.matches(r); r_ind++; } if (!resolved) { - if (k.checked) { missingStreams += k.listName(); } + if (k.checked) missingStreams.append(MissingStreamItem{k.listName(), true, k}); knownStreams.removeAt(k_ind); } else { k_ind++; @@ -339,31 +435,13 @@ std::vector MainWindow::refreshStreams() { // Clear the streamList // Add missing items first. // Then add knownStreams (only in list if resolved). - const QBrush good_brush(QColor(0, 128, 0)), bad_brush(QColor(255, 0, 0)); - ui->streamList->clear(); - for (auto& m : std::as_const(missingStreams)) { - auto *item = new QListWidgetItem(m, ui->streamList); - item->setCheckState(Qt::Checked); - item->setForeground(bad_brush); - ui->streamList->addItem(item); - } - for (auto& k : knownStreams) { - auto *item = new QListWidgetItem(k.listName(), ui->streamList); - item->setCheckState(k.checked ? Qt::Checked : Qt::Unchecked); - item->setForeground(good_brush); - item->setToolTip(QString("Name: %1\nType: %2\nSource ID: %3\nHostname: %4") - .arg(QString::fromStdString(k.name), - QString::fromStdString(k.type), - QString::fromStdString(k.id), - QString::fromStdString(k.host))); - ui->streamList->addItem(item); - } + rebuildStreamList(); // return a std::vector of streams of checked and not missing streams. std::vector requestedAndAvailableStreams; for (const auto &r : resolvedStreams) { for (auto &k : knownStreams) { - if ((r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id)) { + if (k.matches(r)) { if (k.checked) { requestedAndAvailableStreams.push_back(r); } break; } @@ -372,22 +450,23 @@ std::vector MainWindow::refreshStreams() { return requestedAndAvailableStreams; } -void MainWindow::startRecording() { +MainWindow::StartResult MainWindow::startRecording() { if (!currentRecording) { - // automatically refresh streams const std::vector requestedAndAvailableStreams = refreshStreams(); + const QStringList selectedMissing = selectedMissingStreams(); + const auto watchfor = selectedMissingStreamQueries(); if (!hideWarnings) { // if a checked stream is now missing - if (!missingStreams.isEmpty()) { + if (!selectedMissing.isEmpty()) { // are you sure? QMessageBox msgBox(QMessageBox::Warning, "Stream not found", "At least one of the streams that you checked seems to be offline", QMessageBox::Yes | QMessageBox::No, this); msgBox.setInformativeText("Do you want to start recording anyway?"); msgBox.setDefaultButton(QMessageBox::No); - if (msgBox.exec() != QMessageBox::Yes) return; + if (msgBox.exec() != QMessageBox::Yes) return StartResult::Failed; } if (requestedAndAvailableStreams.size() == 0) { @@ -395,7 +474,7 @@ void MainWindow::startRecording() { "You have selected no streams", QMessageBox::Yes | QMessageBox::No, this); msgBox.setInformativeText("Do you want to start recording anyway?"); msgBox.setDefaultButton(QMessageBox::No); - if (msgBox.exec() != QMessageBox::Yes) return; + if (msgBox.exec() != QMessageBox::Yes) return StartResult::Failed; } } @@ -403,13 +482,13 @@ void MainWindow::startRecording() { QString recFilename = replaceFilename(QDir::cleanPath(ui->lineEdit_template->text())); if (recFilename.isEmpty()) { QMessageBox::critical(this, "Filename empty", "Can not record without a file name"); - return; + return StartResult::Failed; } if (ui->rootEdit->text().trimmed().isEmpty()) { QMessageBox::critical(this, "Study Root empty", "Can not record without a Study Root folder. " "Please set a Study Root before recording."); - return; + return StartResult::Failed; } recFilename.prepend(QDir::cleanPath(ui->rootEdit->text()) + '/'); @@ -418,7 +497,7 @@ void MainWindow::startRecording() { if (recFileInfo.isDir()) { QMessageBox::warning( this, "Error", "Recording path already exists and is a directory"); - return; + return StartResult::Failed; } QString rename_to = recFileInfo.absolutePath() + '/' + recFileInfo.baseName() + "_old%1." + recFileInfo.suffix(); @@ -429,7 +508,7 @@ void MainWindow::startRecording() { if (!QFile::rename(recFileInfo.absoluteFilePath(), newname)) { QMessageBox::warning(this, "Permissions issue", "Cannot rename the file " + recFilename + " to " + newname); - return; + return StartResult::Failed; } qInfo() << "Moved existing file to " << newname; recFileInfo.refresh(); @@ -440,42 +519,23 @@ void MainWindow::startRecording() { QMessageBox::warning(this, "Permissions issue", "Can not create the directory " + recFileInfo.dir().path() + ". Please check your permissions."); - return; + return StartResult::Failed; } - std::vector watchfor; - for (const QString &missing : std::as_const(missingStreams)) { - std::string query; - // Convert missing to query expected by lsl::resolve_stream - // name='BioSemi' and hostname=AASDFSDF - QRegularExpression re("(.+)\\s+\\((\\S+)\\)"); - QRegularExpressionMatch match = re.match(missing); - if (match.hasMatch()) - { - QString name = match.captured(1); - QString host = match.captured(2); - query = "name='" + match.captured(1).toStdString() + "'"; - if (host.size() > 1) { - query += " and hostname='" + host.toStdString() + "'"; - } - } else { - // Regexp failed but we can try using the entire string as the stream name. - query = "name='" + missing.toStdString() + "'"; - } - watchfor.push_back(query); - } - qInfo() << "Missing: " << missingStreams; + qInfo() << "Missing: " << selectedMissing; currentRecording = std::make_unique(recFilename.toStdString(), requestedAndAvailableStreams, watchfor, syncOptionsByStreamName, true); ui->stopButton->setEnabled(true); ui->startButton->setEnabled(false); startTime = (int)lsl::local_clock(); + return StartResult::Started; } else if (!hideWarnings) { QMessageBox::information( this, "Already recording", "The recording is already running", QMessageBox::Ok); } + return StartResult::AlreadyRecording; } void MainWindow::stopRecording() { @@ -507,6 +567,44 @@ void MainWindow::selectNoStreams() { } } +bool MainWindow::hasSelectedStreams() const { + for (int i = 0; i < ui->streamList->count(); i++) { + const QListWidgetItem *item = ui->streamList->item(i); + if (item->checkState() == Qt::Checked) return true; + } + return false; +} + +MainWindow::SelectResult MainWindow::selectStreams(const QString &query) { + updateStreamSelectionFromUi(); + std::vector matchedStreams; + try { + matchedStreams = lsl::resolve_stream(query.toStdString(), 0, 1.0); + } catch (std::exception &e) { + qWarning() << "Invalid stream selection query" << query << ":" << e.what(); + return SelectResult::InvalidQuery; + } + if (matchedStreams.empty()) return SelectResult::NoMatches; + + for (const auto &stream : matchedStreams) { + bool known = false; + for (auto &k : knownStreams) { + if (k.matches(stream)) { + k.updateInfo(stream); + k.checked = true; + known = true; + break; + } + } + if (!known) knownStreams << StreamItem(stream, true); + for (int i = missingStreams.count() - 1; i >= 0; --i) { + if (missingStreams[i].matches(stream)) missingStreams.removeAt(i); + } + } + rebuildStreamList(); + return SelectResult::Selected; +} + void MainWindow::buildBidsTemplate() { // path/to/CurrentStudy/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf @@ -647,6 +745,7 @@ void MainWindow::enableRcs(bool bEnable) { connect(rcs.get(), &RemoteControlSocket::filename, this, &MainWindow::rcsUpdateFilename); connect(rcs.get(), &RemoteControlSocket::select_all, this, &MainWindow::selectAllStreams); connect(rcs.get(), &RemoteControlSocket::select_none, this, &MainWindow::selectNoStreams); + connect(rcs.get(), &RemoteControlSocket::select_stream, this, &MainWindow::rcsSelectStreams); } bool oldState = ui->rcsCheckBox->blockSignals(true); ui->rcsCheckBox->setChecked(bEnable); @@ -660,17 +759,48 @@ void MainWindow::rcsportValueChangedInt(int value) { } } -void MainWindow::rcsStartRecording() { - // since we want to avoid a pop-up window when streams are missing or unchecked, - // we'll check all the streams and start recording +void MainWindow::rcsStartRecording(QTcpSocket *sock) { + // Remote start should record the current stream selection. Do not call + // selectAllStreams() here; doing so would override TCP `select ` commands. + // hideWarnings suppresses non-critical confirmation dialogs for remote control. + if (currentRecording) { + if (sock) sock->write("WARNING already recording"); + return; + } + if (!hasSelectedStreams()) { + qWarning() << "Remote start rejected: no streams selected"; + if (sock) sock->write("ERROR no streams selected"); + return; + } + const bool oldHideWarnings = hideWarnings; hideWarnings = true; - selectAllStreams(); - startRecording(); + const StartResult result = startRecording(); + hideWarnings = oldHideWarnings; + if (!sock) return; + if (result == StartResult::Started) + sock->write("OK"); + else if (result == StartResult::AlreadyRecording) + sock->write("WARNING already recording"); + else + sock->write("ERROR failed to start recording"); +} + +void MainWindow::rcsSelectStreams(const QString &query, QTcpSocket *sock) { + const SelectResult result = selectStreams(query); + if (!sock) return; + if (result == SelectResult::Selected) + sock->write("OK"); + else if (result == SelectResult::NoMatches) + sock->write("WARNING no streams matched"); + else + sock->write("ERROR invalid select query"); } void MainWindow::rcsStopRecording() { + const bool oldHideWarnings = hideWarnings; hideWarnings = true; stopRecording(); + hideWarnings = oldHideWarnings; } void MainWindow::rcsUpdateFilename(QString s) { diff --git a/src/mainwindow.h b/src/mainwindow.h index abeaad4..28dc177 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -7,6 +7,7 @@ #include #include #include //for std::unique_ptr +#include // LSL #include @@ -17,22 +18,44 @@ class MainWindow; class recording; class RemoteControlSocket; +class QTcpSocket; class StreamItem { public: - StreamItem(std::string stream_name, std::string stream_type, std::string source_id, - std::string hostname, bool required) - : name(stream_name), type(stream_type), id(source_id), host(hostname), checked(required) {} + StreamItem(const lsl::stream_info &info, bool required) + : name(info.name()), type(info.type()), id(info.source_id()), host(info.hostname()), + sessionId(info.session_id()), checked(required) {} - QString listName() { return QString::fromStdString(name + " (" + host + ")"); } + QString listName() const { return QString::fromStdString(name + " (" + host + ")"); } + bool matches(const lsl::stream_info &info) const { + if (!id.empty() || !info.source_id().empty()) + return id == info.source_id() && name == info.name() && type == info.type(); + return name == info.name() && type == info.type() && host == info.hostname() && + sessionId == info.session_id(); + } + void updateInfo(const lsl::stream_info &info) { + name = info.name(); + type = info.type(); + id = info.source_id(); + host = info.hostname(); + sessionId = info.session_id(); + } std::string name; std::string type; std::string id; std::string host; + std::string sessionId; bool checked; }; +struct MissingStreamItem { + QString label; + bool checked; + // Configured requirements have only a label; discovered streams retain their identity. + std::optional lastKnown; + bool matches(const lsl::stream_info &info) const; +}; class MainWindow : public QMainWindow { Q_OBJECT @@ -46,7 +69,6 @@ private slots: void closeEvent(QCloseEvent *ev) override; void blockSelected(const QString &block); std::vector refreshStreams(void); - void startRecording(void); void stopRecording(void); void selectAllStreams(); void selectNoStreams(); @@ -56,15 +78,28 @@ private slots: void enableRcs(bool bEnable); void rcsCheckBoxChanged(bool checked); void rcsUpdateFilename(QString s); - void rcsStartRecording(); + void rcsStartRecording(QTcpSocket *sock); + void rcsSelectStreams(const QString &query, QTcpSocket *sock); void rcsStopRecording(); void rcsportValueChangedInt(int value); private: + friend class StreamSelectionTest; + + enum class StartResult { Started, AlreadyRecording, Failed }; + enum class SelectResult { Selected, NoMatches, InvalidQuery }; + QString replaceFilename(QString fullfile) const; // function for loading / saving the config file QString find_config_file(const char *filename); QString counterPlaceholder() const; + StartResult startRecording(); + SelectResult selectStreams(const QString &query); + bool hasSelectedStreams() const; + QStringList selectedMissingStreams() const; + std::vector selectedMissingStreamQueries() const; + void updateStreamSelectionFromUi(); + void rebuildStreamList(); void load_config(QString filename); void save_config(QString filename); @@ -75,7 +110,7 @@ private slots: std::unique_ptr timer; QList knownStreams; - QSet missingStreams; + QList missingStreams; std::map syncOptionsByStreamName; // QString recFilename; diff --git a/src/tcpinterface.cpp b/src/tcpinterface.cpp index 044486b..6ff3c37 100644 --- a/src/tcpinterface.cpp +++ b/src/tcpinterface.cpp @@ -17,20 +17,25 @@ void RemoteControlSocket::addClient() { void RemoteControlSocket::handleLine(QString s, QTcpSocket *sock) { qInfo() << s; - if (s == "start") - emit start(); - else if (s == "stop") + if (s == "start") { + emit start(sock); + return; + } else if (s == "stop") emit stop(); else if (s == "update") emit refresh_streams(); - else if (s.contains("filename")) { + else if (s == "filename" || s.startsWith("filename ")) { emit filename(s); - } else if (s.contains("select")) { - if (s.contains("all")) { - emit select_all(); - } else if (s.contains("none")) { - emit select_none(); - } + } else if (s == "select all") { + emit select_all(); + } else if (s == "select none") { + emit select_none(); + } else if (s.startsWith("select ")) { + emit select_stream(s.mid(QStringLiteral("select ").size()), sock); + return; + } else { + sock->write("ERROR unknown command"); + return; } sock->write("OK"); // TODO: select /deselect streams diff --git a/src/tcpinterface.h b/src/tcpinterface.h index 3eeef1e..53a2f36 100644 --- a/src/tcpinterface.h +++ b/src/tcpinterface.h @@ -16,11 +16,12 @@ class RemoteControlSocket : public QObject { signals: void refresh_streams(); - void start(); + void start(QTcpSocket *sock); void stop(); void filename(QString s); void select_all(); void select_none(); + void select_stream(QString query, QTcpSocket *sock); public slots: void addClient(); diff --git a/src/test_stream_selection.cpp b/src/test_stream_selection.cpp new file mode 100644 index 0000000..d0a9e67 --- /dev/null +++ b/src/test_stream_selection.cpp @@ -0,0 +1,196 @@ +#include "mainwindow.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +void require(bool condition, const char *message) { + if (!condition) throw std::runtime_error(message); +} + +void requireOnly(const std::vector &streams, const std::string &id) { + require(streams.size() == 1, "Expected exactly one selected stream after refresh"); + require(streams.front().source_id() == id, "Refresh selected the wrong source ID"); +} + +void writeConfig(const QString &path, const QString &root, const QStringList &required = {}) { + QSettings config(path, QSettings::IniFormat); + config.setValue("StudyRoot", root); + config.setValue("PathTemplate", "selection-test.xdf"); + config.setValue("RCSEnabled", false); + config.setValue("AutoStart", false); + config.setValue("RequiredStreams", required); + config.sync(); + require(config.status() == QSettings::NoError, "Cannot write temporary recorder config"); +} +} // namespace + +// Access is limited to this executable; no QtTest or alternate selection implementation. +class StreamSelectionTest { +public: + static void sourceIds(const QString &configPath) { + lsl::stream_outlet anonymous(lsl::stream_info("SelectionTwin", "Test", 1, 0, + lsl::cf_float32, "")); + lsl::stream_outlet identified(lsl::stream_info("SelectionTwin", "Test", 1, 0, + lsl::cf_float32, "different-source")); + MainWindow window(nullptr, configPath.toUtf8().constData()); + const auto matches = lsl::resolve_stream("name='SelectionTwin'", 2, 5.0); + require(matches.size() == 2, "Could not discover both test outlets"); + + // Resolver order is unspecified. Exercise both orders of the existing GUI rows. + for (bool reverse : {false, true}) { + window.findChild("streamList")->clear(); + window.knownStreams.clear(); + window.knownStreams << StreamItem(matches[reverse ? 1 : 0], false) + << StreamItem(matches[reverse ? 0 : 1], false); + window.rebuildStreamList(); + window.selectNoStreams(); + require(window.selectStreams("source_id='different-source'") == + MainWindow::SelectResult::Selected, "Source-ID query did not select a stream"); + requireOnly(window.refreshStreams(), "different-source"); + } + + // Invalid and unmatched queries must preserve the existing selection. + require(window.selectStreams("name='absent'") == MainWindow::SelectResult::NoMatches, + "Unmatched query returned the wrong result"); + requireOnly(window.refreshStreams(), "different-source"); + require(window.selectStreams("name='unterminated") == MainWindow::SelectResult::InvalidQuery, + "Malformed query returned the wrong result"); + requireOnly(window.refreshStreams(), "different-source"); + } + + static void missingSelection(const QString &configPath, const QString &root) { + lsl::stream_outlet intended(lsl::stream_info("SelectionIntended", "Test", 1, 0, + lsl::cf_float32, "intended")); + const QString missing = "SelectionMissing (" + + QString::fromStdString(intended.info().hostname()) + ")"; + writeConfig(configPath, root, {missing}); + MainWindow window(nullptr, configPath.toUtf8().constData()); + require(window.selectedMissingStreams().contains(missing), + "Configured missing stream should initially be selected"); + window.selectNoStreams(); + require(!window.hasSelectedStreams(), "Select none left a stream selected"); + window.refreshStreams(); + require(!window.hasSelectedStreams(), "Refresh reselected a missing stream"); + window.load_config(configPath); + require(window.selectedMissingStreams().contains(missing), + "Loading config did not restore its required-stream defaults"); + window.selectNoStreams(); + require(window.selectStreams("source_id='intended'") == MainWindow::SelectResult::Selected, + "Intended stream query did not match"); + requireOnly(window.refreshStreams(), "intended"); + const auto rows = window.findChild("streamList") + ->findItems(missing, Qt::MatchExactly); + require(rows.size() == 1 && rows.front()->checkState() == Qt::Unchecked, + "Query/rebuild lost the missing stream's unchecked state"); + require(window.selectedMissingStreamQueries().empty(), + "An unchecked missing stream would enter the recording watchlist"); + window.save_config(root + "/saved.cfg"); + QSettings saved(root + "/saved.cfg", QSettings::IniFormat); + require(!saved.value("RequiredStreams").toStringList().contains(missing), + "Saving config restored an unchecked missing stream as required"); + + window.selectAllStreams(); + window.refreshStreams(); + require(window.selectedMissingStreams().contains(missing), + "Select all failed to restore the missing stream to the watchlist"); + require(window.selectedMissingStreamQueries().size() == 1, + "Checked required stream was omitted from the watchlist"); + window.selectNoStreams(); + window.selectStreams("source_id='intended'"); + lsl::stream_outlet late(lsl::stream_info("SelectionMissing", "Test", 1, 0, + lsl::cf_float32, "excluded-late")); + require(lsl::resolve_stream("source_id='excluded-late'", 1, 5.0).size() == 1, + "Could not discover the late test outlet"); + requireOnly(window.refreshStreams(), "intended"); + } + + static void reconnectIdentity(const QString &configPath) { + auto sourceA = std::make_unique(lsl::stream_info( + "ReconnectTwin", "Test", 1, 0, lsl::cf_float32, "source-A")); + MainWindow window(nullptr, configPath.toUtf8().constData()); + window.selectNoStreams(); + require(window.selectStreams("source_id='source-A'") == MainWindow::SelectResult::Selected, + "Source A was not selected"); + requireOnly(window.refreshStreams(), "source-A"); + const auto originalInfo = sourceA->info(); + sourceA.reset(); + require(window.refreshStreams().empty(), "Source A should be offline"); + require(window.selectedMissingStreams().size() == 1, "Lost selected source A while offline"); + lsl::stream_outlet sourceB(lsl::stream_info( + "ReconnectTwin", "Test", 1, 0, lsl::cf_float32, "source-B")); + require(lsl::resolve_stream("source_id='source-B'", 1, 5.0).size() == 1, + "Could not discover source B"); + require(window.refreshStreams().empty(), "Source B replaced selected source A"); + require(window.selectedMissingStreams().size() == 1, "Source B removed missing source A"); + const auto queries = window.selectedMissingStreamQueries(); + require(queries.size() == 1 && originalInfo.matches_query(queries.front().c_str()) && + !sourceB.info().matches_query(queries.front().c_str()), + "Recording watchlist no longer requires source A's identity"); + sourceA = std::make_unique(lsl::stream_info( + "ReconnectTwin", "Test", 1, 0, lsl::cf_float32, "source-A")); + require(lsl::resolve_stream("source_id='source-A'", 1, 5.0).size() == 1, + "Could not rediscover source A"); + requireOnly(window.refreshStreams(), "source-A"); + sourceA.reset(); + window.refreshStreams(); + window.selectStreams("source_id='source-B'"); + require(window.selectedMissingStreams().size() == 1, + "Selecting source B erased the pending selection of source A"); + } + + static void watchlistIdentity(const QString &configPath) { + MainWindow window(nullptr, configPath.toUtf8().constData()); + // Also exercise metadata quoting and the empty-ID fallback without live discovery. + for (const std::string id : {std::string(), std::string("source-'\"A")}) { + lsl::stream_info original("Quoted '\" stream", "Test", 1, 0, lsl::cf_float32, id); + lsl::stream_info other("Quoted '\" stream", "Test", 1, 0, lsl::cf_float32, "other"); + window.missingStreams = { + MissingStreamItem{"same label", true, StreamItem(original, true)}, + MissingStreamItem{"same label", true, StreamItem(other, true)}}; + window.rebuildStreamList(); + window.findChild("streamList")->item(1)->setCheckState(Qt::Unchecked); + window.updateStreamSelectionFromUi(); + const auto queries = window.selectedMissingStreamQueries(); + require(queries.size() == 1 && original.matches_query(queries.front().c_str()) && + !other.matches_query(queries.front().c_str()), + "Missing rows with equal labels lost their independent identity/selection"); + } + } +}; + +int main(int argc, char **argv) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + QTemporaryDir temp; + if (!temp.isValid()) return 1; + // Isolate discovery from other experiments before creating any liblsl object. + QFile lslConfig(temp.filePath("lsl_api.cfg")); + if (!lslConfig.open(QIODevice::WriteOnly)) return 1; + lslConfig.write("[lab]\nSessionID=" + QUuid::createUuid().toByteArray(QUuid::WithoutBraces) + + "\nKnownPeers={127.0.0.1}\n[multicast]\nResolveScope=machine\n"); + lslConfig.close(); + qputenv("LSLAPICFG", lslConfig.fileName().toUtf8()); + const QString configPath = temp.filePath("LabRecorder.cfg"); + int failures = 0; + const auto run = [&](const char *name, auto test) { + try { + writeConfig(configPath, temp.path()); + test(); + std::cout << "PASS: " << name << '\n'; + } catch (const std::exception &error) { + std::cerr << "FAIL: " << name << ": " << error.what() << '\n'; + ++failures; + } + }; + run("source IDs after refresh", [&] { StreamSelectionTest::sourceIds(configPath); }); + run("missing stream selection", [&] { StreamSelectionTest::missingSelection(configPath, temp.path()); }); + run("reconnect identity", [&] { StreamSelectionTest::reconnectIdentity(configPath); }); + run("watchlist identity", [&] { StreamSelectionTest::watchlistIdentity(configPath); }); + return failures ? 1 : 0; +}