From 81ee7c04681f5accdf3608016109931a85dfffe9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 08:28:06 +0700 Subject: [PATCH 01/43] fix(qml): show the selected IED Browser with non-shadowed slot services --- apps/ied_simulator/qml/Main.qml | 38 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index c11595578..501f3af68 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -449,28 +449,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 { - property int slotIndex: index + required property int 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() } } From cd8244a8c03639e3400909831b590d56d0d205b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 08:28:10 +0700 Subject: [PATCH 02/43] test(browser): restore validated rendered fleet routing and service contracts --- apps/ied_simulator/src/main.cpp | 169 ++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) 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] { From edc3540e2186e75ff25e2f986221a26f0af4b8f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 08:28:12 +0700 Subject: [PATCH 03/43] test(browser): restore validated rendered fleet routing and service contracts --- apps/ied_simulator/test_browser_control_ux.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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: From ec8d87e9d7459e68056d38ccd8bcfc69ea2611b8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 08:28:15 +0700 Subject: [PATCH 04/43] test(browser): restore validated rendered fleet routing and service contracts --- .../ied_simulator/test_multi_ied_workspace.py | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index d9106470d..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", - "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 +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: From 517c7862808804c03769fe1f6db2d124b34cfc22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 08:28:18 +0700 Subject: [PATCH 05/43] test(browser): reject shadowed services and wrong active-slot geometry --- .../test_browser_delegate_bindings.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/ied_simulator/test_browser_delegate_bindings.py b/apps/ied_simulator/test_browser_delegate_bindings.py index ef11bde15..3e97fda54 100644 --- a/apps/ied_simulator/test_browser_delegate_bindings.py +++ b/apps/ied_simulator/test_browser_delegate_bindings.py @@ -15,15 +15,18 @@ 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)", + '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)", ) missing = [token for token in required_main if token not in main] if missing: From 3550f2f5ea3e00f097c42e56f4c71b56db84599c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 08:28:22 +0700 Subject: [PATCH 06/43] ci(browser): gate rendered active IED and offline signal routing --- .github/workflows/ied-simulator-qt.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 4d66c60a7..6ff3fb5da 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 From f59089fd89bf6ba5c6eaac8ff903b50468b0f100 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 08:28:30 +0700 Subject: [PATCH 07/43] ci(browser): verify active slot routing in release hardening --- .github/workflows/ied-simulator-release-hardening.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From 684e32eaca11472af5c4944a23fb4a31b79cf77d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:14:11 +0700 Subject: [PATCH 08/43] fix(qml): restore canonical Browser tree roles and rendered-tree identity --- apps/ied_simulator/qml/IedBrowserNavigation.qml | 3 +++ 1 file changed, 3 insertions(+) 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 From 1b104d443e8cb16f79d30cbb2ff93e3a711eb6e5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:14:14 +0700 Subject: [PATCH 09/43] fix(qml): bind value-table model roles with required delegate index --- apps/ied_simulator/qml/MmsClientWorkspace.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ied_simulator/qml/MmsClientWorkspace.qml b/apps/ied_simulator/qml/MmsClientWorkspace.qml index 759f831fa..c3ad44a05 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 From 0d1beb8f648ce1ca1a561563ee38cee3f58de71b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:14:35 +0700 Subject: [PATCH 10/43] test(browser): assert rendered model values as well as active signal tree --- apps/ied_simulator/src/main.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 6ca0859fd..a093b2477 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -501,11 +501,16 @@ int main(int argc, char* argv[]) { auto* const signalList = second ? second->findChild(QStringLiteral("iedBrowserSignalTree")) : nullptr; + auto* const valueList = second + ? second->findChild(QStringLiteral("iedBrowserValueTable")) + : nullptr; const auto renderedReady = [&]() { - return first && second && signalList && selected && + return first && second && signalList && valueList && selected && signalList->property("count").toInt() >= 3 && signalList->width() >= 100 && signalList->isVisible() && + valueList->property("count").toInt() >= 3 && + valueList->width() >= 100 && valueList->isVisible() && !first->isVisible() && second->isVisible() && selected->loaded() && !selected->online() && selected->iedName() == QStringLiteral("QA_IED_B") && @@ -530,6 +535,9 @@ int main(int argc, char* argv[]) { << "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) << "firstVisible=" << (first ? first->isVisible() : false) << "secondVisible=" << (second ? second->isVisible() : false) << "secondSize=" << (second ? second->width() : -1.0) @@ -562,7 +570,8 @@ int main(int argc, char* argv[]) { QCoreApplication::processEvents(QEventLoop::AllEvents, 50); if (first->isVisible() || !second->isVisible() || !selected->loaded() || selected->treeModel()->visibleNodeCount() < 3 || - signalList->property("count").toInt() < 3) { + signalList->property("count").toInt() < 3 || + valueList->property("count").toInt() < 3) { fail("offline_signal_tree_lost_after_switch", 68); return; } @@ -582,7 +591,7 @@ int main(int argc, char* argv[]) { } qInfo().noquote() << "BROWSER_FLEET_ROUTING_PASS active_tab=QA_IED_B" - << "offline_signals=visible" + << "offline_signals=visible" << "model_values=visible" << "switching=pass reindex=pass cross_ied_panel=false"; app.exit(0); }); From ed68748da9919f603a2082adeb9d0a43a143d757 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:14:54 +0700 Subject: [PATCH 11/43] test(qml): lock required model roles and observable Browser tree IDs --- apps/ied_simulator/test_browser_observation_workflow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ied_simulator/test_browser_observation_workflow.py b/apps/ied_simulator/test_browser_observation_workflow.py index f41d0b4b2..060ad2202 100644 --- a/apps/ied_simulator/test_browser_observation_workflow.py +++ b/apps/ied_simulator/test_browser_observation_workflow.py @@ -30,6 +30,9 @@ 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"', + "required property int index", + "required property var model", "root.refreshCurrentVisible(root.firstVisibleRow(), root.lastVisibleRow())", 'text: "OBSERVED VALUE"', 'text: "ENGINEERING IDENTITY"', From b649c71d3752966be8b0f1fc4a8e4f9b2e63c7cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:14:56 +0700 Subject: [PATCH 12/43] test(qml): lock required model roles and observable Browser tree IDs --- apps/ied_simulator/test_browser_convergence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 82e4824ce..e4025ca43 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -28,6 +28,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', From cd5551bd9b63a78c4fbbc1dc349325d084018a5d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:16:39 +0700 Subject: [PATCH 13/43] test(browser): verify all live service pointers match selected IED slot --- apps/ied_simulator/src/main.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index a093b2477..7aee6dbaa 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -517,7 +517,13 @@ int main(int argc, char* argv[]) { selected->treeModel()->totalNodeCount() >= 5 && selected->treeModel()->visibleNodeCount() >= 3 && second->width() >= 100 && second->height() >= 100 && - second->property("context").value() == selected; + 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; @@ -549,6 +555,10 @@ int main(int argc, char* argv[]) { << "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); From f092db2c478535dcc8566f9ac4c23ab6c10df992 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:17:48 +0700 Subject: [PATCH 14/43] test(qml): expose rendered model value label for nonblank GUI assertion --- apps/ied_simulator/qml/MmsClientWorkspace.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ied_simulator/qml/MmsClientWorkspace.qml b/apps/ied_simulator/qml/MmsClientWorkspace.qml index c3ad44a05..dc03ce94e 100644 --- a/apps/ied_simulator/qml/MmsClientWorkspace.qml +++ b/apps/ied_simulator/qml/MmsClientWorkspace.qml @@ -488,6 +488,7 @@ Item { } } Label { + objectName: "iedBrowserValueName_" + valueRow.index Layout.fillWidth: true text: model.label color: theme.text From 21dc6df93086767a94a73f746d7498eb22863d6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:18:03 +0700 Subject: [PATCH 15/43] test(browser): fail rendered QA when first model value label is blank --- apps/ied_simulator/src/main.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 7aee6dbaa..86cf05c5d 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -504,6 +504,14 @@ int main(int argc, char* argv[]) { auto* const valueList = second ? second->findChild(QStringLiteral("iedBrowserValueTable")) : nullptr; + // A non-empty ListView is not sufficient: verify the rendered + // delegate's actual Name label resolves from the model role. + const auto firstValueText = [valueList]() -> QString { + if (!valueList) return {}; + auto* const name = valueList->findChild( + QStringLiteral("iedBrowserValueName_0")); + return name ? name->property("text").toString() : QString{}; + }; const auto renderedReady = [&]() { return first && second && signalList && valueList && selected && @@ -511,6 +519,7 @@ int main(int argc, char* argv[]) { 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") && @@ -544,6 +553,7 @@ int main(int argc, char* argv[]) { << "valueList=" << static_cast(valueList) << "valueCount=" << (valueList ? valueList->property("count").toInt() : -1) << "valueVisible=" << (valueList ? valueList->isVisible() : false) + << "firstValue=" << firstValueText() << "firstVisible=" << (first ? first->isVisible() : false) << "secondVisible=" << (second ? second->isVisible() : false) << "secondSize=" << (second ? second->width() : -1.0) @@ -601,7 +611,7 @@ int main(int argc, char* argv[]) { } qInfo().noquote() << "BROWSER_FLEET_ROUTING_PASS active_tab=QA_IED_B" - << "offline_signals=visible" << "model_values=visible" + << "offline_signals=visible" << "model_values=visible" << "value_label=bound" << "switching=pass reindex=pass cross_ied_panel=false"; app.exit(0); }); From 48739625c861acab581e057155efc309eeee9f09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:18:11 +0700 Subject: [PATCH 16/43] test(browser): retain visible value label identity in observation contract --- apps/ied_simulator/test_browser_observation_workflow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ied_simulator/test_browser_observation_workflow.py b/apps/ied_simulator/test_browser_observation_workflow.py index 060ad2202..6a97d2827 100644 --- a/apps/ied_simulator/test_browser_observation_workflow.py +++ b/apps/ied_simulator/test_browser_observation_workflow.py @@ -31,6 +31,7 @@ def require(source: str, tokens: tuple[str, ...], label: str) -> None: '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())", From 18e39772dd6d9d39e8056384c68644aaf321fa50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:50:49 +0700 Subject: [PATCH 17/43] fix(qml): prevent File Home session self-binding loop --- apps/ied_simulator/qml/Main.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 501f3af68..90bc272af 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -290,7 +290,7 @@ ApplicationWindow { theme: appTheme workspace: sclWorkspace hardening: hardening - browserSession: browserSession + browserSession: root.browserSession context: iedContext onBrowserRequested: root.workspaceIndex = 1 onOpenSourceRequested: function(fileUrl) { From 24bba54925dec8d4dad3a0360936e308ad9cb507 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:50:53 +0700 Subject: [PATCH 18/43] fix(qml): guard transient DataSet selection before dereferencing ownership --- apps/ied_simulator/qml/BrowserDataSetPane.qml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 775345713..533c6a723 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -25,7 +25,7 @@ Rectangle { return context.treeModel.nodeForReference(reference) } - function hasValue(value) { + readonly property var selectedDataSet: reports && reports.dataSets\n && reports.selectedDataSetIndex >= 0\n && reports.selectedDataSetIndex < reports.dataSets.length\n ? reports.dataSets[reports.selectedDataSetIndex] : null\n\n function hasValue(value) { return value !== undefined && value !== null && String(value).length > 0 } @@ -66,16 +66,16 @@ 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 +83,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 From 2f71067c1cd70f18c5119b055a97d5b3b796e15f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:51:15 +0700 Subject: [PATCH 19/43] test(browser): traverse ListView visual children for rendered label assertion --- apps/ied_simulator/src/main.cpp | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 86cf05c5d..678eb5068 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -504,13 +504,21 @@ int main(int argc, char* argv[]) { auto* const valueList = second ? second->findChild(QStringLiteral("iedBrowserValueTable")) : nullptr; - // A non-empty ListView is not sufficient: verify the rendered - // delegate's actual Name label resolves from the model role. + // 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 {}; - auto* const name = valueList->findChild( - QStringLiteral("iedBrowserValueName_0")); - return name ? name->property("text").toString() : QString{}; + 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 = [&]() { @@ -554,6 +562,8 @@ int main(int argc, char* argv[]) { << "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) From 0d322f0f5aceb9c088d741b309fdf88c74152bee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:51:34 +0700 Subject: [PATCH 20/43] fix(qml): correct DataSet guard syntax and use safe selection throughout --- apps/ied_simulator/qml/BrowserDataSetPane.qml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 533c6a723..3d03cf05d 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -25,7 +25,12 @@ Rectangle { return context.treeModel.nodeForReference(reference) } - readonly property var selectedDataSet: reports && reports.dataSets\n && reports.selectedDataSetIndex >= 0\n && reports.selectedDataSetIndex < reports.dataSets.length\n ? reports.dataSets[reports.selectedDataSetIndex] : null\n\n function hasValue(value) { + readonly property var selectedDataSet: reports && reports.dataSets + && reports.selectedDataSetIndex >= 0 + && reports.selectedDataSetIndex < reports.dataSets.length + ? reports.dataSets[reports.selectedDataSetIndex] : 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,8 +65,7 @@ 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 From aeefe1b56921a58a0f215c4f1589f36d6f3243b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:54:19 +0700 Subject: [PATCH 21/43] fix(qml): give confirmation dialogs explicit widths to avoid implicit sizing loops --- apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml | 2 ++ 1 file changed, 2 insertions(+) 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 From c821c58ad576eb2258db3ca175888460c4a2150d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:54:22 +0700 Subject: [PATCH 22/43] fix(qml): give confirmation dialogs explicit widths to avoid implicit sizing loops --- apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml | 1 + 1 file changed, 1 insertion(+) 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 From f4a12d6f956cd535b2b6175c15367fd1f402949e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:54:41 +0700 Subject: [PATCH 23/43] ci(browser): fail rendered milestone on QML binding and runtime errors --- .github/workflows/ied-simulator-qt.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 6ff3fb5da..b6273d470 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -74,7 +74,13 @@ jobs: - 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 + 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: From 4af3da4817204eb75600b38df4a5a657c9503a75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:54:44 +0700 Subject: [PATCH 24/43] ci(browser): fail rendered milestone on QML binding and runtime errors --- .github/workflows/ied-simulator-release-hardening.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 5285cdf7c..4b0daa8bc 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -45,7 +45,13 @@ jobs: - 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 + 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: From 0d858cfb9a438277d49f7fa2e2424e18cd90fbac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:57:12 +0700 Subject: [PATCH 25/43] fix(qml): normalize transient undefined DataSet entry to null --- apps/ied_simulator/qml/BrowserDataSetPane.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 3d03cf05d..54fb89cb7 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -28,7 +28,7 @@ Rectangle { readonly property var selectedDataSet: reports && reports.dataSets && reports.selectedDataSetIndex >= 0 && reports.selectedDataSetIndex < reports.dataSets.length - ? reports.dataSets[reports.selectedDataSetIndex] : null + ? (reports.dataSets[reports.selectedDataSetIndex] || null) : null function hasValue(value) { return value !== undefined && value !== null && String(value).length > 0 From cab1eb01aed54e4a1de976ae15c0a4f2bf61d06d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:58:36 +0700 Subject: [PATCH 26/43] ci: cancel superseded workbench Qt and release runs; validate exact head --- .github/workflows/ied-simulator-qt.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index b6273d470..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 From 35e97a77525264548f9d1f17e0a1767486ce0c29 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:58:39 +0700 Subject: [PATCH 27/43] ci: cancel superseded workbench Qt and release runs; validate exact head --- .github/workflows/ied-simulator-release-hardening.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 4b0daa8bc..eedc697aa 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 From e53a35389cfdf99aa514bb49e53cda29a55ed9e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:59:11 +0700 Subject: [PATCH 28/43] test(browser): reject File session self-binding regression --- apps/ied_simulator/test_browser_delegate_bindings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ied_simulator/test_browser_delegate_bindings.py b/apps/ied_simulator/test_browser_delegate_bindings.py index 3e97fda54..198492e7c 100644 --- a/apps/ied_simulator/test_browser_delegate_bindings.py +++ b/apps/ied_simulator/test_browser_delegate_bindings.py @@ -15,6 +15,7 @@ required_main = ( "property var browserFleet: fleet", + "browserSession: root.browserSession", 'id: perIedBrowserHost', 'objectName: "iedBrowserView_" + index', "anchors.fill: parent", @@ -37,6 +38,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") From 48728708a29d02546b30a7085a30a4e74f9b6867 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 09:59:20 +0700 Subject: [PATCH 29/43] test(browser): enforce null-safe DataSet ownership binding --- apps/ied_simulator/test_browser_observation_workflow.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ied_simulator/test_browser_observation_workflow.py b/apps/ied_simulator/test_browser_observation_workflow.py index 6a97d2827..2fe226684 100644 --- a/apps/ied_simulator/test_browser_observation_workflow.py +++ b/apps/ied_simulator/test_browser_observation_workflow.py @@ -48,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"', @@ -68,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" From 08b9ac9047414d4f9b411812df5a67ddd7218993 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:25:14 +0700 Subject: [PATCH 30/43] fix(fleet): defer retired QObject services until QML Repeater visual teardown --- .../src/IedBrowserFleetController.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp index 1a8b0dd7b..f844a48c1 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,15 @@ 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)); + QTimer::singleShot(0, this, [retiredLifetime] { + // The lifetime guard is released after queued QML delegate removal. + }); return true; } From 4e93973d3b36fa46a0fa14c7a015512dfccd10b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:25:23 +0700 Subject: [PATCH 31/43] test(fleet): require deferred visual-safe service retirement --- apps/ied_simulator/test_multi_ied_workspace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index 117ba5cc2..e767cd4e8 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(0, this, [retiredLifetime]", ), "fleet authority") require(fleet_hpp, ( "QAbstractListModel", From fe1e41ba41902793425bfc0be97d5415bbbd5456 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:26:42 +0700 Subject: [PATCH 32/43] fix(browser): detach QML delegates before fleet destruction --- apps/ied_simulator/qml/Main.qml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 90bc272af..ea6b51da8 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 } From b883d55842f343be8cc36e8cf4e01ad6af283d67 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:26:51 +0700 Subject: [PATCH 33/43] fix(browser): detach QML fleet views in aboutToQuit before C++ teardown --- apps/ied_simulator/src/main.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 678eb5068..ea06a34ba 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -151,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)) { From d06716adfa26a71e0fcfa20f7f050fca0dca66ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:27:03 +0700 Subject: [PATCH 34/43] test(browser): preserve shutdown delegate-detach contract --- apps/ied_simulator/test_browser_delegate_bindings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ied_simulator/test_browser_delegate_bindings.py b/apps/ied_simulator/test_browser_delegate_bindings.py index 198492e7c..cd550d3a0 100644 --- a/apps/ied_simulator/test_browser_delegate_bindings.py +++ b/apps/ied_simulator/test_browser_delegate_bindings.py @@ -15,6 +15,8 @@ required_main = ( "property var browserFleet: fleet", + "function prepareForShutdown()", + "iedBrowserRepeater.model = null", "browserSession: root.browserSession", 'id: perIedBrowserHost', 'objectName: "iedBrowserView_" + index', From ca040f0966de84dc01be031b66badd73ffc5999f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:28:07 +0700 Subject: [PATCH 35/43] ci(release): gate Windows RC artifacts on rendered Browser acceptance --- .github/workflows/ied-simulator-release-hardening.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index eedc697aa..2800850ce 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -75,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 From fcb2c0ab44384a55434db3f2a30c51b1ad3f04e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:35:20 +0700 Subject: [PATCH 36/43] fix(qml): freeze per-IED service bindings at delegate creation across reindex/removal --- apps/ied_simulator/qml/Main.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index ea6b51da8..4009828fa 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -475,6 +475,18 @@ ApplicationWindow { model: root.browserFleet IedBrowserWorkspace { 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 From c33e7d28181853bc4fb76706ea1e3b09d7961b21 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:35:31 +0700 Subject: [PATCH 37/43] fix(fleet): keep removed IED services alive through visual teardown frame --- apps/ied_simulator/src/IedBrowserFleetController.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp index f844a48c1..86ffa888b 100644 --- a/apps/ied_simulator/src/IedBrowserFleetController.cpp +++ b/apps/ied_simulator/src/IedBrowserFleetController.cpp @@ -269,8 +269,11 @@ bool IedBrowserFleetController::closeWorkspace(const int index) { // 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)); - QTimer::singleShot(0, this, [retiredLifetime] { - // The lifetime guard is released after queued QML delegate removal. + // 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; } From d64d4b97315909ae7d077bdc05648260d45f1a0c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:35:43 +0700 Subject: [PATCH 38/43] test(browser): lock per-slot service snapshots against Repeater reindex --- apps/ied_simulator/test_browser_delegate_bindings.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ied_simulator/test_browser_delegate_bindings.py b/apps/ied_simulator/test_browser_delegate_bindings.py index cd550d3a0..1e2cfc0cf 100644 --- a/apps/ied_simulator/test_browser_delegate_bindings.py +++ b/apps/ied_simulator/test_browser_delegate_bindings.py @@ -23,6 +23,13 @@ "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)", From 8b077f5e7e3f16d0d9a0c13559dbfb5ec67b74a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 11:36:14 +0700 Subject: [PATCH 39/43] test(fleet): track bounded visual teardown grace period --- apps/ied_simulator/test_multi_ied_workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index e767cd4e8..72b5e23cd 100644 --- a/apps/ied_simulator/test_multi_ied_workspace.py +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -40,7 +40,7 @@ def require(haystack, words, label): "activeEngineering()->adoptSourceFrom(*original->engineering)", "auto retired = std::move(entries_[static_cast(index)])", "std::shared_ptr(std::move(retired))", - "QTimer::singleShot(0, this, [retiredLifetime]", + "QTimer::singleShot(50, this, [retiredLifetime]", ), "fleet authority") require(fleet_hpp, ( "QAbstractListModel", From 0f581c8a491aebbe59dac9b0be479c2049fc78bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 18:55:24 +0700 Subject: [PATCH 40/43] feat(browser-ux): compact two-row IEDScout-style model and connection command bar --- .../ied_simulator/qml/IedBrowserWorkspace.qml | 288 +++++++++--------- 1 file changed, 152 insertions(+), 136 deletions(-) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index e7cc21e54..e31c7c291 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -121,163 +121,179 @@ 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 + 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 + Layout.preferredWidth: 190 + placeholderText: "IED IP / hostname" + text: session.host + 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 + } + 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 + 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 + } } } } From a7ed2bb606ee5c7db595832adb2b13bc96b331fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 18:56:02 +0700 Subject: [PATCH 41/43] test(browser-ux): expose command-bar and endpoint geometry for minimum-width rendered QA --- apps/ied_simulator/qml/IedBrowserWorkspace.qml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index e31c7c291..496bf6c27 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -126,6 +126,8 @@ Item { // wider than its container, leaving ghost/blank controls in the GUI. Rectangle { Layout.fillWidth: true + id: browserCommandBar + objectName: "iedBrowserCommandBar" Layout.preferredHeight: 96 color: root.theme.chrome border.width: 1 @@ -216,6 +218,7 @@ Item { } TextField { id: hostField + objectName: "iedBrowserHostField" Layout.preferredWidth: 190 placeholderText: "IED IP / hostname" text: session.host @@ -225,6 +228,7 @@ Item { } SpinBox { id: portField + objectName: "iedBrowserPortField" Layout.preferredWidth: 102 from: 1 to: 65535 @@ -280,6 +284,7 @@ Item { : root.theme.muted } Label { + objectName: "iedBrowserSessionStatus" Layout.fillWidth: true text: session.lastError.length ? session.lastError : session.stateText + (session.endpoint.length ? " · " + session.endpoint : "") From 1c3e3c55b86a65224255d57cb0f1376aa3fef1dd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 18:56:21 +0700 Subject: [PATCH 42/43] test(browser-ux): render 1024px minimum width and assert endpoint command bar fits --- apps/ied_simulator/src/main.cpp | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index ea06a34ba..929edae80 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -485,6 +485,9 @@ int main(int argc, char* argv[]) { 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); @@ -503,6 +506,28 @@ int main(int argc, char* argv[]) { 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; @@ -528,6 +553,7 @@ int main(int argc, char* argv[]) { 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 && @@ -558,6 +584,8 @@ int main(int argc, char* argv[]) { 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) @@ -626,7 +654,7 @@ int main(int argc, char* argv[]) { } qInfo().noquote() << "BROWSER_FLEET_ROUTING_PASS active_tab=QA_IED_B" - << "offline_signals=visible" << "model_values=visible" << "value_label=bound" + << "offline_signals=visible" << "model_values=visible" << "value_label=bound" << "toolbar_1024=visible" << "switching=pass reindex=pass cross_ied_panel=false"; app.exit(0); }); From 3a3ceef9149fc0dd31b27f4c9232418a68fae0e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 18:56:35 +0700 Subject: [PATCH 43/43] test(browser-ux): enforce compact two-row header and unique endpoint inputs --- apps/ied_simulator/test_browser_convergence.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index e4025ca43..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"', @@ -49,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: