diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 4d66c60a7..10d425ad1 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -38,6 +38,11 @@ on: - 'CMakeLists.txt' - '.github/workflows/ied-simulator-qt.yml' +# Superseded commits must not occupy CI runners or mask the exact-head result. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -71,6 +76,17 @@ 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: | + set -o pipefail + ./build-ied-simulator-qt/arstack_ied_simulator --qa-browser-routing 2>&1 | tee browser-routing.log + if grep -E 'ReferenceError:|TypeError:|Binding loop detected' browser-routing.log; then + echo '::error::Qt Browser emitted an unresolved runtime binding diagnostic' + exit 1 + fi + - 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..2800850ce 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -11,6 +11,11 @@ on: - 'apps/ied_simulator/**' - '.github/workflows/ied-simulator-release-hardening.yml' +# Superseded commits must not occupy CI runners or mask the exact-head result. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -42,6 +47,17 @@ 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: | + set -o pipefail + ./build-ied-simulator-release/arstack_ied_simulator --qa-browser-routing 2>&1 | tee browser-routing.log + if grep -E 'ReferenceError:|TypeError:|Binding loop detected' browser-routing.log; then + echo '::error::Qt Browser emitted an unresolved runtime binding diagnostic' + exit 1 + fi + - name: MMS reconnect association soak env: QT_QPA_PLATFORM: offscreen @@ -59,6 +75,8 @@ jobs: --cycles 12 windows-release-package: + # Do not publish a Windows RC if rendered Linux Browser QA is red. + needs: linux-release-soak runs-on: windows-2025 steps: - uses: actions/checkout@v4 diff --git a/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml b/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml index 282b7c7e6..a8baaca1c 100644 --- a/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml +++ b/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml @@ -285,6 +285,7 @@ Dialog { Dialog { id: createConfirm + width: 510 title: "Confirm dynamic DataSet create" modal: true anchors.centerIn: Overlay.overlay @@ -303,6 +304,7 @@ Dialog { Dialog { id: deleteDialog + width: 510 title: "Confirm dynamic DataSet delete" modal: true anchors.centerIn: Overlay.overlay diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 775345713..54fb89cb7 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -25,6 +25,11 @@ Rectangle { return context.treeModel.nodeForReference(reference) } + readonly property var selectedDataSet: reports && reports.dataSets + && reports.selectedDataSetIndex >= 0 + && reports.selectedDataSetIndex < reports.dataSets.length + ? (reports.dataSets[reports.selectedDataSetIndex] || null) : null + function hasValue(value) { return value !== undefined && value !== null && String(value).length > 0 } @@ -50,8 +55,8 @@ Rectangle { Label { Layout.fillWidth: true - text: reports.selectedDataSetIndex >= 0 && reports.selectedDataSetIndex < reports.dataSets.length - ? root.text(reports.dataSets[reports.selectedDataSetIndex].reference) + text: root.selectedDataSet !== null + ? root.text(root.selectedDataSet.reference) : "Select a DataSet in the navigation tree." color: theme.textSoft font.pixelSize: theme.labelSize @@ -60,22 +65,21 @@ Rectangle { RowLayout { Layout.fillWidth: true - visible: reports.selectedDataSetIndex >= 0 - && reports.selectedDataSetIndex < reports.dataSets.length + visible: root.selectedDataSet !== null Rectangle { Layout.preferredWidth: 104 Layout.preferredHeight: 22 radius: 11 - color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + color: root.selectedDataSet !== null && root.selectedDataSet.dynamicOwned === true ? theme.greenSoft : theme.surface border.width: 1 - border.color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + border.color: root.selectedDataSet !== null && root.selectedDataSet.dynamicOwned === true ? theme.green : theme.lineSoft Label { anchors.centerIn: parent - text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + text: root.selectedDataSet !== null && root.selectedDataSet.dynamicOwned === true ? "DYNAMIC OWNED" : "STATIC / READ-ONLY" - color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + color: root.selectedDataSet !== null && root.selectedDataSet.dynamicOwned === true ? theme.green : theme.muted font.pixelSize: 7 font.weight: Font.Bold @@ -83,7 +87,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + text: root.selectedDataSet !== null && 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/BrowserReportAuthoringDialog.qml b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml index f4fec65a4..6b6bb31a4 100644 --- a/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml +++ b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml @@ -321,6 +321,7 @@ Dialog { Dialog { id: confirmEnable + width: 540 title: "Confirm authored RCB enable" modal: true anchors.centerIn: Overlay.overlay diff --git a/apps/ied_simulator/qml/IedBrowserNavigation.qml b/apps/ied_simulator/qml/IedBrowserNavigation.qml index eec8093c4..b795cdc69 100644 --- a/apps/ied_simulator/qml/IedBrowserNavigation.qml +++ b/apps/ied_simulator/qml/IedBrowserNavigation.qml @@ -387,6 +387,7 @@ Rectangle { ListView { id: modelTree + objectName: "iedBrowserSignalTree" Layout.fillWidth: true Layout.preferredHeight: Math.max(260, Math.min(contentHeight, 620)) clip: true @@ -398,6 +399,8 @@ Rectangle { delegate: Rectangle { id: modelRow required property int index + // Required delegate properties disable implicit model context roles. + required property var model width: modelTree.width height: 30 color: model.selected ? root.theme.accentSoft diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index e7cc21e54..496bf6c27 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -121,163 +121,184 @@ Item { anchors.fill: parent spacing: 0 + // Two distinct, compact tool rows keep every label and command visible + // at the supported 1024px window width. The old single RowLayout was + // wider than its container, leaving ghost/blank controls in the GUI. Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 68 + id: browserCommandBar + objectName: "iedBrowserCommandBar" + Layout.preferredHeight: 96 color: root.theme.chrome border.width: 1 border.color: root.theme.lineSoft - RowLayout { + ColumnLayout { anchors.fill: parent - anchors.leftMargin: 14 - anchors.rightMargin: 14 - spacing: 8 + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.topMargin: 7 + anchors.bottomMargin: 7 + spacing: 5 - ColumnLayout { - Layout.preferredWidth: 168 - spacing: 1 - Label { - text: "MODEL" - color: root.theme.text - font.pixelSize: root.theme.labelSize - font.weight: Font.DemiBold - } - Label { - text: context.loaded - ? root.activeIedLabel() + " · " + context.authority - : "Open SCL or discover one IED endpoint" - color: root.theme.muted - font.pixelSize: root.theme.captionSize - } - } - - ActionButton { - theme: root.theme - text: "Open SCL" - enabled: !session.configurationLocked && !engineering.busy - onClicked: browserSclDialog.open() - ToolTip.visible: hovered - ToolTip.text: "Open SCL/CID/SCD/IID/ICD into the persistent Browser model." - } - - ActionButton { - theme: root.theme - text: engineering.busy ? "Saving…" : "Save SCL" - visible: context.loaded && context.authorityKey === "live-discovery" - enabled: engineering.engineeringContextExportSupported - && !engineering.busy - onClicked: saveSclDialog.open() - ToolTip.visible: hovered - ToolTip.text: context.online - ? "Save the cached canonical IED model locally; no rediscovery is performed." - : "Save the persistent canonical IED model while offline." - } - - TextField { - id: hostField - Layout.preferredWidth: 190 - text: session.host - placeholderText: "IED IP / hostname" - selectByMouse: true - enabled: !session.configurationLocked - onEditingFinished: session.host = text - } - - SpinBox { - id: portField - Layout.preferredWidth: 102 - from: 1 - to: 65535 - value: session.port - editable: true - enabled: !session.configurationLocked - onValueModified: session.port = value - } + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 35 + spacing: 8 - ActionButton { - theme: root.theme - text: "Online" - visible: context.loaded - enabled: !session.connected && !session.busy && !context.selectionRequired - primary: enabled - onClicked: { - session.host = hostField.text - session.port = portField.value - session.connectUsingEngineeringContext() + ColumnLayout { + Layout.preferredWidth: 240 + spacing: 1 + Label { + text: "MODEL" + color: root.theme.text + font.pixelSize: root.theme.labelSize + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: context.loaded + ? root.activeIedLabel() + " · " + context.authority + : "Open SCL or discover one IED endpoint" + color: root.theme.muted + font.pixelSize: root.theme.captionSize + elide: Text.ElideRight + } } - ToolTip.visible: hovered - ToolTip.text: context.authorityKey === "scl" - ? "Connect using the active trusted SCL model." - : "Reconnect/discover the active IED endpoint." - } - ActionButton { - theme: root.theme - text: session.busy && !session.connected ? "Discovering…" : "Discover IED" - enabled: !session.connected && !session.busy - onClicked: { - session.host = hostField.text - session.port = portField.value - session.discoverAndConnect() + ActionButton { + theme: root.theme + text: "Open SCL" + enabled: !session.configurationLocked && !engineering.busy + onClicked: browserSclDialog.open() + ToolTip.visible: hovered + ToolTip.text: "Open SCL/CID/SCD/IID/ICD into the persistent Browser model." } - ToolTip.visible: hovered - ToolTip.text: "Ignore trusted SCL for this connect, discover the live MMS model, and publish it into this Browser context." - } - - ActionButton { - theme: root.theme - text: "Disconnect" - enabled: session.connected || session.busy - danger: true - onClicked: session.disconnectFromIed() - } - - Rectangle { - width: 8 - height: 8 - radius: 4 - color: session.lastError.length ? root.theme.red - : session.connected ? root.theme.green - : session.busy ? root.theme.amber - : root.theme.muted - } - - ColumnLayout { - spacing: 0 - Label { - text: session.stateText - color: session.lastError.length ? root.theme.red : root.theme.textSoft - font.pixelSize: root.theme.labelSize - font.weight: Font.DemiBold + ActionButton { + theme: root.theme + text: engineering.busy ? "Saving…" : "Save SCL" + visible: context.loaded && context.authorityKey === "live-discovery" + enabled: engineering.engineeringContextExportSupported && !engineering.busy + onClicked: saveSclDialog.open() + ToolTip.visible: hovered + ToolTip.text: "Save the cached canonical IED model locally; no rediscovery is performed." } - Label { - text: session.endpoint - color: root.theme.muted - font.pixelSize: root.theme.captionSize + Item { Layout.fillWidth: true } + ColumnLayout { + visible: context.loaded + spacing: 0 + Label { + Layout.alignment: Qt.AlignRight + text: context.iedName.length ? context.iedName + : context.selectionRequired ? "Select active IED" : "IED model" + color: root.theme.text + font.pixelSize: root.theme.labelSize + font.weight: Font.DemiBold + } + Label { + Layout.alignment: Qt.AlignRight + text: context.logicalDeviceCount + " LD · " + context.logicalNodeCount + " LN · " + + context.dataAttributeCount + " DA · " + + (context.online ? "ONLINE" : "OFFLINE") + color: root.theme.muted + font.pixelSize: root.theme.captionSize + } } } - Item { Layout.fillWidth: true } + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 37 + spacing: 8 - ColumnLayout { - visible: context.loaded - spacing: 0 Label { - Layout.alignment: Qt.AlignRight - text: context.iedName.length ? context.iedName - : context.selectionRequired ? "Select active IED" : "IED model" - color: root.theme.text - font.pixelSize: root.theme.labelSize - font.weight: Font.DemiBold + text: "IED endpoint" + color: root.theme.textSoft + font.pixelSize: root.theme.captionSize + } + TextField { + id: hostField + objectName: "iedBrowserHostField" + Layout.preferredWidth: 190 + placeholderText: "IED IP / hostname" + text: session.host + selectByMouse: true + enabled: !session.configurationLocked + onEditingFinished: session.host = text + } + SpinBox { + id: portField + objectName: "iedBrowserPortField" + Layout.preferredWidth: 102 + from: 1 + to: 65535 + value: session.port + editable: true + enabled: !session.configurationLocked + onValueModified: session.port = value + } + ActionButton { + theme: root.theme + text: "Online" + visible: context.loaded && !session.connected + enabled: !session.busy && !context.selectionRequired + primary: enabled + onClicked: { + session.host = hostField.text + session.port = portField.value + session.connectUsingEngineeringContext() + } + ToolTip.visible: hovered + ToolTip.text: context.authorityKey === "scl" + ? "Connect using the active trusted SCL model." + : "Reconnect/discover the active IED endpoint." + } + ActionButton { + theme: root.theme + text: session.busy && !session.connected ? "Discovering…" : "Discover IED" + visible: !session.connected + enabled: !session.busy + onClicked: { + session.host = hostField.text + session.port = portField.value + session.discoverAndConnect() + } + ToolTip.visible: hovered + ToolTip.text: "Discover the live MMS model and publish it into this Browser context." + } + ActionButton { + theme: root.theme + text: "Disconnect" + visible: session.connected || session.busy + enabled: session.connected || session.busy + danger: true + onClicked: session.disconnectFromIed() + } + Rectangle { + width: 7 + height: 7 + radius: 4 + color: session.lastError.length ? root.theme.red + : session.connected ? root.theme.green + : session.busy ? root.theme.amber + : root.theme.muted } Label { - Layout.alignment: Qt.AlignRight - text: context.logicalDeviceCount + " LD · " + context.logicalNodeCount + " LN · " - + context.dataAttributeCount + " DA · " - + (context.online ? "ONLINE" : "OFFLINE") - color: root.theme.muted + objectName: "iedBrowserSessionStatus" + Layout.fillWidth: true + text: session.lastError.length ? session.lastError + : session.stateText + (session.endpoint.length ? " · " + session.endpoint : "") + color: session.lastError.length ? root.theme.red : root.theme.textSoft font.pixelSize: root.theme.captionSize + elide: Text.ElideRight + ToolTip.visible: sessionStatusMouse.containsMouse + ToolTip.text: text + MouseArea { + id: sessionStatusMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } } } } diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index c11595578..4009828fa 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -134,6 +134,13 @@ ApplicationWindow { } } + // Detach rendered Browser delegates while their per-IED service owners + // remain alive. Destroying the fleet first during application exit + // otherwise nulls nested QML properties and raises runtime TypeErrors. + function prepareForShutdown() { + iedBrowserRepeater.model = null + } + function importModel() { sclDialog.open() } Shortcut { sequence: "Ctrl+1"; onActivated: root.workspaceIndex = 0 } @@ -290,7 +297,7 @@ ApplicationWindow { theme: appTheme workspace: sclWorkspace hardening: hardening - browserSession: browserSession + browserSession: root.browserSession context: iedContext onBrowserRequested: root.workspaceIndex = 1 onOpenSourceRequested: function(fileUrl) { @@ -449,28 +456,50 @@ 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 { - property int slotIndex: index + required property int index + // One independently owned controller set belongs to this + // delegate for its entire lifetime. Repeater reindex/removal + // must NEVER rebind an outgoing IED to another slot or null. + Component.onCompleted: { + 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) + } + objectName: "iedBrowserView_" + index + anchors.fill: parent + visible: index === root.browserFleet.activeIndex theme: appTheme productState: hardening - fleet: browserFleet - session: browserFleet.sessionAt(slotIndex) - client: browserFleet.clientAt(slotIndex) - context: browserFleet.contextAt(slotIndex) - reports: browserFleet.reportsAt(slotIndex) - utilities: browserFleet.utilitiesAt(slotIndex) - controls: browserFleet.controlsAt(slotIndex) - engineering: browserFleet.engineeringAt(slotIndex) + 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() } } diff --git a/apps/ied_simulator/qml/MmsClientWorkspace.qml b/apps/ied_simulator/qml/MmsClientWorkspace.qml index 759f831fa..dc03ce94e 100644 --- a/apps/ied_simulator/qml/MmsClientWorkspace.qml +++ b/apps/ied_simulator/qml/MmsClientWorkspace.qml @@ -426,6 +426,7 @@ Item { ListView { id: valueTable + objectName: "iedBrowserValueTable" Layout.fillWidth: true Layout.fillHeight: true clip: true @@ -441,6 +442,8 @@ Item { delegate: Rectangle { id: valueRow required property int index + // Bind the complete model role object alongside the index. + required property var model width: valueTable.width height: 31 color: model.selected @@ -485,6 +488,7 @@ Item { } } Label { + objectName: "iedBrowserValueName_" + valueRow.index Layout.fillWidth: true text: model.label color: theme.text diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp index 1a8b0dd7b..86ffa888b 100644 --- a/apps/ied_simulator/src/IedBrowserFleetController.cpp +++ b/apps/ied_simulator/src/IedBrowserFleetController.cpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "IedBrowserFleetController.hpp" +#include + #include #include @@ -260,8 +262,18 @@ bool IedBrowserFleetController::closeWorkspace(const int index) { if (activeIndex_ > index) --activeIndex_; else if (activeIndex_ == index) activeIndex_ = std::min(index, workspaceCount() - 1); emit activeChanged(); - // Retire services only AFTER Qt views process the row removal and all - // active QObject bindings have been retargeted away from the removed IED. - retired.reset(); + // QML Repeater removal and its nested delegates may finish after + // endRemoveRows() returns. Destroying QObject services synchronously here + // nulls dozens of still-live required QML properties, even though the + // remaining IED and its canonical model are correct. Keep this Entry alive + // through the next event-loop turn so visual teardown can complete. + // Capture a copyable shared owner because Qt stores the callback. + const auto retiredLifetime = std::shared_ptr(std::move(retired)); + // Let the Qt Quick removal complete through a rendered frame before the + // old controller set is retired. This is a bounded teardown grace period, + // not a timer for MMS requests or reporting. + QTimer::singleShot(50, this, [retiredLifetime] { + // The retired Entry dies after the old visual delegate is detached. + }); return true; } diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 8a1123adf..929edae80 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, @@ -142,6 +151,11 @@ int main(int argc, char* argv[]) { if (!engine.rootObjects().isEmpty()) { auto* const rootObject = engine.rootObjects().constFirst(); + // Qt Quick views must detach before their C++ per-IED services die, + // including when the user closes the application window normally. + QObject::connect(&app, &QCoreApplication::aboutToQuit, rootObject, [rootObject] { + QMetaObject::invokeMethod(rootObject, "prepareForShutdown"); + }); auto* const backend = rootObject->findChild(QStringLiteral("simulatorBackend")); int defaultPort = 102; if (backend != nullptr && parser.isSet(portOption)) { @@ -418,6 +432,233 @@ 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; + } + // Exercise the actual minimum supported desktop width, not + // just the 1360px default where overfull toolbars appear fine. + rootObject->setProperty("width", 1024); + 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 commandBar = second + ? second->findChild(QStringLiteral("iedBrowserCommandBar")) + : nullptr; + auto* const hostField = second + ? second->findChild(QStringLiteral("iedBrowserHostField")) + : nullptr; + auto* const portField = second + ? second->findChild(QStringLiteral("iedBrowserPortField")) + : nullptr; + auto* const sessionStatus = second + ? second->findChild(QStringLiteral("iedBrowserSessionStatus")) + : nullptr; + const auto toolbarFits = [&]() { + if (!commandBar || !hostField || !portField || !sessionStatus) return false; + if (commandBar->height() < 90 || commandBar->width() < 980) return false; + const auto left = hostField->mapToScene(QPointF{}).x(); + const auto port = portField->mapToScene(QPointF{}).x(); + const auto right = sessionStatus->mapToScene( + QPointF{sessionStatus->width(), 0}).x(); + return left >= 0 && port > left && right <= 1024 && + sessionStatus->width() >= 50 && sessionStatus->isVisible(); + }; + auto* const signalList = second + ? second->findChild(QStringLiteral("iedBrowserSignalTree")) + : nullptr; + auto* const valueList = second + ? second->findChild(QStringLiteral("iedBrowserValueTable")) + : nullptr; + // ListView delegates are visual children of its contentItem. + // QObject::findChild does not reliably traverse that visual tree, + // so inspect QQuickItem::childItems instead of weakening the QA. + const auto firstValueText = [valueList]() -> QString { + if (!valueList) return {}; + const auto target = QStringLiteral("iedBrowserValueName_0"); + QVector pending{valueList}; + while (!pending.isEmpty()) { + auto* const item = pending.takeLast(); + if (!item) continue; + if (item->objectName() == target) + return item->property("text").toString(); + for (auto* child : item->childItems()) pending.push_back(child); + } + return {}; + }; + + const auto renderedReady = [&]() { + return first && second && signalList && valueList && selected && + toolbarFits() && + signalList->property("count").toInt() >= 3 && + signalList->width() >= 100 && signalList->isVisible() && + valueList->property("count").toInt() >= 3 && + valueList->width() >= 100 && valueList->isVisible() && + firstValueText() == QStringLiteral("QA_IED_B") && + !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 && + second->property("client").value() == fleet->clientAt(1) && + second->property("session").value() == fleet->sessionAt(1) && + second->property("reports").value() == fleet->reportsAt(1) && + second->property("utilities").value() == fleet->utilitiesAt(1) && + second->property("controls").value() == fleet->controlsAt(1) && + second->property("engineering").value() == fleet->engineeringAt(1); + }; + + 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() + << "toolbarFits1024=" << toolbarFits() + << "statusWidth=" << (sessionStatus ? sessionStatus->width() : -1) + << "host=" << static_cast(browserHost) + << "visualChildren=" << (browserHost ? browserHost->childItems().size() : -1) + << "first=" << static_cast(first) + << "second=" << static_cast(second) + << "signalList=" << static_cast(signalList) + << "valueList=" << static_cast(valueList) + << "valueCount=" << (valueList ? valueList->property("count").toInt() : -1) + << "valueVisible=" << (valueList ? valueList->isVisible() : false) + << "firstValue=" << firstValueText() + << "expectedValue=" << selected->treeModel()->data( + selected->treeModel()->index(0, 0), MmsLiveTreeModel::LabelRole).toString() + << "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) + << "clientMatch=" << (second && fleet->clientAt(1) + ? second->property("client").value() == fleet->clientAt(1) : false) + << "sessionMatch=" << (second && fleet->sessionAt(1) + ? second->property("session").value() == fleet->sessionAt(1) : false) + << "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 || + valueList->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" << "model_values=visible" << "value_label=bound" << "toolbar_1024=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_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 82e4824ce..a26bf90e6 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -11,6 +11,11 @@ workspace_required = [ 'text: "MODEL"', + 'objectName: "iedBrowserCommandBar"', + 'Layout.preferredHeight: 96', + 'objectName: "iedBrowserHostField"', + 'objectName: "iedBrowserPortField"', + 'objectName: "iedBrowserSessionStatus"', 'text: "COMMANDS"', 'text: "Online"', '"Discover IED"', @@ -28,6 +33,9 @@ 'title: "DataSets"', 'title: "Data Model"', 'context.treeModel', + 'objectName: "iedBrowserSignalTree"', + 'required property int index', + 'required property var model', ] status_required = [ 'readonly property int maximumEntries: 80', @@ -46,6 +54,11 @@ if missing: raise SystemExit("BROWSER_CONVERGENCE_FAIL " + label + " missing=" + ",".join(missing)) +if "Layout.preferredHeight: 68" in workspace: + raise SystemExit("BROWSER_CONVERGENCE_FAIL overfull single-row toolbar restored") +if workspace.count("id: hostField") != 1 or workspace.count("id: portField") != 1: + raise SystemExit("BROWSER_CONVERGENCE_FAIL endpoint controls duplicated") + if "client.treeModel" in navigation: raise SystemExit("BROWSER_CONVERGENCE_FAIL navigation regressed to session-owned model") if "Timer {" in status: diff --git a/apps/ied_simulator/test_browser_delegate_bindings.py b/apps/ied_simulator/test_browser_delegate_bindings.py index ef11bde15..1e2cfc0cf 100644 --- a/apps/ied_simulator/test_browser_delegate_bindings.py +++ b/apps/ied_simulator/test_browser_delegate_bindings.py @@ -15,15 +15,28 @@ required_main = ( "property var browserFleet: fleet", - "property int slotIndex: index", - "fleet: browserFleet", - "session: browserFleet.sessionAt(slotIndex)", - "client: browserFleet.clientAt(slotIndex)", - "context: browserFleet.contextAt(slotIndex)", - "reports: browserFleet.reportsAt(slotIndex)", - "utilities: browserFleet.utilitiesAt(slotIndex)", - "controls: browserFleet.controlsAt(slotIndex)", - "engineering: browserFleet.engineeringAt(slotIndex)", + "function prepareForShutdown()", + "iedBrowserRepeater.model = null", + "browserSession: root.browserSession", + 'id: perIedBrowserHost', + 'objectName: "iedBrowserView_" + index', + "anchors.fill: parent", + "visible: index === root.browserFleet.activeIndex", + "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)", + "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)", ) missing = [token for token in required_main if token not in main] if missing: @@ -34,6 +47,7 @@ line == "fleet: fleet" or line.startswith("session: fleet.") or line.startswith("client: fleet.") + or line == "browserSession: browserSession" for line in delegate_lines ): raise SystemExit("BROWSER_DELEGATE_FAIL self-binding fleet authority") diff --git a/apps/ied_simulator/test_browser_observation_workflow.py b/apps/ied_simulator/test_browser_observation_workflow.py index f41d0b4b2..2fe226684 100644 --- a/apps/ied_simulator/test_browser_observation_workflow.py +++ b/apps/ied_simulator/test_browser_observation_workflow.py @@ -30,6 +30,10 @@ def require(source: str, tokens: tuple[str, ...], label: str) -> None: 'text: "Model values"', 'text: root.client.operationBusy ? "Reading…" : "Read visible"', "model: root.modelProvider.treeModel", + 'objectName: "iedBrowserValueTable"', + 'objectName: "iedBrowserValueName_" + valueRow.index', + "required property int index", + "required property var model", "root.refreshCurrentVisible(root.firstVisibleRow(), root.lastVisibleRow())", 'text: "OBSERVED VALUE"', 'text: "ENGINEERING IDENTITY"', @@ -44,6 +48,9 @@ def require(source: str, tokens: tuple[str, ...], label: str) -> None: datasets, ( "context.treeModel.nodeForReference(reference)", + "readonly property var selectedDataSet:", + "(reports.dataSets[reports.selectedDataSetIndex] || null)", + "root.selectedDataSet !== null", "root.client.refreshEngineeringReferences(reports.selectedDataSetMembers)", "signal inspectRequested(string reference)", 'text: "Ordered members and observed values"', @@ -64,6 +71,9 @@ def require(source: str, tokens: tuple[str, ...], label: str) -> None: "browser routing", ) +if "reports.dataSets[reports.selectedDataSetIndex].dynamicOwned" in datasets: + raise SystemExit("BROWSER_OBSERVATION_FAIL unguarded DataSet ownership selection") + if "ListModel {" in inspector or "ListModel {" in datasets: raise SystemExit( "BROWSER_OBSERVATION_FAIL canonical projection duplicated into a QML ListModel" diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index d9106470d..72b5e23cd 100644 --- a/apps/ied_simulator/test_multi_ied_workspace.py +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -39,7 +39,8 @@ def require(haystack, words, label): "entry->reports->cleanupRequired()", "activeEngineering()->adoptSourceFrom(*original->engineering)", "auto retired = std::move(entries_[static_cast(index)])", - "retired.reset();", + "std::shared_ptr(std::move(retired))", + "QTimer::singleShot(50, this, [retiredLifetime]", ), "fleet authority") require(fleet_hpp, ( "QAbstractListModel", @@ -58,14 +59,16 @@ def require(haystack, words, label): "property var browserSession: fleet.activeSession", "property var sclWorkspace: fleet.activeEngineering", "model: fleet", - "currentIndex: fleet.activeIndex", - "browserFleet.contextAt(slotIndex)", - "browserFleet.sessionAt(slotIndex)", - "browserFleet.clientAt(slotIndex)", - "browserFleet.reportsAt(slotIndex)", - "browserFleet.controlsAt(slotIndex)", - "browserFleet.utilitiesAt(slotIndex)", - "browserFleet.engineeringAt(slotIndex)", + "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 +119,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: