diff --git a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs index 47c91af..ff4e994 100644 --- a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs @@ -75,10 +75,12 @@ public static LegacySasSclExportResult Build( sourceName); var document = new XDocument(filtered.Document); + ApplyExactRuntimeReportControlIdentity(document, options.SelectedReportControl); var root = document.Root ?? throw new InvalidDataException("Filtered SCL document has no root element."); var schema = SclSchemaProfiles.Get(options.SchemaProfile); ApplySchemaProfile(root, schema); Validate(document, normalized.SelectedIedName); + ValidateExactRuntimeReportControlIdentity(document, options.SelectedReportControl); var retained = AssertSingleRetained(filtered); var findings = normalized.Findings @@ -100,7 +102,7 @@ public static LegacySasSclExportResult Build( IedName = normalized.SelectedIedName, AccessPointName = retained.AccessPointName, SclSchema = schema.DisplayName, - RetainedReportControlReference = retained.DisplayReference, + RetainedReportControlReference = ExactRetainedReference(retained, options.SelectedReportControl), RetainedDataSetName = retained.DataSetName, RetainedDataSetMemberCount = retained.DataSetMemberCount, RemovedReportControlCount = filtered.RemovedReportControlCount, @@ -149,6 +151,63 @@ public static LegacySasSclExportResult WriteFiles( return written; } + private static void ApplyExactRuntimeReportControlIdentity( + XDocument document, + SclReportControlSelection selection) + { + var exactRuntimeName = (selection.ExportName ?? string.Empty).Trim(); + if (exactRuntimeName.Length == 0) + return; + + var reportControls = document.Descendants(Scl + "ReportControl").ToArray(); + if (reportControls.Length != 1) + throw new InvalidDataException($"Exact runtime RCB normalization requires one retained ReportControl; found {reportControls.Length}."); + + var retained = reportControls[0]; + retained.SetAttributeValue("name", exactRuntimeName); + retained.SetAttributeValue("indexed", "false"); + + // ExportName already identifies the concrete MMS RCB instance. Keeping + // RptEnabled max=1 makes some legacy clients instantiate that exact name + // again and append another "01" (for example A_BRCB_1201 -> + // A_BRCB_120101). A non-indexed exact instance must therefore not carry + // the indexed-instantiation element in this legacy interoperability CID. + foreach (var rptEnabled in retained.Elements(Scl + "RptEnabled").ToArray()) + rptEnabled.Remove(); + } + + private static void ValidateExactRuntimeReportControlIdentity( + XDocument document, + SclReportControlSelection selection) + { + var exactRuntimeName = (selection.ExportName ?? string.Empty).Trim(); + if (exactRuntimeName.Length == 0) + return; + + var retained = document.Descendants(Scl + "ReportControl").Single(); + var actualName = (string?)retained.Attribute("name") ?? string.Empty; + if (!actualName.Equals(exactRuntimeName, StringComparison.Ordinal)) + throw new InvalidDataException($"Filtered SCL changed exact runtime RCB name '{exactRuntimeName}' to '{actualName}'."); + if (!string.Equals((string?)retained.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Exact runtime RCB '{exactRuntimeName}' must be exported as non-indexed."); + if (retained.Elements(Scl + "RptEnabled").Any()) + throw new InvalidDataException($"Exact runtime RCB '{exactRuntimeName}' must not contain RptEnabled because that can append a second instance suffix."); + } + + private static string ExactRetainedReference( + SclReportControlDescriptor retained, + SclReportControlSelection selection) + { + var exactRuntimeName = (selection.ExportName ?? string.Empty).Trim(); + if (exactRuntimeName.Length == 0) + return retained.DisplayReference; + + var separator = retained.DisplayReference.LastIndexOf('.'); + return separator < 0 + ? exactRuntimeName + : retained.DisplayReference[..(separator + 1)] + exactRuntimeName; + } + private static SclReportControlDescriptor AssertSingleRetained(SclReportControlFilterResult filtered) { if (filtered.RetainedReportControls.Count != 1) @@ -223,4 +282,4 @@ private static string BuildMarkdown(LegacySasSclExportResult result) } return builder.ToString(); } -} +} \ No newline at end of file diff --git a/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs index 49001e2..df7751c 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclReportControlFilterTests.cs @@ -128,6 +128,34 @@ public void LegacyExporter_Applies_Edition1_Profile_And_Filter_With_Evidence() Assert.DoesNotContain(result.Findings, finding => finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void LegacyExporter_Preserves_Exact_Runtime_Rcb_Name_Without_Double_Indexing() + { + var source = XDocument.Parse(Fixture()); + var descriptor = SclReportControlFilter.Inspect(source, "IED1.cid", "IED1", "AP1") + .ReportControls.Single(item => item.Name == "BRCB_EVENTS"); + + var result = LegacySasSclExporter.Build( + source, + "IED1.cid", + new LegacySasSclExportOptions + { + IedName = "IED1", + AccessPointName = "AP1", + SchemaProfile = SclSchemaProfile.Edition1V16, + SelectedReportControl = new SclReportControlSelection( + descriptor.SelectionKey, + "A_BRCB_1201") + }); + + var retained = Assert.Single(result.Document.Descendants(Scl + "ReportControl")); + Assert.Equal("A_BRCB_1201", (string?)retained.Attribute("name")); + Assert.Equal("false", (string?)retained.Attribute("indexed")); + Assert.Empty(retained.Elements(Scl + "RptEnabled")); + Assert.EndsWith(".A_BRCB_1201", result.RetainedReportControlReference, StringComparison.Ordinal); + Assert.DoesNotContain("A_BRCB_120101", result.Document.ToString(SaveOptions.DisableFormatting), StringComparison.Ordinal); + } + [Fact] public void FilterLiveModel_Retains_One_Rcb_And_Validates_DataSet() { @@ -210,4 +238,4 @@ private static string Fixture() """; -} +} \ No newline at end of file