From 71e1bc20f22b1071a9043792371c2bdd0d4c082d Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:22:57 +0700 Subject: [PATCH 01/32] Add IO testing domain model --- Models/IoTesting/IoTestModels.cs | 125 +++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Models/IoTesting/IoTestModels.cs diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs new file mode 100644 index 000000000..33748ddd6 --- /dev/null +++ b/Models/IoTesting/IoTestModels.cs @@ -0,0 +1,125 @@ +namespace ArIED61850Tester.Models.IoTesting; + +public enum IoTestPointState +{ + NotStarted, + WaitingForBaseline, + WaitingForOffBaseline, + ArmedForOn, + OnCaptured, + Passed, + Review, + Failed +} + +public enum IoEvidenceTransition +{ + Baseline, + On, + Off +} + +public enum IoEvidenceVerdict +{ + Accepted, + Review, + Rejected +} + +public sealed record IoTestObservation( + bool? NormalizedState, + string RawValue, + DateTimeOffset CapturedAt, + DateTimeOffset? IedTimestamp, + string Quality, + string AcquisitionSource, + long Sequence, + long ConnectionGeneration); + +public sealed record IoTestTransitionEvidence( + Guid EvidenceId, + IoEvidenceTransition Transition, + bool? PreviousState, + bool ObservedState, + string RawValue, + DateTimeOffset CapturedAt, + DateTimeOffset? IedTimestamp, + string Quality, + string AcquisitionSource, + long Sequence, + long ConnectionGeneration, + IoEvidenceVerdict Verdict, + string VerdictReason); + +public sealed class IoTestPointRuntime +{ + public IoTestPointState State { get; internal set; } = IoTestPointState.NotStarted; + public bool? LastObservedState { get; internal set; } + public long LastSequence { get; internal set; } = -1; + public long ConnectionGeneration { get; internal set; } = -1; + public IoTestTransitionEvidence? OnEvidence { get; internal set; } + public IoTestTransitionEvidence? OffEvidence { get; internal set; } + public string StatusReason { get; internal set; } = "Not started"; + public int Attempt { get; internal set; } + + public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed; + + internal void ResetAttempt() + { + State = IoTestPointState.WaitingForBaseline; + LastObservedState = null; + LastSequence = -1; + ConnectionGeneration = -1; + OnEvidence = null; + OffEvidence = null; + StatusReason = "Waiting for a trustworthy baseline"; + Attempt++; + } +} + +public sealed class IoTestPointPlan +{ + public required string TestPointId { get; init; } + public required string IedName { get; init; } + public required string IpAddress { get; init; } + public required string SignalName { get; init; } + public required string ObjectReference { get; init; } + public required string FunctionalConstraint { get; init; } + public required string ExpectedOnText { get; init; } + public required string ExpectedOffText { get; init; } + public string SourceSheet { get; init; } = string.Empty; + public int SourceRow { get; init; } + public bool TestEnabled { get; set; } = true; + public bool ImportReady { get; init; } = true; + public string BindingStatus { get; init; } = string.Empty; + public IoTestPointRuntime Runtime { get; } = new(); +} + +public sealed class IoTestIedPlan +{ + public required string IedName { get; init; } + public required string IpAddress { get; init; } + public string IedRole { get; init; } = string.Empty; + public string Location { get; init; } = string.Empty; + public string VoltageLevel { get; init; } = string.Empty; + public List TestPoints { get; init; } = new(); + + public int EnabledCount => TestPoints.Count(point => point.TestEnabled); + public int PassedCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Passed); + public int ReviewCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Review); + public int PendingCount => EnabledCount - PassedCount - ReviewCount; +} + +public sealed class IoTestProject +{ + public required string ProjectId { get; init; } + public required string SchemaVersion { get; init; } + public required string ProjectName { get; init; } + public string SourceWorkbookName { get; init; } = string.Empty; + public string SourceWorkbookSha256 { get; init; } = string.Empty; + public DateTimeOffset ImportedAt { get; init; } = DateTimeOffset.UtcNow; + public List Ieds { get; init; } = new(); + + public int SignalCount => Ieds.Sum(ied => ied.TestPoints.Count); + public int ReadySignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.ImportReady)); +} From 0140ada82d88922b4b31b60c0982915d2c05a389 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:23:40 +0700 Subject: [PATCH 02/32] Add IO evidence state machine --- .../IoTesting/IoTestTransitionEvaluator.cs | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 Services/IoTesting/IoTestTransitionEvaluator.cs diff --git a/Services/IoTesting/IoTestTransitionEvaluator.cs b/Services/IoTesting/IoTestTransitionEvaluator.cs new file mode 100644 index 000000000..cb6bd5758 --- /dev/null +++ b/Services/IoTesting/IoTestTransitionEvaluator.cs @@ -0,0 +1,238 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record IoTestEvaluationResult( + bool StateChanged, + IoTestPointState State, + IoTestTransitionEvidence? Evidence, + string Reason); + +public sealed class IoTestTransitionEvaluator +{ + public IoTestEvaluationResult StartAttempt(IoTestPointPlan point, IoTestObservation baseline) + { + ArgumentNullException.ThrowIfNull(point); + ArgumentNullException.ThrowIfNull(baseline); + + var runtime = point.Runtime; + runtime.ResetAttempt(); + runtime.ConnectionGeneration = baseline.ConnectionGeneration; + runtime.LastSequence = baseline.Sequence; + runtime.LastObservedState = baseline.NormalizedState; + + if (!point.TestEnabled) + { + runtime.State = IoTestPointState.NotStarted; + runtime.StatusReason = "Signal is disabled in the FAT test plan"; + return Result(runtime, changed: true, evidence: null); + } + + if (!point.ImportReady || string.IsNullOrWhiteSpace(point.ObjectReference)) + { + runtime.State = IoTestPointState.Review; + runtime.StatusReason = "Signal is not safely bound to an IEC 61850 object reference"; + return Result(runtime, changed: true, evidence: null); + } + + ApplyBaseline(runtime, baseline.NormalizedState); + return Result(runtime, changed: true, evidence: null); + } + + public IoTestEvaluationResult Observe(IoTestPointPlan point, IoTestObservation observation) + { + ArgumentNullException.ThrowIfNull(point); + ArgumentNullException.ThrowIfNull(observation); + + var runtime = point.Runtime; + if (!point.TestEnabled) + return Result(runtime, changed: false, evidence: null, "Signal is disabled"); + + if (runtime.State == IoTestPointState.NotStarted) + return Result(runtime, changed: false, evidence: null, "Test attempt has not been started"); + + if (runtime.ConnectionGeneration != observation.ConnectionGeneration) + return HandleConnectionGenerationChange(runtime, observation); + + if (observation.Sequence <= runtime.LastSequence) + return Result(runtime, changed: false, evidence: null, "Duplicate or out-of-order observation ignored"); + + var previous = runtime.LastObservedState; + runtime.LastSequence = observation.Sequence; + runtime.LastObservedState = observation.NormalizedState; + + if (observation.NormalizedState is null) + { + runtime.StatusReason = "Observation has no normalized digital state"; + return Result(runtime, changed: false, evidence: null); + } + + return runtime.State switch + { + IoTestPointState.WaitingForBaseline => HandleBaseline(runtime, observation), + IoTestPointState.WaitingForOffBaseline => HandleWaitingForOffBaseline(runtime, observation), + IoTestPointState.ArmedForOn => HandleArmedForOn(runtime, observation, previous), + IoTestPointState.OnCaptured => HandleOnCaptured(runtime, observation, previous), + _ => Result(runtime, changed: false, evidence: null, "Observation retained without changing the completed test state") + }; + } + + private static IoTestEvaluationResult HandleConnectionGenerationChange( + IoTestPointRuntime runtime, + IoTestObservation observation) + { + runtime.ConnectionGeneration = observation.ConnectionGeneration; + runtime.LastSequence = observation.Sequence; + runtime.LastObservedState = observation.NormalizedState; + + if (runtime.OnEvidence != null && runtime.OffEvidence == null) + { + runtime.State = IoTestPointState.Review; + runtime.StatusReason = "IED connection changed after ON evidence; OFF transition continuity cannot be proven"; + return Result(runtime, changed: true, evidence: null); + } + + runtime.State = IoTestPointState.WaitingForBaseline; + runtime.StatusReason = "Connection changed; first image is baseline only"; + ApplyBaseline(runtime, observation.NormalizedState); + return Result(runtime, changed: true, evidence: null); + } + + private static IoTestEvaluationResult HandleBaseline( + IoTestPointRuntime runtime, + IoTestObservation observation) + { + ApplyBaseline(runtime, observation.NormalizedState); + return Result(runtime, changed: true, evidence: null); + } + + private static IoTestEvaluationResult HandleWaitingForOffBaseline( + IoTestPointRuntime runtime, + IoTestObservation observation) + { + if (observation.NormalizedState != false) + return Result(runtime, changed: false, evidence: null, "Waiting for OFF baseline before arming ON evidence"); + + runtime.State = IoTestPointState.ArmedForOn; + runtime.StatusReason = "OFF baseline confirmed; waiting for a new ON transition"; + return Result(runtime, changed: true, evidence: null); + } + + private static IoTestEvaluationResult HandleArmedForOn( + IoTestPointRuntime runtime, + IoTestObservation observation, + bool? previous) + { + if (previous != false || observation.NormalizedState != true) + return Result(runtime, changed: false, evidence: null, "Waiting for OFF to ON transition"); + + var evidence = CreateEvidence(IoEvidenceTransition.On, previous, observation); + if (evidence.Verdict == IoEvidenceVerdict.Rejected) + { + runtime.StatusReason = $"ON transition rejected: {evidence.VerdictReason}"; + return Result(runtime, changed: false, evidence); + } + + runtime.OnEvidence = evidence; + runtime.State = IoTestPointState.OnCaptured; + runtime.StatusReason = evidence.Verdict == IoEvidenceVerdict.Accepted + ? "ON evidence captured; waiting for OFF transition" + : "ON evidence captured for review; waiting for OFF transition"; + return Result(runtime, changed: true, evidence); + } + + private static IoTestEvaluationResult HandleOnCaptured( + IoTestPointRuntime runtime, + IoTestObservation observation, + bool? previous) + { + if (previous != true || observation.NormalizedState != false) + return Result(runtime, changed: false, evidence: null, "Waiting for ON to OFF transition"); + + var evidence = CreateEvidence(IoEvidenceTransition.Off, previous, observation); + if (evidence.Verdict == IoEvidenceVerdict.Rejected) + { + runtime.StatusReason = $"OFF transition rejected: {evidence.VerdictReason}"; + return Result(runtime, changed: false, evidence); + } + + runtime.OffEvidence = evidence; + var onAccepted = runtime.OnEvidence?.Verdict == IoEvidenceVerdict.Accepted; + var offAccepted = evidence.Verdict == IoEvidenceVerdict.Accepted; + runtime.State = onAccepted && offAccepted + ? IoTestPointState.Passed + : IoTestPointState.Review; + runtime.StatusReason = runtime.State == IoTestPointState.Passed + ? "PASS: ON and OFF transitions captured in order" + : "ON and OFF evidence captured, but one or both transitions require review"; + return Result(runtime, changed: true, evidence); + } + + private static void ApplyBaseline(IoTestPointRuntime runtime, bool? state) + { + runtime.State = state switch + { + false => IoTestPointState.ArmedForOn, + true => IoTestPointState.WaitingForOffBaseline, + null => IoTestPointState.WaitingForBaseline + }; + runtime.StatusReason = state switch + { + false => "OFF baseline confirmed; waiting for a new ON transition", + true => "Signal was already ON; waiting for OFF baseline before testing", + null => "Waiting for a trustworthy digital baseline" + }; + } + + private static IoTestTransitionEvidence CreateEvidence( + IoEvidenceTransition transition, + bool? previous, + IoTestObservation observation) + { + var (verdict, reason) = EvaluateQuality(observation.Quality); + return new IoTestTransitionEvidence( + Guid.NewGuid(), + transition, + previous, + observation.NormalizedState!.Value, + observation.RawValue, + observation.CapturedAt, + observation.IedTimestamp, + observation.Quality, + observation.AcquisitionSource, + observation.Sequence, + observation.ConnectionGeneration, + verdict, + reason); + } + + internal static (IoEvidenceVerdict Verdict, string Reason) EvaluateQuality(string? quality) + { + var normalized = (quality ?? string.Empty).Trim().ToLowerInvariant(); + if (normalized.Contains("invalid") || + normalized.Contains("bad") || + normalized.Contains("failure") || + normalized.Contains("blocked")) + { + return (IoEvidenceVerdict.Rejected, "IEC 61850 quality is invalid or bad"); + } + + if (normalized.Contains("good") || normalized == "valid") + return (IoEvidenceVerdict.Accepted, "Quality accepted"); + + return (IoEvidenceVerdict.Review, "Quality is missing, unknown, or questionable"); + } + + private static IoTestEvaluationResult Result( + IoTestPointRuntime runtime, + bool changed, + IoTestTransitionEvidence? evidence, + string? reason = null) + { + return new IoTestEvaluationResult( + changed, + runtime.State, + evidence, + reason ?? runtime.StatusReason); + } +} From d9540e8c352b6bd9ac8839f1856a353a8e4a3d26 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:24:13 +0700 Subject: [PATCH 03/32] Add IO evidence transition tests --- .../IoTestTransitionEvaluatorTests.cs | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/ARSAS.Tests/IoTestTransitionEvaluatorTests.cs diff --git a/tests/ARSAS.Tests/IoTestTransitionEvaluatorTests.cs b/tests/ARSAS.Tests/IoTestTransitionEvaluatorTests.cs new file mode 100644 index 000000000..e8e197704 --- /dev/null +++ b/tests/ARSAS.Tests/IoTestTransitionEvaluatorTests.cs @@ -0,0 +1,155 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoTestTransitionEvaluatorTests +{ + private readonly IoTestTransitionEvaluator _evaluator = new(); + + [Fact] + public void OffOnOffSequence_PassesWithOrderedEvidence() + { + var point = CreatePoint(); + _evaluator.StartAttempt(point, Observation(false, 1)); + + var on = _evaluator.Observe(point, Observation(true, 2)); + var off = _evaluator.Observe(point, Observation(false, 3)); + + Assert.Equal(IoTestPointState.OnCaptured, on.State); + Assert.Equal(IoEvidenceTransition.On, on.Evidence?.Transition); + Assert.Equal(IoTestPointState.Passed, off.State); + Assert.Equal(IoEvidenceTransition.Off, off.Evidence?.Transition); + Assert.NotNull(point.Runtime.OnEvidence); + Assert.NotNull(point.Runtime.OffEvidence); + Assert.True(point.Runtime.OffEvidence!.CapturedAt > point.Runtime.OnEvidence!.CapturedAt); + } + + [Fact] + public void InitiallyOn_DoesNotCreateOnEvidenceUntilNewCycle() + { + var point = CreatePoint(); + var start = _evaluator.StartAttempt(point, Observation(true, 1)); + + Assert.Equal(IoTestPointState.WaitingForOffBaseline, start.State); + Assert.Null(point.Runtime.OnEvidence); + + var baselineOff = _evaluator.Observe(point, Observation(false, 2)); + var on = _evaluator.Observe(point, Observation(true, 3)); + + Assert.Equal(IoTestPointState.ArmedForOn, baselineOff.State); + Assert.Equal(IoTestPointState.OnCaptured, on.State); + Assert.Equal(3, point.Runtime.OnEvidence?.Sequence); + } + + [Fact] + public void DuplicateValues_DoNotCreateEvidence() + { + var point = CreatePoint(); + _evaluator.StartAttempt(point, Observation(false, 1)); + + var duplicateOff = _evaluator.Observe(point, Observation(false, 2)); + var on = _evaluator.Observe(point, Observation(true, 3)); + var duplicateOn = _evaluator.Observe(point, Observation(true, 4)); + + Assert.False(duplicateOff.StateChanged); + Assert.Null(duplicateOff.Evidence); + Assert.Equal(IoTestPointState.OnCaptured, on.State); + Assert.False(duplicateOn.StateChanged); + Assert.Null(duplicateOn.Evidence); + Assert.Null(point.Runtime.OffEvidence); + } + + [Fact] + public void ReconnectAfterOnEvidence_ForcesReviewInsteadOfFalsePass() + { + var point = CreatePoint(); + _evaluator.StartAttempt(point, Observation(false, 1, generation: 10)); + _evaluator.Observe(point, Observation(true, 2, generation: 10)); + + var firstImageAfterReconnect = _evaluator.Observe( + point, + Observation(false, 1, generation: 11)); + + Assert.Equal(IoTestPointState.Review, firstImageAfterReconnect.State); + Assert.Null(point.Runtime.OffEvidence); + Assert.Contains("continuity cannot be proven", point.Runtime.StatusReason); + } + + [Fact] + public void QuestionableOnQuality_CapturesEvidenceButFinalResultNeedsReview() + { + var point = CreatePoint(); + _evaluator.StartAttempt(point, Observation(false, 1)); + + var on = _evaluator.Observe(point, Observation(true, 2, quality: "Questionable")); + var off = _evaluator.Observe(point, Observation(false, 3, quality: "Good")); + + Assert.Equal(IoEvidenceVerdict.Review, on.Evidence?.Verdict); + Assert.Equal(IoTestPointState.Review, off.State); + Assert.NotNull(point.Runtime.OnEvidence); + Assert.NotNull(point.Runtime.OffEvidence); + } + + [Fact] + public void InvalidQuality_DoesNotAdvanceTheStateMachine() + { + var point = CreatePoint(); + _evaluator.StartAttempt(point, Observation(false, 1)); + + var invalidOn = _evaluator.Observe(point, Observation(true, 2, quality: "Invalid")); + + Assert.Equal(IoTestPointState.ArmedForOn, invalidOn.State); + Assert.Equal(IoEvidenceVerdict.Rejected, invalidOn.Evidence?.Verdict); + Assert.Null(point.Runtime.OnEvidence); + } + + [Fact] + public void OutOfOrderSequence_IsIgnored() + { + var point = CreatePoint(); + _evaluator.StartAttempt(point, Observation(false, 10)); + + var stale = _evaluator.Observe(point, Observation(true, 9)); + + Assert.False(stale.StateChanged); + Assert.Null(stale.Evidence); + Assert.Equal(IoTestPointState.ArmedForOn, stale.State); + } + + private static IoTestPointPlan CreatePoint() + { + return new IoTestPointPlan + { + TestPointId = "CCPP-AA1C1F03R4-0001", + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + SignalName = "CB closed", + ObjectReference = "AA1C1F03R4ADD/GGIO6.CBClsd.stVal", + FunctionalConstraint = "ST", + ExpectedOnText = "Active", + ExpectedOffText = "InActive", + ImportReady = true, + BindingStatus = "CID_DATASET_EXACT" + }; + } + + private static IoTestObservation Observation( + bool state, + long sequence, + long generation = 1, + string quality = "Good") + { + var captured = new DateTimeOffset(2026, 7, 28, 8, 0, 0, TimeSpan.Zero) + .AddMilliseconds(sequence * 100); + return new IoTestObservation( + state, + state ? "true" : "false", + captured, + captured.AddMilliseconds(-5), + quality, + "BRCB", + sequence, + generation); + } +} From 34de3b9b2c13454d9bd67f7d9add733d115f877d Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:24:45 +0700 Subject: [PATCH 04/32] Add IO import contract validation --- Services/IoTesting/IoTestImportValidator.cs | 174 ++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 Services/IoTesting/IoTestImportValidator.cs diff --git a/Services/IoTesting/IoTestImportValidator.cs b/Services/IoTesting/IoTestImportValidator.cs new file mode 100644 index 000000000..6216a5675 --- /dev/null +++ b/Services/IoTesting/IoTestImportValidator.cs @@ -0,0 +1,174 @@ +using System.Net; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public enum IoTestImportFindingSeverity +{ + Warning, + Error +} + +public sealed record IoTestImportFinding( + IoTestImportFindingSeverity Severity, + string Code, + string Message, + string? TestPointId = null, + string? SourceSheet = null, + int? SourceRow = null); + +public sealed record IoTestImportValidationResult( + IReadOnlyList Findings, + int IedCount, + int SignalCount, + int ReadySignalCount) +{ + public bool IsValid => Findings.All(finding => finding.Severity != IoTestImportFindingSeverity.Error); +} + +public sealed class IoTestImportValidator +{ + public const string SupportedSchemaVersion = "ARSAS-FAT-IO-1.0"; + + public IoTestImportValidationResult Validate(IoTestProject project) + { + ArgumentNullException.ThrowIfNull(project); + + var findings = new List(); + if (!string.Equals(project.SchemaVersion, SupportedSchemaVersion, StringComparison.Ordinal)) + { + findings.Add(Error( + "SCHEMA_UNSUPPORTED", + $"Schema '{project.SchemaVersion}' is not supported. Expected '{SupportedSchemaVersion}'.")); + } + + if (string.IsNullOrWhiteSpace(project.ProjectId)) + findings.Add(Error("PROJECT_ID_REQUIRED", "ProjectId is required.")); + + if (project.Ieds.Count == 0) + findings.Add(Error("IED_LIST_EMPTY", "The imported project contains no IED plans.")); + + var testPointIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var endpointOwners = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var ied in project.Ieds) + { + ValidateIed(ied, findings, endpointOwners); + foreach (var point in ied.TestPoints) + { + ValidatePoint(ied, point, findings); + if (!testPointIds.Add(point.TestPointId)) + { + findings.Add(Error( + "TEST_POINT_DUPLICATE", + $"TestPointId '{point.TestPointId}' occurs more than once.", + point)); + } + } + } + + return new IoTestImportValidationResult( + findings, + project.Ieds.Count, + project.SignalCount, + project.ReadySignalCount); + } + + private static void ValidateIed( + IoTestIedPlan ied, + ICollection findings, + IDictionary endpointOwners) + { + if (string.IsNullOrWhiteSpace(ied.IedName)) + findings.Add(Error("IED_NAME_REQUIRED", "IEDName is required.")); + + if (!IPAddress.TryParse(ied.IpAddress, out _)) + { + findings.Add(Error( + "IED_IP_INVALID", + $"IED '{ied.IedName}' has invalid IP address '{ied.IpAddress}'.")); + } + else if (endpointOwners.TryGetValue(ied.IpAddress, out var existingIed) && + !string.Equals(existingIed, ied.IedName, StringComparison.OrdinalIgnoreCase)) + { + findings.Add(new IoTestImportFinding( + IoTestImportFindingSeverity.Warning, + "IED_IP_SHARED", + $"IP address '{ied.IpAddress}' is assigned to both '{existingIed}' and '{ied.IedName}'.")); + } + else + { + endpointOwners[ied.IpAddress] = ied.IedName; + } + + if (ied.TestPoints.Count == 0) + { + findings.Add(new IoTestImportFinding( + IoTestImportFindingSeverity.Warning, + "IED_SIGNALS_EMPTY", + $"IED '{ied.IedName}' has no SDI test points.")); + } + } + + private static void ValidatePoint( + IoTestIedPlan ied, + IoTestPointPlan point, + ICollection findings) + { + if (string.IsNullOrWhiteSpace(point.TestPointId)) + findings.Add(Error("TEST_POINT_ID_REQUIRED", "TestPointId is required.", point)); + + if (!string.Equals(point.IedName, ied.IedName, StringComparison.OrdinalIgnoreCase)) + { + findings.Add(Error( + "TEST_POINT_IED_MISMATCH", + $"Test point '{point.TestPointId}' belongs to '{point.IedName}' but is grouped under '{ied.IedName}'.", + point)); + } + + if (string.IsNullOrWhiteSpace(point.SignalName)) + findings.Add(Error("SIGNAL_NAME_REQUIRED", "SignalName is required.", point)); + + if (point.ImportReady && string.IsNullOrWhiteSpace(point.ObjectReference)) + { + findings.Add(Error( + "REFERENCE_REQUIRED", + "Import-ready signals require ObjectReference.", + point)); + } + + if (point.ImportReady && + !string.Equals(point.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase)) + { + findings.Add(Error( + "SDI_FC_INVALID", + $"The first IO Testing release accepts SDI status points with FC=ST; received '{point.FunctionalConstraint}'.", + point)); + } + + if (!point.ImportReady) + { + findings.Add(new IoTestImportFinding( + IoTestImportFindingSeverity.Warning, + "SIGNAL_REVIEW_REQUIRED", + $"Signal '{point.SignalName}' is retained for review and will not be auto-tested.", + point.TestPointId, + point.SourceSheet, + point.SourceRow)); + } + } + + private static IoTestImportFinding Error( + string code, + string message, + IoTestPointPlan? point = null) + { + return new IoTestImportFinding( + IoTestImportFindingSeverity.Error, + code, + message, + point?.TestPointId, + point?.SourceSheet, + point?.SourceRow); + } +} From 978dc960e390077efa3269f4080e31507f7533ed Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:25:08 +0700 Subject: [PATCH 05/32] Add IO import validation tests --- .../ARSAS.Tests/IoTestImportValidatorTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/ARSAS.Tests/IoTestImportValidatorTests.cs diff --git a/tests/ARSAS.Tests/IoTestImportValidatorTests.cs b/tests/ARSAS.Tests/IoTestImportValidatorTests.cs new file mode 100644 index 000000000..85a0b1970 --- /dev/null +++ b/tests/ARSAS.Tests/IoTestImportValidatorTests.cs @@ -0,0 +1,120 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoTestImportValidatorTests +{ + private readonly IoTestImportValidator _validator = new(); + + [Fact] + public void ValidProject_IsAccepted() + { + var project = CreateProject(CreatePoint("TP-001")); + + var result = _validator.Validate(project); + + Assert.True(result.IsValid); + Assert.Equal(1, result.IedCount); + Assert.Equal(1, result.SignalCount); + Assert.Equal(1, result.ReadySignalCount); + Assert.Empty(result.Findings); + } + + [Fact] + public void DuplicateTestPointId_IsRejected() + { + var project = CreateProject(CreatePoint("TP-001"), CreatePoint("TP-001")); + + var result = _validator.Validate(project); + + Assert.False(result.IsValid); + Assert.Contains(result.Findings, finding => finding.Code == "TEST_POINT_DUPLICATE"); + } + + [Fact] + public void ImportReadySignalWithoutReference_IsRejected() + { + var point = CreatePoint("TP-001", objectReference: string.Empty); + var project = CreateProject(point); + + var result = _validator.Validate(project); + + Assert.False(result.IsValid); + Assert.Contains(result.Findings, finding => finding.Code == "REFERENCE_REQUIRED"); + } + + [Fact] + public void NonReadySignal_IsRetainedAsWarning() + { + var point = CreatePoint("TP-001", objectReference: string.Empty, importReady: false); + var project = CreateProject(point); + + var result = _validator.Validate(project); + + Assert.True(result.IsValid); + Assert.Contains(result.Findings, finding => + finding.Code == "SIGNAL_REVIEW_REQUIRED" && + finding.Severity == IoTestImportFindingSeverity.Warning); + } + + [Fact] + public void UnsupportedSchema_IsRejected() + { + var project = CreateProject(CreatePoint("TP-001")); + project = new IoTestProject + { + ProjectId = project.ProjectId, + ProjectName = project.ProjectName, + SchemaVersion = "ARSAS-FAT-IO-9.9", + Ieds = project.Ieds + }; + + var result = _validator.Validate(project); + + Assert.False(result.IsValid); + Assert.Contains(result.Findings, finding => finding.Code == "SCHEMA_UNSUPPORTED"); + } + + private static IoTestProject CreateProject(params IoTestPointPlan[] points) + { + return new IoTestProject + { + ProjectId = "CCPP-260728", + ProjectName = "CCPP FAT", + SchemaVersion = IoTestImportValidator.SupportedSchemaVersion, + Ieds = + { + new IoTestIedPlan + { + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + IedRole = "BCU - 6MD85", + TestPoints = points.ToList() + } + } + }; + } + + private static IoTestPointPlan CreatePoint( + string testPointId, + string objectReference = "AA1C1F03R4ADD/GGIO6.CBClsd.stVal", + bool importReady = true) + { + return new IoTestPointPlan + { + TestPointId = testPointId, + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + SignalName = "CB closed", + ObjectReference = objectReference, + FunctionalConstraint = "ST", + ExpectedOnText = "Active", + ExpectedOffText = "InActive", + ImportReady = importReady, + BindingStatus = importReady ? "CID_DATASET_EXACT" : "REFERENCE_MISSING", + SourceSheet = "ARSAS_SIGNAL_IMPORT", + SourceRow = 2 + }; + } +} From fe54fc410ca7ff4977d736f0c816af41aa7ad32d Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:26:03 +0700 Subject: [PATCH 06/32] Add dedicated IO testing window shell --- IoListTestingWindow.xaml | 167 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 IoListTestingWindow.xaml diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml new file mode 100644 index 000000000..0d018142f --- /dev/null +++ b/IoListTestingWindow.xaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +