diff --git a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs index b38a88a6..d2e5c8ff 100644 --- a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs +++ b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs @@ -12,7 +12,9 @@ public enum Iec61850FaultRecordFileKind Information, Combined, Archive, - VendorPackage + VendorPackage, + /// Optional relay-settings sidecar, never a standalone COMTRADE record. + RioSidecar } public sealed class Iec61850FaultRecordFile @@ -108,6 +110,7 @@ public static class Iec61850FaultRecordCatalogBuilder [".dat"] = Iec61850FaultRecordFileKind.Data, [".hdr"] = Iec61850FaultRecordFileKind.Header, [".inf"] = Iec61850FaultRecordFileKind.Information, + [".rio"] = Iec61850FaultRecordFileKind.RioSidecar, [".cff"] = Iec61850FaultRecordFileKind.Combined, [".zip"] = Iec61850FaultRecordFileKind.Archive }; @@ -140,6 +143,11 @@ public static Iec61850FaultRecordCatalog Build( .GroupBy( file => BuildRecordId(file.RemoteDirectory, file.BaseName), StringComparer.OrdinalIgnoreCase) + // A lone RIO is ordinary root inventory, not a downloadable + // COMTRADE record. Only exact directory + base-name matches join + // an already recognized primary CFG/DAT/package identity. + .Where(group => group.Any(file => + file.Kind != Iec61850FaultRecordFileKind.RioSidecar)) .Select(group => BuildRecord(group.Key, group)) .OrderByDescending(record => record.LastModifiedUtc ?? DateTimeOffset.MinValue) .ThenBy(record => record.RecordId, StringComparer.OrdinalIgnoreCase) diff --git a/tests/AR.Iec61850.Tests/ComtradeRioSidecarCatalogTests.cs b/tests/AR.Iec61850.Tests/ComtradeRioSidecarCatalogTests.cs new file mode 100644 index 00000000..98772c65 --- /dev/null +++ b/tests/AR.Iec61850.Tests/ComtradeRioSidecarCatalogTests.cs @@ -0,0 +1,66 @@ +using AR.Iec61850.FaultRecords; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests; + +public sealed class ComtradeRioSidecarCatalogTests +{ + private static MmsFileDirectoryEntry File(string path, uint size = 10) => + new() { Path = path, Name = Path.GetFileName(path), SizeBytes = size }; + + [Fact] + public void RioSidecar_JoinsOnlySameDirectoryAndBasename_WithCaseInsensitiveExtension() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build( + [ + File("dir/line_1.cfg", 100), + File("dir/line_1.dat", 200), + File("dir/line_1.RIO", 30), + File("dir/line_2.RIO", 40), // no matching primary + File("other/line_1.RIO", 50) // wrong folder + ]); + var record = Assert.Single(catalog.Records); + Assert.Equal("line_1", record.BaseName); + Assert.True(record.IsComplete); + Assert.Equal(3, record.Files.Count); + Assert.Contains(record.Files, f => + f.Name == "line_1.RIO" && + f.Kind == Iec61850FaultRecordFileKind.RioSidecar && + f.RemotePath == "dir/line_1.RIO"); + Assert.DoesNotContain(record.Files, f => + f.RemotePath == "other/line_1.RIO"); + Assert.Equal(330L, record.KnownSizeBytes); + } + + [Fact] + public void OrphanRio_IsNotAuthorizedAsFaultRecord() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build([File("line_1.RIO")]); + Assert.Empty(catalog.Records); + Assert.True(Iec61850FaultRecordCatalogBuilder.IsSupportedFile("line_1.rio")); + } + + [Fact] + public void RioDoesNotMakeIncompleteCfgOrDatComplete() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build( + [File("line_1.cfg"), File("line_1.rio")]); + var record = Assert.Single(catalog.Records); + Assert.False(record.IsComplete); + Assert.Equal(2, record.Files.Count); + Assert.False(record.CanDownload && record.IsComplete); + } + + [Fact] + public void DuplicateOrDifferentlyCasedRemoteDirectoryListing_DoesNotDuplicateRio() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build( + [ + File("dir/line_1.cfg"), + File("dir/line_1.dat"), + File("dir/line_1.RIO"), + File("dir/line_1.RIO") + ]); + Assert.Equal(3, Assert.Single(catalog.Records).Files.Count); + } +}