diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 10d425ad1..3f237d823 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -163,6 +163,15 @@ jobs: apps/ied_simulator/qml/MmsClientWorkspace.qml apps/ied_simulator/qml/BrowserDataSetPane.qml + - name: R1A canonical Signal Catalog UX and safety contract + run: >- + python3 apps/ied_simulator/test_browser_signal_catalog.py + apps/ied_simulator/qml/IedBrowserWorkspace.qml + apps/ied_simulator/qml/IedBrowserNavigation.qml + apps/ied_simulator/qml/BrowserDataSetPane.qml + apps/ied_simulator/qml/BrowserSignalCatalog.qml + apps/ied_simulator/src/main.cpp + - name: Canonical Browser service reuse contract run: >- python3 apps/ied_simulator/test_browser_service_reuse.py diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 688a18167..f161eda14 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -103,6 +103,7 @@ qt_add_qml_module(arstack_ied_simulator qml/IedBrowserNavigation.qml qml/BrowserStatusConsole.qml qml/BrowserDataSetPane.qml + qml/BrowserSignalCatalog.qml qml/BrowserGoosePane.qml qml/BrowserGlobalDataPane.qml qml/FleetGlobalDataPane.qml diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 54fb89cb7..b52da7922 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -9,6 +9,8 @@ Rectangle { required property var reports required property var context required property var client + required property var dataSetCatalog + required property string selectedDataSetReference property int valueRevision: 0 @@ -20,15 +22,37 @@ Rectangle { return value === undefined || value === null || String(value).length === 0 ? "—" : String(value) } + function memberReference(member) { + // The canonical catalog shows FC as evidence, not as part of its + // DataAttribute key. Preserve the displayed member order and label. + return String(member || "").replace(/\s+\[[^\]]+\]$/, "") + } + + function memberFc(member) { + var matched = String(member || "").match(/\[([^\]]+)\]$/) + return matched ? matched[1] : "" + } + function resolvedNode(reference) { root.valueRevision - return context.treeModel.nodeForReference(reference) + return context.treeModel.nodeForReference(root.memberReference(reference)) } - readonly property var selectedDataSet: reports && reports.dataSets - && reports.selectedDataSetIndex >= 0 - && reports.selectedDataSetIndex < reports.dataSets.length - ? (reports.dataSets[reports.selectedDataSetIndex] || null) : null + readonly property var selectedDataSet: { + for (var i = 0; i < dataSetCatalog.length; ++i) { + if (dataSetCatalog[i].reference === selectedDataSetReference) + return dataSetCatalog[i] + } + return null + } + readonly property var selectedDataSetMembers: selectedDataSet + ? (selectedDataSet.members || []) : [] + readonly property var readableMemberReferences: { + var result = [] + for (var i = 0; i < selectedDataSetMembers.length; ++i) + result.push(root.memberReference(selectedDataSetMembers[i])) + return result + } function hasValue(value) { return value !== undefined && value !== null && String(value).length > 0 @@ -109,13 +133,13 @@ Rectangle { theme: root.theme text: root.client.operationBusy ? "Reading…" : "Read members" enabled: root.client.connected && !root.client.operationBusy - && reports.selectedDataSetMembers.length > 0 - onClicked: root.client.refreshEngineeringReferences(reports.selectedDataSetMembers) + && root.readableMemberReferences.length > 0 + onClicked: root.client.refreshEngineeringReferences(root.readableMemberReferences) ToolTip.visible: hovered ToolTip.text: "Read the ordered DataSet members through the canonical model. Requests remain bounded by the client controller." } Label { - text: String(reports.selectedDataSetMembers.length) + text: String(root.selectedDataSetMembers.length) color: theme.muted font.pixelSize: theme.captionSize } @@ -143,11 +167,12 @@ Rectangle { ListView { id: memberList + objectName: "iedBrowserDataSetMembers" Layout.fillWidth: true Layout.fillHeight: true clip: true reuseItems: true - model: reports.selectedDataSetMembers + model: root.selectedDataSetMembers spacing: 1 ScrollBar.vertical: ScrollBar {} @@ -173,15 +198,16 @@ Rectangle { font.pixelSize: theme.captionSize } Label { + objectName: "iedBrowserDataSetMember_" + memberRow.index Layout.fillWidth: true - text: modelData + text: memberRow.modelData color: theme.text font.pixelSize: theme.labelSize elide: Text.ElideMiddle } Label { Layout.preferredWidth: 48 - text: memberRow.resolved.functionalConstraint || "" + text: memberRow.resolved.functionalConstraint || root.memberFc(modelData) color: theme.muted font.pixelSize: theme.captionSize } diff --git a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml index ae5150a4a..533001afb 100644 --- a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml +++ b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml @@ -144,7 +144,9 @@ Rectangle { candidates = row.membersText.split("\n") } for (var j = 0; j < candidates.length; ++j) { - var reference = candidates[j] + // FC is display evidence in a DataSet member, not part of + // the canonical read key. Keep the watchlist text unchanged. + var reference = String(candidates[j]).replace(/\s+\[[^\]]+\]$/, "") if (reference.length === 0 || seen[reference]) continue seen[reference] = true diff --git a/apps/ied_simulator/qml/BrowserSignalCatalog.qml b/apps/ied_simulator/qml/BrowserSignalCatalog.qml new file mode 100644 index 000000000..7e563f864 --- /dev/null +++ b/apps/ied_simulator/qml/BrowserSignalCatalog.qml @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Read-only entry point for both Open SCL and live MMS discovery. The catalog +// derives solely from the canonical engineering context; it never creates a +// DataSet, connects another association or enables reporting implicitly. +Dialog { + id: root + objectName: "iedSignalCatalog" + required property var theme + required property var context + signal dataSetRequested(string reference) + signal browseRequested() + + title: "Signal Catalog" + modal: true + width: Math.min(650, Overlay.overlay ? Overlay.overlay.width - 32 : 650) + height: Math.min(520, Overlay.overlay ? Overlay.overlay.height - 32 : 520) + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Close + + readonly property var canonicalDataSets: context.loaded ? context.dataSets : [] + + contentItem: ColumnLayout { + spacing: 10 + + Label { + Layout.fillWidth: true + text: root.context.iedName + " · " + root.context.authority + color: root.theme.text + font.pixelSize: root.theme.labelSize + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: "Choose exact DataSet members or browse the complete canonical model." + color: root.theme.textSoft + font.pixelSize: root.theme.captionSize + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Button { + objectName: "iedCatalogDatasetSignals" + text: "Dataset Signals" + enabled: root.canonicalDataSets.length > 0 + onClicked: { + root.dataSetRequested(root.canonicalDataSets[0].reference) + root.close() + } + } + Button { + objectName: "iedCatalogBrowseSignals" + text: "Browse All Signals" + onClicked: { + root.browseRequested() + root.close() + } + } + Item { Layout.fillWidth: true } + Label { + text: root.canonicalDataSets.length + " DataSet(s)" + color: root.theme.muted + font.pixelSize: root.theme.captionSize + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + Label { + text: "DATASET SIGNALS · ORDERED IED MEMBERSHIP" + color: root.theme.muted + font.pixelSize: root.theme.captionSize + font.weight: Font.DemiBold + } + + ListView { + id: dataSetsView + objectName: "iedSignalCatalogDataSets" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + model: root.canonicalDataSets + spacing: 2 + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + required property int index + required property var modelData + width: ListView.view.width + height: 48 + radius: 5 + color: dsMouse.containsMouse ? root.theme.surfaceRaised : root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + Layout.fillWidth: true + text: modelData.reference || "DataSet" + color: root.theme.text + font.pixelSize: root.theme.labelSize + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: modelData.usedByReports && modelData.usedByReports.length + ? "RCB: " + modelData.usedByReports.join(", ") + : "RCB binding not identified in model" + color: root.theme.muted + font.pixelSize: root.theme.captionSize + elide: Text.ElideMiddle + } + } + Label { + text: (modelData.memberCount || 0) + " members" + color: root.theme.textSoft + font.pixelSize: root.theme.captionSize + } + Label { + text: "›" + color: root.theme.accent + font.pixelSize: root.theme.labelSize + } + } + MouseArea { + id: dsMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + root.dataSetRequested(modelData.reference) + root.close() + } + } + } + + Label { + anchors.centerIn: parent + visible: dataSetsView.count === 0 + text: "No DataSets in this canonical model. Browse All Signals instead." + color: root.theme.muted + font.pixelSize: root.theme.labelSize + } + } + Label { + Layout.fillWidth: true + text: "Viewing never changes IED membership or writes an RCB. Report enable and GI remain explicit actions." + color: root.theme.muted + font.pixelSize: root.theme.captionSize + wrapMode: Text.WordWrap + } + } +} diff --git a/apps/ied_simulator/qml/IedBrowserNavigation.qml b/apps/ied_simulator/qml/IedBrowserNavigation.qml index b795cdc69..640ddb88a 100644 --- a/apps/ied_simulator/qml/IedBrowserNavigation.qml +++ b/apps/ied_simulator/qml/IedBrowserNavigation.qml @@ -12,6 +12,10 @@ Rectangle { required property var context required property var reports required property var utilities + required property var dataSetCatalog + required property string selectedDataSetReference + + signal dataSetRequested(string reference) property int section: 0 property int globalDataCount: 0 @@ -301,19 +305,19 @@ Rectangle { Loader { Layout.fillWidth: true - active: root.section === 1 && reports.dataSets.length > 0 + active: root.section === 1 && root.dataSetCatalog.length > 0 visible: active sourceComponent: Component { ColumnLayout { spacing: 0 Repeater { - model: reports.dataSets + model: root.dataSetCatalog delegate: Rectangle { required property int index required property var modelData Layout.fillWidth: true implicitHeight: 30 - color: reports.selectedDataSetIndex === index + color: root.selectedDataSetReference === modelData.reference ? root.theme.surfaceRaised : dsMouse.containsMouse ? root.theme.surface : "transparent" RowLayout { anchors.fill: parent @@ -338,8 +342,7 @@ Rectangle { anchors.fill: parent hoverEnabled: true onClicked: { - reports.selectDataSet(index) - root.choose(1, "DataSets") + root.dataSetRequested(modelData.reference) } } } diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index e7cc21e54..f86ee2cd0 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -22,6 +22,79 @@ Item { property string activeSectionTitle: "Data Model" property var selectedModelNode: context.treeModel.selectedNode + // Read-only canonical inventory is available even without an MMS association. + // A live directory may add verified membership and owned dynamic entries. + // Both Open SCL and Discovery use this same selected-reference path. + property string selectedCatalogReference: "" + property var lastPromptedCatalogGeneration: -1 + readonly property var dataSetCatalog: { + var canonical = context.loaded ? context.dataSets : [] + var live = reports.connected ? reports.dataSets : [] + var result = [] + for (var i = 0; i < canonical.length; ++i) { + var item = canonical[i] + var matching = null + for (var j = 0; j < live.length; ++j) { + if (live[j].reference === item.reference) { + matching = live[j] + break + } + } + result.push(matching && matching.directoryAvailable === true + ? matching : item) + } + for (var k = 0; k < live.length; ++k) { + var exists = false + for (var n = 0; n < result.length; ++n) { + if (result[n].reference === live[k].reference) { + exists = true + break + } + } + if (!exists) + result.push(live[k]) + } + return result + } + readonly property var selectedCatalogDataSet: { + for (var i = 0; i < dataSetCatalog.length; ++i) { + if (dataSetCatalog[i].reference === selectedCatalogReference) + return dataSetCatalog[i] + } + return dataSetCatalog.length ? dataSetCatalog[0] : null + } + readonly property string effectiveCatalogReference: selectedCatalogDataSet + ? selectedCatalogDataSet.reference : "" + + function chooseCatalogDataSet(reference) { + var found = false + for (var i = 0; i < dataSetCatalog.length; ++i) { + if (dataSetCatalog[i].reference === reference) { + found = true + break + } + } + if (!found) + return false + root.selectedCatalogReference = reference + // Selection only: do not connect, create, rebind or enable an RCB. + if (reports.connected) { + for (var j = 0; j < reports.dataSets.length; ++j) { + if (reports.dataSets[j].reference === reference) { + reports.selectDataSet(j) + break + } + } + } + root.selectSection(1, "DataSets") + return true + } + + function openSignalCatalog() { + if (context.loaded && !context.selectionRequired) + signalCatalogDialog.open() + } + signal watchedDataChanged() function watchedRows() { return globalDataPane.snapshotRows() } @@ -46,8 +119,8 @@ Item { return ref.length ? root.activeIedLabel() + " / Data Model / " + ref : root.activeIedLabel() + " / Data Model" } if (activeSection === 1) { - if (reports.selectedDataSetIndex >= 0 && reports.selectedDataSetIndex < reports.dataSets.length) - return root.activeIedLabel() + " / DataSets / " + (reports.dataSets[reports.selectedDataSetIndex].reference || "") + if (root.selectedCatalogDataSet) + return root.activeIedLabel() + " / DataSets / " + root.selectedCatalogDataSet.reference return root.activeIedLabel() + " / DataSets" } if (activeSection === 2) @@ -72,6 +145,26 @@ Item { function onWatchSnapshotChanged() { root.watchedDataChanged() } } + Connections { + target: context + function onContextChanged() { + // A discovery (not a reconnect or an offline SCL open) surfaces + // its catalog once per canonical generation. Other IED tabs are + // not interrupted by a dialog belonging to an inactive slot. + if (!root.visible || !context.loaded + || context.authorityKey !== "live-discovery" + || context.dataSetCount <= 0 + || root.lastPromptedCatalogGeneration === context.contextGeneration) + return + root.lastPromptedCatalogGeneration = context.contextGeneration + Qt.callLater(function() { + if (root.visible && context.loaded + && context.authorityKey === "live-discovery") + root.openSignalCatalog() + }) + } + } + onActiveSectionChanged: ensureActiveService() Shortcut { sequence: "Alt+1"; enabled: root.visible; onActivated: root.selectSection(0, "Data Model") } @@ -355,6 +448,11 @@ Item { utilities: root.utilities globalDataCount: globalDataPane.watchCount section: root.activeSection + dataSetCatalog: root.dataSetCatalog + selectedDataSetReference: root.effectiveCatalogReference + onDataSetRequested: function(reference) { + root.chooseCatalogDataSet(reference) + } onSectionRequested: function(section, title) { root.selectSection(section, title) } @@ -426,6 +524,14 @@ Item { onClicked: client.readEngineeringSelected() } + Button { + objectName: "iedBrowserSignalCatalogAction" + visible: root.activeSection === 0 || root.activeSection === 1 + text: "Signal Catalog…" + enabled: context.loaded && !context.selectionRequired + onClicked: root.openSignalCatalog() + } + Button { visible: root.activeSection === 0 text: "Control…" @@ -460,14 +566,10 @@ Item { Button { visible: root.activeSection === 1 text: "Add to Global Data" - enabled: reports.selectedDataSetIndex >= 0 - && reports.selectedDataSetIndex < reports.dataSets.length - onClicked: { - var selected = reports.dataSets[reports.selectedDataSetIndex] - globalDataPane.addDataSet( - selected.reference || "", - reports.selectedDataSetMembers) - } + enabled: root.selectedCatalogDataSet !== null + onClicked: globalDataPane.addDataSet( + root.selectedCatalogDataSet.reference, + root.selectedCatalogDataSet.members || []) } Button { @@ -580,6 +682,8 @@ Item { reports: root.reports context: root.context client: root.client + dataSetCatalog: root.dataSetCatalog + selectedDataSetReference: root.effectiveCatalogReference onInspectRequested: function(reference) { if (root.context.treeModel.selectReference(reference)) root.selectSection(0, "Data Model") @@ -637,6 +741,16 @@ Item { controls: root.controls } + BrowserSignalCatalog { + id: signalCatalogDialog + theme: root.theme + context: root.context + onDataSetRequested: function(reference) { + root.chooseCatalogDataSet(reference) + } + onBrowseRequested: root.selectSection(0, "Data Model") + } + BrowserDataSetAuthoringDialog { id: dataSetAuthorDialog theme: root.theme diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index ea06a34ba..c424151de 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -469,6 +469,17 @@ int main(int argc, char* argv[]) { signal.basic_type = "INT32"; signal.cdc = "INC"; signal.signal_reference = name + "LD0/LLN0.Mod.stVal"; + // One real canonical DataSet per synthetic IED: the GUI + // must browse its exact ordered membership while offline. + ar::iec61850::scl::SclDataSet dataSet; + dataSet.key = name + "LD0/LLN0.QASet"; + dataSet.ied_name = name; + dataSet.ld_inst = "LD0"; + dataSet.logical_node_path = "LLN0"; + dataSet.name = "QASet"; + dataSet.reference = name + "LD0/LLN0.QASet"; + dataSet.entries.push_back(signal); + document.data_sets.push_back(std::move(dataSet)); document.model_entries.push_back(std::move(signal)); } if (!fleet->contextAt(0)->publishSclDocument( @@ -589,6 +600,59 @@ int main(int argc, char* argv[]) { fail("active_tab_panel_or_offline_signals_mismatch", 64); return; } + // R1A: the same canonical SCL DataSet must render its ordered + // members without a live report association or MMS polling. + const auto canonicalReference = + QStringLiteral("QA_IED_BLD0/LLN0.QASet"); + second->setProperty("selectedCatalogReference", canonicalReference); + second->setProperty("activeSection", 1); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + auto* const catalogMembers = second->findChild( + QStringLiteral("iedBrowserDataSetMembers")); + const auto firstMemberText = [catalogMembers]() -> QString { + if (!catalogMembers) return {}; + QVector pending{catalogMembers}; + while (!pending.isEmpty()) { + auto* const item = pending.takeLast(); + if (!item) continue; + if (item->objectName() == + QStringLiteral("iedBrowserDataSetMember_0")) + return item->property("text").toString(); + for (auto* child : item->childItems()) pending.push_back(child); + } + return {}; + }; + QElapsedTimer catalogSettle; + catalogSettle.start(); + while (catalogMembers && catalogMembers->isVisible() && + firstMemberText().isEmpty() && catalogSettle.elapsed() < 1'000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 25); + QThread::msleep(10); + } + if (selected->dataSetCount() != 1 || !catalogMembers || + !catalogMembers->isVisible() || + catalogMembers->property("count").toInt() != 1 || + second->property("selectedCatalogReference").toString() != canonicalReference || + firstMemberText() != + QStringLiteral("QA_IED_BLD0/LLN0.Mod.stVal [ST]")) { + qCritical().noquote() + << "BROWSER_DATASET_CATALOG_DIAG" + << "count=" << (catalogMembers + ? catalogMembers->property("count").toInt() : -1) + << "visible=" << (catalogMembers ? catalogMembers->isVisible() : false) + << "size=" << (catalogMembers ? catalogMembers->width() : -1) + << "x" << (catalogMembers ? catalogMembers->height() : -1) + << "member=" << firstMemberText() + << "reference=" << second->property("selectedCatalogReference").toString(); + fail("offline_canonical_dataset_members_not_rendered", 71); + return; + } + qInfo().noquote() + << "BROWSER_DATASET_CATALOG_PASS source=opened_scl" + << "ordered_members=1 read_only=true offline=true"; + second->setProperty("activeSection", 0); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (!fleet->switchTo(0)) { fail("switch_to_pending_source", 65); return; diff --git a/apps/ied_simulator/test_browser_observation_workflow.py b/apps/ied_simulator/test_browser_observation_workflow.py index 2fe226684..674e52e59 100644 --- a/apps/ied_simulator/test_browser_observation_workflow.py +++ b/apps/ied_simulator/test_browser_observation_workflow.py @@ -47,11 +47,13 @@ def require(source: str, tokens: tuple[str, ...], label: str) -> None: require( datasets, ( - "context.treeModel.nodeForReference(reference)", + "context.treeModel.nodeForReference(root.memberReference(reference))", "readonly property var selectedDataSet:", - "(reports.dataSets[reports.selectedDataSetIndex] || null)", + "selectedDataSetReference", + "dataSetCatalog[i].reference === selectedDataSetReference", "root.selectedDataSet !== null", - "root.client.refreshEngineeringReferences(reports.selectedDataSetMembers)", + "root.client.refreshEngineeringReferences(root.readableMemberReferences)", + "objectName: \"iedBrowserDataSetMembers\"", "signal inspectRequested(string reference)", 'text: "Ordered members and observed values"', 'text: root.client.operationBusy ? "Reading…" : "Read members"', @@ -73,6 +75,8 @@ def require(source: str, tokens: tuple[str, ...], label: str) -> None: if "reports.dataSets[reports.selectedDataSetIndex].dynamicOwned" in datasets: raise SystemExit("BROWSER_OBSERVATION_FAIL unguarded DataSet ownership selection") +if "model: reports.selectedDataSetMembers" in datasets: + raise SystemExit("BROWSER_OBSERVATION_FAIL offline DataSet path bypassed") if "ListModel {" in inspector or "ListModel {" in datasets: raise SystemExit( diff --git a/apps/ied_simulator/test_browser_signal_catalog.py b/apps/ied_simulator/test_browser_signal_catalog.py new file mode 100644 index 000000000..ac5992ab2 --- /dev/null +++ b/apps/ied_simulator/test_browser_signal_catalog.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""R1A source contract: one canonical DataSet browsing path for SCL and Discovery.""" + +from pathlib import Path +import sys + +if len(sys.argv) != 6: + raise SystemExit( + "usage: test_browser_signal_catalog.py " + "IedBrowserWorkspace.qml IedBrowserNavigation.qml " + "BrowserDataSetPane.qml BrowserSignalCatalog.qml main.cpp" + ) + +workspace, navigation, pane, catalog, main = ( + Path(path).read_text(encoding="utf-8") for path in sys.argv[1:] +) + +def require(source: str, tokens: tuple[str, ...], label: str) -> None: + missing = [token for token in tokens if token not in source] + if missing: + raise SystemExit("R1A_SIGNAL_CATALOG_FAIL " + label + " missing=" + ",".join(missing)) + +require(workspace, ( + "readonly property var dataSetCatalog:", + "context.loaded ? context.dataSets : []", + "matching.directoryAvailable === true", + "function chooseCatalogDataSet(reference)", + "root.selectedCatalogReference = reference", + "if (reports.connected)", + "reports.selectDataSet(j)", + 'root.selectSection(1, "DataSets")', + 'context.authorityKey !== "live-discovery"', + "root.lastPromptedCatalogGeneration = context.contextGeneration", + "BrowserSignalCatalog {", + "onDataSetRequested: function(reference)", + "root.chooseCatalogDataSet(reference)", + "onBrowseRequested: root.selectSection(0, \"Data Model\")", + "dataSetCatalog: root.dataSetCatalog", + "selectedDataSetReference: root.effectiveCatalogReference", +), "workspace") + +require(navigation, ( + "required property var dataSetCatalog", + "signal dataSetRequested(string reference)", + "model: root.dataSetCatalog", + "root.dataSetRequested(modelData.reference)", +), "navigation") + +require(pane, ( + "required property var dataSetCatalog", + "required property string selectedDataSetReference", + "dataSetCatalog[i].reference === selectedDataSetReference", + "model: root.selectedDataSetMembers", + 'objectName: "iedBrowserDataSetMembers"', + "root.memberReference(reference)", + "root.client.refreshEngineeringReferences(root.readableMemberReferences)", + "memberRow.resolved.functionalConstraint || root.memberFc(modelData)", +), "DataSet pane") + +require(catalog, ( + 'objectName: "iedSignalCatalog"', + "readonly property var canonicalDataSets: context.loaded ? context.dataSets : []", + 'text: "Dataset Signals"', + 'text: "Browse All Signals"', + "signal dataSetRequested(string reference)", + "signal browseRequested()", + "root.dataSetRequested(modelData.reference)", + 'objectName: "iedSignalCatalogDataSets"', +), "catalog") + +require(main, ( + 'dataSet.name = "QASet"', + "document.data_sets.push_back(std::move(dataSet))", +), "rendered fixture") + +for label, source in (("catalog", catalog), ("pane", pane)): + for forbidden in ("Timer {", "reports.enableSelected(", "reports.createDynamicDataSet(", + "reports.enableSelectedAuthored(", "client.connectToIed("): + if forbidden in source: + raise SystemExit("R1A_SIGNAL_CATALOG_FAIL " + label + " forbidden=" + forbidden) + +if "model: reports.dataSets" in navigation or "model: reports.selectedDataSetMembers" in pane: + raise SystemExit("R1A_SIGNAL_CATALOG_FAIL offline catalog bypassed") + +print("R1A_SIGNAL_CATALOG_PASS canonical_scl=true discovery=true ordered_members=true " + "explicit_read=true no_implicit_enable=true no_polling=true")