From e166d72aba022618cdfc9063b63cab3d75de60dc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:40:07 +0700 Subject: [PATCH 1/5] test(workbench): selected native q/t evidence visual QA and negative gates (M2I) Reuse canonical typed decoder and DataSets user selection, capture quality/timestamp in both native Browser panes, fail closed for malformed quality and stale DataSet selection. Synthetic samples exist only in the CLI QA harness; no protocol/engine changes, polling or duplicate model. --- .github/workflows/ied-simulator-qt.yml | 19 ++ .../qml/BrowserDataModelPane.qml | 1 + apps/ied_simulator/qml/BrowserDataSetPane.qml | 31 +- .../ied_simulator/qml/IedBrowserWorkspace.qml | 2 + .../src/BrowserEvidenceVisualQa.hpp | 273 ++++++++++++++++++ apps/ied_simulator/src/main.cpp | 17 ++ .../ied_simulator/test_browser_convergence.py | 29 +- 7 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 67c2ff4d2..30adeb010 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -385,6 +385,25 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Render and validate selected Quality and TimeQuality in native Browser + env: + QT_QPA_PLATFORM: offscreen + run: | + ./build-ied-simulator-qt/arstack_ied_simulator \ + --browser-model ./tests/fixtures/scl/minimal-station-brcb.scd \ + --qa-browser-evidence-output ./ied-browser-typed-evidence + for name in data-model-quality data-model-timestamp datasets-quality datasets-timestamp; do + test -s "ied-browser-typed-evidence/${name}.png" + done + + - name: Upload selected typed-evidence visual regression + uses: actions/upload-artifact@v4 + with: + name: ied-browser-typed-evidence-m2i + path: ied-browser-typed-evidence/*.png + if-no-files-found: error + retention-days: 14 + - name: Prove GUI value is visible through MMS run: >- python3 apps/ied_simulator/test_gui_live_value.py diff --git a/apps/ied_simulator/qml/BrowserDataModelPane.qml b/apps/ied_simulator/qml/BrowserDataModelPane.qml index 496cdd527..2b3c8ce49 100644 --- a/apps/ied_simulator/qml/BrowserDataModelPane.qml +++ b/apps/ied_simulator/qml/BrowserDataModelPane.qml @@ -303,6 +303,7 @@ Rectangle { } BrowserEngineeringEvidence { + objectName: "browserDataModelTypedEvidence" theme: root.theme engineeringEvidence: root.currentNode.engineeringEvidence || ({}) } diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 17137e59a..89ed4cb17 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -50,6 +50,18 @@ Rectangle { return modelProvider.treeModel.nodeForReference(reference) } + // Shared canonical DA selection for user input and deterministic GUI QA. + // Never perform an MMS Read, duplicate q/t decoding, or cache semantic data. + function selectEvidenceAttribute(domain, item, expectedReference) { + const selected = root.modelProvider.treeModel.selectMmsItem(domain, item) + const canonical = selected ? root.modelProvider.treeModel.selectedNode : ({}) + const exact = selected && canonical.kind === "DA" + && canonical.reference === expectedReference + root.selectedAttributeReference = exact ? canonical.reference : "" + root.showSelectedEvidence = exact + return exact + } + function toggleMember(row, raw) { showSelectedEvidence = false selectedAttributeReference = "" @@ -239,11 +251,10 @@ Rectangle { else if (memberRow.resolved.readable === true) { // Direct FCDA/DA members must expose typed q/t too, // without expansion or an implicit MMS Read. - const selected = root.modelProvider.treeModel.selectMmsItem( - memberRow.resolved.mmsDomain, memberRow.resolved.mmsItem) - root.selectedAttributeReference = selected - ? memberRow.resolved.reference : "" - root.showSelectedEvidence = selected + root.selectEvidenceAttribute( + memberRow.resolved.mmsDomain, + memberRow.resolved.mmsItem, + memberRow.resolved.reference) } } } @@ -288,11 +299,10 @@ Rectangle { MouseArea { anchors.fill: parent onClicked: { - const selected = root.modelProvider.treeModel.selectMmsItem( - attributeRow.modelData.mmsDomain, attributeRow.modelData.mmsItem) - root.selectedAttributeReference = selected - ? attributeRow.modelData.reference : "" - root.showSelectedEvidence = selected + root.selectEvidenceAttribute( + attributeRow.modelData.mmsDomain, + attributeRow.modelData.mmsItem, + attributeRow.modelData.reference) } } } @@ -346,6 +356,7 @@ Rectangle { } } BrowserEngineeringEvidence { + objectName: "browserDataSetTypedEvidence" theme: root.theme engineeringEvidence: root.selectedAttributeEvidence } diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 226ca85b1..74076bd39 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -760,12 +760,14 @@ Item { currentIndex: root.activeSection BrowserDataModelPane { + objectName: "browserDataModelPane" theme: root.theme client: root.client modelProvider: root.context } BrowserDataSetPane { + objectName: "browserDataSetPane" theme: root.theme reports: root.reports modelProvider: root.context diff --git a/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp new file mode 100644 index 000000000..16e8a69b3 --- /dev/null +++ b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// Opt-in CI-only rendering. Typed QA fixtures enter the existing canonical +// MmsLiveTreeModel; neither protocol state nor normal UI startup is modified. +#include "IedBrowserFleetController.hpp" +#include "MmsReportController.hpp" +#include "MmsEngineeringEvidence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace arstack::iedsim { +namespace detail { + +class BrowserEvidenceVisualQa final + : public std::enable_shared_from_this { +public: + BrowserEvidenceVisualQa(QCoreApplication& app, QObject* root, + IedBrowserFleetController* fleet, QString directory) + : app_(app), root_(root), fleet_(fleet), directory_(std::move(directory)) {} + void start() { schedule(100); } + +private: + struct Attribute { + QString reference; + QString domain; + QString item; + [[nodiscard]] bool valid() const { + return !reference.isEmpty() && !domain.isEmpty() && !item.isEmpty(); + } + [[nodiscard]] QString key() const { return domain + QChar(0x1f) + item; } + }; + + void fail(const char* reason) { + qCritical().noquote() << "BROWSER_EVIDENCE_VISUAL_FAIL" << reason; + app_.exit(72); + } + + void schedule(const int ms) { + const auto self = shared_from_this(); + QTimer::singleShot(ms, root_, [self] { self->advance(); }); + } + + [[nodiscard]] static Attribute attribute(const QVariantMap& node) { + return {node.value(QStringLiteral("reference")).toString(), + node.value(QStringLiteral("mmsDomain")).toString(), + node.value(QStringLiteral("mmsItem")).toString()}; + } + + [[nodiscard]] bool prepare() { + context_ = fleet_ ? fleet_->activeContext() : nullptr; + reports_ = fleet_ ? fleet_->activeReports() : nullptr; + model_ = context_ ? context_->treeModel() : nullptr; + if (!context_ || !reports_ || !model_ || !context_->loaded() || + context_->selectionRequired() || model_->totalNodeCount() <= 0) return false; + + // Find both typed DAs in an actual canonical DataSet. No guessed + // index, synthesized reference, or second semantic inventory. + for (int index = 0; index < reports_->dataSets().size(); ++index) { + if (!reports_->selectDataSet(index)) continue; + Attribute q, t; + for (const auto& member : reports_->selectedDataSetMembers()) { + const auto node = model_->nodeForReference(member); + if (!node.value(QStringLiteral("readable")).toBool()) continue; + const auto entry = attribute(node); + if (entry.item.endsWith(QStringLiteral("$q"))) q = entry; + else if (entry.item.endsWith(QStringLiteral("$t"))) t = entry; + } + if (q.valid() && t.valid()) { + dataSetIndex_ = index; + quality_ = q; + timestamp_ = t; + break; + } + } + if (dataSetIndex_ < 0) return false; + + auto* host = root_->findChild(QStringLiteral("perIedBrowserHost")); + if (!host) return false; + for (auto* item : host->childItems()) { + if (item && item->objectName() == QStringLiteral("iedBrowserView_0")) { + browser_ = item; + break; + } + } + if (!browser_ || !browser_->isVisible()) return false; + modelPane_ = browser_->findChild(QStringLiteral("browserDataModelPane")); + dataSetPane_ = browser_->findChild(QStringLiteral("browserDataSetPane")); + modelEvidence_ = modelPane_ ? modelPane_->findChild( + QStringLiteral("browserDataModelTypedEvidence")) : nullptr; + dataSetEvidence_ = dataSetPane_ ? dataSetPane_->findChild( + QStringLiteral("browserDataSetTypedEvidence")) : nullptr; + window_ = qobject_cast(root_); + if (!modelPane_ || !dataSetPane_ || !modelEvidence_ || !dataSetEvidence_ || + !window_ || window_->width() < 800 || window_->height() < 500) return false; + + const std::array bits{0x60U, 0x38U}; + const auto qSample = ar::iec61850::mms::MmsDataValue::bit_string(3U, bits); + const ar::iec61850::mms::Iec61850UtcTime utc{ + std::chrono::system_clock::time_point{std::chrono::seconds{1720000000}}, 0xBFU}; + const auto tSample = ar::iec61850::mms::MmsDataValue::utc_time(utc); + const auto qEvidence = decodeEngineeringEvidence(quality_.item, qSample); + const auto tEvidence = decodeEngineeringEvidence(timestamp_.item, tSample); + if (qEvidence.value(QStringLiteral("kind")).toString() != QStringLiteral("quality") || + tEvidence.value(QStringLiteral("kind")).toString() != QStringLiteral("timestamp")) + return false; + model_->applyReadValue(quality_.key(), QStringLiteral("QA typed Quality"), qEvidence); + model_->applyReadValue(timestamp_.key(), QStringLiteral("QA typed UTC"), tEvidence); + return QDir().mkpath(directory_); + } + + [[nodiscard]] bool selectModel(const Attribute& entry) { + browser_->setProperty("activeSection", 0); + browser_->setProperty("activeSectionTitle", QStringLiteral("Data Model")); + if (!model_->selectMmsItem(entry.domain, entry.item)) return false; + auto* detail = qobject_cast(model_->detailModel()); + if (!detail) return false; + detail->selectParent(); // Preserve the DO/DA tree beside inspector. + for (int row = 0; row < detail->rowCount(); ++row) { + if (detail->data(detail->index(row), MmsLiveDetailModel::ReferenceRole) + .toString() != entry.reference) continue; + detail->select(row); // Same selection path as an inline user click. + return detail->rootKind() == QStringLiteral("DO") && + model_->selectedNode().value(QStringLiteral("reference")).toString() + == entry.reference; + } + return false; + } + + [[nodiscard]] bool selectDataSet(const Attribute& entry) { + browser_->setProperty("activeSection", 1); + browser_->setProperty("activeSectionTitle", QStringLiteral("DataSets")); + if (!reports_->selectDataSet(dataSetIndex_)) return false; + QVariant accepted; + const bool invoked = QMetaObject::invokeMethod( + dataSetPane_, "selectEvidenceAttribute", Q_RETURN_ARG(QVariant, accepted), + Q_ARG(QVariant, QVariant(entry.domain)), + Q_ARG(QVariant, QVariant(entry.item)), + Q_ARG(QVariant, QVariant(entry.reference))); + return invoked && accepted.toBool() && + model_->selectedNode().value(QStringLiteral("reference")).toString() + == entry.reference; + } + + [[nodiscard]] bool visibleEvidence(QQuickItem* component, const QString& kind) const { + return component && component->isVisible() && + component->property("hasEvidence").toBool() && + component->property("engineeringEvidence").toMap() + .value(QStringLiteral("kind")).toString() == kind; + } + + void capture(const char* path, QQuickItem* component, const QString& kind) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (!visibleEvidence(component, kind)) { + fail("typed_details_not_visible"); + return; + } + const QImage image = window_->grabWindow(); + if (image.isNull() || image.width() < 800 || image.height() < 500 || + !image.save(QDir(directory_).filePath(QString::fromLatin1(path)))) { + fail("rendered_screenshot_invalid"); + return; + } + if (!previous_.isNull() && image == previous_) { + fail("different_selected_values_produced_identical_screenshot"); + return; + } + previous_ = image; + ++phase_; + schedule(0); + } + + void advance() { + if (phase_ == 0) { + auto* context = fleet_ ? fleet_->activeContext() : nullptr; + if (!context || !context->loaded()) { + if (++readinessChecks_ > 40) { fail("source_load_timeout"); return; } + schedule(100); // QA-only bounded readiness; never a runtime poll. + return; + } + if (!prepare() || !selectModel(quality_)) { + fail("quality_model_setup"); return; + } + phase_ = 1; + schedule(150); + } else if (phase_ == 1) { + capture("data-model-quality.png", modelEvidence_, QStringLiteral("quality")); + } else if (phase_ == 2) { + if (!selectModel(timestamp_)) { fail("timestamp_model_selection"); return; } + phase_ = 3; schedule(150); + } else if (phase_ == 3) { + capture("data-model-timestamp.png", modelEvidence_, QStringLiteral("timestamp")); + } else if (phase_ == 4) { + if (!selectDataSet(quality_)) { fail("quality_dataset_selection"); return; } + phase_ = 5; schedule(150); + } else if (phase_ == 5) { + capture("datasets-quality.png", dataSetEvidence_, QStringLiteral("quality")); + } else if (phase_ == 6) { + if (!selectDataSet(timestamp_)) { fail("timestamp_dataset_selection"); return; } + phase_ = 7; schedule(150); + } else if (phase_ == 7) { + capture("datasets-timestamp.png", dataSetEvidence_, QStringLiteral("timestamp")); + } else if (phase_ == 8) { + const std::array bits{0x60U, 0x38U}; + const auto malformedEvidence = decodeEngineeringEvidence( + quality_.item, ar::iec61850::mms::MmsDataValue::bit_string(2U, bits)); + if (!malformedEvidence.isEmpty()) { fail("fabricated_malformed_evidence"); return; } + model_->applyReadValue(quality_.key(), QStringLiteral("QA invalid quality"), + malformedEvidence); + if (!selectDataSet(quality_) || + visibleEvidence(dataSetEvidence_, QStringLiteral("quality")) || + !model_->selectedNode().value(QStringLiteral("engineeringEvidence")) + .toMap().isEmpty()) { + fail("stale_quality_evidence"); return; + } + if (!reports_->selectDataSet(-1) || + dataSetPane_->property("showSelectedEvidence").toBool()) { + fail("stale_dataset_selection"); return; + } + qInfo().noquote() + << "BROWSER_EVIDENCE_VISUAL_PASS screenshots=4" + << "data_model=q,t datasets=q,t malformed=unknown" + << "stale_selection=cleared polling=false synthetic_qa=true"; + app_.exit(0); + } else { + fail("invalid_qa_phase"); + } + } + + QCoreApplication& app_; + QObject* root_{}; + IedBrowserFleetController* fleet_{}; + QString directory_; + IedEngineeringContextController* context_{}; + MmsReportController* reports_{}; + MmsLiveTreeModel* model_{}; + QQuickWindow* window_{}; + QQuickItem* browser_{}; + QQuickItem* modelPane_{}; + QQuickItem* dataSetPane_{}; + QQuickItem* modelEvidence_{}; + QQuickItem* dataSetEvidence_{}; + Attribute quality_; + Attribute timestamp_; + QImage previous_; + int dataSetIndex_{-1}; + int readinessChecks_{}; + int phase_{}; +}; + +} // namespace detail + +inline void runBrowserEvidenceVisualQa( + QCoreApplication& app, QObject* root, IedBrowserFleetController* fleet, + const QString& directory) { + std::make_shared( + app, root, fleet, directory)->start(); +} +} // namespace arstack::iedsim diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index db420d7e8..ce42b160b 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -2,6 +2,7 @@ #include "IedFleetController.hpp" #include "IedBrowserFleetController.hpp" +#include "BrowserEvidenceVisualQa.hpp" #include "SclWorkspaceController.hpp" #include @@ -113,6 +114,10 @@ int main(int argc, char* argv[]) { const QCommandLineOption smokeOption{ QStringLiteral("smoke-test"), QStringLiteral("Load the QML scene, wait briefly, and exit.")}; + const QCommandLineOption qaBrowserEvidenceOption{ + QStringLiteral("qa-browser-evidence-output"), + QStringLiteral("QA: render selected q/t evidence from canonical SCL with synthetic typed values."), + QStringLiteral("directory")}; 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.")}; @@ -145,6 +150,7 @@ int main(int argc, char* argv[]) { screenshotOption, smokeOption, qaBrowserRoutingOption, + qaBrowserEvidenceOption, portOption, setFirstValueOption, qaLiveBurstOption, @@ -645,6 +651,17 @@ int main(int argc, char* argv[]) { app.exit(0); }); } + if (parser.isSet(qaBrowserEvidenceOption)) { + if (!parser.isSet(browserModelOption) || parser.isSet(screenshotOption) || + browserFleet == nullptr) { + qCritical().noquote() + << "BROWSER_EVIDENCE_VISUAL_FAIL requires --browser-model without --screenshot"; + QTimer::singleShot(0, &app, [] { QCoreApplication::exit(71); }); + } else { + arstack::iedsim::runBrowserEvidenceVisualQa( + app, rootObject, browserFleet, parser.value(qaBrowserEvidenceOption)); + } + } if (parser.isSet(screenshotOption)) { const auto outputPath = parser.value(screenshotOption); QTimer::singleShot(1600, &app, [&app, rootObject, outputPath] { diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 4aa9667b3..078328157 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -57,6 +57,7 @@ 'BrowserEngineeringEvidence {', 'root.currentNode.engineeringEvidence', 'objectName: "browserDataModelSelectedAttributeInspector"', + 'objectName: "browserDataModelTypedEvidence"', 'root.detail.rootChildCount === 0 || root.currentNode.kind === "DA"', 'function onValuesChanged() { root.valueRevision += 1 }', ] @@ -79,14 +80,17 @@ 'root.expandedMemberIndex', 'maximumExpandedChildren: 64', 'required property var modelData', - 'root.showSelectedEvidence = selected', + 'root.showSelectedEvidence = exact', 'BrowserEngineeringEvidence {', 'objectName: "browserDataSetEvidenceInspector"', 'root.selectedAttributeNode.engineeringEvidence || ({})', 'selected.reference === root.selectedAttributeReference', - 'root.selectedAttributeReference = selected', - 'memberRow.resolved.reference : ""', - 'root.showSelectedEvidence = selected', + 'root.selectedAttributeReference = exact', + 'function selectEvidenceAttribute(domain, item, expectedReference)', + 'memberRow.resolved.reference)', + 'attributeRow.modelData.reference)', + 'objectName: "browserDataSetTypedEvidence"', + 'root.showSelectedEvidence = exact', ] missing_dataset = [term for term in data_set_required if term not in data_set] if missing_dataset: @@ -105,6 +109,23 @@ if 'readEngineeringSelected()' in data_set or 'readEngineeringSelected()' in data_model: raise SystemExit("BROWSER_TYPED_EVIDENCE_FAIL passive inspector initiated MMS Read") +qa_source = (workspace_path.parent.parent / "src" / "BrowserEvidenceVisualQa.hpp").read_text(encoding="utf-8") +for token in [ + 'BROWSER_EVIDENCE_VISUAL_PASS', + 'decodeEngineeringEvidence(', + 'MmsLiveDetailModel::ReferenceRole', + 'selectEvidenceAttribute', + 'malformedEvidence.isEmpty()', + 'data-model-quality.png', + 'data-model-timestamp.png', + 'datasets-quality.png', + 'datasets-timestamp.png', +]: + if token not in qa_source: + raise SystemExit("BROWSER_TYPED_EVIDENCE_QA_FAIL missing=" + token) +if 'readEngineeringSelected()' in qa_source or 'std::thread' in qa_source: + raise SystemExit("BROWSER_TYPED_EVIDENCE_QA_FAIL hidden MMS read or worker") + report_controller = (workspace_path.parent.parent / "src" / "MmsReportController.cpp").read_text(encoding="utf-8") for token in [ 'kMaximumTypedReportEvidence = 96', From 3af35a7c8088e2d746ee921ce6251f0850066049 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:45:44 +0700 Subject: [PATCH 2/5] fix(workbench): keep selected q/t evidence inside clipped inspector viewport Put typed Quality/TimeQuality before generic DA metadata and assert actual viewport intersection of typed rows in rendered screenshots. Preserve virtualized tree, lazy DataSets and canonical decoder. --- .../qml/BrowserDataModelPane.qml | 12 +++++----- .../src/BrowserEvidenceVisualQa.hpp | 22 ++++++++++++++++--- .../ied_simulator/test_browser_convergence.py | 6 +++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserDataModelPane.qml b/apps/ied_simulator/qml/BrowserDataModelPane.qml index 2b3c8ce49..50e43329c 100644 --- a/apps/ied_simulator/qml/BrowserDataModelPane.qml +++ b/apps/ied_simulator/qml/BrowserDataModelPane.qml @@ -260,6 +260,12 @@ Rectangle { } } + BrowserEngineeringEvidence { + objectName: "browserDataModelTypedEvidence" + theme: root.theme + engineeringEvidence: root.currentNode.engineeringEvidence || ({}) + } + Repeater { model: [ {label: "Reference", value: root.currentNode.reference}, @@ -302,12 +308,6 @@ Rectangle { } } - BrowserEngineeringEvidence { - objectName: "browserDataModelTypedEvidence" - theme: root.theme - engineeringEvidence: root.currentNode.engineeringEvidence || ({}) - } - Label { visible: !root.currentNode.kind Layout.fillWidth: true diff --git a/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp index 16e8a69b3..a903d5cf2 100644 --- a/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp +++ b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -157,10 +158,25 @@ class BrowserEvidenceVisualQa final } [[nodiscard]] bool visibleEvidence(QQuickItem* component, const QString& kind) const { - return component && component->isVisible() && - component->property("hasEvidence").toBool() && + if (!component || !component->isVisible() || + !component->property("hasEvidence").toBool() || component->property("engineeringEvidence").toMap() - .value(QStringLiteral("kind")).toString() == kind; + .value(QStringLiteral("kind")).toString() != kind) return false; + + // Qt Quick visible=true does not prove pixels survive ScrollView + // clipping. Require at least the typed heading + one evidence row to + // intersect the actual selected inspector viewport. + QQuickItem* viewport = component == modelEvidence_ + ? modelPane_->findChild( + QStringLiteral("browserDataModelSelectedAttributeInspector")) + : dataSetPane_->findChild( + QStringLiteral("browserDataSetEvidenceInspector")); + if (!viewport || !viewport->isVisible()) return false; + const QRectF bounds = component->mapRectToItem( + viewport, QRectF(0, 0, component->width(), component->height())); + const QRectF clipped = bounds.intersected( + QRectF(0, 0, viewport->width(), viewport->height())); + return clipped.width() > 80 && clipped.height() >= 50; } void capture(const char* path, QQuickItem* component, const QString& kind) { diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 078328157..da1606f7d 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -108,6 +108,10 @@ raise SystemExit("BROWSER_DATASET_EVIDENCE_FAIL duplicate/hidden evidence inspector") if 'readEngineeringSelected()' in data_set or 'readEngineeringSelected()' in data_model: raise SystemExit("BROWSER_TYPED_EVIDENCE_FAIL passive inspector initiated MMS Read") +# A clipped 235px selected-DA inspector must put typed q/t before the +# generic reference/type rows; otherwise all typed pixels are below the fold. +if data_model.find('BrowserEngineeringEvidence {') > data_model.find(' Repeater {'): + raise SystemExit("BROWSER_TYPED_EVIDENCE_FAIL typed details below clipped metadata rows") qa_source = (workspace_path.parent.parent / "src" / "BrowserEvidenceVisualQa.hpp").read_text(encoding="utf-8") for token in [ @@ -116,6 +120,8 @@ 'MmsLiveDetailModel::ReferenceRole', 'selectEvidenceAttribute', 'malformedEvidence.isEmpty()', + 'mapRectToItem(', + 'clipped.height() >= 50', 'data-model-quality.png', 'data-model-timestamp.png', 'datasets-quality.png', From 87b2efac0c29f41988b4b6952f1ead3e014ee20a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:58:11 +0700 Subject: [PATCH 3/5] test(workbench): wait for bounded QML evidence readiness and diagnose canonical selection Separate canonical SCL completion from native pane instantiation; allow up to 40 QA-only event-loop checks and report exact setup stage on failure. Do not introduce polling into production UI. --- .../src/BrowserEvidenceVisualQa.hpp | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp index a903d5cf2..7b30f1e89 100644 --- a/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp +++ b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp @@ -208,8 +208,46 @@ class BrowserEvidenceVisualQa final schedule(100); // QA-only bounded readiness; never a runtime poll. return; } - if (!prepare() || !selectModel(quality_)) { - fail("quality_model_setup"); return; + // SCL loading and QML visual instantiation have separate + // lifecycles. Source ready does not imply the active Browser pane + // and its StackLayout children have been rendered yet. + if (!prepared_) { + if (!prepare()) { + if (++readinessChecks_ > 40) { + qCritical().noquote() + << "BROWSER_EVIDENCE_VISUAL_DIAG" + << "loaded=" << (context_ && context_->loaded()) + << "datasets=" << (reports_ ? reports_->dataSets().size() : -1) + << "nodes=" << (model_ ? model_->totalNodeCount() : -1) + << "selected_dataset=" << dataSetIndex_ + << "q_valid=" << quality_.valid() + << "t_valid=" << timestamp_.valid() + << "browser=" << static_cast(browser_) + << "browser_visible=" << (browser_ && browser_->isVisible()) + << "model_pane=" << static_cast(modelPane_) + << "dataset_pane=" << static_cast(dataSetPane_) + << "model_typed=" << static_cast(modelEvidence_) + << "dataset_typed=" << static_cast(dataSetEvidence_); + fail("rendered_qa_preparation_timeout"); + return; + } + schedule(100); + return; + } + prepared_ = true; + } + if (!selectModel(quality_)) { + auto* detail = qobject_cast(model_->detailModel()); + qCritical().noquote() + << "BROWSER_EVIDENCE_VISUAL_DIAG" + << "q_domain=" << quality_.domain + << "q_item=" << quality_.item + << "q_reference=" << quality_.reference + << "selected=" << model_->selectedNode().value(QStringLiteral("reference")) + << "detail_root=" << (detail ? detail->rootKind() : QString{}) + << "detail_rows=" << (detail ? detail->rowCount() : -1); + fail("quality_model_selection"); + return; } phase_ = 1; schedule(150); @@ -275,6 +313,7 @@ class BrowserEvidenceVisualQa final QImage previous_; int dataSetIndex_{-1}; int readinessChecks_{}; + bool prepared_{}; int phase_{}; }; From 19674bd0ced4611abc40523269500e2ac4c1bc63 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 07:08:45 +0700 Subject: [PATCH 4/5] test(workbench): expose bounded IEC/FCDA versus MMS identity diagnostics for M2I On deterministic QA setup failure only, report the selected DataSet's IEC members, indexed canonical node lookup, and authoritative MMS member identities. Keep QA fail-closed; do not guess or rewrite protocol references. --- .../src/BrowserEvidenceVisualQa.hpp | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp index 7b30f1e89..895149fbf 100644 --- a/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp +++ b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -228,6 +229,44 @@ class BrowserEvidenceVisualQa final << "dataset_pane=" << static_cast(dataSetPane_) << "model_typed=" << static_cast(modelEvidence_) << "dataset_typed=" << static_cast(dataSetEvidence_); + // Diagnose the canonical IEC-versus-MMS member + // mapping before considering any resolver change. + // This bounded QA log is never part of a live callback. + if (reports_ && model_) { + const int bound = std::min(4, int(reports_->dataSets().size())); + for (int i = 0; i < bound; ++i) { + if (!reports_->selectDataSet(i)) continue; + const auto members = reports_->selectedDataSetMembers(); + qCritical().noquote() << "BROWSER_EVIDENCE_DATASET_DIAG" + << "index=" << i << "members=" << members.size(); + for (int n = 0; n < std::min(8, int(members.size())); ++n) { + const auto& ref = members.at(n); + const auto node = model_->nodeForReference(ref); + qCritical().noquote() + << "BROWSER_EVIDENCE_MEMBER_DIAG" + << "member=" << ref.left(180) + << "node_kind=" << node.value(QStringLiteral("kind")) + << "node_ref=" << node.value(QStringLiteral("reference")) + << "node_item=" << node.value(QStringLiteral("mmsItem")) + << "node_fc=" << node.value(QStringLiteral("functionalConstraint")); + } + } + if (const auto* source = context_->modelSnapshot()) { + for (const auto& ds : source->data_sets) { + qCritical().noquote() + << "BROWSER_EVIDENCE_CANONICAL_DS_DIAG" + << "reference=" << QString::fromStdString(ds.reference).left(180) + << "members=" << ds.members.size(); + for (std::size_t n = 0; n < std::min(8U, ds.members.size()); ++n) { + const auto& member = ds.members[n]; + qCritical().noquote() << "BROWSER_EVIDENCE_CANONICAL_MEMBER_DIAG" + << QString::fromStdString(member.reference).left(180) + << QString::fromStdString(member.mms_reference).left(180) + << QString::fromStdString(member.functional_constraint); + } + } + } + } fail("rendered_qa_preparation_timeout"); return; } From 3c11cd3a132516930409cac18c556ce5dad4b69c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 07:11:26 +0700 Subject: [PATCH 5/5] fix(workbench): canonicalize typed SCL FCDA member identity at ingress (M2I) SCL source references use IED/LD/LN.DO.DA [FC], while the indexed canonical MMS model uses IEDLD/LN.DO.DA and an independent FC. Normalize from parsed typed SCL fields once, preserve member order and the original SCL document for provenance, avoid duplicated FC in DataSets, and add deterministic direct q/t lookup regression. --- .../src/IedEngineeringContextController.cpp | 20 ++++++++- .../src/ied_engineering_context_qa.cpp | 42 +++++++++++++++++++ .../ied_simulator/test_browser_convergence.py | 9 ++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/IedEngineeringContextController.cpp b/apps/ied_simulator/src/IedEngineeringContextController.cpp index c54a347ac..659a66db1 100644 --- a/apps/ied_simulator/src/IedEngineeringContextController.cpp +++ b/apps/ied_simulator/src/IedEngineeringContextController.cpp @@ -38,6 +38,20 @@ std::string mmsItem(const scl::SclDataSetEntry& entry) { return item; } +// SCL signal_reference is a source/display identifier (IED/LD/... [FC]), +// not a canonical domain-specific MMS/IEC reference. Convert typed FCDA fields +// exactly once at the SCL ingress; FC stays separate and SCL XML remains +// source provenance. Unresolved fields remain explicitly unresolved. +std::string canonicalFcdaReference(const scl::SclDataSetEntry& entry) { + const auto ln = logicalNodeName(entry); + if (entry.ied_name.empty() || entry.ld_inst.empty() || + ln.empty() || entry.do_name.empty()) return {}; + std::string reference = entry.ied_name + entry.ld_inst + "/" + + ln + "." + entry.do_name; + if (!entry.da_name.empty()) reference += "." + entry.da_name; + return reference; +} + std::string mmsTypeForSclBasicType(std::string value) { std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { return static_cast(std::toupper(ch)); @@ -198,7 +212,11 @@ mms::MmsLiveModelDocument buildSclEngineeringModel( const auto& entry = dataSet.entries[index]; mms::MmsLiveDataSetMember member; member.index = index; - member.reference = entry.signal_reference; + // Use one indexed, FC-neutral IEC identity for both opened SCL + // and live discovery. Never carry source IED/LD/... [FC] spelling + // into the canonical DataSet presentation and append FC twice. + member.reference = canonicalFcdaReference(entry); + if (member.reference.empty()) member.reference = entry.signal_reference; member.functional_constraint = entry.functional_constraint; member.mms_reference = (entry.ied_name + entry.ld_inst) + "/" + mmsItem(entry); projected.members.push_back(std::move(member)); diff --git a/apps/ied_simulator/src/ied_engineering_context_qa.cpp b/apps/ied_simulator/src/ied_engineering_context_qa.cpp index 9c5d09175..de75ae37d 100644 --- a/apps/ied_simulator/src/ied_engineering_context_qa.cpp +++ b/apps/ied_simulator/src/ied_engineering_context_qa.cpp @@ -3,6 +3,7 @@ #include +#include #include namespace scl = ar::iec61850::scl; @@ -179,6 +180,46 @@ int main(int argc, char** argv) { return 8; } + // Real SCL parser reference spelling includes source IED/LD separators + // and an FC suffix. The canonical Browser must have one FC-neutral + // identity shared by Data Model and DataSets for direct q/t selection. + auto sourceStyled = makeIed("IED_A", "192.0.2.10"); + sourceStyled.model_entries.front().signal_reference = + "IED_A/LD0/LLN0.Mod.stVal [ST]"; + sourceStyled.data_sets.front().entries.front().signal_reference = + "IED_A/LD0/LLN0.Mod.stVal [ST]"; + auto qEntry = sourceStyled.model_entries.front(); + qEntry.da_name = "q"; + qEntry.basic_type = "Quality"; + qEntry.signal_reference = "IED_A/LD0/LLN0.Mod.q [ST]"; + auto tEntry = sourceStyled.model_entries.front(); + tEntry.da_name = "t"; + tEntry.basic_type = "Timestamp"; + tEntry.signal_reference = "IED_A/LD0/LLN0.Mod.t [ST]"; + sourceStyled.model_entries.push_back(qEntry); + sourceStyled.model_entries.push_back(tEntry); + sourceStyled.data_sets.front().entries.push_back(qEntry); + sourceStyled.data_sets.front().entries.push_back(tEntry); + if (!context.publishSclDocument(sourceStyled, QStringLiteral("/tmp/source-fcda.cid"))) + return 9; + const auto* normalized = context.modelSnapshot(); + if (!normalized || normalized->data_sets.size() != 1U || + normalized->data_sets.front().members.size() != 3U) return 10; + const auto& canonicalMembers = normalized->data_sets.front().members; + const std::string canonicalRoot = "IED_ALD0/LLN0.Mod."; + const std::array leaves{"stVal", "q", "t"}; + for (std::size_t i = 0U; i < leaves.size(); ++i) { + const auto expected = canonicalRoot + leaves[i]; + if (canonicalMembers[i].reference != expected || + canonicalMembers[i].functional_constraint != "ST" || + context.treeModel()->nodeForReference( + QString::fromStdString(expected + " [ST]")) + .value(QStringLiteral("readable")).toBool() != true) { + std::cerr << "IED_ENGINEERING_CONTEXT_FAIL canonical_scl_fcda_member\n"; + return 11; + } + } + std::cout << "IED_ENGINEERING_CONTEXT_PASS" << " scl_ingress=pass" @@ -186,6 +227,7 @@ int main(int argc, char** argv) { << " multi_ied_selection=pass" << " disconnect_persistence=pass" << " structural_authority=pass" + << " canonical_scl_fcda_qt=pass" << " fingerprint=" << context.structuralFingerprint().toStdString() << "\n"; return 0; diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index da1606f7d..3aa417876 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -113,6 +113,15 @@ if data_model.find('BrowserEngineeringEvidence {') > data_model.find(' Repeater {'): raise SystemExit("BROWSER_TYPED_EVIDENCE_FAIL typed details below clipped metadata rows") +scl_ingress = (workspace_path.parent.parent / "src" / "IedEngineeringContextController.cpp").read_text(encoding="utf-8") +for token in [ + 'canonicalFcdaReference(const scl::SclDataSetEntry& entry)', + 'member.reference = canonicalFcdaReference(entry);', + 'member.functional_constraint = entry.functional_constraint;', +]: + if token not in scl_ingress: + raise SystemExit("BROWSER_DATASET_CANONICAL_FCDA_FAIL missing=" + token) + qa_source = (workspace_path.parent.parent / "src" / "BrowserEvidenceVisualQa.hpp").read_text(encoding="utf-8") for token in [ 'BROWSER_EVIDENCE_VISUAL_PASS',