From 11e439b2d5e84d1cd3247c2568d17cd7a892a66c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:55:12 +0700 Subject: [PATCH 01/18] P0: route visible Browser by active fleet slot rather than Repeater layout index --- apps/ied_simulator/qml/Main.qml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 577d2a884..015959e18 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -446,18 +446,28 @@ 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: + // currentIndex: fleet.activeIndex 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 + objectName: "iedBrowserRepeater" model: fleet IedBrowserWorkspace { required property int index + objectName: "iedBrowserView_" + index + anchors.fill: parent + visible: index === fleet.activeIndex theme: appTheme productState: hardening fleet: fleet From dcbcc61d5f19effce573a3cfff154986844fcebc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:56:37 +0700 Subject: [PATCH 02/18] P0: add executable offscreen multi-IED Browser panel/signal routing QA --- apps/ied_simulator/src/main.cpp | 120 ++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 8a1123adf..d93a2127b 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,8 @@ #include #include #include +#include +#include #include #include @@ -99,6 +102,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 +131,7 @@ int main(int argc, char* argv[]) { startIedOption, screenshotOption, smokeOption, + qaBrowserRoutingOption, portOption, setFirstValueOption, qaLiveBurstOption, @@ -418,6 +425,119 @@ 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 first = rootObject->findChild( + QStringLiteral("iedBrowserView_0")); + auto* const second = rootObject->findChild( + QStringLiteral("iedBrowserView_1")); + const auto* const selected = fleet->contextAt(1); + if (!first || !second || first->isVisible() || !second->isVisible() || + !selected || !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) { + 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) { + 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 = rootObject->findChild( + QStringLiteral("iedBrowserView_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 83aee05974ff5ddd0a2473d355d123ba13e44f6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:57:09 +0700 Subject: [PATCH 03/18] P0: permanently prohibit off-by-one Repeater StackLayout fleet routing --- apps/ied_simulator/test_multi_ied_workspace.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index 7d92ebfbf..34e641e3c 100644 --- a/apps/ied_simulator/test_multi_ied_workspace.py +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -58,7 +58,9 @@ def require(haystack, words, label): "property var browserSession: fleet.activeSession", "property var sclWorkspace: fleet.activeEngineering", "model: fleet", - "currentIndex: fleet.activeIndex", + "visible: index === fleet.activeIndex", + "anchors.fill: parent", + 'objectName: "iedBrowserView_" + index', "fleet.contextAt(index)", "fleet.sessionAt(index)", "fleet.clientAt(index)", @@ -116,6 +118,18 @@ def require(haystack, words, label): ): if f"{typename} {{" in main: raise SystemExit(f"P6E_FAIL singleton {typename} remained in Main.qml") +# 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 === fleet.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 bd679c0a0fd902d90eac1f2ebe61fbd90eb3b546 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:57:30 +0700 Subject: [PATCH 04/18] P0: clarify non-indexed Browser routing invariant --- 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 015959e18..3c8ecee58 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -454,7 +454,7 @@ ApplicationWindow { // A Repeater is itself an Item. Putting it inside a // StackLayout adds a layout child before its delegates: - // currentIndex: fleet.activeIndex can show a different + // 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 From 3c1e9ba72c95c9c08d218a1c948a37b67b03c22f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:58:32 +0700 Subject: [PATCH 05/18] P0: gate real rendered two-IED Browser routing in Qt and release CI --- .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 1e29f2661..6b9cbf498 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -71,6 +71,11 @@ jobs: QT_QPA_PLATFORM: offscreen run: ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test + - name: P0 rendered fleet Browser and offline signal routing + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-qt/arstack_ied_simulator --qa-browser-routing + - name: MMS client discovery workbench gates env: QT_QPA_PLATFORM: offscreen From 8bc8ca91e49a1ed1df38fb2368a8f0b7a2699e7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:58:35 +0700 Subject: [PATCH 06/18] P0: gate real rendered two-IED Browser routing in Qt and release CI --- .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 79785cf409ea8146be292f15766cc50de664e7e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:59:01 +0700 Subject: [PATCH 07/18] P0: expose rendered signal list identity for end-to-end routing gate --- apps/ied_simulator/qml/IedBrowserNavigation.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ied_simulator/qml/IedBrowserNavigation.qml b/apps/ied_simulator/qml/IedBrowserNavigation.qml index 2745aa56a..c9261cb47 100644 --- a/apps/ied_simulator/qml/IedBrowserNavigation.qml +++ b/apps/ied_simulator/qml/IedBrowserNavigation.qml @@ -386,6 +386,7 @@ Rectangle { ListView { id: modelTree + objectName: "iedBrowserSignalTree" Layout.fillWidth: true Layout.preferredHeight: Math.max(260, Math.min(contentHeight, 620)) clip: true From 59c954529014197090e86ac5703632346f9e8929 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 17:59:25 +0700 Subject: [PATCH 08/18] P0: assert rendered ListView signal rows rather than model counts alone --- apps/ied_simulator/src/main.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index d93a2127b..ae72e63e0 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -487,7 +487,13 @@ int main(int argc, char* argv[]) { auto* const second = rootObject->findChild( QStringLiteral("iedBrowserView_1")); const auto* const selected = fleet->contextAt(1); - if (!first || !second || first->isVisible() || !second->isVisible() || + auto* const signalList = second + ? second->findChild(QStringLiteral("iedBrowserSignalTree")) + : nullptr; + if (!first || !second || !signalList || + signalList->property("count").toInt() < 3 || + signalList->width() < 100 || !signalList->isVisible() || + first->isVisible() || !second->isVisible() || !selected || !selected->loaded() || selected->online() || selected->iedName() != QStringLiteral("QA_IED_B") || selected->treeModel()->totalNodeCount() < 5 || @@ -512,7 +518,8 @@ int main(int argc, char* argv[]) { } QCoreApplication::processEvents(QEventLoop::AllEvents, 50); if (first->isVisible() || !second->isVisible() || - !selected->loaded() || selected->treeModel()->visibleNodeCount() < 3) { + !selected->loaded() || selected->treeModel()->visibleNodeCount() < 3 || + signalList->property("count").toInt() < 3) { fail("offline_signal_tree_lost_after_switch", 68); return; } From 920dda692f2b61a43c2181b4450364579d18a9d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 18:02:35 +0700 Subject: [PATCH 09/18] P0 QA: use mutable context accessor for canonical tree assertions --- apps/ied_simulator/src/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index ae72e63e0..d3d4e3c51 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -486,7 +486,7 @@ int main(int argc, char* argv[]) { QStringLiteral("iedBrowserView_0")); auto* const second = rootObject->findChild( QStringLiteral("iedBrowserView_1")); - const auto* const selected = fleet->contextAt(1); + auto* const selected = fleet->contextAt(1); auto* const signalList = second ? second->findChild(QStringLiteral("iedBrowserSignalTree")) : nullptr; From 398cbe726e361f37fc6382adac96058ae298771b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 18:09:01 +0700 Subject: [PATCH 10/18] P0: resolve QML fleet self-shadowing so Browser service/context bindings are real --- apps/ied_simulator/qml/Main.qml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 3c8ecee58..689db07f8 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -21,6 +21,7 @@ ApplicationWindow { property bool mmsWasConnected: false property bool simulatorAutoSelectPending: true property bool allIedMonitor: false + property var browserFleet: fleet property var iedContext: fleet.activeContext property var mmsClient: fleet.activeClient property var reports: fleet.activeReports @@ -462,22 +463,22 @@ ApplicationWindow { Repeater { id: iedBrowserRepeater objectName: "iedBrowserRepeater" - model: fleet + model: root.browserFleet IedBrowserWorkspace { required property int index objectName: "iedBrowserView_" + index anchors.fill: parent - visible: index === fleet.activeIndex + visible: index === root.browserFleet.activeIndex theme: appTheme productState: hardening - fleet: fleet - session: fleet.sessionAt(index) - client: fleet.clientAt(index) - context: fleet.contextAt(index) - reports: fleet.reportsAt(index) - utilities: fleet.utilitiesAt(index) - controls: fleet.controlsAt(index) - engineering: fleet.engineeringAt(index) + fleet: root.browserFleet + session: root.browserFleet.sessionAt(index) + client: root.browserFleet.clientAt(index) + context: root.browserFleet.contextAt(index) + reports: root.browserFleet.reportsAt(index) + utilities: root.browserFleet.utilitiesAt(index) + controls: root.browserFleet.controlsAt(index) + engineering: root.browserFleet.engineeringAt(index) onWatchedDataChanged: fleetGlobalData.refreshRows() } } @@ -486,7 +487,7 @@ ApplicationWindow { FleetGlobalDataPane { id: fleetGlobalData theme: appTheme - fleet: fleet + fleet: root.browserFleet browserViews: iedBrowserRepeater onIedRequested: function(index) { fleet.switchTo(index) From 4877a8094dbe14229bc7019566d2db37ab552424 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 18:09:27 +0700 Subject: [PATCH 11/18] P0: gate against self-shadowed QML fleet bindings and blank Browser --- .../ied_simulator/test_multi_ied_workspace.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index 34e641e3c..1844189b2 100644 --- a/apps/ied_simulator/test_multi_ied_workspace.py +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -58,7 +58,7 @@ def require(haystack, words, label): "property var browserSession: fleet.activeSession", "property var sclWorkspace: fleet.activeEngineering", "model: fleet", - "visible: index === fleet.activeIndex", + "visible: index === root.browserFleet.activeIndex", "anchors.fill: parent", 'objectName: "iedBrowserView_" + index', "fleet.contextAt(index)", @@ -118,6 +118,23 @@ 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 \ @@ -126,7 +143,7 @@ def require(haystack, words, label): require(main, ( 'id: perIedBrowserHost', 'id: iedBrowserRepeater', - 'visible: index === fleet.activeIndex', + 'visible: index === root.browserFleet.activeIndex', 'objectName: "iedBrowserView_" + index', ), "exact active Browser panel routing") From 936a3b052c65ab2adbf2d231a21c8a7c5fdd014f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 18:16:20 +0700 Subject: [PATCH 12/18] P0: repair QML signal role binding and File/Home session ownership --- 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 c9261cb47..2f2d6d509 100644 --- a/apps/ied_simulator/qml/IedBrowserNavigation.qml +++ b/apps/ied_simulator/qml/IedBrowserNavigation.qml @@ -398,6 +398,9 @@ Rectangle { delegate: Rectangle { id: modelRow required property int index + // Required properties disable legacy context-role injection. + // Explicitly bind the role object used by model.* below. + required property var model width: modelTree.width height: 25 color: model.selected ? root.theme.accentSoft From ce41798dd401d4c2fc8e942c2d695148cc1f994c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 18:16:24 +0700 Subject: [PATCH 13/18] P0: repair QML signal role binding and File/Home session ownership --- 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 689db07f8..898f358a9 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -288,7 +288,7 @@ ApplicationWindow { theme: appTheme workspace: sclWorkspace hardening: hardening - browserSession: browserSession + browserSession: root.browserSession context: iedContext onBrowserRequested: root.workspaceIndex = 1 onOpenSourceRequested: function(fileUrl) { From f68a58387d4ccc0385563ed079b67240ab38774a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 18:17:29 +0700 Subject: [PATCH 14/18] P0: guard empty DataSet selection from undefined QML row bindings --- apps/ied_simulator/qml/BrowserDataSetPane.qml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 83186cdee..a818d470b 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -7,6 +7,9 @@ Rectangle { id: root required property var theme required property var reports + readonly property var selectedDataSet: reports.selectedDataSetIndex >= 0 + && reports.selectedDataSetIndex < reports.dataSets.length + ? reports.dataSets[reports.selectedDataSetIndex] : ({}) color: theme.background @@ -44,16 +47,16 @@ Rectangle { Layout.preferredWidth: 104 Layout.preferredHeight: 22 radius: 11 - color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + color: root.selectedDataSet.dynamicOwned === true ? theme.greenSoft : theme.surface border.width: 1 - border.color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + border.color: root.selectedDataSet.dynamicOwned === true ? theme.green : theme.lineSoft Label { anchors.centerIn: parent - text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + text: root.selectedDataSet.dynamicOwned === true ? "DYNAMIC OWNED" : "STATIC / READ-ONLY" - color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + color: root.selectedDataSet.dynamicOwned === true ? theme.green : theme.muted font.pixelSize: 7 font.weight: Font.Bold @@ -61,7 +64,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + text: root.selectedDataSet.dynamicOwned === true ? "Created and verified on this live association; eligible for owned-only delete." : "Membership is immutable in Browser authoring." color: theme.muted From 6e0672f3f8c04b98941c688c7f15d69175b5c826 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 19:09:15 +0700 Subject: [PATCH 15/18] P0: harden rendered Browser routing QA with settle diagnostics --- apps/ied_simulator/src/main.cpp | 52 ++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index d3d4e3c51..9fc3e1bf6 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include @@ -490,16 +492,46 @@ int main(int argc, char* argv[]) { auto* const signalList = second ? second->findChild(QStringLiteral("iedBrowserSignalTree")) : nullptr; - if (!first || !second || !signalList || - signalList->property("count").toInt() < 3 || - signalList->width() < 100 || !signalList->isVisible() || - first->isVisible() || !second->isVisible() || - !selected || !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) { + + 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() + << "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; } From 57a5bfa79e41b7779c2e710dbcbce4739733eb9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 19:16:46 +0700 Subject: [PATCH 16/18] P0: validate Repeater Browser delegates through visual item hierarchy --- apps/ied_simulator/src/main.cpp | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 9fc3e1bf6..6ca0859fd 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -484,10 +484,19 @@ int main(int argc, char* argv[]) { rootObject->setProperty("allIedMonitor", false); QCoreApplication::processEvents(QEventLoop::AllEvents, 50); - auto* const first = rootObject->findChild( - QStringLiteral("iedBrowserView_0")); - auto* const second = rootObject->findChild( - QStringLiteral("iedBrowserView_1")); + 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")) @@ -516,6 +525,8 @@ int main(int argc, char* argv[]) { 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) @@ -561,8 +572,7 @@ int main(int argc, char* argv[]) { return; } QCoreApplication::processEvents(QEventLoop::AllEvents, 50); - auto* const retained = rootObject->findChild( - QStringLiteral("iedBrowserView_0")); + auto* const retained = browserViewAt(0); if (fleet->workspaceCount() != 1 || fleet->activeIndex() != 0 || !retained || !retained->isVisible() || fleet->activeContext() != selected || From f111282963f761896b8d091c4f2e46f1f218d7c3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 19:26:20 +0700 Subject: [PATCH 17/18] P0: align P6B policy gate with non-shadowed fleet routing --- 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 ace34df69a2e76733940230371172ddc2ecf3dbe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 20:03:01 +0700 Subject: [PATCH 18/18] P0: align routing contract with non-shadowed root.browserFleet service bindings --- apps/ied_simulator/test_multi_ied_workspace.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index 1844189b2..117ba5cc2 100644 --- a/apps/ied_simulator/test_multi_ied_workspace.py +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -61,13 +61,13 @@ def require(haystack, words, label): "visible: index === root.browserFleet.activeIndex", "anchors.fill: parent", 'objectName: "iedBrowserView_" + index', - "fleet.contextAt(index)", - "fleet.sessionAt(index)", - "fleet.clientAt(index)", - "fleet.reportsAt(index)", - "fleet.controlsAt(index)", - "fleet.utilitiesAt(index)", - "fleet.engineeringAt(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 {",