diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 136d9dff1..476df00cb 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -20,12 +20,19 @@ Rectangle { readonly property bool hasLastReport: latestReport.reportId !== undefined readonly property var configuredMembers: membersForSelectedRcb() property bool attributesExpanded: true - property bool triggerExpanded: true - property bool optionalExpanded: true - property bool informationExpanded: true + // Readable process values first; secondary bit tables and diagnostics + // remain one click away, rather than consuming the opening viewport. + property bool triggerExpanded: false + property bool optionalExpanded: false + property bool informationExpanded: false property bool dataExpanded: true - property bool eventsExpanded: true + property bool eventsExpanded: false property int selectedReportMemberIndex: -1 + readonly property int selectedRcbRow: reports.selectedRcbIndex + onSelectedRcbRowChanged: { + if (detailScroll.contentItem) detailScroll.contentItem.contentY = 0 + selectedReportMemberIndex = -1 + } // Preserve selection through repeat snapshots of the same report frame; // reset only when the actual ReportID/sequence/entry identity changes. readonly property string latestReportIdentity: !hasLastReport ? "" : [ @@ -190,7 +197,7 @@ Rectangle { readonly property var rawValue: lastReportSection ? root.informationValue(index) : root.attributeValue(index) readonly property int stripe: index - implicitHeight: 25 + implicitHeight: 30 Layout.fillWidth: true color: stripe % 2 ? root.theme.surfaceSoft : root.theme.surface Rectangle { @@ -214,7 +221,7 @@ Rectangle { verticalAlignment: Text.AlignVCenter text: propertyRow.title color: root.theme.textSoft - font.pixelSize: 10 + font.pixelSize: 12 elide: Text.ElideRight ToolTip.visible: titleMouse.containsMouse && truncated ToolTip.text: title @@ -227,7 +234,7 @@ Rectangle { verticalAlignment: Text.AlignVCenter text: root.asText(rawValue) color: root.theme.text - font.pixelSize: 10 + font.pixelSize: 12 elide: Text.ElideMiddle ToolTip.visible: valueMouse.containsMouse && truncated ToolTip.text: text @@ -242,7 +249,7 @@ Rectangle { property bool opened: true signal toggleRequested() Layout.fillWidth: true - implicitHeight: 26 + implicitHeight: 30 color: root.theme.surfaceRaised Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: root.theme.line } RowLayout { @@ -252,12 +259,12 @@ Rectangle { spacing: 7 Label { text: opened ? "▾" : "▸" - font.pixelSize: 10 + font.pixelSize: 12 color: root.theme.textSoft } Label { text: heading - font.pixelSize: 10 + font.pixelSize: 12 color: root.theme.text font.weight: Font.DemiBold Layout.fillWidth: true @@ -265,7 +272,7 @@ Rectangle { Label { visible: evidence.length > 0 text: evidence - font.pixelSize: 9 + font.pixelSize: 11 color: root.theme.muted } } @@ -287,7 +294,7 @@ Rectangle { ? root.memberValue(modelData) : "—" readonly property int stripe: index Layout.fillWidth: true - implicitHeight: 25 + implicitHeight: 30 color: received && root.selectedReportMemberIndex === index ? root.theme.accentSoft : stripe % 2 ? root.theme.surfaceSoft : root.theme.surface @@ -300,14 +307,14 @@ Rectangle { text: "DA" Layout.preferredWidth: 25 color: root.theme.accent - font.pixelSize: 9 + font.pixelSize: 11 font.weight: Font.DemiBold } Label { text: dataRow.reference Layout.preferredWidth: Math.max(100, parent.width * 0.55 - 28) color: root.theme.text - font.pixelSize: 10 + font.pixelSize: 12 elide: Text.ElideMiddle ToolTip.visible: dataNameMouse.containsMouse && truncated ToolTip.text: text @@ -319,7 +326,7 @@ Rectangle { leftPadding: 8 text: dataRow.displayValue color: root.theme.text - font.pixelSize: 10 + font.pixelSize: 12 elide: Text.ElideMiddle ToolTip.visible: dataValueMouse.containsMouse && truncated ToolTip.text: text @@ -335,6 +342,113 @@ Rectangle { Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: root.theme.lineSoft } } + // Presentation-only tree over authoritative MMS-decoded child values. + // Unknown child types retain positional names; SCL labels are never guessed. + component TreeDataRow: ColumnLayout { + id: treeRow + required property int index + required property var modelData + readonly property bool topLevel: modelData.depth === 0 + readonly property bool expanded: root.selectedReportMemberIndex === modelData.memberIndex + visible: topLevel || expanded + Layout.fillWidth: true + spacing: 0 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 32 + color: treeRow.topLevel && treeRow.expanded + ? root.theme.accentSoft + : treeRow.index % 2 ? root.theme.surfaceSoft : root.theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + Math.min(treeRow.modelData.depth, 4) * 16 + anchors.rightMargin: 8 + spacing: 6 + Label { + Layout.preferredWidth: 21 + text: treeRow.topLevel + ? (treeRow.modelData.container + ? (treeRow.expanded ? "▾" : "▸") : "DA") + : "·" + color: root.theme.accent + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + Layout.preferredWidth: Math.max(100, parent.width * 0.49 - 35) + text: treeRow.modelData.name || "—" + color: root.theme.text + font.pixelSize: 12 + font.weight: treeRow.topLevel ? Font.DemiBold : Font.Normal + elide: Text.ElideMiddle + ToolTip.visible: treeNameHover.containsMouse && truncated + ToolTip.text: treeRow.modelData.reference || text + MouseArea { + id: treeNameHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + Label { + Layout.fillWidth: true + text: treeRow.modelData.value || "—" + color: treeRow.modelData.received ? root.theme.text : root.theme.muted + font.pixelSize: 12 + elide: Text.ElideRight + ToolTip.visible: treeValueHover.containsMouse && truncated + ToolTip.text: text + MouseArea { + id: treeValueHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + Label { + visible: !treeRow.topLevel + Layout.preferredWidth: 66 + text: treeRow.modelData.kind || "" + color: root.theme.muted + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + } + } + MouseArea { + anchors.fill: parent + enabled: treeRow.topLevel + cursorShape: Qt.PointingHandCursor + onClicked: root.selectedReportMemberIndex = treeRow.expanded + ? -1 : treeRow.modelData.memberIndex + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: root.theme.lineSoft + } + } + Label { + visible: treeRow.topLevel && treeRow.expanded && + Boolean(treeRow.modelData.reason) + Layout.fillWidth: true + leftPadding: 32 + topPadding: 4 + bottomPadding: 4 + color: root.theme.textSoft + font.pixelSize: 11 + text: "Reason for inclusion: " + (treeRow.modelData.reason || "") + } + BrowserEngineeringEvidence { + theme: root.theme + // Native tree row carries exactly this member's typed decoder + // evidence, so quality flags/time-quality stay visible on click. + engineeringEvidence: treeRow.topLevel && treeRow.expanded + ? treeRow.modelData.typedEvidence || ({}) : ({}) + } + } + ColumnLayout { anchors.fill: parent spacing: 0 @@ -353,7 +467,7 @@ Rectangle { Rectangle { implicitWidth: 20; implicitHeight: 17; radius: 2 color: theme.accent - Label { anchors.centerIn: parent; text: "R"; color: "#ffffff"; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { anchors.centerIn: parent; text: "R"; color: "#ffffff"; font.pixelSize: 12; font.weight: Font.DemiBold } } Label { Layout.fillWidth: true @@ -372,7 +486,7 @@ Rectangle { : root.rcb.probeOk === true ? "VERIFIED" : "ENGINEERING" color: reports.cleanupRequired ? theme.red : root.selectedIsSubscribed ? theme.green : theme.muted - font.pixelSize: 9 + font.pixelSize: 11 } } } @@ -398,9 +512,9 @@ Rectangle { anchors.leftMargin: 8 anchors.rightMargin: 8 spacing: 8 - Label { text: modelData.buffered ? "BR" : "UR"; color: theme.accent; font.pixelSize: 9; Layout.preferredWidth: 22 } - Label { text: modelData.name || modelData.reference || "RCB"; Layout.fillWidth: true; font.pixelSize: 10; color: theme.text; elide: Text.ElideMiddle } - Label { text: modelData.logicalNode || "LLN0"; font.pixelSize: 9; color: theme.muted } + Label { text: modelData.buffered ? "BR" : "UR"; color: theme.accent; font.pixelSize: 11; Layout.preferredWidth: 22 } + Label { text: modelData.name || modelData.reference || "RCB"; Layout.fillWidth: true; font.pixelSize: 12; color: theme.text; elide: Text.ElideMiddle } + Label { text: modelData.logicalNode || "LLN0"; font.pixelSize: 11; color: theme.muted } } MouseArea { id: reportMouse; anchors.fill: parent; hoverEnabled: true; onClicked: reports.selectRcb(index) } } @@ -428,7 +542,7 @@ Rectangle { bottomPadding: 5 color: theme.muted wrapMode: Text.WordWrap - font.pixelSize: 10 + font.pixelSize: 12 text: "Another RCB is actively subscribed on this MMS association: " + reports.subscriptionRcbReference + ". The selected RCB is inspection-only; Stop active RCB acts on " @@ -468,7 +582,7 @@ Rectangle { topPadding: 6 bottomPadding: 6 color: theme.muted - font.pixelSize: 10 + font.pixelSize: 12 text: "TrgOps " + root.readStatusLabel(root.rcb.triggerOptionsReadStatus) + " · no on/off bit state is inferred." } @@ -480,14 +594,39 @@ Rectangle { columnSpacing: 8 Repeater { model: root.triggerFields - delegate: CheckBox { + delegate: RowLayout { + id: bitRow required property var modelData - text: modelData.label - checked: root.rcb.triggerOptionNames - && root.rcb.triggerOptionNames.indexOf(modelData.name) >= 0 - enabled: false // inspected bit, never an MMS Write - font.pixelSize: 10 + readonly property bool bitSet: root.rcb.triggerOptionNames + && root.rcb.triggerOptionNames.indexOf(modelData.name) >= 0 Layout.fillWidth: true + Layout.minimumHeight: 30 + spacing: 6 + // Passive bit observation: no CheckBox/MouseArea and + // no disabled-text styling or MMS Write affordance. + Rectangle { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + radius: 3 + color: bitRow.bitSet ? theme.accentSoft : theme.surface + border.width: 1 + border.color: bitRow.bitSet ? theme.accent : theme.line + Label { + anchors.centerIn: parent + text: "✓" + visible: bitRow.bitSet + color: theme.accent + font.pixelSize: 12 + font.weight: Font.Bold + } + } + Label { + text: modelData.label + color: theme.text + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } } } } @@ -510,7 +649,7 @@ Rectangle { topPadding: 6 bottomPadding: 6 color: theme.muted - font.pixelSize: 10 + font.pixelSize: 12 text: "OptFlds " + root.readStatusLabel(root.rcb.optionalFieldsReadStatus) + " · no on/off bit state is inferred." } @@ -522,14 +661,39 @@ Rectangle { columnSpacing: 8 Repeater { model: root.optionalFields - delegate: CheckBox { + delegate: RowLayout { + id: bitRow required property var modelData - text: modelData.label - checked: root.rcb.optionalFieldNames - && root.rcb.optionalFieldNames.indexOf(modelData.name) >= 0 - enabled: false // inspected bit, never an MMS Write - font.pixelSize: 10 + readonly property bool bitSet: root.rcb.optionalFieldNames + && root.rcb.optionalFieldNames.indexOf(modelData.name) >= 0 Layout.fillWidth: true + Layout.minimumHeight: 30 + spacing: 6 + // Passive bit observation: no CheckBox/MouseArea and + // no disabled-text styling or MMS Write affordance. + Rectangle { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + radius: 3 + color: bitRow.bitSet ? theme.accentSoft : theme.surface + border.width: 1 + border.color: bitRow.bitSet ? theme.accent : theme.line + Label { + anchors.centerIn: parent + text: "✓" + visible: bitRow.bitSet + color: theme.accent + font.pixelSize: 12 + font.weight: Font.Bold + } + } + Label { + text: modelData.label + color: theme.text + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } } } } @@ -543,7 +707,7 @@ Rectangle { anchors.fill: parent anchors.margins: 7 wrapMode: Text.WordWrap - font.pixelSize: 10 + font.pixelSize: 12 color: root.observedRcbInUse() ? theme.red : theme.muted text: root.rcb.probeOk !== true ? "RCB live state not verified · never infer an unused reservation." @@ -585,37 +749,71 @@ Rectangle { spacing: 0 Rectangle { Layout.fillWidth: true - implicitHeight: 25 + implicitHeight: 30 color: theme.chrome RowLayout { anchors.fill: parent anchors.leftMargin: 8 anchors.rightMargin: 8 - Label { text: "Name"; Layout.preferredWidth: Math.max(100, parent.width * 0.55 - 1); color: theme.textSoft; font.pixelSize: 10; font.weight: Font.DemiBold } - Label { text: "Value"; Layout.fillWidth: true; color: theme.textSoft; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { text: "Name"; Layout.preferredWidth: Math.max(100, parent.width * 0.55 - 1); color: theme.textSoft; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: "Value"; Layout.fillWidth: true; color: theme.textSoft; font.pixelSize: 12; font.weight: Font.DemiBold } } } Repeater { - model: root.hasLastReport && root.latestReport.values - ? root.latestReport.values : [] + model: root.hasLastReport && root.latestReport.valueTree + ? root.latestReport.valueTree : [] + delegate: TreeDataRow {} + } + Repeater { + model: root.hasLastReport && + (!root.latestReport.valueTree || + root.latestReport.valueTree.length === 0) + && root.latestReport.values ? root.latestReport.values : [] delegate: DataRow { received: true } } Repeater { model: !root.hasLastReport ? root.configuredMembers.slice(0, 256) : [] delegate: DataRow { received: false } } + Label { + visible: root.hasLastReport && + root.latestReport.treeNestedTruncated === true + Layout.fillWidth: true + leftPadding: 9 + topPadding: 3 + bottomPadding: 3 + text: "Nested values exceed the bounded preview. Native MMS frame retained." + color: theme.muted + font.pixelSize: 11 + } BrowserEngineeringEvidence { theme: root.theme - engineeringEvidence: root.selectedReportMemberEvidence() + engineeringEvidence: root.hasLastReport && + (!root.latestReport.valueTree || + root.latestReport.valueTree.length === 0) + ? root.selectedReportMemberEvidence() : ({}) + } + Label { + visible: root.hasLastReport && + root.latestReport.treeTypedEvidenceOmitted === true + Layout.fillWidth: true + leftPadding: 9 + topPadding: 3 + bottomPadding: 3 + text: "Quality/TimeQuality details withheld: member identity was truncated." + color: theme.muted + font.pixelSize: 11 } Label { visible: root.hasLastReport && + (!root.latestReport.valueTree || + root.latestReport.valueTree.length === 0) && root.latestReport.valueEvidenceTruncated === true Layout.fillWidth: true leftPadding: 9 text: "Some report q/t details were omitted by the bounded projection." color: theme.muted - font.pixelSize: 9 + font.pixelSize: 11 } Label { visible: root.hasLastReport && @@ -624,7 +822,7 @@ Rectangle { leftPadding: 9 text: "Some values are preview-truncated; the report payload is preserved by MMS runtime." color: theme.muted - font.pixelSize: 9 + font.pixelSize: 11 } Label { visible: root.hasLastReport ? !root.latestReport.values || root.latestReport.values.length === 0 @@ -639,7 +837,7 @@ Rectangle { ? "No bound DataSet reference is available for this RCB." : "The configured DataSet has no exposed members. Offline values are not fabricated." color: theme.muted - font.pixelSize: 10 + font.pixelSize: 12 } Label { visible: root.hasLastReport ? root.latestReport.valuesTruncated === true @@ -648,7 +846,7 @@ Rectangle { leftPadding: 10 text: "Showing first 256 members. Narrow the context for more." color: theme.muted - font.pixelSize: 9 + font.pixelSize: 11 } } @@ -672,7 +870,7 @@ Rectangle { bottomPadding: 3 text: modelData color: theme.textSoft - font.pixelSize: 9 + font.pixelSize: 11 wrapMode: Text.WordWrap } } @@ -690,7 +888,7 @@ Rectangle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.margins: 8 - font.pixelSize: 10 + font.pixelSize: 12 wrapMode: Text.WordWrap color: theme.amber text: reports.rejectedReportCount + " foreign/stale report(s) withheld from process values · " @@ -708,7 +906,7 @@ Rectangle { id: issueLabel anchors.fill: parent anchors.margins: 8 - font.pixelSize: 10 + font.pixelSize: 12 wrapMode: Text.WordWrap color: theme.red text: reports.cleanupRequired diff --git a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp index 2f03d202d..fd3f75420 100644 --- a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp +++ b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -146,4 +147,159 @@ inline constexpr int kBrowserReportTextLimit = 1024; } return projection; } -} // namespace arstack::iedsim + +// A bounded, ready-to-render hierarchy. The native report frame remains the +// source of truth; QML must never parse an MMS structure's preview string. +// Positional MMS children deliberately say "Field N": without a verified SCL +// type they must NOT be misidentified as stVal, q, t, origin, etc. +inline constexpr int kBrowserReportTreeChildLimit = 192; +inline constexpr unsigned kBrowserReportTreeDepthLimit = 4U; +inline constexpr int kBrowserReportTreeScalarTextLimit = 160; + +[[nodiscard]] inline QString browserReportTreeKind(const mms::MmsDataKind kind) { + using K = mms::MmsDataKind; + switch (kind) { + case K::structure: return QStringLiteral("STRUCT"); + case K::array: return QStringLiteral("ARRAY"); + case K::bit_string: return QStringLiteral("BIT STRING"); + case K::utc_time: return QStringLiteral("UTC"); + case K::boolean: return QStringLiteral("BOOL"); + case K::integer: return QStringLiteral("INT"); + case K::unsigned_integer: return QStringLiteral("UINT"); + case K::floating_point: return QStringLiteral("FLOAT"); + case K::visible_string: return QStringLiteral("STRING"); + case K::octet_string: return QStringLiteral("OCTETS"); + default: return QStringLiteral("MMS"); + } +} + +[[nodiscard]] inline QVariantList projectBrowserReportTree( + const std::vector& members, + bool* nestedFieldsTruncated = nullptr, + bool* typedEvidenceOmitted = nullptr) { + if (nestedFieldsTruncated) *nestedFieldsTruncated = false; + if (typedEvidenceOmitted) *typedEvidenceOmitted = false; + QVariantList rows; + const auto visibleCount = std::min( + members.size(), static_cast(kBrowserReportVisibleMemberLimit)); + rows.reserve(static_cast(visibleCount) + kBrowserReportTreeChildLimit); + int remainingChildren = kBrowserReportTreeChildLimit; + + // Each nested child is serialized once, with bounded depth and text; all + // labels are explicitly positional unless canonical identity is present. + const auto appendValue = [&](const auto& visit, const mms::MmsDataValue& value, + const int ordinal, const unsigned depth, + const QString& title, const QString& fullReference, + const bool fullReferenceShortened) -> void { + const bool isContainer = value.kind() == mms::MmsDataKind::structure || + value.kind() == mms::MmsDataKind::array; + QString displayed; + if (isContainer) { + displayed = QStringLiteral("%1 %2").arg( + static_cast(value.children().size())).arg( + value.kind() == mms::MmsDataKind::array + ? QStringLiteral("item(s)") : QStringLiteral("field(s)")); + } else { + const auto preview = previewMmsValue( + value, kBrowserReportTreeScalarTextLimit); + displayed = QString::fromStdString(preview.text); + if (preview.truncated) displayed += QStringLiteral(" …"); + } + // Keep the complete shared decoder evidence with the exact direct + // q/t leaf so operator flags remain inspectable on row expansion. + QVariantMap typedEvidence; + if (depth == 0U && !isContainer && !fullReferenceShortened) { + typedEvidence = decodeEngineeringEvidence(fullReference, value); + if (!typedEvidence.isEmpty()) + displayed = typedEvidence.value(QStringLiteral("summary")).toString(); + } + rows.push_back(QVariantMap{ + {QStringLiteral("typedEvidence"), typedEvidence}, + {QStringLiteral("memberIndex"), ordinal}, + {QStringLiteral("depth"), static_cast(depth)}, + {QStringLiteral("name"), title}, + {QStringLiteral("reference"), fullReference}, + {QStringLiteral("value"), displayed}, + {QStringLiteral("kind"), browserReportTreeKind(value.kind())}, + {QStringLiteral("container"), isContainer}, + {QStringLiteral("received"), true} + }); + if (!isContainer) return; + if (depth >= kBrowserReportTreeDepthLimit) { + if (!value.children().empty() && nestedFieldsTruncated) + *nestedFieldsTruncated = true; + return; + } + int index = 0; + for (const auto& child : value.children()) { + if (remainingChildren <= 0) { + if (nestedFieldsTruncated) *nestedFieldsTruncated = true; + break; + } + --remainingChildren; + visit(visit, child, ordinal, depth + 1U, + QStringLiteral("Field %1").arg(++index), fullReference, + fullReferenceShortened); + } + }; + + for (std::size_t i = 0U; i < visibleCount; ++i) { + const auto& member = members[i]; + const std::string* source = &member.data_reference; + if (source->empty() && member.member) source = &member.member->user_reference; + if (source->empty() && member.member) source = &member.member->mms_reference; + bool shortened = false; + const QString fullReference = boundedReportMetadata( + *source, kBrowserReportTextLimit, shortened); + // A truncated q/t identity cannot be safely fed to the shared typed + // decoder; retain a specific omission indicator instead of showing a + // misleading good/unknown interpretation or suppressing all warnings. + if (shortened && typedEvidenceOmitted && member.value && + hasReportTypedLeaf(*source, member.value->kind())) + *typedEvidenceOmitted = true; + QString name = fullReference; + // Prefer compact canonical IEC paths while keeping full reference in + // the per-row tooltip and never forging a functional constraint. + const int slash = name.indexOf(QLatin1Char('/')); + if (slash >= 0) name = name.mid(slash + 1); + if (name.isEmpty()) name = QStringLiteral("Member %1").arg( + static_cast(member.data_set_index + 1U)); + + const auto rowIndex = rows.size(); + if (member.value) { + appendValue(appendValue, *member.value, static_cast(i), + 0U, name, fullReference, shortened); + } else { + rows.push_back(QVariantMap{ + {QStringLiteral("memberIndex"), static_cast(i)}, + {QStringLiteral("depth"), 0}, + {QStringLiteral("name"), name}, + {QStringLiteral("reference"), fullReference}, + {QStringLiteral("value"), QStringLiteral("Read failed")}, + {QStringLiteral("kind"), QStringLiteral("MMS")}, + {QStringLiteral("container"), false}, + {QStringLiteral("received"), false} + }); + } + // Preserve ReasonForInclusion even for a failed MMS AccessResult. + // ReasonForInclusion is optional wire evidence. Keep the exact + // bounded reason names only for the verified top-level member. + QString reasons; + bool ignoredTruncation = false; + for (const auto& reason : member.reason_for_inclusion.names) { + if (reasons.size() >= kBrowserReportTreeScalarTextLimit) break; + if (!reasons.isEmpty()) reasons += QStringLiteral(", "); + reasons += boundedReportMetadata(reason, + static_cast(std::max(0, + kBrowserReportTreeScalarTextLimit - int(reasons.size()))), + ignoredTruncation); + if (ignoredTruncation) break; + } + auto row = rows.at(rowIndex).toMap(); + row.insert(QStringLiteral("reason"), reasons); + rows[rowIndex] = row; + + } + return rows; +} +} // namespace arstack::iedsim \ No newline at end of file diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 810b76336..344ebd59b 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -533,6 +533,18 @@ ReportUi buildSubscriptionUi( const auto projected = arstack::iedsim::projectBrowserReportMembers( frame.values, typedEvidenceBudget); map.insert(QStringLiteral("values"), projected.values); + // Only the newest retained frame gets a deep, bounded hierarchy. + // Older snapshots keep their existing light string previews. + // No second decoder, protocol store or UI-owned report mutation. + if (ui.reports.isEmpty()) { + bool nestedTruncated = false; + bool typedEvidenceOmitted = false; + map.insert(QStringLiteral("valueTree"), + arstack::iedsim::projectBrowserReportTree( + frame.values, &nestedTruncated, &typedEvidenceOmitted)); + map.insert(QStringLiteral("treeNestedTruncated"), nestedTruncated); + map.insert(QStringLiteral("treeTypedEvidenceOmitted"), typedEvidenceOmitted); + } map.insert(QStringLiteral("valueEvidence"), projected.evidence); map.insert(QStringLiteral("valueEvidenceTruncated"), projected.evidenceTruncated); map.insert(QStringLiteral("valuesTruncated"), projected.rowsTruncated); diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index e65d06704..8cd55e94d 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "BrowserReportMemberProjection.hpp" #include "IedFleetController.hpp" #include "IedReportControlManifest.hpp" #include "MmsReportController.hpp" @@ -17,9 +18,101 @@ #include #include +#include #include +#include +#include namespace { +bool verifyReadableReportTreeProjection() { + namespace mms = ar::iec61850::mms; + using arstack::iedsim::projectBrowserReportTree; + // Positional MMS child identity must NEVER be invented as stVal/q/t. + mms::MmsReportValue structured; + structured.data_set_index = 0U; + structured.data_reference = "IED/LLN0.Pos"; + structured.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::boolean(false), + mms::MmsDataValue::integer(42), + mms::MmsDataValue::array({mms::MmsDataValue::boolean(true)}) + }); + const std::array goodQuality{0x00U, 0x00U}; + mms::MmsReportValue quality; + quality.data_set_index = 1U; + quality.data_reference = "IED/LLN0.Pos.q"; + quality.reason_for_inclusion.names.push_back("general-interrogation"); + quality.value = mms::MmsDataValue::bit_string(3U, + std::span{goodQuality}); + mms::MmsReportValue notRead; + notRead.data_set_index = 2U; + notRead.data_reference = "IED/LLN0.NotRead"; + notRead.reason_for_inclusion.names.push_back("data-change"); + + const auto rows = projectBrowserReportTree({structured, quality, notRead}); + if (rows.size() != 7) return false; // struct, 3 children + 1 array item, q, unread + const auto parent = rows.at(0).toMap(); + const auto child = rows.at(1).toMap(); + const auto nestedChild = rows.at(4).toMap(); + const auto q = rows.at(5).toMap(); + const auto unread = rows.at(6).toMap(); + if (parent.value("name").toString() != QStringLiteral("LLN0.Pos") || + parent.value("depth").toInt() != 0 || + parent.value("value").toString() != QStringLiteral("3 field(s)") || + child.value("name").toString() != QStringLiteral("Field 1") || + child.value("value").toString() != QStringLiteral("false") || + child.value("depth").toInt() != 1 || + nestedChild.value("depth").toInt() != 2 || + q.value("name").toString() != QStringLiteral("LLN0.Pos.q") || + q.value("value").toString() != QStringLiteral("good") || + q.value("reason").toString() != QStringLiteral("general-interrogation") || + q.value("typedEvidence").toMap().value("kind").toString() != QStringLiteral("quality") || + q.value("typedEvidence").toMap().value("rows").toList().size() < 10 || + unread.value("received").toBool() || + unread.value("reason").toString() != QStringLiteral("data-change") || + unread.value("value").toString() != QStringLiteral("Read failed")) + return false; + + // The shared projection is bounded even for hostile deep/long MMS values. + mms::MmsReportValue oversized; + oversized.data_reference = "IED/LLN0.Large"; + oversized.value = mms::MmsDataValue::structure( + std::vector( + 500U, mms::MmsDataValue::boolean(false))); + bool truncated = false; + const auto limited = projectBrowserReportTree({oversized}, &truncated); + if (!truncated || + limited.size() != arstack::iedsim::kBrowserReportTreeChildLimit + 1) + return false; + // Recursion must not imply completeness at the configured depth cap. + auto deep = mms::MmsDataValue::boolean(true); + for (unsigned i = 0; i <= arstack::iedsim::kBrowserReportTreeDepthLimit; ++i) + deep = mms::MmsDataValue::structure({std::move(deep)}); + mms::MmsReportValue nested; + nested.data_reference = "IED/LLN0.Nested"; + nested.value = std::move(deep); + truncated = false; + const auto deepRows = projectBrowserReportTree({nested}, &truncated); + if (!truncated || deepRows.isEmpty()) return false; + // Shortened references must not be used to infer q/t semantic identity. + mms::MmsReportValue ambiguous; + ambiguous.data_reference = std::string(1500U, 'X') + ".q"; + ambiguous.value = mms::MmsDataValue::bit_string(3U, + std::span{goodQuality}); + bool qOmitted = false; + const auto raw = projectBrowserReportTree({ambiguous}, nullptr, &qOmitted); + if (raw.isEmpty() || !qOmitted || + raw.first().toMap().value("value").toString() + == QStringLiteral("good")) + return false; + qOmitted = false; + const auto directQuality = projectBrowserReportTree({quality}, nullptr, &qOmitted); + if (directQuality.isEmpty() || qOmitted || + directQuality.first().toMap().value("value").toString() + != QStringLiteral("good")) + return false; + return true; +} + bool isIndexedReportFixture(const QString& sclPath) { return QFileInfo(sclPath).fileName().endsWith(QStringLiteral("indexed-reports.scd"), Qt::CaseInsensitive); } @@ -167,6 +260,7 @@ bool reportProjectionPresent(const MmsReportController& reports) { !frame.value(QStringLiteral("rcbReference")).toString().isEmpty() && !frame.value(QStringLiteral("dataSet")).toString().isEmpty() && !frame.value(QStringLiteral("values")).toStringList().isEmpty() && + !frame.value(QStringLiteral("valueTree")).toList().isEmpty() && frame.contains(QStringLiteral("entryId")) && frame.contains(QStringLiteral("overflow")); } @@ -181,6 +275,11 @@ int main(int argc, char* argv[]) { return 2; } + if (!verifyReadableReportTreeProjection()) { + qCritical() << "REPORTS_WORKBENCH_FAIL typed_tree_and_bounds"; + return 38; + } + if (!verifyIndexedRcbManifestExpansion()) { qCritical() << "REPORTS_WORKBENCH_FAIL indexed_rcb_manifest"; return 20; diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 9b76d3ac6..0570ead11 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -219,6 +219,68 @@ 'QString::fromStdString(reason)' in projection_header): raise SystemExit("BROWSER_REPORT_METADATA_BOUNDED_FAIL eager untrusted metadata conversion") +# A live InformationReport may carry deeply nested MMS structures. The +# inspector must consume bounded native typed rows, never split "{...}" in QML +# or allocate a full hierarchy for every retained historical frame. +for token in ( + 'kBrowserReportTreeChildLimit = 192', + 'kBrowserReportTreeDepthLimit = 4U', + 'kBrowserReportTreeScalarTextLimit = 160', + 'projectBrowserReportTree(', + 'bool* nestedFieldsTruncated = nullptr', + 'bool* typedEvidenceOmitted = nullptr', + 'if (shortened && typedEvidenceOmitted && member.value', + 'QStringLiteral("Field %1")', + 'decodeEngineeringEvidence(fullReference, value)', + 'depth == 0U && !isContainer && !fullReferenceShortened', + 'if (remainingChildren <= 0) {', + 'depth >= kBrowserReportTreeDepthLimit', + '*nestedFieldsTruncated = true', + 'QStringLiteral("reason")', + 'QStringLiteral("typedEvidence")', +): + if token not in projection_header: + raise SystemExit("BROWSER_REPORT_TYPED_TREE_FAIL missing=" + token) +for token in ( + 'if (ui.reports.isEmpty()) {', + 'QStringLiteral("valueTree")', + 'QStringLiteral("treeNestedTruncated")', + 'QStringLiteral("treeTypedEvidenceOmitted")', + 'projectBrowserReportTree(', +): + if token not in report_controller: + raise SystemExit("BROWSER_REPORT_TYPED_TREE_FAIL controller missing=" + token) +for token in ( + 'component TreeDataRow: ColumnLayout {', + 'delegate: TreeDataRow {}', + 'root.latestReport.valueTree', + 'treeRow.modelData.memberIndex', + 'treeRow.modelData.value', + 'treeRow.modelData.typedEvidence', + 'Reason for inclusion:', + 'visible: topLevel || expanded', + 'property bool triggerExpanded: false', + 'property bool optionalExpanded: false', + 'property bool informationExpanded: false', + 'onSelectedRcbRowChanged:', + 'root.latestReport.treeNestedTruncated === true', + 'root.latestReport.treeTypedEvidenceOmitted === true', + 'root.latestReport.valueEvidenceTruncated === true', +): + if token not in report_inspector: + raise SystemExit("BROWSER_REPORT_TYPED_TREE_FAIL QML missing=" + token) +# Do not tell operators q/t flags are missing merely because the old 96 +# row evidence budget was exhausted; the new native tree retains direct q/t. +evidence_warning = report_inspector.split( + 'root.latestReport.valueEvidenceTruncated === true', 1)[0] +if ('(!root.latestReport.valueTree ||' not in evidence_warning[-220:] or + 'root.latestReport.valueTree.length === 0) &&' not in evidence_warning[-220:]): + raise SystemExit("BROWSER_REPORT_TYPED_TREE_FAIL legacy q/t warning leaks into typed tree") +if 'text: root.memberValue(modelData)' in report_inspector: + raise SystemExit("BROWSER_REPORT_TYPED_TREE_FAIL concatenated-string value in active inspector") +if report_controller.count('projectBrowserReportTree(') != 1: + raise SystemExit("BROWSER_REPORT_TYPED_TREE_FAIL per-frame structure expansion") + value_preview = (workspace_path.parent.parent / "src" / "BrowserBoundedMmsPreview.hpp").read_text(encoding="utf-8") for token in [ 'BoundedMmsPreview', @@ -267,7 +329,9 @@ 'root.rcb.optionalFieldsReadStatus === "verified"', 'root.rcb.triggerOptionNames.indexOf(modelData.name)', 'root.rcb.optionalFieldNames.indexOf(modelData.name)', - 'enabled: false // inspected bit, never an MMS Write', + 'Passive bit observation: no CheckBox/MouseArea', + 'readonly property bool bitSet:', + 'visible: bitRow.bitSet', 'no on/off bit state is inferred.', 'RCB in use: enabled, reservation or Owner evidence is present.', @@ -275,6 +339,10 @@ missing_report = [term for term in report_required if term not in report_inspector] if missing_report: raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL missing=" + ",".join(missing_report)) +if 'delegate: CheckBox {' in report_inspector: + raise SystemExit("BROWSER_REPORT_LEGIBILITY_FAIL disabled native checkbox leaked") +if report_inspector.count('id: bitRow') != 2: + raise SystemExit("BROWSER_REPORT_LEGIBILITY_FAIL typed read-only bit indicators missing") if 'String(frame.reportId) === wanted' in report_inspector: raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL report ID alone is not exact RCB provenance") if 'title: modelData[0]' in report_inspector or 'rawValue: modelData[1]' in report_inspector: