diff --git a/.github/workflows/validate-io-testing.yml b/.github/workflows/validate-io-testing.yml index 94e3b147f..e923db24a 100644 --- a/.github/workflows/validate-io-testing.yml +++ b/.github/workflows/validate-io-testing.yml @@ -10,6 +10,9 @@ on: - "Services/IoTesting/**" - "tests/ARSAS.Tests/IoTest*" - "tests/ARSAS.Tests/IoList*" + - "tests/ARSAS.Tests/IoFat*" + - "tests/ARSAS.Tests/FatDataSet*" + - "tests/ARSAS.Tests/FatScl*" - "engines/ARIEC61850.lock.json" - ".github/workflows/validate-io-testing.yml" workflow_dispatch: @@ -85,7 +88,7 @@ jobs: if-no-files-found: error - name: Run IO testing regression tests - run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~IoTest|FullyQualifiedName~IoList" --logger "trx;LogFileName=io-testing-tests.trx" --results-directory .\ARSAS\TestResults + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~IoTest|FullyQualifiedName~IoList|FullyQualifiedName~IoFat|FullyQualifiedName~FatDataSet|FullyQualifiedName~FatScl" --logger "trx;LogFileName=io-testing-tests.trx" --results-directory .\ARSAS\TestResults - name: Upload IO testing test results if: always() diff --git a/Services/IoTesting/FatDataSetSignalProjectionService.cs b/Services/IoTesting/FatDataSetSignalProjectionService.cs index f3e41c8ab..1b78c2072 100644 --- a/Services/IoTesting/FatDataSetSignalProjectionService.cs +++ b/Services/IoTesting/FatDataSetSignalProjectionService.cs @@ -106,12 +106,16 @@ private static FatVerificationSignal CreateRow( internal static FatSignalKind Classify(string? functionalConstraint, string? dataType) { - var fc = (functionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); + // Functional constraint describes service/semantic grouping, not the value domain. + // In particular MX may legally carry enumerated or structured values. Treating every + // MX member as numeric would silently opt such members into analog semantics. Capture + // policy is therefore conservative and driven by the ARIEC-resolved basic/MMS type. + _ = functionalConstraint; var type = (dataType ?? string.Empty).Trim().ToUpperInvariant(); - if (fc == "MX" || - type.Contains("FLOAT", StringComparison.Ordinal) || + if (type.Contains("FLOAT", StringComparison.Ordinal) || type.Contains("DOUBLE", StringComparison.Ordinal) || + type.Contains("REAL", StringComparison.Ordinal) || type.Contains("ANALOG", StringComparison.Ordinal)) { return FatSignalKind.Analog; @@ -121,13 +125,15 @@ internal static FatSignalKind Classify(string? functionalConstraint, string? dat type.Contains("BOOL", StringComparison.Ordinal) || type.Contains("DBPOS", StringComparison.Ordinal) || type.Contains("DOUBLE-POINT", StringComparison.Ordinal) || - type.Contains("DOUBLE POINT", StringComparison.Ordinal)) + type.Contains("DOUBLE POINT", StringComparison.Ordinal) || + type.Contains("SINGLE-POINT", StringComparison.Ordinal) || + type.Contains("SINGLE POINT", StringComparison.Ordinal)) { return FatSignalKind.Discrete; } - // ST is intentionally not assumed to be boolean by itself. Enumerated/integer ST - // values need an explicit discrete semantic before automatic two-state capture. + // Integer/enumerated/bit-string/structured values remain required FAT rows, but use + // operator snapshot capture until a stronger two-state/numeric semantic is proven. return FatSignalKind.Other; } diff --git a/Services/IoTesting/IoFatSclProjectImportService.cs b/Services/IoTesting/IoFatSclProjectImportService.cs new file mode 100644 index 000000000..798a96440 --- /dev/null +++ b/Services/IoTesting/IoFatSclProjectImportService.cs @@ -0,0 +1,345 @@ +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record IoFatSclImportFinding( + string SourceFileName, + string Severity, + string Code, + string Message, + string ObjectReference = ""); + +public sealed record IoFatSclProjectImportResult( + IoTestProject Project, + FatVerificationProject VerificationProject, + IReadOnlyList Sources, + IReadOnlyList SourceInputs, + IReadOnlyList Findings); + +/// +/// Production SCL -> FAT orchestration. +/// +/// ARIEC61850 owns XML/SCL parsing, workspace construction, static DataSet semantics and +/// mandatory signal projection. This service only composes those engine-owned results into +/// the persisted ARSAS FAT project model introduced by P3. +/// +public sealed class IoFatSclProjectImportService +{ + private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".scd", ".cid", ".icd", ".iid", ".ssd", ".xml" + }; + + private readonly SclWorkspaceService _workspaceService; + + public IoFatSclProjectImportService() + : this(new SclWorkspaceService()) + { + } + + internal IoFatSclProjectImportService(SclWorkspaceService workspaceService) + { + _workspaceService = workspaceService ?? throw new ArgumentNullException(nameof(workspaceService)); + } + + public async Task ImportAsync( + IReadOnlyCollection sclPaths, + string? projectName = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sclPaths); + var requestedPaths = sclPaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (requestedPaths.Length == 0) + throw new InvalidDataException("Select at least one SCL file for FAT import."); + + var findings = new List(); + var loadedSources = new List(); + var seenHashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var path in requestedPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidatePath(path); + + var input = new IoFatSourceInput(path, IoFatSourceKinds.Scl); + var descriptor = await IoFatSourceIdentity.DescribeAsync(input, cancellationToken).ConfigureAwait(false); + if (seenHashes.TryGetValue(descriptor.Sha256, out var duplicate)) + { + findings.Add(new IoFatSclImportFinding( + descriptor.FileName, + "Info", + "SCL_DUPLICATE_CONTENT", + $"Identical SCL bytes are already represented by '{duplicate.Descriptor.FileName}'; the duplicate source was not added twice.")); + continue; + } + + var document = await _workspaceService.OpenAsync( + path, + cancellationToken: cancellationToken).ConfigureAwait(false); + if (!descriptor.Sha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"ARIEC source identity for '{descriptor.FileName}' does not match the FAT source SHA-256."); + } + + var loaded = new LoadedSource(descriptor, input, document); + loadedSources.Add(loaded); + seenHashes.Add(descriptor.Sha256, loaded); + + foreach (var finding in document.Findings) + { + findings.Add(new IoFatSclImportFinding( + descriptor.FileName, + finding.Severity, + finding.Code, + finding.Message, + finding.ObjectReference)); + } + + if (document.Ieds.Count == 0) + { + findings.Add(new IoFatSclImportFinding( + descriptor.FileName, + "Warning", + "SCL_NO_IED_WORKSPACE", + "ARIEC opened the SCL source but returned no IED/AccessPoint workspace.")); + } + } + + if (loadedSources.Count == 0) + throw new InvalidDataException("No unique SCL source remained after source validation."); + + var workspaceSources = loadedSources + .SelectMany(source => source.Document.Ieds.Select(workspace => new FatSclWorkspaceSource( + source.Descriptor.FileName, + source.Descriptor.Sha256, + workspace))) + .ToArray(); + + FatVerificationProject verificationProject; + if (workspaceSources.Length == 0) + { + verificationProject = new FatVerificationProject(); + } + else + { + // This existing adapter enforces one row per ARIEC static DataSet membership, + // rejects competing engineering authorities for the same IED/AP, and never + // deduplicates two memberships merely because they resolve to one runtime leaf. + verificationProject = FatSclWorkspaceImportService.Import(workspaceSources).Project; + } + + var sourceByWorkspace = BuildSourceAuthorityMap(loadedSources); + foreach (var loaded in loadedSources) + { + foreach (var workspace in loaded.Document.Ieds) + { + var staticMemberCount = workspace.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count); + if (staticMemberCount == 0) + { + findings.Add(new IoFatSclImportFinding( + loaded.Descriptor.FileName, + "Warning", + "SCL_NO_STATIC_DATASET_MEMBERS", + $"{workspace.WorkspaceKey} contains no static DataSet members; FAT did not fabricate any rows.")); + } + + if (workspace.RequiresEndpointBinding) + { + findings.Add(new IoFatSclImportFinding( + loaded.Descriptor.FileName, + "Info", + "SCL_ENDPOINT_REQUIRED", + $"{workspace.WorkspaceKey} has no usable MMS endpoint. Static FAT scope remains available offline; endpoint binding is a separate live-acquisition step.")); + } + } + } + + var sourceDescriptors = loadedSources + .Select(source => source.Descriptor) + .OrderBy(source => source.SourceId, StringComparer.Ordinal) + .ToArray(); + var sourceFingerprint = IoFatSourceIdentity.ComputeSetFingerprint(sourceDescriptors); + var project = new IoTestProject + { + ProjectId = "FAT-SCL-" + sourceFingerprint[..16], + SchemaVersion = "ARSAS-FAT-SCL-1.0", + ProjectName = BuildProjectName(projectName, sourceDescriptors), + DocumentControl = new IoFatDocumentControl + { + DocumentTitle = "IEC 61850 FAT", + SourceDocumentName = string.Join("; ", sourceDescriptors.Select(source => source.FileName)) + }, + Ieds = BuildIedPlans(loadedSources, verificationProject, sourceByWorkspace) + }; + project.SetSources(sourceDescriptors, sourceFingerprint); + project.InitializeRuntimeNotifications(); + + if (project.SignalCount != verificationProject.Signals.Count) + { + throw new InvalidDataException( + $"SCL FAT project contains {project.SignalCount} persisted point(s), but authoritative projection produced {verificationProject.Signals.Count}. Import refuses partial persistence."); + } + if (project.SignalCount != loadedSources.Sum(source => + source.Document.Ieds.Sum(workspace => workspace.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count)))) + { + throw new InvalidDataException( + "SCL FAT project row count does not equal the complete ARIEC static DataSet membership count."); + } + + return new IoFatSclProjectImportResult( + project, + verificationProject, + sourceDescriptors, + loadedSources.Select(source => source.Input).ToArray(), + findings); + } + + private static List BuildIedPlans( + IReadOnlyCollection loadedSources, + FatVerificationProject verificationProject, + IReadOnlyDictionary sourceByWorkspace) + { + var plans = new List(); + foreach (var source in loadedSources.OrderBy(item => item.Descriptor.SourceId, StringComparer.Ordinal)) + { + foreach (var workspace in source.Document.Ieds + .OrderBy(item => item.IedName, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.AccessPointName, StringComparer.OrdinalIgnoreCase)) + { + var authorityKey = WorkspaceIdentity(workspace.IedName, workspace.AccessPointName); + if (!sourceByWorkspace.TryGetValue(authorityKey, out var authority) || + !authority.Descriptor.SourceId.Equals(source.Descriptor.SourceId, StringComparison.OrdinalIgnoreCase)) + { + // Exact-content duplicate workspaces are collapsed by the authoritative + // aggregation layer; only the chosen source authority receives rows. + continue; + } + + var rows = verificationProject.Signals + .Where(signal => WorkspaceIdentity(signal.IedName, signal.AccessPointName) + .Equals(authorityKey, StringComparison.OrdinalIgnoreCase)) + .OrderBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.DataSetMemberIndex) + .ToArray(); + var endpoint = workspace.PreferredEndpoint?.HasUsableAddress == true + ? workspace.PreferredEndpoint + : null; + + plans.Add(new IoTestIedPlan + { + IedName = workspace.IedName, + IpAddress = endpoint?.IpAddress ?? string.Empty, + IedRole = FirstNonEmpty(workspace.IedType, workspace.Manufacturer), + TestPoints = rows.Select(signal => ToPointPlan(signal, source, workspace)).ToList() + }); + } + } + return plans; + } + + private static IoTestPointPlan ToPointPlan( + FatVerificationSignal signal, + LoadedSource source, + SclIedWorkspace workspace) + { + var discrete = signal.SignalKind == FatSignalKind.Discrete; + return new IoTestPointPlan + { + TestPointId = $"scl-{source.Descriptor.SourceId}-{signal.SignalId}", + IedName = signal.IedName, + IpAddress = workspace.PreferredEndpoint?.HasUsableAddress == true + ? workspace.PreferredEndpoint.IpAddress + : string.Empty, + SignalName = signal.SignalName, + ObjectReference = FirstNonEmpty(signal.RuntimeReference, signal.StaticMemberReference), + FunctionalConstraint = signal.FunctionalConstraint, + ExpectedOnText = discrete ? "TRUE" : "Value 1", + ExpectedOffText = discrete ? "FALSE" : "Value 2", + ExpectedOnRaw = 1, + ExpectedOffRaw = 0, + DataType = signal.DataType, + SignalAddress = source.Descriptor.SourceId, + DataSetName = signal.DataSetReference, + SourceIecReference = signal.StaticMemberReference, + ReportDisplayReference = signal.StaticMemberReference, + EventLogSearchReference = signal.StaticMemberReference, + EvidenceExpected = signal.CaptureMode == FatCaptureMode.AutomaticTransition + ? "Automatic Value 1 / Value 2 transition capture" + : "Operator Value 1 / Value 2 snapshot capture", + SourceSheet = source.Descriptor.FileName, + SourceRow = signal.DataSetMemberIndex + 1, + TestEnabled = true, + ImportReady = true, + BindingStatus = "SCL_DATASET_AUTHORITY", + BindingEvidence = string.Join(" • ", new[] + { + "ARIEC static DataSet authority", + $"sourceId={source.Descriptor.SourceId}", + $"sourceSha256={source.Descriptor.Sha256}", + $"workspace={workspace.WorkspaceKey}", + $"dataset={signal.DataSetReference}", + $"memberIndex={signal.DataSetMemberIndex}", + $"static={signal.StaticMemberReference}", + $"kind={signal.SignalKind}", + $"capture={signal.CaptureMode}" + }) + }; + } + + private static Dictionary BuildSourceAuthorityMap(IEnumerable sources) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var group in sources + .SelectMany(source => source.Document.Ieds.Select(workspace => (source, workspace))) + .GroupBy(item => WorkspaceIdentity(item.workspace.IedName, item.workspace.AccessPointName), StringComparer.OrdinalIgnoreCase)) + { + var hashes = group.Select(item => item.source.Descriptor.Sha256) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (hashes.Length > 1) + { + throw new InvalidDataException( + $"Conflicting SCL sources define the same IED/AccessPoint '{group.Key}'. FAT import will not silently merge competing engineering authorities."); + } + map[group.Key] = group + .OrderBy(item => item.source.Descriptor.FileName, StringComparer.OrdinalIgnoreCase) + .First().source; + } + return map; + } + + private static string BuildProjectName(string? requested, IReadOnlyList sources) + { + if (!string.IsNullOrWhiteSpace(requested)) + return requested.Trim(); + if (sources.Count == 1) + return Path.GetFileNameWithoutExtension(sources[0].FileName) + " FAT"; + return $"IEC 61850 SCL FAT ({sources.Count} sources)"; + } + + private static string WorkspaceIdentity(string? iedName, string? accessPointName) + => $"{(iedName ?? string.Empty).Trim()}|{(accessPointName ?? string.Empty).Trim()}"; + + private static void ValidatePath(string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException("The selected SCL source was not found.", path); + var extension = Path.GetExtension(path); + if (!SupportedExtensions.Contains(extension)) + throw new InvalidDataException($"'{Path.GetFileName(path)}' is not a supported IEC 61850 SCL source."); + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + private sealed record LoadedSource( + IoFatSourceDescriptor Descriptor, + IoFatSourceInput Input, + SclWorkspaceDocument Document); +} diff --git a/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs b/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs new file mode 100644 index 000000000..d29aeebeb --- /dev/null +++ b/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs @@ -0,0 +1,331 @@ +using System.Xml.Linq; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatSclProjectImportTests +{ + [Fact] + public async Task ProductionLoader_Siemens58_NoCommunication_ProjectsEveryStaticMember() + { + var root = TempDirectory(); + var path = Path.Combine(root, "Siprotec_58_member.cid"); + BuildSiemens58Fixture().Save(path); + + var imported = await new IoFatSclProjectImportService().ImportAsync(new[] { path }, "Siemens FAT"); + + Assert.Single(imported.Sources); + Assert.Equal(IoFatSourceKinds.Scl, imported.Sources[0].Kind); + Assert.Equal(58, imported.VerificationProject.Signals.Count); + Assert.Equal(58, imported.Project.SignalCount); + Assert.Equal(58, imported.Project.ReadySignalCount); + var ied = Assert.Single(imported.Project.Ieds); + Assert.Equal("AA1C1F13R4", ied.IedName); + Assert.Equal(string.Empty, ied.IpAddress); + Assert.All(ied.TestPoints, point => + { + Assert.True(point.TestEnabled); + Assert.True(point.ImportReady); + Assert.Equal("SCL_DATASET_AUTHORITY", point.BindingStatus); + Assert.Equal(imported.Sources[0].SourceId, point.SignalAddress); + Assert.Equal("Siprotec_58_member.cid", point.SourceSheet); + }); + + Assert.Equal(36, imported.VerificationProject.Signals.Count(signal => signal.SignalKind == FatSignalKind.Discrete)); + Assert.Equal(22, imported.VerificationProject.Signals.Count(signal => signal.SignalKind == FatSignalKind.Analog)); + Assert.Contains(imported.Findings, finding => finding.Code == "SCL_ENDPOINT_REQUIRED"); + Assert.Contains(ied.TestPoints, point => point.SourceIecReference == "AA1C1F13R4ADD/GGIO1.Dig01"); + Assert.Contains(ied.TestPoints, point => point.SourceIecReference == "AA1C1F13R4MEAS/MMXU1.Ana22"); + } + + [Fact] + public async Task ProductionLoader_DuplicateMembershipAndMxInteger_RemainRequiredDistinctOtherRows() + { + var root = TempDirectory(); + var path = Path.Combine(root, "other-duplicate.cid"); + BuildOtherDuplicateFixture().Save(path); + + var imported = await new IoFatSclProjectImportService().ImportAsync(new[] { path }); + + Assert.Equal(2, imported.Project.SignalCount); + Assert.Equal(2, imported.VerificationProject.Signals.Count); + Assert.Equal(2, imported.VerificationProject.Signals.Select(signal => signal.DataSetReference).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal(2, imported.Project.Ieds.SelectMany(ied => ied.TestPoints).Select(point => point.TestPointId).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(imported.VerificationProject.Signals, signal => + { + Assert.Equal("MX", signal.FunctionalConstraint); + Assert.Equal(FatSignalKind.Other, signal.SignalKind); + Assert.Equal(FatCaptureMode.OperatorSnapshot, signal.CaptureMode); + Assert.True(signal.IsIncludedInFat); + }); + Assert.All(imported.Project.Ieds.SelectMany(ied => ied.TestPoints), point => + { + Assert.True(point.TestEnabled); + Assert.True(point.ImportReady); + Assert.Contains("kind=Other", point.BindingEvidence, StringComparison.Ordinal); + }); + } + + [Fact] + public async Task MultiScl_ImportOrderDoesNotChangeIdentity_AndP3PersistenceKeepsRowProvenance() + { + var root = TempDirectory(); + var first = Path.Combine(root, "relay-a.cid"); + var second = Path.Combine(root, "relay-b.scd"); + BuildSinglePointFixture("IED-A", "S1", "ADD", "GGIO", "1", "Dig01", "ST", "BOOLEAN").Save(first); + BuildSinglePointFixture("IED-B", "S1", "MEAS", "MMXU", "1", "Ana01", "MX", "FLOAT32").Save(second); + var importer = new IoFatSclProjectImportService(); + + var forward = await importer.ImportAsync(new[] { first, second }, "Multi SCL FAT"); + var reverse = await importer.ImportAsync(new[] { second, first }, "Multi SCL FAT"); + + Assert.Equal(forward.Project.ProjectId, reverse.Project.ProjectId); + Assert.Equal(forward.Project.SourceSetSha256, reverse.Project.SourceSetSha256); + Assert.Equal(2, forward.Project.Sources.Count); + Assert.Equal(2, forward.Project.SignalCount); + Assert.Equal(2, forward.Project.Ieds.Count); + + var projectsRoot = Path.Combine(root, "projects"); + var evidenceRoot = Path.Combine(root, "evidence"); + var opened = await IoTestWorkspaceBootstrapService.OpenSourcesAsync( + forward.Project, + forward.SourceInputs, + projectsRoot, + evidenceRoot, + Session); + string excludedPointId; + using (opened.Session) + using (opened.Workspace) + { + var points = opened.Project.Ieds.SelectMany(ied => ied.TestPoints).ToArray(); + excludedPointId = points[0].TestPointId; + points[0].TestEnabled = false; + opened.Workspace.SaveNow(); + Assert.All(points, point => + { + Assert.False(string.IsNullOrWhiteSpace(point.SignalAddress)); + Assert.False(string.IsNullOrWhiteSpace(point.SourceSheet)); + Assert.Contains("sourceSha256=", point.BindingEvidence, StringComparison.Ordinal); + }); + } + + var restoredImport = await importer.ImportAsync(new[] { second, first }, "Multi SCL FAT"); + var restored = await IoTestWorkspaceBootstrapService.OpenSourcesAsync( + restoredImport.Project, + restoredImport.SourceInputs, + projectsRoot, + evidenceRoot, + Session); + var package = Path.Combine(root, "p4-multi-scl.arsas"); + using (restored.Session) + using (restored.Workspace) + { + Assert.True(restored.RestoredProgress); + var point = restored.Project.Ieds.SelectMany(ied => ied.TestPoints) + .Single(candidate => candidate.TestPointId == excludedPointId); + Assert.False(point.TestEnabled); + Assert.Contains(restored.Project.Sources, source => source.SourceId == point.SignalAddress); + await IoFatProjectPackageService.ExportAsync(restored.Workspace, restored.Session, package); + } + + var packaged = await IoTestWorkspaceBootstrapService.OpenPackageAsync( + package, + Path.Combine(root, "projects-package"), + Path.Combine(root, "evidence-package"), + Session); + using (packaged.Session) + using (packaged.Workspace) + { + Assert.Equal(2, packaged.Project.Sources.Count); + Assert.Equal(2, packaged.Project.SignalCount); + var point = packaged.Project.Ieds.SelectMany(ied => ied.TestPoints) + .Single(candidate => candidate.TestPointId == excludedPointId); + Assert.False(point.TestEnabled); + Assert.False(string.IsNullOrWhiteSpace(point.SourceSheet)); + Assert.False(string.IsNullOrWhiteSpace(point.SignalAddress)); + Assert.Contains("dataset=", point.BindingEvidence, StringComparison.Ordinal); + } + } + + [Fact] + public async Task ProductionLoader_NoStaticDataSet_ReturnsZeroRowsAndExplicitFinding() + { + var root = TempDirectory(); + var path = Path.Combine(root, "no-dataset.icd"); + BuildNoDataSetFixture().Save(path); + + var imported = await new IoFatSclProjectImportService().ImportAsync(new[] { path }); + + Assert.Equal(0, imported.Project.SignalCount); + Assert.Empty(imported.VerificationProject.Signals); + Assert.Single(imported.Project.Ieds); + Assert.Contains(imported.Findings, finding => finding.Code == "SCL_NO_STATIC_DATASET_MEMBERS"); + } + + private static XDocument BuildSiemens58Fixture() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + var digitalDataSet = new XElement(ns + "DataSet", new XAttribute("name", "Digital")); + foreach (var index in Enumerable.Range(1, 36)) + digitalDataSet.Add(Fcda(ns, "ADD", "GGIO", "1", $"Dig{index:00}", "ST")); + + var analogDataSet = new XElement(ns + "DataSet", new XAttribute("name", "Analog")); + foreach (var index in Enumerable.Range(1, 22)) + analogDataSet.Add(Fcda(ns, "MEAS", "MMXU", "1", $"Ana{index:00}", "MX")); + + var ggioType = new XElement(ns + "LNodeType", + new XAttribute("id", "GGIOType"), new XAttribute("lnClass", "GGIO")); + foreach (var index in Enumerable.Range(1, 36)) + ggioType.Add(new XElement(ns + "DO", new XAttribute("name", $"Dig{index:00}"), new XAttribute("type", "SpsType"))); + + var mmxuType = new XElement(ns + "LNodeType", + new XAttribute("id", "MMXUType"), new XAttribute("lnClass", "MMXU")); + foreach (var index in Enumerable.Range(1, 22)) + mmxuType.Add(new XElement(ns + "DO", new XAttribute("name", $"Ana{index:00}"), new XAttribute("type", "MvType"))); + + return new XDocument(new XElement(ns + "SCL", + new XAttribute("version", "2007"), new XAttribute("revision", "B"), + new XElement(ns + "Header", new XAttribute("id", "SIEMENS_58_P4")), + new XElement(ns + "IED", new XAttribute("name", "AA1C1F13R4"), + new XElement(ns + "AccessPoint", new XAttribute("name", "E"), + new XElement(ns + "Server", + new XElement(ns + "LDevice", new XAttribute("inst", "Application"), + new XElement(ns + "LN0", new XAttribute("lnClass", "LLN0"), new XAttribute("lnType", "LLN0Type"), analogDataSet, digitalDataSet)), + new XElement(ns + "LDevice", new XAttribute("inst", "ADD"), + new XElement(ns + "LN0", new XAttribute("lnClass", "LLN0"), new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", new XAttribute("lnClass", "GGIO"), new XAttribute("inst", "1"), new XAttribute("lnType", "GGIOType"))), + new XElement(ns + "LDevice", new XAttribute("inst", "MEAS"), + new XElement(ns + "LN0", new XAttribute("lnClass", "LLN0"), new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", new XAttribute("lnClass", "MMXU"), new XAttribute("inst", "1"), new XAttribute("lnType", "MMXUType")))))), + new XElement(ns + "DataTypeTemplates", + new XElement(ns + "LNodeType", new XAttribute("id", "LLN0Type"), new XAttribute("lnClass", "LLN0")), + ggioType, + mmxuType, + new XElement(ns + "DOType", new XAttribute("id", "SpsType"), new XAttribute("cdc", "SPS"), + new XElement(ns + "DA", new XAttribute("name", "stVal"), new XAttribute("bType", "BOOLEAN"), new XAttribute("fc", "ST"))), + new XElement(ns + "DOType", new XAttribute("id", "MvType"), new XAttribute("cdc", "MV"), + new XElement(ns + "DA", new XAttribute("name", "mag"), new XAttribute("bType", "Struct"), new XAttribute("type", "AnalogueValue"), new XAttribute("fc", "MX"))), + new XElement(ns + "DAType", new XAttribute("id", "AnalogueValue"), + new XElement(ns + "BDA", new XAttribute("name", "f"), new XAttribute("bType", "FLOAT32")))))); + } + + private static XDocument BuildOtherDuplicateFixture() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + return BuildBaseScl( + ns, + "IED-OTHER", + "S1", + "MEAS", + "MMXU", + "1", + "Level01", + "MX", + new XElement(ns + "DOType", new XAttribute("id", "PointType"), new XAttribute("cdc", "MV"), + new XElement(ns + "DA", new XAttribute("name", "mag"), new XAttribute("bType", "INT32"), new XAttribute("fc", "MX"))), + new[] + { + new XElement(ns + "DataSet", new XAttribute("name", "FAT_A"), Fcda(ns, "MEAS", "MMXU", "1", "Level01", "MX", "mag")), + new XElement(ns + "DataSet", new XAttribute("name", "FAT_B"), Fcda(ns, "MEAS", "MMXU", "1", "Level01", "MX", "mag")) + }); + } + + private static XDocument BuildSinglePointFixture( + string iedName, + string accessPoint, + string ldInst, + string lnClass, + string lnInst, + string doName, + string fc, + string bType) + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + var daName = fc.Equals("MX", StringComparison.OrdinalIgnoreCase) ? "mag" : "stVal"; + var cdc = fc.Equals("MX", StringComparison.OrdinalIgnoreCase) ? "MV" : "SPS"; + var doType = new XElement(ns + "DOType", new XAttribute("id", "PointType"), new XAttribute("cdc", cdc), + new XElement(ns + "DA", new XAttribute("name", daName), new XAttribute("bType", bType), new XAttribute("fc", fc))); + return BuildBaseScl(ns, iedName, accessPoint, ldInst, lnClass, lnInst, doName, fc, doType, + new[] { new XElement(ns + "DataSet", new XAttribute("name", "FAT"), Fcda(ns, ldInst, lnClass, lnInst, doName, fc, daName)) }); + } + + private static XDocument BuildNoDataSetFixture() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + return BuildBaseScl( + ns, + "IED-NO-DS", + "S1", + "ADD", + "GGIO", + "1", + "Dig01", + "ST", + new XElement(ns + "DOType", new XAttribute("id", "PointType"), new XAttribute("cdc", "SPS"), + new XElement(ns + "DA", new XAttribute("name", "stVal"), new XAttribute("bType", "BOOLEAN"), new XAttribute("fc", "ST"))), + Array.Empty()); + } + + private static XDocument BuildBaseScl( + XNamespace ns, + string iedName, + string accessPoint, + string ldInst, + string lnClass, + string lnInst, + string doName, + string fc, + XElement doType, + IReadOnlyCollection dataSets) + { + var ln0 = new XElement(ns + "LN0", new XAttribute("lnClass", "LLN0"), new XAttribute("lnType", "LLN0Type")); + foreach (var dataSet in dataSets) + ln0.Add(dataSet); + return new XDocument(new XElement(ns + "SCL", + new XAttribute("version", "2007"), new XAttribute("revision", "B"), + new XElement(ns + "Header", new XAttribute("id", "P4")), + new XElement(ns + "IED", new XAttribute("name", iedName), + new XElement(ns + "AccessPoint", new XAttribute("name", accessPoint), + new XElement(ns + "Server", + new XElement(ns + "LDevice", new XAttribute("inst", "Application"), ln0), + new XElement(ns + "LDevice", new XAttribute("inst", ldInst), + new XElement(ns + "LN0", new XAttribute("lnClass", "LLN0"), new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", new XAttribute("lnClass", lnClass), new XAttribute("inst", lnInst), new XAttribute("lnType", "PointLnType")))))), + new XElement(ns + "DataTypeTemplates", + new XElement(ns + "LNodeType", new XAttribute("id", "LLN0Type"), new XAttribute("lnClass", "LLN0")), + new XElement(ns + "LNodeType", new XAttribute("id", "PointLnType"), new XAttribute("lnClass", lnClass), + new XElement(ns + "DO", new XAttribute("name", doName), new XAttribute("type", "PointType"))), + doType))); + } + + private static XElement Fcda( + XNamespace ns, + string ldInst, + string lnClass, + string lnInst, + string doName, + string fc, + string? daName = null) + { + var element = new XElement(ns + "FCDA", + new XAttribute("ldInst", ldInst), + new XAttribute("lnClass", lnClass), + new XAttribute("lnInst", lnInst), + new XAttribute("doName", doName), + new XAttribute("fc", fc)); + if (!string.IsNullOrWhiteSpace(daName)) + element.Add(new XAttribute("daName", daName)); + return element; + } + + private static IoTestSessionController Session(IoTestProject project, string evidenceRoot) + => new(project, _ => null, action => action(), evidenceRoot); + + private static string TempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +}