From c2e7868a05e574ef804592b62a5c4958616d6e30 Mon Sep 17 00:00:00 2001 From: senaer <2799280+sena-neuro@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:30:04 +0200 Subject: [PATCH 1/5] Add remote stream selection command --- README.md | 3 +- src/mainwindow.cpp | 131 +++++++++++++++++++++++++++++++------------ src/mainwindow.h | 42 ++++++++++++-- src/tcpinterface.cpp | 21 +++---- src/tcpinterface.h | 3 +- 5 files changed, 146 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 6a4574d..21f1a25 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,8 @@ 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 ...` diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index be82724..1a260f8 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -287,6 +287,41 @@ QString info_to_listName(const lsl::stream_info& info) { return QString::fromStdString(info.name() + " (" + info.hostname() + ")"); } +void MainWindow::updateKnownStreamSelectionFromUi() { + 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; + } +} + +void MainWindow::rebuildStreamList() { + 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 (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\nUID: %5") + .arg(QString::fromStdString(k.name), + QString::fromStdString(k.type), + QString::fromStdString(k.id), + QString::fromStdString(k.host), + QString::fromStdString(k.uid))); + ui->streamList->addItem(item); + } +} + /** * @brief MainWindow::refreshStreams Find streams, generate a list of missing streams * and fill the UI streamlist. @@ -294,30 +329,25 @@ QString info_to_listName(const lsl::stream_info& info) { */ std::vector MainWindow::refreshStreams() { const std::vector resolvedStreams = lsl::resolve_streams(1.0); + updateKnownStreamSelectionFromUi(); // 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. 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); + knownStreams << StreamItem(s, 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 each item in knownStreams; if it is not resolved then drop it. If it was checked then add back to missingStreams. int k_ind = 0; while (k_ind < knownStreams.count()) { @@ -326,7 +356,7 @@ 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) { @@ -339,31 +369,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; } @@ -507,6 +519,40 @@ 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; +} + +void MainWindow::selectStreams(const QString &query) { + updateKnownStreamSelectionFromUi(); + 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; + } + + 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); + missingStreams.remove(info_to_listName(stream)); + } + rebuildStreamList(); +} + 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 +693,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::selectStreams); } bool oldState = ui->rcsCheckBox->blockSignals(true); ui->rcsCheckBox->setChecked(bEnable); @@ -660,17 +707,27 @@ 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 (!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(); + hideWarnings = oldHideWarnings; + if (sock) sock->write("OK"); } 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..2d8497b 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -17,19 +17,47 @@ 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()), + uid(info.uid()), sessionId(info.session_id()), channelCount(info.channel_count()), + nominalSrate(info.nominal_srate()), channelFormat(info.channel_format()), + createdAt(info.created_at()), 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 name == info.name() && type == info.type() && id == info.source_id(); + return name == info.name() && type == info.type() && host == info.hostname() && + sessionId == info.session_id() && channelCount == info.channel_count() && + nominalSrate == info.nominal_srate() && channelFormat == info.channel_format(); + } + void updateInfo(const lsl::stream_info &info) { + name = info.name(); + type = info.type(); + id = info.source_id(); + host = info.hostname(); + uid = info.uid(); + sessionId = info.session_id(); + channelCount = info.channel_count(); + nominalSrate = info.nominal_srate(); + channelFormat = info.channel_format(); + createdAt = info.created_at(); + } std::string name; std::string type; std::string id; std::string host; + std::string uid; + std::string sessionId; + int32_t channelCount; + double nominalSrate; + lsl::channel_format_t channelFormat; + double createdAt; bool checked; }; @@ -50,13 +78,14 @@ private slots: void stopRecording(void); void selectAllStreams(); void selectNoStreams(); + void selectStreams(const QString &query); void buildFilename(); void buildBidsTemplate(); void printReplacedFilename(); void enableRcs(bool bEnable); void rcsCheckBoxChanged(bool checked); void rcsUpdateFilename(QString s); - void rcsStartRecording(); + void rcsStartRecording(QTcpSocket *sock); void rcsStopRecording(); void rcsportValueChangedInt(int value); @@ -65,6 +94,9 @@ private slots: // function for loading / saving the config file QString find_config_file(const char *filename); QString counterPlaceholder() const; + bool hasSelectedStreams() const; + void updateKnownStreamSelectionFromUi(); + void rebuildStreamList(); void load_config(QString filename); void save_config(QString filename); diff --git a/src/tcpinterface.cpp b/src/tcpinterface.cpp index 044486b..eaaa480 100644 --- a/src/tcpinterface.cpp +++ b/src/tcpinterface.cpp @@ -17,20 +17,21 @@ 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->write("OK"); // TODO: select /deselect streams diff --git a/src/tcpinterface.h b/src/tcpinterface.h index 3eeef1e..dfec692 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); public slots: void addClient(); From 0ae59bb79fc0098c1cd9ef416ee808f8d3f4d66b Mon Sep 17 00:00:00 2001 From: senaer <2799280+sena-neuro@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:11:42 +0200 Subject: [PATCH 2/5] Tighten remote stream selection handling --- README.md | 2 ++ src/mainwindow.cpp | 59 ++++++++++++++++++++++++++++++-------------- src/mainwindow.h | 29 ++++++++-------------- src/tcpinterface.cpp | 6 ++++- src/tcpinterface.h | 2 +- 5 files changed, 59 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 21f1a25..2342216 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ Currently supported commands include: * `update` * `filename ...` +Commands respond with `OK`, `WARNING ...`, or `ERROR ...`. + `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 1a260f8..53ee375 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: ") + @@ -312,12 +312,11 @@ void MainWindow::rebuildStreamList() { 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\nUID: %5") + 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), - QString::fromStdString(k.uid))); + QString::fromStdString(k.host))); ui->streamList->addItem(item); } } @@ -384,9 +383,8 @@ std::vector MainWindow::refreshStreams() { return requestedAndAvailableStreams; } -void MainWindow::startRecording() { +MainWindow::StartResult MainWindow::startRecording() { if (!currentRecording) { - // automatically refresh streams const std::vector requestedAndAvailableStreams = refreshStreams(); @@ -399,7 +397,7 @@ void MainWindow::startRecording() { 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) { @@ -407,7 +405,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; } } @@ -415,13 +413,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()) + '/'); @@ -430,7 +428,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(); @@ -441,7 +439,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(); @@ -452,7 +450,7 @@ 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; @@ -483,11 +481,13 @@ void MainWindow::startRecording() { 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() { @@ -527,15 +527,16 @@ bool MainWindow::hasSelectedStreams() const { return false; } -void MainWindow::selectStreams(const QString &query) { +MainWindow::SelectResult MainWindow::selectStreams(const QString &query) { updateKnownStreamSelectionFromUi(); 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; + return SelectResult::InvalidQuery; } + if (matchedStreams.empty()) return SelectResult::NoMatches; for (const auto &stream : matchedStreams) { bool known = false; @@ -551,6 +552,7 @@ void MainWindow::selectStreams(const QString &query) { missingStreams.remove(info_to_listName(stream)); } rebuildStreamList(); + return SelectResult::Selected; } void MainWindow::buildBidsTemplate() { @@ -693,7 +695,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::selectStreams); + connect(rcs.get(), &RemoteControlSocket::select_stream, this, &MainWindow::rcsSelectStreams); } bool oldState = ui->rcsCheckBox->blockSignals(true); ui->rcsCheckBox->setChecked(bEnable); @@ -711,6 +713,10 @@ 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"); @@ -718,9 +724,26 @@ void MainWindow::rcsStartRecording(QTcpSocket *sock) { } const bool oldHideWarnings = hideWarnings; hideWarnings = true; - startRecording(); + const StartResult result = startRecording(); hideWarnings = oldHideWarnings; - if (sock) sock->write("OK"); + 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() { diff --git a/src/mainwindow.h b/src/mainwindow.h index 2d8497b..477b5ee 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -24,40 +24,27 @@ class StreamItem { public: StreamItem(const lsl::stream_info &info, bool required) : name(info.name()), type(info.type()), id(info.source_id()), host(info.hostname()), - uid(info.uid()), sessionId(info.session_id()), channelCount(info.channel_count()), - nominalSrate(info.nominal_srate()), channelFormat(info.channel_format()), - createdAt(info.created_at()), checked(required) {} + sessionId(info.session_id()), checked(required) {} QString listName() const { return QString::fromStdString(name + " (" + host + ")"); } bool matches(const lsl::stream_info &info) const { - if (!id.empty() || !info.source_id().empty()) - return name == info.name() && type == info.type() && id == info.source_id(); + 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() && channelCount == info.channel_count() && - nominalSrate == info.nominal_srate() && channelFormat == info.channel_format(); + sessionId == info.session_id(); } void updateInfo(const lsl::stream_info &info) { name = info.name(); type = info.type(); id = info.source_id(); host = info.hostname(); - uid = info.uid(); sessionId = info.session_id(); - channelCount = info.channel_count(); - nominalSrate = info.nominal_srate(); - channelFormat = info.channel_format(); - createdAt = info.created_at(); } std::string name; std::string type; std::string id; std::string host; - std::string uid; std::string sessionId; - int32_t channelCount; - double nominalSrate; - lsl::channel_format_t channelFormat; - double createdAt; bool checked; }; @@ -74,11 +61,9 @@ 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(); - void selectStreams(const QString &query); void buildFilename(); void buildBidsTemplate(); void printReplacedFilename(); @@ -86,14 +71,20 @@ private slots: void rcsCheckBoxChanged(bool checked); void rcsUpdateFilename(QString s); void rcsStartRecording(QTcpSocket *sock); + void rcsSelectStreams(const QString &query, QTcpSocket *sock); void rcsStopRecording(); void rcsportValueChangedInt(int value); private: + 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; void updateKnownStreamSelectionFromUi(); void rebuildStreamList(); diff --git a/src/tcpinterface.cpp b/src/tcpinterface.cpp index eaaa480..6ff3c37 100644 --- a/src/tcpinterface.cpp +++ b/src/tcpinterface.cpp @@ -31,7 +31,11 @@ void RemoteControlSocket::handleLine(QString s, QTcpSocket *sock) { } else if (s == "select none") { emit select_none(); } else if (s.startsWith("select ")) { - emit select_stream(s.mid(QStringLiteral("select ").size())); + 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 dfec692..53a2f36 100644 --- a/src/tcpinterface.h +++ b/src/tcpinterface.h @@ -21,7 +21,7 @@ class RemoteControlSocket : public QObject { void filename(QString s); void select_all(); void select_none(); - void select_stream(QString query); + void select_stream(QString query, QTcpSocket *sock); public slots: void addClient(); From cf8ef015c4618c808b1be8539b86bd0a31a5159c Mon Sep 17 00:00:00 2001 From: senaer <2799280+sena-neuro@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:23:35 +0200 Subject: [PATCH 3/5] Keep streams with empty and nonempty source IDs distinct --- src/mainwindow.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mainwindow.h b/src/mainwindow.h index 477b5ee..8fc3997 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -28,7 +28,7 @@ class StreamItem { QString listName() const { return QString::fromStdString(name + " (" + host + ")"); } bool matches(const lsl::stream_info &info) const { - if (!id.empty() && !info.source_id().empty()) + 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(); From 5807db92e159a97bccd775dc122f8bfd92d95e3d Mon Sep 17 00:00:00 2001 From: senaer <2799280+sena-neuro@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:26:09 +0200 Subject: [PATCH 4/5] Preserve missing stream selection and reconnect identity --- README.md | 6 ++ src/mainwindow.cpp | 136 +++++++++++++++++++++++++++++++-------------- src/mainwindow.h | 14 ++++- 3 files changed, 111 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 2342216..c8163fb 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,12 @@ Currently supported commands include: 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 53ee375..c69eb26 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -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,22 +290,81 @@ QString info_to_listName(const lsl::stream_info& info) { return QString::fromStdString(info.name() + " (" + info.hostname() + ")"); } -void MainWindow::updateKnownStreamSelectionFromUi() { +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 (auto& m : std::as_const(missingStreams)) { - auto *item = new QListWidgetItem(m, ui->streamList); - item->setCheckState(Qt::Checked); + 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); } @@ -328,10 +390,10 @@ void MainWindow::rebuildStreamList() { */ std::vector MainWindow::refreshStreams() { const std::vector resolvedStreams = lsl::resolve_streams(1.0); - updateKnownStreamSelectionFromUi(); + 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) { @@ -342,9 +404,14 @@ std::vector MainWindow::refreshStreams() { } } if (!known) { - bool found = missingStreams.contains(info_to_listName(s)); - knownStreams << StreamItem(s, found); - if (found) { missingStreams.remove(info_to_listName(s)); } + bool checked = false; + 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. @@ -359,7 +426,7 @@ std::vector MainWindow::refreshStreams() { 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++; @@ -387,10 +454,12 @@ 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", @@ -453,28 +522,7 @@ MainWindow::StartResult MainWindow::startRecording() { 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); @@ -528,7 +576,7 @@ bool MainWindow::hasSelectedStreams() const { } MainWindow::SelectResult MainWindow::selectStreams(const QString &query) { - updateKnownStreamSelectionFromUi(); + updateStreamSelectionFromUi(); std::vector matchedStreams; try { matchedStreams = lsl::resolve_stream(query.toStdString(), 0, 1.0); @@ -549,7 +597,9 @@ MainWindow::SelectResult MainWindow::selectStreams(const QString &query) { } } if (!known) knownStreams << StreamItem(stream, true); - missingStreams.remove(info_to_listName(stream)); + for (int i = missingStreams.count() - 1; i >= 0; --i) { + if (missingStreams[i].matches(stream)) missingStreams.removeAt(i); + } } rebuildStreamList(); return SelectResult::Selected; diff --git a/src/mainwindow.h b/src/mainwindow.h index 8fc3997..f563266 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -7,6 +7,7 @@ #include #include #include //for std::unique_ptr +#include // LSL #include @@ -48,6 +49,13 @@ class StreamItem { 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 @@ -86,7 +94,9 @@ private slots: StartResult startRecording(); SelectResult selectStreams(const QString &query); bool hasSelectedStreams() const; - void updateKnownStreamSelectionFromUi(); + QStringList selectedMissingStreams() const; + std::vector selectedMissingStreamQueries() const; + void updateStreamSelectionFromUi(); void rebuildStreamList(); void load_config(QString filename); void save_config(QString filename); @@ -98,7 +108,7 @@ private slots: std::unique_ptr timer; QList knownStreams; - QSet missingStreams; + QList missingStreams; std::map syncOptionsByStreamName; // QString recFilename; From ff84233ecaa08ff59ad83287cf524bbc7775aa73 Mon Sep 17 00:00:00 2001 From: senaer <2799280+sena-neuro@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:26:25 +0200 Subject: [PATCH 5/5] Add standalone GUI stream selection regression checks --- BUILD.md | 19 ++++ CMakeLists.txt | 14 +++ src/mainwindow.h | 2 + src/test_stream_selection.cpp | 196 ++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/test_stream_selection.cpp 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 55bfb79..9da07cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -194,6 +194,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/src/mainwindow.h b/src/mainwindow.h index f563266..28dc177 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -84,6 +84,8 @@ private slots: void rcsportValueChangedInt(int value); private: + friend class StreamSelectionTest; + enum class StartResult { Started, AlreadyRecording, Failed }; enum class SelectResult { Selected, NoMatches, InvalidQuery }; 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; +}