diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 1e29f2661..6b9cbf498 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -71,6 +71,11 @@ jobs: QT_QPA_PLATFORM: offscreen run: ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test + - name: P0 rendered fleet Browser and offline signal routing + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-qt/arstack_ied_simulator --qa-browser-routing + - name: MMS client discovery workbench gates env: QT_QPA_PLATFORM: offscreen diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 2400fa5cd..5285cdf7c 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -42,6 +42,11 @@ jobs: QT_QPA_PLATFORM: offscreen run: ./build-ied-simulator-release/ied_product_hardening_qa + - name: P0 rendered fleet Browser and offline signal routing + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-release/arstack_ied_simulator --qa-browser-routing + - name: MMS reconnect association soak env: QT_QPA_PLATFORM: offscreen diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 83186cdee..a818d470b 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -7,6 +7,9 @@ Rectangle { id: root required property var theme required property var reports + readonly property var selectedDataSet: reports.selectedDataSetIndex >= 0 + && reports.selectedDataSetIndex < reports.dataSets.length + ? reports.dataSets[reports.selectedDataSetIndex] : ({}) color: theme.background @@ -44,16 +47,16 @@ Rectangle { Layout.preferredWidth: 104 Layout.preferredHeight: 22 radius: 11 - color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + color: root.selectedDataSet.dynamicOwned === true ? theme.greenSoft : theme.surface border.width: 1 - border.color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + border.color: root.selectedDataSet.dynamicOwned === true ? theme.green : theme.lineSoft Label { anchors.centerIn: parent - text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + text: root.selectedDataSet.dynamicOwned === true ? "DYNAMIC OWNED" : "STATIC / READ-ONLY" - color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + color: root.selectedDataSet.dynamicOwned === true ? theme.green : theme.muted font.pixelSize: 7 font.weight: Font.Bold @@ -61,7 +64,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + text: root.selectedDataSet.dynamicOwned === true ? "Created and verified on this live association; eligible for owned-only delete." : "Membership is immutable in Browser authoring." color: theme.muted diff --git a/apps/ied_simulator/qml/IedBrowserNavigation.qml b/apps/ied_simulator/qml/IedBrowserNavigation.qml index 2745aa56a..2f2d6d509 100644 --- a/apps/ied_simulator/qml/IedBrowserNavigation.qml +++ b/apps/ied_simulator/qml/IedBrowserNavigation.qml @@ -386,6 +386,7 @@ Rectangle { ListView { id: modelTree + objectName: "iedBrowserSignalTree" Layout.fillWidth: true Layout.preferredHeight: Math.max(260, Math.min(contentHeight, 620)) clip: true @@ -397,6 +398,9 @@ Rectangle { delegate: Rectangle { id: modelRow required property int index + // Required properties disable legacy context-role injection. + // Explicitly bind the role object used by model.* below. + required property var model width: modelTree.width height: 25 color: model.selected ? root.theme.accentSoft diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 577d2a884..898f358a9 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -21,6 +21,7 @@ ApplicationWindow { property bool mmsWasConnected: false property bool simulatorAutoSelectPending: true property bool allIedMonitor: false + property var browserFleet: fleet property var iedContext: fleet.activeContext property var mmsClient: fleet.activeClient property var reports: fleet.activeReports @@ -287,7 +288,7 @@ ApplicationWindow { theme: appTheme workspace: sclWorkspace hardening: hardening - browserSession: browserSession + browserSession: root.browserSession context: iedContext onBrowserRequested: root.workspaceIndex = 1 onOpenSourceRequested: function(fileUrl) { @@ -446,28 +447,38 @@ ApplicationWindow { Layout.fillHeight: true currentIndex: root.allIedMonitor ? 1 : 0 - StackLayout { + Item { + id: perIedBrowserHost + objectName: "perIedBrowserHost" Layout.fillWidth: true Layout.fillHeight: true - currentIndex: fleet.activeIndex - // Delegates persist per slot: each IED retains its own - // navigation and P6A Global Data watchlist. + // A Repeater is itself an Item. Putting it inside a + // StackLayout adds a layout child before its delegates: + // indexing by the fleet slot can show a different + // IED's panel from the selected tab. Keep delegates as + // siblings in a plain Item, size them explicitly, and + // show exactly the active slot. Hidden views retain + // their per-IED navigation and Global Data watchlists. Repeater { id: iedBrowserRepeater - model: fleet + objectName: "iedBrowserRepeater" + model: root.browserFleet IedBrowserWorkspace { required property int index + objectName: "iedBrowserView_" + index + anchors.fill: parent + visible: index === root.browserFleet.activeIndex theme: appTheme productState: hardening - fleet: fleet - session: fleet.sessionAt(index) - client: fleet.clientAt(index) - context: fleet.contextAt(index) - reports: fleet.reportsAt(index) - utilities: fleet.utilitiesAt(index) - controls: fleet.controlsAt(index) - engineering: fleet.engineeringAt(index) + fleet: root.browserFleet + session: root.browserFleet.sessionAt(index) + client: root.browserFleet.clientAt(index) + context: root.browserFleet.contextAt(index) + reports: root.browserFleet.reportsAt(index) + utilities: root.browserFleet.utilitiesAt(index) + controls: root.browserFleet.controlsAt(index) + engineering: root.browserFleet.engineeringAt(index) onWatchedDataChanged: fleetGlobalData.refreshRows() } } @@ -476,7 +487,7 @@ ApplicationWindow { FleetGlobalDataPane { id: fleetGlobalData theme: appTheme - fleet: fleet + fleet: root.browserFleet browserViews: iedBrowserRepeater onIedRequested: function(index) { fleet.switchTo(index) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 8a1123adf..6ca0859fd 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "IedFleetController.hpp" +#include "IedBrowserFleetController.hpp" #include #include @@ -10,6 +11,10 @@ #include #include #include +#include +#include +#include +#include #include #include @@ -99,6 +104,9 @@ int main(int argc, char* argv[]) { const QCommandLineOption smokeOption{ QStringLiteral("smoke-test"), QStringLiteral("Load the QML scene, wait briefly, and exit.")}; + const QCommandLineOption qaBrowserRoutingOption{ + QStringLiteral("qa-browser-routing"), + QStringLiteral("QA: verify the rendered active IED Browser and its offline signal tree match the selected fleet slot.")}; const QCommandLineOption portOption{ QStringLiteral("port"), QStringLiteral("Override the default MMS listen port."), @@ -125,6 +133,7 @@ int main(int argc, char* argv[]) { startIedOption, screenshotOption, smokeOption, + qaBrowserRoutingOption, portOption, setFirstValueOption, qaLiveBurstOption, @@ -418,6 +427,166 @@ int main(int argc, char* argv[]) { liveTimer->start(); } } + if (parser.isSet(qaBrowserRoutingOption)) { + QTimer::singleShot(150, &app, [&app, rootObject] { + auto fail = [&app](const char* reason, const int code) { + qCritical().noquote() << "BROWSER_FLEET_ROUTING_FAIL" << reason; + app.exit(code); + }; + auto* const fleet = rootObject->findChild( + QStringLiteral("iedBrowserFleetBackend")); + if (!fleet || fleet->workspaceCount() != 1 || !fleet->contextAt(0)) { + fail("initial_fleet", 61); + return; + } + + // Deliberately reproduce the reported mismatch: an unselected + // multi-IED SCL in slot 0 and a fully loaded, offline IED in + // slot 1. The active tab and rendered Browser MUST be slot 1. + ar::iec61850::scl::SclDocument document; + document.source_name = "fleet-routing-qa.scd"; + document.edition = ar::iec61850::scl::SclEdition::edition2; + for (const std::string name : {"QA_IED_A", "QA_IED_B"}) { + document.ieds.push_back({name, "ARStack", "BrowserQA", "1"}); + ar::iec61850::scl::SclLogicalNode node; + node.ied_name = name; + node.ld_inst = "LD0"; + node.ln_class = "LLN0"; + node.name = "LLN0"; + document.logical_nodes.push_back(std::move(node)); + ar::iec61850::scl::SclDataSetEntry signal; + signal.ied_name = name; + signal.ld_inst = "LD0"; + signal.ln_class = "LLN0"; + signal.do_name = "Mod"; + signal.da_name = "stVal"; + signal.functional_constraint = "ST"; + signal.basic_type = "INT32"; + signal.cdc = "INC"; + signal.signal_reference = name + "LD0/LLN0.Mod.stVal"; + document.model_entries.push_back(std::move(signal)); + } + if (!fleet->contextAt(0)->publishSclDocument( + document, QStringLiteral("/qa/fleet-routing.scd")) || + !fleet->contextAt(0)->selectionRequired() || + fleet->contextAt(0)->loaded()) { + fail("unselected_source_boundary", 62); + return; + } + if (!fleet->newWorkspace() || fleet->activeIndex() != 1 || + !fleet->contextAt(1)->publishSclDocument( + document, QStringLiteral("/qa/fleet-routing.scd"), + QStringLiteral("QA_IED_B"))) { + fail("second_source", 63); + return; + } + rootObject->setProperty("workspaceIndex", 1); + rootObject->setProperty("allIedMonitor", false); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + + auto* const browserHost = rootObject->findChild( + QStringLiteral("perIedBrowserHost")); + const auto browserViewAt = [browserHost](const int index) -> QQuickItem* { + if (!browserHost) return nullptr; + const auto expected = + QStringLiteral("iedBrowserView_%1").arg(index); + for (auto* item : browserHost->childItems()) { + if (item && item->objectName() == expected) return item; + } + return nullptr; + }; + auto* const first = browserViewAt(0); + auto* const second = browserViewAt(1); + auto* const selected = fleet->contextAt(1); + auto* const signalList = second + ? second->findChild(QStringLiteral("iedBrowserSignalTree")) + : nullptr; + + const auto renderedReady = [&]() { + return first && second && signalList && selected && + signalList->property("count").toInt() >= 3 && + signalList->width() >= 100 && signalList->isVisible() && + !first->isVisible() && second->isVisible() && + selected->loaded() && !selected->online() && + selected->iedName() == QStringLiteral("QA_IED_B") && + selected->treeModel()->totalNodeCount() >= 5 && + selected->treeModel()->visibleNodeCount() >= 3 && + second->width() >= 100 && second->height() >= 100 && + second->property("context").value() == selected; + }; + + QElapsedTimer routingSettle; + routingSettle.start(); + while (!renderedReady() && routingSettle.elapsed() < 1'000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 25); + QThread::msleep(10); + } + if (!renderedReady()) { + qCritical().noquote() + << "BROWSER_FLEET_ROUTING_DIAG" + << "activeIndex=" << fleet->activeIndex() + << "host=" << static_cast(browserHost) + << "visualChildren=" << (browserHost ? browserHost->childItems().size() : -1) + << "first=" << static_cast(first) + << "second=" << static_cast(second) + << "signalList=" << static_cast(signalList) + << "firstVisible=" << (first ? first->isVisible() : false) + << "secondVisible=" << (second ? second->isVisible() : false) + << "secondSize=" << (second ? second->width() : -1.0) + << "x" << (second ? second->height() : -1.0) + << "listVisible=" << (signalList ? signalList->isVisible() : false) + << "listCount=" << (signalList ? signalList->property("count").toInt() : -1) + << "listWidth=" << (signalList ? signalList->width() : -1.0) + << "selectedLoaded=" << (selected ? selected->loaded() : false) + << "selectedIed=" << (selected ? selected->iedName() : QStringLiteral("")) + << "totalNodes=" << (selected ? selected->treeModel()->totalNodeCount() : -1) + << "visibleNodes=" << (selected ? selected->treeModel()->visibleNodeCount() : -1) + << "contextMatch=" << (second && selected + ? second->property("context").value() == selected : false); + fail("active_tab_panel_or_offline_signals_mismatch", 64); + return; + } + if (!fleet->switchTo(0)) { + fail("switch_to_pending_source", 65); + return; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (!first->isVisible() || second->isVisible()) { + fail("wrong_panel_after_switch", 66); + return; + } + if (!fleet->switchTo(1)) { + fail("switch_back_to_loaded_ied", 67); + return; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (first->isVisible() || !second->isVisible() || + !selected->loaded() || selected->treeModel()->visibleNodeCount() < 3 || + signalList->property("count").toInt() < 3) { + fail("offline_signal_tree_lost_after_switch", 68); + return; + } + + if (!fleet->closeWorkspace(0)) { + fail("close_inactive_workspace", 69); + return; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + auto* const retained = browserViewAt(0); + if (fleet->workspaceCount() != 1 || fleet->activeIndex() != 0 || + !retained || !retained->isVisible() || + fleet->activeContext() != selected || + fleet->activeContext()->treeModel()->visibleNodeCount() < 3) { + fail("active_model_lost_after_reindex", 70); + return; + } + qInfo().noquote() + << "BROWSER_FLEET_ROUTING_PASS active_tab=QA_IED_B" + << "offline_signals=visible" + << "switching=pass reindex=pass cross_ied_panel=false"; + app.exit(0); + }); + } if (parser.isSet(screenshotOption)) { const auto outputPath = parser.value(screenshotOption); QTimer::singleShot(1600, &app, [&app, rootObject, outputPath] { diff --git a/apps/ied_simulator/test_browser_control_ux.py b/apps/ied_simulator/test_browser_control_ux.py index db6d800cd..2159b28ef 100644 --- a/apps/ied_simulator/test_browser_control_ux.py +++ b/apps/ied_simulator/test_browser_control_ux.py @@ -88,9 +88,9 @@ def require(text: str, token: str, label: str) -> None: # per IED slot. Both shell shapes must still wire the proven P6B service. if 'IedBrowserFleetController {' in main: for token in ( - 'controls: fleet.controlsAt(index)', - 'session: fleet.sessionAt(index)', - 'context: fleet.contextAt(index)', + 'controls: root.browserFleet.controlsAt(index)', + 'session: root.browserFleet.sessionAt(index)', + 'context: root.browserFleet.contextAt(index)', ): require(main, token, "Per-IED application shell") else: diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index 7d92ebfbf..117ba5cc2 100644 --- a/apps/ied_simulator/test_multi_ied_workspace.py +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -58,14 +58,16 @@ def require(haystack, words, label): "property var browserSession: fleet.activeSession", "property var sclWorkspace: fleet.activeEngineering", "model: fleet", - "currentIndex: fleet.activeIndex", - "fleet.contextAt(index)", - "fleet.sessionAt(index)", - "fleet.clientAt(index)", - "fleet.reportsAt(index)", - "fleet.controlsAt(index)", - "fleet.utilitiesAt(index)", - "fleet.engineeringAt(index)", + "visible: index === root.browserFleet.activeIndex", + "anchors.fill: parent", + 'objectName: "iedBrowserView_" + index', + "root.browserFleet.contextAt(index)", + "root.browserFleet.sessionAt(index)", + "root.browserFleet.clientAt(index)", + "root.browserFleet.reportsAt(index)", + "root.browserFleet.controlsAt(index)", + "root.browserFleet.utilitiesAt(index)", + "root.browserFleet.engineeringAt(index)", "onOpenSourceRequested: function(fileUrl)", "fleet.prepareForNewSource()", "FleetGlobalDataPane {", @@ -116,6 +118,35 @@ def require(haystack, words, label): ): if f"{typename} {{" in main: raise SystemExit(f"P6E_FAIL singleton {typename} remained in Main.qml") +# QML required property 'fleet' in IedBrowserWorkspace shadows Main's +# id 'fleet': `fleet: fleet` resolves to itself and leaves every Browser +# service/context undefined, rendering the exact blank signal screenshot. +require(main, ( + "property var browserFleet: fleet", + "fleet: root.browserFleet", + "session: root.browserFleet.sessionAt(index)", + "client: root.browserFleet.clientAt(index)", + "context: root.browserFleet.contextAt(index)", + "reports: root.browserFleet.reportsAt(index)", + "utilities: root.browserFleet.utilitiesAt(index)", + "controls: root.browserFleet.controlsAt(index)", + "engineering: root.browserFleet.engineeringAt(index)", +), "non-shadowed Browser context/service routing") +if "fleet: fleet" in main: + raise SystemExit("P6E_FAIL QML fleet self-shadowing would empty the Browser") + +# A StackLayout containing a Repeater has an extra layout child and can +# render slot N-1 while the active tab points at slot N. +if "currentIndex: fleet.activeIndex" in main or \ + "currentIndex: fleet.activeIndex + 1" in main: + raise SystemExit("P6E_FAIL Repeater delegates must not use StackLayout indexing") +require(main, ( + 'id: perIedBrowserHost', + 'id: iedBrowserRepeater', + 'visible: index === root.browserFleet.activeIndex', + 'objectName: "iedBrowserView_" + index', +), "exact active Browser panel routing") + if "Timer {" in all_data: raise SystemExit("P6E_FAIL aggregate Global Data timer/polling introduced") if "setEngineeringContext(fleet.activeContext)" in main: