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..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,11 +308,6 @@ Rectangle { } } - BrowserEngineeringEvidence { - theme: root.theme - engineeringEvidence: root.currentNode.engineeringEvidence || ({}) - } - Label { visible: !root.currentNode.kind Layout.fillWidth: true 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..895149fbf --- /dev/null +++ b/apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp @@ -0,0 +1,367 @@ +// 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 +#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 { + if (!component || !component->isVisible() || + !component->property("hasEvidence").toBool() || + component->property("engineeringEvidence").toMap() + .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) { + 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; + } + // 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_); + // 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; + } + 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); + } 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_{}; + bool prepared_{}; + 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/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/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..3aa417876 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: @@ -104,6 +108,38 @@ 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") + +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', + 'decodeEngineeringEvidence(', + 'MmsLiveDetailModel::ReferenceRole', + 'selectEvidenceAttribute', + 'malformedEvidence.isEmpty()', + 'mapRectToItem(', + 'clipped.height() >= 50', + '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 [