diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 10d425ad1..02177a883 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -40,7 +40,7 @@ on: # Superseded commits must not occupy CI runners or mask the exact-head result. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true permissions: @@ -360,6 +360,22 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Capture IED Browser visual regression + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/arstack_ied_simulator + --qa-browser-routing + --screenshot ./ied-browser-workbench.png + + - name: Upload IED Browser screenshot + uses: actions/upload-artifact@v4 + with: + name: ied-browser-workbench + path: ied-browser-workbench.png + if-no-files-found: error + retention-days: 14 + - name: Prove GUI value is visible through MMS run: >- python3 apps/ied_simulator/test_gui_live_value.py diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 2800850ce..e919a33fe 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -13,7 +13,7 @@ on: # Superseded commits must not occupy CI runners or mask the exact-head result. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true permissions: diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 2a1213b1a..eafaef5c2 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -118,7 +118,7 @@ Rectangle { Item { Layout.fillWidth: true } ActionButton { theme: root.theme - text: "Inspect bound RCB…" + text: "Static Reporting…" enabled: root.selectedDataSet !== null && root.selectedDataSet.directoryAvailable === true && root.selectedDataSet.deletable !== true @@ -127,7 +127,7 @@ Rectangle { && reports.selectedDataSetMembers.length > 0 onClicked: root.inspectStaticReportRequested() ToolTip.visible: hovered - ToolTip.text: "Navigate to an RCB bound to this exact static DataSet. No write, enable or GI." + ToolTip.text: "Prepare this exact static DataSet in the shared Reports path. Selection only: no DatSet write, enable or GI." } ActionButton { theme: root.theme diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 4698153e0..e302a8208 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -617,6 +617,23 @@ Item { reports.dataSets[reports.selectedDataSetIndex]) } + Label { + visible: root.activeSection === 2 && reports.staticRouteDataSet.length > 0 + text: "STATIC · " + reports.staticRouteDataSet + color: root.theme.green + font.pixelSize: root.theme.captionSize + elide: Text.ElideMiddle + Layout.maximumWidth: 220 + ToolTip.visible: staticRouteMouse.containsMouse + ToolTip.text: "Exact canonical DataSet selected for report-only static reporting. Enable + GI remains explicit." + MouseArea { + id: staticRouteMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + Button { visible: root.activeSection === 2 text: "Enable + GI" diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index cc90bc7f1..4959eb2e5 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -535,20 +536,34 @@ MmsReportController::~MmsReportController() { void MmsReportController::setHost(const QString& value) { const auto normalized = value.trimmed(); if (host_ == normalized) return; + const bool routeChanged = !pendingStaticDataSetReference_.isEmpty(); host_ = normalized; + pendingStaticDataSetReference_.clear(); + pendingStaticAuthorityKey_.clear(); emit configurationChanged(); + if (routeChanged) emit selectionChanged(); } void MmsReportController::setPort(const int value) { if (value < 1 || value > 65'535 || port_ == value) return; + const bool routeChanged = !pendingStaticDataSetReference_.isEmpty(); port_ = value; + pendingStaticDataSetReference_.clear(); + pendingStaticAuthorityKey_.clear(); emit configurationChanged(); + if (routeChanged) emit selectionChanged(); } void MmsReportController::setEngineeringContext(IedEngineeringContextController* value) { if (engineeringContext_ == value) return; if (engineeringContext_) disconnect(engineeringContext_, nullptr, this, nullptr); engineeringContext_ = value; + // A route belongs to one engineering authority only. Never carry a + // DataSet selection across a different IED/context pointer. + const bool routeChanged = !pendingStaticDataSetReference_.isEmpty(); + pendingStaticDataSetReference_.clear(); + pendingStaticAuthorityKey_.clear(); + if (routeChanged) emit selectionChanged(); if (engineeringContext_) { connect( engineeringContext_, @@ -562,6 +577,17 @@ void MmsReportController::setEngineeringContext(IedEngineeringContextController* if (!connected() && !busy()) adoptEngineeringInventory(); } +QString MmsReportController::staticRouteAuthorityKey() const { + if (engineeringContext_ && engineeringContext_->loaded() && + !engineeringContext_->selectionRequired() && + !engineeringContext_->structuralFingerprint().isEmpty()) { + return QStringLiteral("context:") + engineeringContext_->structuralFingerprint(); + } + const auto host = host_.trimmed().toLower(); + if (host.isEmpty() || port_ < 1 || port_ > 65'535) return {}; + return QStringLiteral("endpoint:%1:%2").arg(host).arg(port_); +} + bool MmsReportController::connected() const noexcept { return state_ == State::ready || state_ == State::authoring || state_ == State::enabling || state_ == State::active || state_ == State::disabling || state_ == State::cleanup_required; @@ -635,6 +661,14 @@ void MmsReportController::adoptEngineeringInventory() { const auto& canonical = *engineeringContext_->modelSnapshot(); const auto seed = buildContextDiscoverySeed(canonical); auto ui = buildDiscoveryUi(seed, QString{}); + if (!pendingStaticDataSetReference_.isEmpty() && + (pendingStaticAuthorityKey_.isEmpty() || + pendingStaticAuthorityKey_ != staticRouteAuthorityKey())) { + appendDiagnostic(QStringLiteral( + "Cleared stale static DataSet route after engineering context changed.")); + pendingStaticDataSetReference_.clear(); + pendingStaticAuthorityKey_.clear(); + } // An opened SCL model has no live RCB probe response yet. Preserve its // verified canonical DatSet binding for read-only Browser navigation. // Never mark the RCB as probed or writable based on file evidence alone. @@ -669,6 +703,10 @@ void MmsReportController::adoptEngineeringInventory() { associationProfile_.clear(); selectedRcbIndex_ = reportControls_.isEmpty() ? -1 : 0; selectedDataSetIndex_ = dataSets_.isEmpty() ? -1 : 0; + if (!pendingStaticDataSetReference_.isEmpty() && + !selectStaticRcbForReference(pendingStaticDataSetReference_, false)) { + selectedRcbIndex_ = -1; + } receivedReports_.clear(); events_.clear(); receivedReportCount_ = 0; @@ -778,9 +816,23 @@ bool MmsReportController::connectToIed() { self->state_ = State::ready; self->operationBusy_ = false; self->lastError_.clear(); - if (!self->reportControls_.isEmpty()) self->selectedRcbIndex_ = 0; - else if (!self->dataSets_.isEmpty()) self->selectedDataSetIndex_ = 0; - self->refreshSelection(); + if (!self->pendingStaticDataSetReference_.isEmpty()) { + if (!self->selectStaticRcbForReference( + self->pendingStaticDataSetReference_, true)) { + self->selectedRcbIndex_ = -1; + self->refreshSelection(); + self->lastError_ = QStringLiteral( + "Selected static DataSet has no verified live RCB binding; " + "reporting remains disabled."); + self->appendDiagnostic(self->lastError_); + } + } else if (!self->reportControls_.isEmpty()) { + self->selectedRcbIndex_ = 0; + self->refreshSelection(); + } else if (!self->dataSets_.isEmpty()) { + self->selectedDataSetIndex_ = 0; + self->refreshSelection(); + } self->appendDiagnostic(QStringLiteral("Report service attach complete · %1 DataSet · %2 RCB") .arg(self->dataSets_.size()).arg(self->reportControls_.size())); emit self->inventoryChanged(); @@ -890,6 +942,8 @@ void MmsReportController::refreshSelection() { bool MmsReportController::selectRcb(const int row) { if (row < 0 || row >= reportControls_.size()) return false; + pendingStaticDataSetReference_.clear(); + pendingStaticAuthorityKey_.clear(); selectedRcbIndex_ = row; refreshSelection(); return true; @@ -897,18 +951,38 @@ bool MmsReportController::selectRcb(const int row) { bool MmsReportController::selectDataSet(const int row) { if (row < 0 || row >= dataSets_.size()) return false; + const auto reference = dataSets_.at(row).toMap() + .value(QStringLiteral("reference")).toString(); + if (!pendingStaticDataSetReference_.isEmpty() && + !sameDataSetReference(pendingStaticDataSetReference_, reference)) { + pendingStaticDataSetReference_.clear(); + pendingStaticAuthorityKey_.clear(); + } selectedDataSetIndex_ = row; - selectedDataSetMembers_ = dataSets_.at(row).toMap().value(QStringLiteral("members")).toStringList(); + selectedDataSetMembers_ = dataSets_.at(row).toMap() + .value(QStringLiteral("members")).toStringList(); emit selectionChanged(); return true; } -bool MmsReportController::selectStaticRcbForDataSet(const int row) { - if (row < 0 || row >= dataSets_.size()) return false; - const auto dataSet = dataSets_.at(row).toMap(); - const auto reference = dataSet.value(QStringLiteral("reference")).toString(); - // A browse pivot is not authorization to modify a dynamic/foreign DataSet. - if (reference.isEmpty() || +bool MmsReportController::selectStaticRcbForReference( + const QString& reference, + const bool requireLiveProbe) { + if (reference.trimmed().isEmpty()) return false; + + int dataSetRow = -1; + QVariantMap dataSet; + for (int row = 0; row < dataSets_.size(); ++row) { + const auto candidate = dataSets_.at(row).toMap(); + if (!sameDataSetReference( + reference, candidate.value(QStringLiteral("reference")).toString())) { + continue; + } + dataSetRow = row; + dataSet = candidate; + break; + } + if (dataSetRow < 0 || !dataSet.value(QStringLiteral("directoryAvailable")).toBool() || dataSet.value(QStringLiteral("deletable")).toBool() || dataSet.value(QStringLiteral("dynamicOwned")).toBool() || @@ -916,21 +990,75 @@ bool MmsReportController::selectStaticRcbForDataSet(const int row) { dataSet.value(QStringLiteral("members")).toStringList().isEmpty()) { return false; } + + QString preferredReference; + if (requireLiveProbe) { + int bestRank = std::numeric_limits::min(); + bool sawBoundEvaluation = false; + for (const auto& value : staticCandidates_) { + const auto candidate = value.toMap(); + if (!sameDataSetReference( + reference, candidate.value(QStringLiteral("dataSet")).toString())) { + continue; + } + sawBoundEvaluation = true; + if (!candidate.value(QStringLiteral("selectable")).toBool()) continue; + const int rank = + candidate.value(QStringLiteral("score")).toInt() + + (candidate.value(QStringLiteral("preferred")).toBool() ? 1'000'000 : 0); + if (preferredReference.isEmpty() || rank > bestRank) { + bestRank = rank; + preferredReference = candidate.value(QStringLiteral("reference")).toString(); + } + } + // The pool selector explicitly evaluated bound RCBs but found none safe. + // Do not bypass that decision with a raw first-row fallback. + if (sawBoundEvaluation && preferredReference.isEmpty()) return false; + } + for (int rcbRow = 0; rcbRow < reportControls_.size(); ++rcbRow) { const auto control = reportControls_.at(rcbRow).toMap(); const auto bound = control.value(QStringLiteral("dataSet")).toString(); - if (bound.isEmpty() || control.value(QStringLiteral("dynamicBinding")).toBool() || + const auto controlReference = + control.value(QStringLiteral("reference")).toString(); + if (bound.isEmpty() || + control.value(QStringLiteral("dynamicBinding")).toBool() || + (requireLiveProbe && !control.value(QStringLiteral("probeOk")).toBool()) || + (!preferredReference.isEmpty() && controlReference != preferredReference) || !sameDataSetReference(bound, reference)) { continue; } - // Selection changes only after a verified exact canonical match. + selectedDataSetIndex_ = dataSetRow; selectedRcbIndex_ = rcbRow; refreshSelection(); + appendDiagnostic(QStringLiteral( + "Static DataSet route selected · %1 → %2%3") + .arg( + reference, + controlReference, + requireLiveProbe + ? QStringLiteral(" · live verified") + : QStringLiteral(" · canonical"))); return true; } return false; } +bool MmsReportController::selectStaticRcbForDataSet(const int row) { + if (row < 0 || row >= dataSets_.size()) return false; + const auto reference = dataSets_.at(row).toMap() + .value(QStringLiteral("reference")).toString(); + const auto authorityKey = staticRouteAuthorityKey(); + if (authorityKey.isEmpty() || + !selectStaticRcbForReference(reference, false)) { + return false; + } + pendingStaticDataSetReference_ = reference; + pendingStaticAuthorityKey_ = authorityKey; + emit selectionChanged(); + return true; +} + bool MmsReportController::createDynamicDataSet( const QString& dataSetReference, const QVariantList& canonicalMembers) { diff --git a/apps/ied_simulator/src/MmsReportController.hpp b/apps/ied_simulator/src/MmsReportController.hpp index 82062e050..4b5b593e6 100644 --- a/apps/ied_simulator/src/MmsReportController.hpp +++ b/apps/ied_simulator/src/MmsReportController.hpp @@ -34,6 +34,7 @@ class MmsReportController : public QObject { Q_PROPERTY(QStringList ownedDynamicDataSets READ ownedDynamicDataSets NOTIFY inventoryChanged) Q_PROPERTY(int selectedRcbIndex READ selectedRcbIndex NOTIFY selectionChanged) Q_PROPERTY(int selectedDataSetIndex READ selectedDataSetIndex NOTIFY selectionChanged) + Q_PROPERTY(QString staticRouteDataSet READ staticRouteDataSet NOTIFY selectionChanged) Q_PROPERTY(QVariantMap selectedRcb READ selectedRcb NOTIFY selectionChanged) Q_PROPERTY(QStringList selectedDataSetMembers READ selectedDataSetMembers NOTIFY selectionChanged) Q_PROPERTY(QVariantList receivedReports READ receivedReports NOTIFY reportsChanged) @@ -66,6 +67,7 @@ class MmsReportController : public QObject { [[nodiscard]] QStringList ownedDynamicDataSets() const { return ownedDynamicDataSets_; } [[nodiscard]] int selectedRcbIndex() const noexcept { return selectedRcbIndex_; } [[nodiscard]] int selectedDataSetIndex() const noexcept { return selectedDataSetIndex_; } + [[nodiscard]] QString staticRouteDataSet() const { return pendingStaticDataSetReference_; } [[nodiscard]] QVariantMap selectedRcb() const { return selectedRcb_; } [[nodiscard]] QStringList selectedDataSetMembers() const { return selectedDataSetMembers_; } [[nodiscard]] QVariantList receivedReports() const { return receivedReports_; } @@ -123,6 +125,8 @@ class MmsReportController : public QObject { void clearInventory(); void adoptEngineeringInventory(); void refreshSelection(); + bool selectStaticRcbForReference(const QString& reference, bool requireLiveProbe); + [[nodiscard]] QString staticRouteAuthorityKey() const; void schedulePoll(); [[nodiscard]] std::shared_ptr replaceStopSource(); @@ -145,6 +149,8 @@ class MmsReportController : public QObject { int selectedDataSetIndex_{-1}; QVariantMap selectedRcb_; QStringList selectedDataSetMembers_; + QString pendingStaticDataSetReference_; + QString pendingStaticAuthorityKey_; QVariantList receivedReports_; QStringList events_; QStringList diagnostics_; diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index e8c6e19ec..866ef0ea8 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -337,16 +337,49 @@ int main(int argc, char* argv[]) { !reports.selectStaticRcbForDataSet(-1) && !reports.selectStaticRcbForDataSet(dataSets.size()) && reports.selectedRcbIndex() == matchedRcb; - if (!staticPivotValid || !invalidPivotRejected || !reports.selectRcb(selected) || + const auto staticRoute = reports.staticRouteDataSet(); + if (!staticPivotValid || !invalidPivotRejected || + staticRoute.isEmpty() || reports.selectedRcb().value(QStringLiteral("reference")).toString() != originalRcb) { qCritical() << "REPORTS_WORKBENCH_FAIL dataset_signals_static_rcb_pivot" - << staticDataSetRow << matchedRcb << originalRcb; + << staticDataSetRow << matchedRcb << originalRcb << staticRoute; return 29; } qInfo() << "DATASET_SIGNALS_STATIC_PIVOT_PASS" << "dataset=" << staticDataSetRow << "bound_rcb=" << matchedRcb << "read_only=true no_gi=true"; + // The user's Dataset Signals choice is made before/while auxiliary report + // service attachment in the Browser. Reconnect must restore that exact + // static route after live RCB probing instead of snapping to row 0. + reports.disconnectFromIed(); + if (!waitUntil([&reports] { return !reports.connected() && !reports.busy(); }, 8'000) || + !reports.connectToIed() || + !waitUntil([&reports] { return reports.connected() && !reports.busy(); }, 12'000) || + reports.staticRouteDataSet() != staticRoute || + reports.selectedRcb().value(QStringLiteral("reference")).toString().isEmpty() || + !reports.selectedRcb().value(QStringLiteral("probeOk")).toBool() || + reports.selectedDataSetMembers().isEmpty()) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL static_route_restore" + << "route=" << reports.staticRouteDataSet() + << "rcb=" << reports.selectedRcb().value(QStringLiteral("reference")).toString() + << "probe=" << reports.selectedRcb().value(QStringLiteral("probeOk")).toBool() + << "error=" << reports.lastError(); + return 30; + } + const auto restoredDataSets = reports.dataSets(); + const int restoredDataSetRow = reports.selectedDataSetIndex(); + if (restoredDataSetRow < 0 || restoredDataSetRow >= restoredDataSets.size() || + restoredDataSets.at(restoredDataSetRow).toMap() + .value(QStringLiteral("reference")).toString() != staticRoute) { + qCritical() << "REPORTS_WORKBENCH_FAIL static_route_dataset_changed" + << restoredDataSetRow << staticRoute; + return 31; + } + qInfo() << "STATIC_REPORT_ROUTE_RESTORE_PASS" + << staticRoute << "rcb=" << reports.selectedRcb().value(QStringLiteral("reference")).toString() + << "live_probe=true"; + const int memberCount = reports.selectedDataSetMembers().size(); if (!reports.enableSelected(true) || diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index c9f029e23..c62f8c9c5 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -433,7 +434,9 @@ int main(int argc, char* argv[]) { } } if (parser.isSet(qaBrowserRoutingOption)) { - QTimer::singleShot(150, &app, [&app, rootObject] { + const auto routingScreenshotPath = parser.isSet(screenshotOption) + ? parser.value(screenshotOption) : QString{}; + QTimer::singleShot(150, &app, [&app, rootObject, routingScreenshotPath] { auto fail = [&app](const char* reason, const int code) { qCritical().noquote() << "BROWSER_FLEET_ROUTING_FAIL" << reason; app.exit(code); @@ -763,6 +766,15 @@ int main(int argc, char* argv[]) { << "BROWSER_FLEET_ROUTING_PASS active_tab=QA_IED_B" << "offline_signals=visible" << "model_values=visible" << "value_label=bound" << "toolbar_1024=visible" << "switching=pass reindex=pass cross_ied_panel=false"; + if (!routingScreenshotPath.isEmpty()) { + auto* const window = qobject_cast(rootObject); + const auto image = window ? window->grabWindow() : QImage{}; + if (image.isNull() || !image.save(routingScreenshotPath)) { + fail("browser_screenshot_save", 78); + return; + } + qInfo().noquote() << "BROWSER_SCREENSHOT_PASS" << routingScreenshotPath; + } app.exit(0); }); } diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 485b67c43..3aae5bc8c 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -38,6 +38,9 @@ def require(text: str, token: str, label: str) -> None: "Enable + GI requires general-interrogation in authored TrgOps", "Static/non-owned DataSet binding is immutable", "bool MmsReportController::selectStaticRcbForDataSet(", + "QString MmsReportController::staticRouteAuthorityKey() const", + "pendingStaticAuthorityKey_", + "Selected static DataSet has no verified live RCB binding", "sameDataSetReference(bound, reference)", 'dataSet.value(QStringLiteral("directoryAvailable")).toBool()', 'dataSet.value(QStringLiteral("deletable")).toBool()', @@ -79,6 +82,8 @@ def require(text: str, token: str, label: str) -> None: "else signalCatalogDialog.close()", "function inspectBoundStaticRcb()", "reports.selectStaticRcbForDataSet(reports.selectedDataSetIndex)", + "reports.staticRouteDataSet.length > 0", + 'text: "STATIC · " + reports.staticRouteDataSet', "routeMessage: root.datasetRouteStatus", 'objectName: "iedBrowserSignalCatalog"', 'text: "Dataset Signals"', @@ -117,7 +122,7 @@ def require(text: str, token: str, label: str) -> None: "STATIC / READ-ONLY", "Membership is immutable", "signal inspectStaticReportRequested()", - 'text: "Inspect bound RCB…"', + 'text: "Static Reporting…"', "root.selectedDataSet.directoryAvailable === true", "root.selectedDataSet.deletable !== true", ): @@ -127,6 +132,7 @@ def require(text: str, token: str, label: str) -> None: "ownedDynamicDataSets", "enableSelectedAuthored", "selectStaticRcbForDataSet", + "staticRouteDataSet", "createDynamicDataSet", "deleteDynamicDataSet", ):