diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 359b6d1ef..fd153ed55 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -682,7 +682,10 @@ Rectangle { Layout.fillWidth: true } Label { - text: "Positional · not SCL-labelled" + text: root.selectedReportMemberDetails().length > 0 + && root.selectedReportMemberDetails()[0].authority + ? "SCL verified · MMS type checked" + : "Positional · not SCL-labelled" font.pixelSize: 10 color: theme.muted } @@ -691,40 +694,52 @@ Rectangle { Repeater { model: root.selectedReportMemberIndex === receivedMember.index ? root.selectedReportMemberDetails() : [] - delegate: Rectangle { + delegate: ColumnLayout { + id: semanticLeafRow required property var modelData required property int index Layout.fillWidth: true - implicitHeight: 27 - color: index % 2 ? theme.surfaceSoft : theme.surface - RowLayout { - anchors.fill: parent - anchors.leftMargin: 18 - anchors.rightMargin: 9 - spacing: 10 - Label { - text: modelData.label - Layout.preferredWidth: 180 - elide: Text.ElideRight - font.pixelSize: 11 - color: theme.textSoft - } - Label { - text: modelData.value - Layout.fillWidth: true - elide: Text.ElideMiddle - font.pixelSize: 11 - color: theme.text - ToolTip.visible: detailMouse.containsMouse && truncated - ToolTip.text: text - MouseArea { - id: detailMouse - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton + spacing: 0 + Rectangle { + Layout.fillWidth: true + implicitHeight: 29 + color: semanticLeafRow.index % 2 + ? theme.surfaceSoft : theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 9 + spacing: 10 + Label { + text: semanticLeafRow.modelData.label + Layout.preferredWidth: 180 + elide: Text.ElideRight + font.pixelSize: 11 + color: theme.textSoft + } + Label { + text: semanticLeafRow.modelData.value + Layout.fillWidth: true + elide: Text.ElideMiddle + font.pixelSize: 11 + color: theme.text + ToolTip.visible: detailMouse.containsMouse + ToolTip.text: semanticLeafRow.modelData.reference + || semanticLeafRow.modelData.value + MouseArea { + id: detailMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } } } } + BrowserEngineeringEvidence { + theme: root.theme + engineeringEvidence: semanticLeafRow.modelData.engineeringEvidence + || ({}) + } } } } diff --git a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp index 99a664e9b..9a04cb066 100644 --- a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp +++ b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp @@ -3,6 +3,7 @@ #include "MmsEngineeringEvidence.hpp" #include "BrowserBoundedMmsPreview.hpp" +#include "BrowserReportSemanticBinding.hpp" #include "ariec61850/mms/reporting.hpp" #include @@ -64,7 +65,9 @@ inline constexpr int kBrowserReportDetailChildren = 16; const std::vector& members, int& remainingTypedEvidence, const int requestedVisibleMembers = kBrowserReportVisibleMemberLimit, - const bool includeWireDetails = false) { + const bool includeWireDetails = false, + const mms::MmsLiveModelDocument* trustedModel = nullptr, + const std::string_view dataSetReference = {}) { BrowserReportMemberProjection projection; const int visibleLimit = std::clamp(requestedVisibleMembers, 0, kBrowserReportVisibleMemberLimit); const auto visibleCount = std::min(members.size(), static_cast(visibleLimit)); @@ -113,7 +116,31 @@ inline constexpr int kBrowserReportDetailChildren = 16; .arg(includeWireDetails && ordinal < kBrowserReportDetailMembers ? QStringLiteral(" — select for details") : QString{}); - if (includeWireDetails && ordinal < kBrowserReportDetailMembers) { + const auto verified = includeWireDetails && trustedModel && + ordinal < kBrowserReportDetailMembers + ? bindFlatSclReportStructure(*trustedModel, dataSetReference, member) + : std::nullopt; + if (verified) { + displayValue = QStringLiteral("SCL-verified structure · %1 field(s)") + .arg(static_cast(verified->size())); + for (const auto& leaf : *verified) { + const auto preview = previewMmsValue(*leaf.value, kBrowserReportTextLimit); + const auto full = QString::fromStdString(leaf.reference); + const auto dot = full.lastIndexOf(QLatin1Char('.')); + const auto engineering = decodeEngineeringEvidence(full, *leaf.value); + const QString displayed = engineering.isEmpty() + ? QString::fromStdString(preview.text) + : engineering.value(QStringLiteral("summary")).toString(); + detailRows.push_back(QVariantMap{ + {QStringLiteral("label"), dot >= 0 ? full.mid(dot + 1) : full}, + {QStringLiteral("value"), displayed + + (preview.truncated ? QStringLiteral(" … [truncated]") : QString{})}, + {QStringLiteral("engineeringEvidence"), engineering}, + {QStringLiteral("authority"), QStringLiteral("SCL exact / MMS type checked")}, + {QStringLiteral("reference"), full}}); + if (preview.truncated) projection.textTruncated = true; + } + } else if (includeWireDetails && ordinal < kBrowserReportDetailMembers) { const auto count = std::min(children.size(), static_cast(kBrowserReportDetailChildren)); for (std::size_t child = 0; child < count; ++child) { diff --git a/apps/ied_simulator/src/BrowserReportSemanticBinding.hpp b/apps/ied_simulator/src/BrowserReportSemanticBinding.hpp new file mode 100644 index 000000000..d41a39c97 --- /dev/null +++ b/apps/ied_simulator/src/BrowserReportSemanticBinding.hpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "ariec61850/mms/live_model.hpp" +#include "ariec61850/mms/reporting.hpp" +#include +#include +#include +#include +#include +#include + +// Native port of ARIEC61850 MmsSemanticReportValueProjector's fail-closed +// invariants. Read-only, no additional protocol stack or MMS association. +namespace arstack::iedsim { +namespace semantic_mms = ar::iec61850::mms; +struct VerifiedReportLeaf final { + std::string reference; + const semantic_mms::MmsDataValue* value{}; +}; +inline bool semanticInside(const std::string_view leaf, + const std::string_view root) { + return leaf.size() > root.size() && leaf.substr(0,root.size()) == root && + leaf[root.size()] == '.'; +} +inline semantic_mms::MmsDataKind semanticExpectedKind(const std::string_view basic) { + using K = semantic_mms::MmsDataKind; + if (basic == "BOOLEAN") return K::boolean; + if (basic == "Quality" || basic == "QUALITY") return K::bit_string; + if (basic == "Timestamp" || basic == "TIMESTAMP") return K::utc_time; + if (basic == "Enum" || basic == "ENUM" || basic == "INT32" || + basic == "INT8" || basic == "INT16") return K::integer; + if (basic == "FLOAT32" || basic == "FLOAT64") return K::floating_point; + return K::unknown; +} +inline bool semanticKindCompatible(const semantic_mms::MmsDataValue& value, + const std::string_view basic) { + using K = semantic_mms::MmsDataKind; + if (basic == "BOOLEAN") return value.kind() == K::boolean; + if (basic == "Quality" || basic == "QUALITY") { + if (value.kind() != K::bit_string) return false; + const auto& bits = value.raw_value(); + return bits.size() == 3U && bits[0] == 3U && + (bits[2] & 0x07U) == 0U; + } + if (basic == "Timestamp" || basic == "TIMESTAMP") return value.kind() == K::utc_time; + if (basic == "Enum" || basic == "ENUM" || basic == "INT32" || + basic == "INT8" || basic == "INT16") return value.kind() == K::integer; + if (basic == "FLOAT32" || basic == "FLOAT64") + return value.kind() == K::floating_point; + // Unknown/vendor type cannot authorize semantic labelling. + return false; +} +inline std::optional> bindFlatSclReportStructure( + const semantic_mms::MmsLiveModelDocument& model, + const std::string_view dataSet, + const semantic_mms::MmsReportValue& member) { + using namespace semantic_mms; + if (!member.value || member.value->kind() != MmsDataKind::structure) + return std::nullopt; + // SCL identity/model must be trusted and the FCDA directory must be + // uniquely bound to this DataSet and exact ordinal. Never infer by RptID. + // Canonical Open SCL and SCL-assisted contexts share the same existing + // MMS report runtime; the caller binds the selected model to this endpoint + // and the runtime has validated the frame against the live RCB/DataSet. + const bool openedScl = model.source == "OpenedSCL"; + const bool trustedSnapshot = model.source == "TrustedSclInitialSnapshot"; + if (model.identity.confidence != MmsLiveModelConfidence::exact || + (!openedScl && !trustedSnapshot) || dataSet.empty()) + return std::nullopt; + const MmsLiveDataSet* chosen = nullptr; + // Reuse the ObjectName normalization accepted by the native frame + // validator: dot and dollar forms must resolve to one DataSet identity. + try { + const auto requested = + MmsDataSetDirectoryCodec::parse_data_set_reference(std::string{dataSet}); + for (const auto& ds : model.data_sets) { + if (MmsDataSetDirectoryCodec::parse_data_set_reference(ds.reference) + != requested) + continue; + if (chosen) return std::nullopt; + chosen = &ds; + } + } catch (const std::exception&) { + return std::nullopt; + } + if (!chosen) return std::nullopt; + const MmsLiveDataSetMember* binding = nullptr; + for (const auto& fcda : chosen->members) { + if (fcda.index != member.data_set_index) continue; + if (binding) return std::nullopt; + binding = &fcda; + } + if (!binding || binding->confidence != MmsLiveModelConfidence::exact || + binding->reference.empty() || binding->functional_constraint.empty()) + return std::nullopt; + const auto& raw = member.data_reference; + const auto& dir = member.member; + // The native frame validator accepts IEC and MMS DataRef notation. + // Preserve those aliases only when they resolve to the exact same + // canonical object reference and functional constraint. + const auto matchesExactMember = [&](const std::string_view value) { + if (value.empty()) return false; + if (value == binding->reference) return true; + const auto slash = value.find('/'); + if (slash == std::string_view::npos || slash == 0U) return false; + const auto fcStart = value.find('$', slash + 1U); + if (fcStart == std::string_view::npos) return false; + const auto pathStart = value.find('$', fcStart + 1U); + if (pathStart == std::string_view::npos || + value.substr(fcStart + 1U, pathStart - fcStart - 1U) != + binding->functional_constraint) + return false; + std::string canonical{value.substr(0, fcStart)}; + canonical.push_back('.'); + for (std::size_t i = pathStart + 1U; i < value.size(); ++i) + canonical.push_back(value[i] == '$' ? '.' : value[i]); + return canonical == binding->reference; + }; + if (!raw.empty() && !matchesExactMember(raw)) return std::nullopt; + if (dir && !dir->user_reference.empty() && + !matchesExactMember(dir->user_reference)) return std::nullopt; + if (dir && !dir->mms_reference.empty() && + !matchesExactMember(dir->mms_reference)) return std::nullopt; + if (raw.empty() && (!dir || (dir->user_reference.empty() && + dir->mms_reference.empty()))) + return std::nullopt; + if (dir && !dir->functional_constraint.empty() && + dir->functional_constraint != binding->functional_constraint) + return std::nullopt; + // Flat ST/CF/SG structures only. Nested, array, repeated or vendor + // layouts have no proven ordered schema here and remain positional. + const MmsLiveDataObject* object = nullptr; + for (const auto& ld : model.logical_devices) + for (const auto& ln : ld.logical_nodes) + for (const auto& candidate : ln.data_objects) + if (binding->reference == candidate.reference) { + if (object) return std::nullopt; + object = &candidate; + } + if (!object || object->confidence != MmsLiveModelConfidence::exact) + return std::nullopt; + std::vector attributes; + for (const auto& attribute : object->attributes) { + if (attribute.functional_constraint != binding->functional_constraint || + !semanticInside(attribute.object_reference, binding->reference)) + continue; + if (attribute.source != + (openedScl ? "OpenedSCL" : "TrustedSCL") || + attribute.type_confidence != MmsLiveModelConfidence::exact || + attribute.functional_constraint_confidence != MmsLiveModelConfidence::exact) + return std::nullopt; + // Only one direct segment is admissible; nested structures demand + // matching a hierarchical SCL schema, not guesswork. + const auto suffix = std::string_view{attribute.object_reference} + .substr(binding->reference.size() + 1); + if (suffix.empty() || suffix.find('.') != std::string_view::npos) + return std::nullopt; + attributes.push_back(&attribute); + } + const auto& children = member.value->children(); + if (attributes.empty() || attributes.size() != children.size() || + children.size() > 16U) return std::nullopt; + // Positional order is not independently proven by matching count alone. + // Reject repeated scalar types, where a vendor permutation would look + // valid and produce deceptively named engineering values. + for (std::size_t i = 0; i < attributes.size(); ++i) + for (std::size_t j = i + 1; j < attributes.size(); ++j) + if (semanticExpectedKind(attributes[i]->scl_basic_type) == + semanticExpectedKind(attributes[j]->scl_basic_type)) + return std::nullopt; + std::vector result; + result.reserve(children.size()); + for (std::size_t i = 0; i < children.size(); ++i) { + if (!semanticKindCompatible(children[i], attributes[i]->scl_basic_type)) + return std::nullopt; + if (std::any_of(result.begin(), result.end(), [&](const auto& leaf){ + return leaf.reference == attributes[i]->object_reference; })) + return std::nullopt; + result.push_back({attributes[i]->object_reference, &children[i]}); + } + return result; +} +} // namespace arstack::iedsim diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 758001e55..5a2798ade 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -491,7 +491,8 @@ struct ReportUi final { ReportUi buildSubscriptionUi( const mms::MmsReportSubscriptionSnapshot& subscription, - const bool active) { + const bool active, + const mms::MmsLiveModelDocument* semanticModel = nullptr) { ReportUi ui; ui.active = active; ui.receivedCount = subscription.received_reports; @@ -534,10 +535,19 @@ ReportUi buildSubscriptionUi( // Historical frames retain bounded scalar summaries and typed q/t // evidence; never allocate 32 x 16 child previews per old frame. const bool includeWireDetails = ui.reports.isEmpty(); + // DatSet is optional in OptFlds. Native report binding has already + // verified the subscribed RCB/DataSet directory; when absent use + // only that exact observed RCB state, never a guessed RptID. + const std::string_view verifiedDataSet = + !frame.header.data_set_reference.empty() + ? std::string_view{frame.header.data_set_reference} + : subscription.last_rcb_state + ? std::string_view{subscription.last_rcb_state->data_set_reference} + : std::string_view{}; const auto projected = arstack::iedsim::projectBrowserReportMembers( frame.values, typedEvidenceBudget, arstack::iedsim::kBrowserReportVisibleMemberLimit, - includeWireDetails); + includeWireDetails, semanticModel, verifiedDataSet); map.insert(QStringLiteral("values"), projected.values); map.insert(QStringLiteral("valueEvidence"), projected.evidence); map.insert(QStringLiteral("valueDetails"), projected.details); @@ -557,19 +567,21 @@ ReportUi buildSubscriptionUi( return ui; } -ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { +ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot, + const mms::MmsLiveModelDocument* semanticModel = nullptr) { if (!snapshot.subscription) { ReportUi ui; ui.active = snapshot.active; return ui; } - return buildSubscriptionUi(*snapshot.subscription, snapshot.active); + return buildSubscriptionUi(*snapshot.subscription, snapshot.active, semanticModel); } -ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot) { +ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot, + const mms::MmsLiveModelDocument* semanticModel = nullptr) { return buildSubscriptionUi( snapshot, - snapshot.state == mms::MmsReportSubscriptionState::active); + snapshot.state == mms::MmsReportSubscriptionState::active, semanticModel); } } // namespace @@ -584,6 +596,9 @@ struct MmsReportController::WorkerState final { // implicitly shared containers keep this cheap to retain; deep compare // must never run on the QML/controller thread. std::optional lastPublishedReportUi; + // Association-generation-owned canonical SCL model, captured on GUI thread + // before worker launch; no QObject or model is accessed cross-thread. + std::shared_ptr semanticModel; }; MmsReportController::MmsReportController(QObject* parent) @@ -774,8 +789,13 @@ bool MmsReportController::connectToIed() { if (engineeringContext_ && engineeringContext_->loaded() && !engineeringContext_->selectionRequired() && engineeringContext_->modelSnapshot() != nullptr) { - contextModel = std::make_shared( - *engineeringContext_->modelSnapshot()); + // The Browser may be pointed at a different endpoint from the + // opened SCL context. Never attach foreign schema to that MMS session. + const auto* candidate = engineeringContext_->modelSnapshot(); + if (candidate->endpoint.host == requestedHost.toStdString() && + candidate->endpoint.port == static_cast(requestedPort)) { + contextModel = std::make_shared(*candidate); + } } appendDiagnostic(QStringLiteral("Reports connect %1:%2 · generation %3") .arg(requestedHost).arg(requestedPort).arg(generation)); @@ -786,6 +806,7 @@ bool MmsReportController::connectToIed() { ioPool_.start([self, worker, stop, generation, requestedHost, requestedPort, contextModel] { try { worker->lastPublishedReportUi.reset(); + worker->semanticModel = contextModel; if (worker->report) worker->report->stop(); if (worker->authoredReport) worker->authoredReport->stop(); worker->report.reset(); @@ -1779,11 +1800,11 @@ bool MmsReportController::disableSelected() { ReportUi ui; if (worker->report) { worker->report->stop(stop->get_token()); - ui = buildReportUi(worker->report->snapshot()); + ui = buildReportUi(worker->report->snapshot(), worker->semanticModel.get()); if (!ui.cleanupRequired) worker->report.reset(); } else if (worker->authoredReport) { worker->authoredReport->stop(stop->get_token()); - ui = buildReportUi(worker->authoredReport->snapshot()); + ui = buildReportUi(worker->authoredReport->snapshot(), worker->semanticModel.get()); if (!ui.cleanupRequired) worker->authoredReport.reset(); } else { throw std::runtime_error("No report subscription is active."); @@ -1850,12 +1871,12 @@ bool MmsReportController::retryCleanup() { ReportUi ui; if (worker->report) { worker->report->stop(stop->get_token()); - ui = buildReportUi(worker->report->snapshot()); + ui = buildReportUi(worker->report->snapshot(), worker->semanticModel.get()); if (!ui.cleanupRequired) worker->report.reset(); } else if (worker->authoredReport) { static_cast(worker->authoredReport->retry_cleanup( stop->get_token())); - ui = buildReportUi(worker->authoredReport->snapshot()); + ui = buildReportUi(worker->authoredReport->snapshot(), worker->semanticModel.get()); if (!ui.cleanupRequired) worker->authoredReport.reset(); } else { throw std::runtime_error( @@ -1945,8 +1966,8 @@ void MmsReportController::schedulePoll() { return; } const ReportUi ui = staticActive - ? buildReportUi(worker->report->snapshot()) - : buildReportUi(worker->authoredReport->snapshot()); + ? buildReportUi(worker->report->snapshot(), worker->semanticModel.get()) + : buildReportUi(worker->authoredReport->snapshot(), worker->semanticModel.get()); // Compare complete bounded report presentation on the existing // serial I/O worker, never on the GUI thread. Counter-only checks // would lose event-only and retained-frame evidence changes. @@ -1997,10 +2018,10 @@ void MmsReportController::schedulePoll() { ReportUi ui; if (worker->report) { worker->report->stop(); - ui = buildReportUi(worker->report->snapshot()); + ui = buildReportUi(worker->report->snapshot(), worker->semanticModel.get()); } else if (worker->authoredReport) { worker->authoredReport->stop(); - ui = buildReportUi(worker->authoredReport->snapshot()); + ui = buildReportUi(worker->authoredReport->snapshot(), worker->semanticModel.get()); } if (self) { const auto message = QString::fromUtf8(exception.what()); diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index e65d06704..d848bb935 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -2,6 +2,7 @@ #include "IedFleetController.hpp" #include "IedReportControlManifest.hpp" +#include "BrowserReportSemanticBinding.hpp" #include "MmsReportController.hpp" #include @@ -170,12 +171,111 @@ bool reportProjectionPresent(const MmsReportController& reports) { frame.contains(QStringLiteral("entryId")) && frame.contains(QStringLiteral("overflow")); } +bool verifySemanticReportBinding() { + namespace mms = ar::iec61850::mms; + using arstack::iedsim::bindFlatSclReportStructure; + mms::MmsLiveModelDocument model; + model.source = "TrustedSclInitialSnapshot"; + model.identity.confidence = mms::MmsLiveModelConfidence::exact; + mms::MmsLiveDataSet dataset; + dataset.reference = "IEDLD/LLN0.ds"; + mms::MmsLiveDataSetMember binding; + binding.index = 0U; + binding.reference = "IEDLD/XCBR1.Pos"; + binding.functional_constraint = "ST"; + dataset.members.push_back(binding); + model.data_sets.push_back(dataset); + mms::MmsLiveLogicalDevice ld; + mms::MmsLiveLogicalNode ln; + mms::MmsLiveDataObject object; + object.reference = binding.reference; + object.confidence = mms::MmsLiveModelConfidence::exact; + for (const auto& [name, type] : { + std::pair{"stVal", "Enum"}, + std::pair{"q", "Quality"}}) { + mms::MmsLiveDataAttribute attr; + attr.object_reference = binding.reference + "." + name; + attr.attribute_path = name; + attr.functional_constraint = "ST"; + attr.source = "TrustedSCL"; + attr.type_confidence = mms::MmsLiveModelConfidence::exact; + attr.functional_constraint_confidence = mms::MmsLiveModelConfidence::exact; + attr.scl_basic_type = type; + object.attributes.push_back(std::move(attr)); + } + ln.data_objects.push_back(std::move(object)); + ld.logical_nodes.push_back(std::move(ln)); + model.logical_devices.push_back(std::move(ld)); + const std::array quality{0x00U, 0x00U}; + mms::MmsReportValue value; + value.data_set_index = 0U; + value.data_reference = binding.reference; + value.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::integer(2), + mms::MmsDataValue::bit_string(3U, quality)}); + auto check = [&](const bool expected) { + const auto result = bindFlatSclReportStructure(model, dataset.reference, value); + return result.has_value() == expected; + }; + if (!check(true)) return false; + if (!bindFlatSclReportStructure(model, "IEDLD/LLN0$ds", value)) + return false; // Same DataSet via MMS native ObjectName alias + // The real Browser Open SCL owner uses a distinct source marker from + // the SCL-assisted initial snapshot. Both must work with exact evidence. + model.source = "OpenedSCL"; + for (auto& attribute : model.logical_devices[0].logical_nodes[0] + .data_objects[0].attributes) + attribute.source = "OpenedSCL"; + if (!check(true)) return false; + model.source = "UnverifiedSCL"; + if (!check(false)) return false; + model.source = "OpenedSCL"; + value.data_reference = "IEDLD/XCBR1$ST$Pos"; + if (!check(true)) return false; // valid native MMS DataRef alias + value.data_reference = "IEDLD/XCBR1$MX$Pos"; + if (!check(false)) return false; // conflicting FC + value.data_reference = "IEDLD/XCBR1$ST$Other"; + if (!check(false)) return false; // conflicting member + value.data_reference = binding.reference; + value.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::integer(2), + mms::MmsDataValue::bit_string(0U, quality)}); + if (!check(false)) return false; // invalid 16-bit q, not 13-bit + value.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::integer(2), + mms::MmsDataValue::bit_string(3U, quality)}); + if (!check(true)) return false; + value.data_reference = "OtherIED/XCBR1.Pos"; + if (!check(false)) return false; + value.data_reference = binding.reference; + value.data_set_index = 3U; + if (!check(false)) return false; + value.data_set_index = 0U; + model.identity.confidence = mms::MmsLiveModelConfidence::high; + if (!check(false)) return false; + model.identity.confidence = mms::MmsLiveModelConfidence::exact; + value.value = mms::MmsDataValue::structure({mms::MmsDataValue::integer(2)}); + if (!check(false)) return false; + value.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::integer(2), mms::MmsDataValue::integer(3)}); + if (!check(false)) return false; + value.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::integer(2), mms::MmsDataValue::bit_string(3U, quality)}); + model.logical_devices[0].logical_nodes[0].data_objects[0].attributes[1].scl_basic_type = "Enum"; + if (!check(false)) return false; + return true; +} } // namespace int main(int argc, char* argv[]) { qputenv("QT_QPA_PLATFORM", "offscreen"); qputenv("ARSTACK_IEDSIM_QA", "1"); QGuiApplication app(argc, argv); + if (!verifySemanticReportBinding()) { + qCritical() << "REPORTS_SEMANTIC_BINDING_FAIL"; + return 42; + } + qInfo() << "REPORTS_SEMANTIC_BINDING_PASS canonical=pass mms_alias=pass quality=pass negative=pass"; if (argc != 2) { qCritical() << "usage: ied_report_workspace_qa "; return 2; diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 41fadf5b2..926590c97 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -281,8 +281,51 @@ ): if token not in report_inspector: raise SystemExit("BROWSER_REPORT_INLINE_TREE_FAIL compact selection=" + token) -if report_inspector.count("BrowserEngineeringEvidence {") != 1: - raise SystemExit("BROWSER_REPORT_INLINE_TREE_FAIL duplicate or detached q/t inspector") +# One member-level inspector and one nested semantic-leaf inspector. Both +# must live inside the selected member's lazy Loader, not after the DataSet. +if (member_tree.count("BrowserEngineeringEvidence {") != 2 or + report_inspector.count("BrowserEngineeringEvidence {") != 2 or + "engineeringEvidence: semanticLeafRow.modelData.engineeringEvidence" not in member_tree): + raise SystemExit("BROWSER_REPORT_INLINE_TREE_FAIL incorrect inline q/t inspectors") + +# P0 semantic projection borrows ARIEC61850 exact DataSet/FCDA/type gates. +semantic = (workspace_path.parent.parent / "src" / "BrowserReportSemanticBinding.hpp").read_text(encoding="utf-8") +for token in ( + "model.identity.confidence != MmsLiveModelConfidence::exact", + 'model.source == "OpenedSCL"', + 'model.source == "TrustedSclInitialSnapshot"', + '(openedScl ? "OpenedSCL" : "TrustedSCL")', + "fcda.index != member.data_set_index", + "matchesExactMember(dir->user_reference)", + "matchesExactMember(dir->mms_reference)", + "canonical == binding->reference", + "bits.size() == 3U && bits[0] == 3U", + "attribute.functional_constraint != binding->functional_constraint", + "attributes.size() != children.size()", + "semanticKindCompatible(children[i], attributes[i]->scl_basic_type)", + "semanticExpectedKind(attributes[i]->scl_basic_type) ==", + "semanticExpectedKind(attributes[j]->scl_basic_type)", + "MmsDataSetDirectoryCodec::parse_data_set_reference", +): + if token not in semantic: + raise SystemExit("BROWSER_SEMANTIC_FAIL missing fail-closed binding=" + token) +for token in ( + "worker->semanticModel = contextModel;", + "candidate->endpoint.host == requestedHost.toStdString()", + "candidate->endpoint.port == static_cast(requestedPort)", + "semanticModel, verifiedDataSet", + "subscription.last_rcb_state->data_set_reference", +): + if token not in report_controller: + raise SystemExit("BROWSER_SEMANTIC_FAIL missing generation-owned runtime wiring=" + token) +if 'ordinal < kBrowserReportDetailMembers' not in projection_header: + raise SystemExit("BROWSER_SEMANTIC_FAIL uncapped verified details") +if 'const auto engineering = decodeEngineeringEvidence(full, *leaf.value);' not in projection_header: + raise SystemExit("BROWSER_SEMANTIC_FAIL missing shared q/t native typed decoder") +if 'engineeringEvidence: semanticLeafRow.modelData.engineeringEvidence' not in report_inspector: + raise SystemExit("BROWSER_SEMANTIC_FAIL missing expandable child Quality/TimeQuality") +if "SCL verified · MMS type checked" not in report_inspector: + raise SystemExit("BROWSER_SEMANTIC_FAIL missing semantic/fallback provenance badge") value_preview = (workspace_path.parent.parent / "src" / "BrowserBoundedMmsPreview.hpp").read_text(encoding="utf-8") for token in [