Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
11e439b
P0: route visible Browser by active fleet slot rather than Repeater l…
masarray Sep 26, 2026
dcbcc61
P0: add executable offscreen multi-IED Browser panel/signal routing QA
masarray Sep 26, 2026
83aee05
P0: permanently prohibit off-by-one Repeater StackLayout fleet routing
masarray Sep 26, 2026
bd679c0
P0: clarify non-indexed Browser routing invariant
masarray Sep 26, 2026
3c1e9ba
P0: gate real rendered two-IED Browser routing in Qt and release CI
masarray Sep 26, 2026
8bc8ca9
P0: gate real rendered two-IED Browser routing in Qt and release CI
masarray Sep 26, 2026
79785cf
P0: expose rendered signal list identity for end-to-end routing gate
masarray Sep 26, 2026
59c9545
P0: assert rendered ListView signal rows rather than model counts alone
masarray Sep 26, 2026
920dda6
P0 QA: use mutable context accessor for canonical tree assertions
masarray Sep 26, 2026
398cbe7
P0: resolve QML fleet self-shadowing so Browser service/context bindi…
masarray Sep 26, 2026
4877a80
P0: gate against self-shadowed QML fleet bindings and blank Browser
masarray Sep 26, 2026
936a3b0
P0: repair QML signal role binding and File/Home session ownership
masarray Sep 26, 2026
ce41798
P0: repair QML signal role binding and File/Home session ownership
masarray Sep 26, 2026
f68a583
P0: guard empty DataSet selection from undefined QML row bindings
masarray Sep 26, 2026
6e0672f
P0: harden rendered Browser routing QA with settle diagnostics
masarray Sep 26, 2026
57a5bfa
P0: validate Repeater Browser delegates through visual item hierarchy
masarray Sep 26, 2026
f111282
P0: align P6B policy gate with non-shadowed fleet routing
masarray Sep 26, 2026
ace34df
P0: align routing contract with non-shadowed root.browserFleet servic…
masarray Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ied-simulator-qt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ied-simulator-release-hardening.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions apps/ied_simulator/qml/BrowserDataSetPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -44,24 +47,24 @@ 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
}
}
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
Expand Down
4 changes: 4 additions & 0 deletions apps/ied_simulator/qml/IedBrowserNavigation.qml
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ Rectangle {

ListView {
id: modelTree
objectName: "iedBrowserSignalTree"
Layout.fillWidth: true
Layout.preferredHeight: Math.max(260, Math.min(contentHeight, 620))
clip: true
Expand All @@ -397,6 +398,9 @@ Rectangle {
delegate: Rectangle {
id: modelRow
required property int index
// Required properties disable legacy context-role injection.
// Explicitly bind the role object used by model.* below.
required property var model
width: modelTree.width
height: 25
color: model.selected ? root.theme.accentSoft
Expand Down
41 changes: 26 additions & 15 deletions apps/ied_simulator/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -287,7 +288,7 @@ ApplicationWindow {
theme: appTheme
workspace: sclWorkspace
hardening: hardening
browserSession: browserSession
browserSession: root.browserSession
context: iedContext
onBrowserRequested: root.workspaceIndex = 1
onOpenSourceRequested: function(fileUrl) {
Expand Down Expand Up @@ -446,28 +447,38 @@ ApplicationWindow {
Layout.fillHeight: true
currentIndex: root.allIedMonitor ? 1 : 0

StackLayout {
Item {
id: perIedBrowserHost
objectName: "perIedBrowserHost"
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: fleet.activeIndex

// Delegates persist per slot: each IED retains its own
// navigation and P6A Global Data watchlist.
// A Repeater is itself an Item. Putting it inside a
// StackLayout adds a layout child before its delegates:
// indexing by the fleet slot can show a different
// IED's panel from the selected tab. Keep delegates as
// siblings in a plain Item, size them explicitly, and
// show exactly the active slot. Hidden views retain
// their per-IED navigation and Global Data watchlists.
Repeater {
id: iedBrowserRepeater
model: fleet
objectName: "iedBrowserRepeater"
model: root.browserFleet
IedBrowserWorkspace {
required property int index
objectName: "iedBrowserView_" + index
anchors.fill: parent
visible: index === root.browserFleet.activeIndex
theme: appTheme
productState: hardening
fleet: fleet
session: fleet.sessionAt(index)
client: fleet.clientAt(index)
context: fleet.contextAt(index)
reports: fleet.reportsAt(index)
utilities: fleet.utilitiesAt(index)
controls: fleet.controlsAt(index)
engineering: fleet.engineeringAt(index)
fleet: root.browserFleet
session: root.browserFleet.sessionAt(index)
client: root.browserFleet.clientAt(index)
context: root.browserFleet.contextAt(index)
reports: root.browserFleet.reportsAt(index)
utilities: root.browserFleet.utilitiesAt(index)
controls: root.browserFleet.controlsAt(index)
engineering: root.browserFleet.engineeringAt(index)
onWatchedDataChanged: fleetGlobalData.refreshRows()
}
}
Expand All @@ -476,7 +487,7 @@ ApplicationWindow {
FleetGlobalDataPane {
id: fleetGlobalData
theme: appTheme
fleet: fleet
fleet: root.browserFleet
browserViews: iedBrowserRepeater
onIedRequested: function(index) {
fleet.switchTo(index)
Expand Down
169 changes: 169 additions & 0 deletions apps/ied_simulator/src/main.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later

#include "IedFleetController.hpp"
#include "IedBrowserFleetController.hpp"

#include <QCommandLineOption>
#include <QCommandLineParser>
Expand All @@ -10,6 +11,10 @@
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QQuickWindow>
#include <QQuickItem>
#include <QEventLoop>
#include <QElapsedTimer>
#include <QThread>
#include <QTimer>
#include <QUrl>

Expand Down Expand Up @@ -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."),
Expand All @@ -125,6 +133,7 @@ int main(int argc, char* argv[]) {
startIedOption,
screenshotOption,
smokeOption,
qaBrowserRoutingOption,
portOption,
setFirstValueOption,
qaLiveBurstOption,
Expand Down Expand Up @@ -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<IedBrowserFleetController*>(
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<QQuickItem*>(
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<QQuickItem*>(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<QObject*>() == 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<void*>(browserHost)
<< "visualChildren=" << (browserHost ? browserHost->childItems().size() : -1)
<< "first=" << static_cast<void*>(first)
<< "second=" << static_cast<void*>(second)
<< "signalList=" << static_cast<void*>(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("<null>"))
<< "totalNodes=" << (selected ? selected->treeModel()->totalNodeCount() : -1)
<< "visibleNodes=" << (selected ? selected->treeModel()->visibleNodeCount() : -1)
<< "contextMatch=" << (second && selected
? second->property("context").value<QObject*>() == 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] {
Expand Down
6 changes: 3 additions & 3 deletions apps/ied_simulator/test_browser_control_ux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading