diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 37adfc131..23d9014ee 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -183,7 +183,7 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e) TestMode = TestMode, FeedbackTimeoutMs = _signal.IsPositionControl ? 12000 : 8000, CommandTerminationTimeoutMs = 10000, - OriginCategory = "Maintenance" + OriginCategory = "StationControl" }, _cancellation.Token); @@ -272,13 +272,41 @@ private void PopulateValueOptions(string cdc, string currentValue) private static string BuildCommandResultText(Iec61850ControlCommandResult result) { - var details = new List { result.Message }; + var details = new List(); + var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted); + if (rejectedStep != null) + { + var rejectedStage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action; + details.Add($"IED REJECTED {rejectedStage}: {result.Message}"); + var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase)); + if (rejectedStage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent) + { + details.Add("Operate was NOT sent because SBOw selection failed."); + details.Add("CommandTermination is not expected because Operate never started."); + } + } + else + { + details.Add(result.Message); + } + + if (result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase)) + details.Add("No MMS control request was sent to the IED."); + else if (!string.IsNullOrWhiteSpace(result.ResponseHex)) + details.Add("MMS request/response wire evidence was captured."); + else if (!string.IsNullOrWhiteSpace(result.RequestHex)) + details.Add("MMS request encoding was captured, but no MMS response was captured."); + if (result.WireSteps.Count > 0) + details.Add($"Wire sequence: {string.Join(" → ", result.WireSteps.Select(step => step.Action))}."); if (result.CommandTerminationReceived) details.Add(result.PositiveTermination ? "Positive CommandTermination received." : "Negative CommandTermination received."); if (!string.IsNullOrWhiteSpace(result.ControlError)) details.Add($"ControlError: {result.ControlError}."); if (!string.IsNullOrWhiteSpace(result.AddCause)) + { details.Add($"AddCause: {result.AddCause}."); + details.Add(ExplainAddCause(result.AddCause)); + } if (!string.IsNullOrWhiteSpace(result.LastApplErrorText)) details.Add(result.LastApplErrorText); if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-") @@ -290,6 +318,19 @@ private static string BuildCommandResultText(Iec61850ControlCommandResult result return string.Join(" ", details.Where(text => !string.IsNullOrWhiteSpace(text))); } + private static string ExplainAddCause(string addCause) + => (addCause ?? string.Empty).Trim().ToLowerInvariant() switch + { + "blocked-by-interlocking" => "IED BLOCKED COMMAND BY INTERLOCKING.", + "blocked-by-synchrocheck" => "IED BLOCKED COMMAND BY SYNCHROCHECK.", + "blocked-by-mode" => "IED blocked the command because the active control mode does not permit it.", + "blocked-by-process" => "IED blocked the command by process conditions.", + "blocked-by-health" => "IED blocked the command because of device/process health conditions.", + "no-access-authority" => "IED reports that this client/origin has no control access authority.", + "not-supported" => "IED reports that the requested control condition/service is not supported.", + _ => string.Empty + }; + private static bool TryExtractNumber(string? text, out double value) { value = 0d; diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 80793e070..408cb0f29 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1242,7 +1242,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo } AddLog("INFO", device.Name, - $"MMS command submitted: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}."); + $"IEC 61850 control execution started: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}; wire send is not assumed until native evidence is returned."); var result = await _runtime.ExecuteControlAsync( device.DeviceId, new Iec61850ControlCommandRequest @@ -1255,7 +1255,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo FeedbackTimeoutMs = signal.IsPositionControl ? 12000 : (signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000, CommandTerminationTimeoutMs = 10000, - OriginCategory = "Maintenance" + OriginCategory = "StationControl" }, _applicationCancellation.Token); @@ -1301,6 +1301,22 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") timing.Add($"feedback {result.FeedbackElapsedText}"); var suffix = timing.Count == 0 ? string.Empty : $" • {string.Join(" • ", timing)}"; + + if (!result.IsSuccess) + { + var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted); + if (rejectedStep != null) + { + var stage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action; + var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase)); + var stopped = stage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent + ? " • Operate NOT sent" + : string.Empty; + var cause = string.IsNullOrWhiteSpace(result.AddCause) ? string.Empty : $" • AddCause={result.AddCause}"; + return $"IED REJECTED {stage}: {result.Message}{cause}{stopped}{suffix}"; + } + } + return result.IsSuccess ? $"{result.Stage}: {result.FeedbackValue}{suffix}" : $"{result.Stage}: {result.Message}{suffix}"; diff --git a/Models/ControlModels.cs b/Models/ControlModels.cs index 2ac81f8ac..b0a20a9d7 100644 --- a/Models/ControlModels.cs +++ b/Models/ControlModels.cs @@ -46,11 +46,21 @@ public sealed class Iec61850ControlCommandRequest public bool SynchroCheck { get; init; } public bool TestMode { get; init; } public string Originator { get; init; } = "ARSAS"; - public string OriginCategory { get; init; } = "Maintenance"; + public string OriginCategory { get; init; } = "StationControl"; public int FeedbackTimeoutMs { get; init; } = 12000; public int CommandTerminationTimeoutMs { get; init; } = 10000; } +public sealed class Iec61850ControlWireEvidence +{ + public string Action { get; init; } = string.Empty; + public string Reference { get; init; } = string.Empty; + public bool RequestAccepted { get; init; } + public string RequestHex { get; init; } = string.Empty; + public string ResponseHex { get; init; } = string.Empty; + public string Detail { get; init; } = string.Empty; +} + public sealed class Iec61850ControlCommandResult { public bool IsSuccess { get; init; } @@ -76,4 +86,5 @@ public sealed class Iec61850ControlCommandResult public string TotalElapsedText { get; init; } = "-"; public string RequestHex { get; init; } = string.Empty; public string ResponseHex { get; init; } = string.Empty; + public IReadOnlyList WireSteps { get; init; } = Array.Empty(); } diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 5c219d068..942d0b001 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -486,7 +486,7 @@ public async Task ExecuteControlAsync( throw new InvalidOperationException("The IED must be connected before a command can be sent."); Log("INFO", session.Device.Name, - $"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}."); + $"Control execution requested: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}; IED acceptance is determined only from native MMS wire evidence."); var clientStopwatch = Stopwatch.StartNew(); Interlocked.Increment(ref session.ControlCommandActive); @@ -507,9 +507,24 @@ public async Task ExecuteControlAsync( if (!request.TestMode && result.FeedbackConfirmed && !string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue); + var wireState = result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase) + ? "NOT SENT TO IED" + : result.WireSteps.Count > 0 && result.WireSteps.All(step => !string.IsNullOrWhiteSpace(step.ResponseHex)) + ? $"{result.WireSteps.Count} ordered MMS control response(s) captured" + : result.WireSteps.Count > 0 + ? $"{result.WireSteps.Count} ordered MMS control step(s); incomplete response evidence" + : !string.IsNullOrWhiteSpace(result.ResponseHex) + ? "MMS response received" + : !string.IsNullOrWhiteSpace(result.RequestHex) + ? "MMS request encoded / no response captured" + : result.ServiceAccepted + ? "MMS service accepted" + : "no wire evidence returned"; + var protocolEvidence = string.Join("; ", new[] { string.IsNullOrWhiteSpace(result.CompletionState) ? null : $"completion={result.CompletionState}", + $"wire={wireState}", result.CommandTerminationReceived ? $"termination={(result.PositiveTermination ? "positive" : "negative")}" : null, string.IsNullOrWhiteSpace(result.ControlError) ? null : $"controlError={result.ControlError}", string.IsNullOrWhiteSpace(result.AddCause) ? null : $"addCause={result.AddCause}", @@ -522,6 +537,45 @@ public async Task ExecuteControlAsync( Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name, $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}"); + + var rejectedWireStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted); + if (rejectedWireStep != null) + { + var rejectedStage = rejectedWireStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) + ? "SBOw" + : rejectedWireStep.Action; + var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase)); + Log("ERROR", session.Device.Name, + $"CONTROL_REJECTED_BY_IED: stage={rejectedStage}; reference={rejectedWireStep.Reference}; reason={result.Message}; controlError={(string.IsNullOrWhiteSpace(result.ControlError) ? "-" : result.ControlError)}; addCause={(string.IsNullOrWhiteSpace(result.AddCause) ? "-" : result.AddCause)}; OperateSent={operateSent}; origin={request.OriginCategory}/{request.Originator}."); + } + + if (result.WireSteps.Count > 0) + { + for (var index = 0; index < result.WireSteps.Count; index++) + { + var step = result.WireSteps[index]; + Log(step.RequestAccepted ? "INFO" : "WARN", session.Device.Name, + $"CONTROL_WIRE_STEP: order={index + 1}; action={step.Action}; reference={step.Reference}; accepted={step.RequestAccepted}; requestCaptured={!string.IsNullOrWhiteSpace(step.RequestHex)}; responseCaptured={!string.IsNullOrWhiteSpace(step.ResponseHex)}; detail={step.Detail}"); + if (!string.IsNullOrWhiteSpace(step.RequestHex)) + Log("INFO", session.Device.Name, + $"CONTROL_WIRE_REQUEST: order={index + 1}; action={step.Action}; reference={step.Reference}; requestHEX={step.RequestHex}"); + if (!string.IsNullOrWhiteSpace(step.ResponseHex)) + Log("INFO", session.Device.Name, + $"CONTROL_WIRE_RESPONSE: order={index + 1}; action={step.Action}; reference={step.Reference}; responseHEX={step.ResponseHex}"); + } + } + else + { + // Compatibility fallback for a local failure or older action result without + // ordered service evidence. Never infer server acceptance from request HEX alone. + if (!string.IsNullOrWhiteSpace(result.RequestHex)) + Log("INFO", session.Device.Name, + $"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}"); + if (!string.IsNullOrWhiteSpace(result.ResponseHex)) + Log("INFO", session.Device.Name, + $"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}"); + } + return result; } diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 49f1bf232..507f2d34e 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1229,6 +1229,8 @@ private async Task ExecuteControlCoreAsync( }; ArControl.Iec61850ControlActionResult action; + var wireRequestBeforeControl = _session.LastReadRequestHex; + var wireResponseBeforeControl = _session.LastReadResponseHex; try { action = await RunMmsOperationAsync( @@ -1241,11 +1243,23 @@ private async Task ExecuteControlCoreAsync( } catch (Exception ex) { - return ControlFailure( - "Control exception", - $"{ex.GetType().Name}: {ex.Message}", - capabilities, - expectedValue); + var requestChanged = !string.Equals( + wireRequestBeforeControl, + _session.LastReadRequestHex, + StringComparison.Ordinal); + var responseChanged = !string.Equals( + wireResponseBeforeControl, + _session.LastReadResponseHex, + StringComparison.Ordinal); + + return requestChanged + ? ControlWireUnknownFailure( + ex, + capabilities, + expectedValue, + _session.LastReadRequestHex, + responseChanged ? _session.LastReadResponseHex : string.Empty) + : ControlNotSentFailure(ex, capabilities, expectedValue); } if (!action.IsSuccess) @@ -1705,7 +1719,7 @@ _ when cdc.Contains("INC", StringComparison.OrdinalIgnoreCase) || cdc.Contains(" private static ArControl.Iec61850OriginCategory ParseOriginCategory(string? text) => Enum.TryParse(text, true, out var category) ? category - : ArControl.Iec61850OriginCategory.Maintenance; + : ArControl.Iec61850OriginCategory.StationControl; private static string FormatControlTimeout(TimeSpan? timeout) => timeout.HasValue ? $"{timeout.Value.TotalSeconds:0.###} s" : "-"; @@ -1775,7 +1789,16 @@ private static Iec61850ControlCommandResult MapNativeControlResult( FeedbackElapsedText = feedbackElapsed.HasValue ? $"{feedbackElapsed.Value.TotalMilliseconds:0.###} ms" : "-", TotalElapsedText = totalElapsed.HasValue ? $"{totalElapsed.Value.TotalMilliseconds:0.###} ms" : $"{result.Elapsed.TotalMilliseconds:0.###} ms", RequestHex = result.RequestHex, - ResponseHex = result.ResponseHex + ResponseHex = result.ResponseHex, + WireSteps = result.WireSteps.Select(step => new Iec61850ControlWireEvidence + { + Action = step.Action.ToString(), + Reference = step.Reference, + RequestAccepted = step.RequestAccepted, + RequestHex = step.RequestHex, + ResponseHex = step.ResponseHex, + Detail = step.Detail + }).ToArray() }; private static string InferControlCdc( @@ -1881,6 +1904,46 @@ private static Iec61850ControlCommandResult ControlFailure( FeedbackValue = capabilities.CurrentValue }; + private static Iec61850ControlCommandResult ControlNotSentFailure( + Exception exception, + Iec61850ControlCapabilities capabilities, + string requestedValue) + => new() + { + IsSuccess = false, + ServiceAccepted = false, + FeedbackConfirmed = false, + CompletionState = "NotSent", + Stage = "NOT SENT TO IED", + Message = $"Local IEC 61850 control preparation failed before any MMS control request was built or sent. {exception.GetType().Name}: {exception.Message}", + ControlModelText = capabilities.ControlModelText, + SequenceText = capabilities.SequenceText, + RequestedValue = requestedValue, + FeedbackValue = capabilities.CurrentValue + }; + + private static Iec61850ControlCommandResult ControlWireUnknownFailure( + Exception exception, + Iec61850ControlCapabilities capabilities, + string requestedValue, + string requestHex, + string responseHex) + => new() + { + IsSuccess = false, + ServiceAccepted = false, + FeedbackConfirmed = false, + CompletionState = "WireStateUnknown", + Stage = "MMS control transport incomplete", + Message = $"An MMS control request was encoded and transport may have started, but the control sequence did not complete. {exception.GetType().Name}: {exception.Message}", + ControlModelText = capabilities.ControlModelText, + SequenceText = capabilities.SequenceText, + RequestedValue = requestedValue, + FeedbackValue = capabilities.CurrentValue, + RequestHex = requestHex ?? string.Empty, + ResponseHex = responseHex ?? string.Empty + }; + public async ValueTask DisposeAsync() { await DisposeControlSessionsAsync().ConfigureAwait(false); diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index b563912a5..290c27523 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "249fb130e0e18e7a98e07e8894f24610bdb5642e", - "sourcePullRequest": 89, - "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative." + "commit": "a18e550d07f7bbe4ff7753c180b02615075f6292", + "sourcePullRequest": 90, + "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative, and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints (-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed, and G1 final PR #90 preserves ordered SBO/SBOw-to-Operate wire evidence in the native control result so field acceptance can prove each control service independently before process-feedback confirmation. G1.1 decodes MMS Write DataAccessError values explicitly, including physical SIPROTEC SBOw response code 3 as object-access-denied, while preserving unknown numeric vendor codes for diagnostics." } diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs new file mode 100644 index 000000000..06c8bb3cb --- /dev/null +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -0,0 +1,127 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class G1ControlCorrectnessRegressionTests +{ + [Fact] + public void EngineLock_PinsExactG1ControlEngine() + { + var root = RepoRoot(); + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); + var json = doc.RootElement; + Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); + Assert.Equal("main", json.GetProperty("ref").GetString()); + Assert.Equal("a18e550d07f7bbe4ff7753c180b02615075f6292", json.GetProperty("commit").GetString()); + Assert.Equal(90, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Contains("signed primitive constraints", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("object-access-denied", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LocalControlPreparationFailure_IsExplicitlyNotSent() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); + Assert.Contains("wireRequestBeforeControl", source, StringComparison.Ordinal); + Assert.Contains("ControlNotSentFailure", source, StringComparison.Ordinal); + Assert.Contains("CompletionState = \"NotSent\"", source, StringComparison.Ordinal); + Assert.Contains("Stage = \"NOT SENT TO IED\"", source, StringComparison.Ordinal); + Assert.Contains("before any MMS control request was built or sent", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ControlWireUnknownFailure", source, StringComparison.Ordinal); + } + + [Fact] + public void App_MapsOrderedNativeControlWireSteps() + { + var model = File.ReadAllText(Path.Combine(RepoRoot(), "Models", "ControlModels.cs")); + var client = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); + Assert.Contains("class Iec61850ControlWireEvidence", model, StringComparison.Ordinal); + Assert.Contains("IReadOnlyList WireSteps", model, StringComparison.Ordinal); + Assert.Contains("WireSteps = result.WireSteps.Select", client, StringComparison.Ordinal); + Assert.Contains("Action = step.Action.ToString()", client, StringComparison.Ordinal); + Assert.Contains("Reference = step.Reference", client, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_EmitsOrderedWireStepsAndExactRequestResponseEvidence() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + Assert.Contains("CONTROL_WIRE_STEP:", source, StringComparison.Ordinal); + Assert.Contains("order={index + 1}", source, StringComparison.Ordinal); + Assert.Contains("action={step.Action}", source, StringComparison.Ordinal); + Assert.Contains("reference={step.Reference}", source, StringComparison.Ordinal); + Assert.Contains("CONTROL_WIRE_REQUEST:", source, StringComparison.Ordinal); + Assert.Contains("CONTROL_WIRE_RESPONSE:", source, StringComparison.Ordinal); + Assert.Contains("ordered MMS control response(s) captured", source, StringComparison.Ordinal); + Assert.Contains("Never infer server acceptance from request HEX alone", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("MMS command submitted", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CommandUi_DistinguishesNotSentAndShowsWireSequence() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "ControlCommandWindow.xaml.cs")); + Assert.Contains("No MMS control request was sent to the IED", source, StringComparison.Ordinal); + Assert.Contains("MMS request/response wire evidence was captured", source, StringComparison.Ordinal); + Assert.Contains("MMS request encoding was captured, but no MMS response was captured", source, StringComparison.Ordinal); + Assert.Contains("Wire sequence:", source, StringComparison.Ordinal); + Assert.Contains("result.WireSteps.Select(step => step.Action)", source, StringComparison.Ordinal); + } + + [Fact] + public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl() + { + var model = File.ReadAllText(Path.Combine(RepoRoot(), "Models", "ControlModels.cs")); + var dialog = File.ReadAllText(Path.Combine(RepoRoot(), "ControlCommandWindow.xaml.cs")); + var main = File.ReadAllText(Path.Combine(RepoRoot(), "MainWindow.xaml.cs")); + var client = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); + var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + + Assert.Contains("OriginCategory { get; init; } = \"StationControl\"", model, StringComparison.Ordinal); + Assert.Contains("OriginCategory = \"StationControl\"", dialog, StringComparison.Ordinal); + Assert.Contains("OriginCategory = \"StationControl\"", main, StringComparison.Ordinal); + Assert.DoesNotContain("OriginCategory = \"Maintenance\"", dialog, StringComparison.Ordinal); + Assert.DoesNotContain("OriginCategory = \"Maintenance\"", main, StringComparison.Ordinal); + Assert.Contains("Iec61850OriginCategory.StationControl", client, StringComparison.Ordinal); + Assert.DoesNotContain("MMS command submitted:", main, StringComparison.Ordinal); + Assert.Contains("wire send is not assumed until native evidence is returned", main, StringComparison.OrdinalIgnoreCase); + Assert.Contains("rejectedStep.Action.Equals(\"SelectWithValue\"", dialog, StringComparison.Ordinal); + Assert.Contains("? \"SBOw\" : rejectedStep.Action", dialog, StringComparison.Ordinal); + Assert.Contains("IED REJECTED {rejectedStage}", dialog, StringComparison.Ordinal); + Assert.Contains("Operate was NOT sent because SBOw selection failed", dialog, StringComparison.Ordinal); + Assert.Contains("IED BLOCKED COMMAND BY INTERLOCKING", dialog, StringComparison.Ordinal); + Assert.Contains("IED BLOCKED COMMAND BY SYNCHROCHECK", dialog, StringComparison.Ordinal); + Assert.Contains("requested control condition/service is not supported", dialog, StringComparison.Ordinal); + Assert.Contains("CONTROL_REJECTED_BY_IED:", runtime, StringComparison.Ordinal); + Assert.Contains("Control execution requested:", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("Control intent accepted:", runtime, StringComparison.Ordinal); + Assert.Contains("OperateSent={operateSent}", runtime, StringComparison.Ordinal); + Assert.Contains("origin={request.OriginCategory}/{request.Originator}", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("RetryControl", runtime, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() + { + var engineLock = File.ReadAllText(Path.Combine(RepoRoot(), "engines", "ARIEC61850.lock.json")); + Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); + + var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + Assert.Contains("SmartReconnectPolicy", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); + } + + private static string RepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj"))) + return current.FullName; + current = current.Parent; + } + throw new DirectoryNotFoundException("ARSAS repository root not found."); + } +} diff --git a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs index 7590cd8ea..88ff45a7b 100644 --- a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs +++ b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace ARSAS.Tests; public sealed class HybridReportAssociationCapabilityP3RegressionTests @@ -18,12 +20,16 @@ public void HybridPlanning_UsesAssociationCapabilityForInitialPlanAndFreshRevali } [Fact] - public void EngineLock_PinsP62BStabilityEngineWhilePreservingP61AndP62EvidenceHistory() + public void EngineLock_PreservesP62BStabilityHistoryAcrossLaterReviewedEnginePins() { var source = Read("engines/ARIEC61850.lock.json"); + using var document = JsonDocument.Parse(source); + var root = document.RootElement; - Assert.Contains("249fb130e0e18e7a98e07e8894f24610bdb5642e", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 89", source, StringComparison.Ordinal); + Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); + Assert.Equal("main", root.GetProperty("ref").GetString()); + Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); Assert.Contains("PR #87", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("baseline-safe static precedence", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #88", source, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index b8223a9a0..b9d397a60 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -69,14 +69,16 @@ public void SignalSelectionRecovery_RunsAgainAfterLegacyConstructorDisplayPrepar } [Fact] - public void EngineLock_PreservesReportProjectionHistoryAndPinsP62BStabilityEngine() + public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEnginePins() { var source = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); using var document = JsonDocument.Parse(source); var root = document.RootElement; - Assert.Equal("249fb130e0e18e7a98e07e8894f24610bdb5642e", root.GetProperty("commit").GetString()); - Assert.Equal(89, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); + Assert.Equal("main", root.GetProperty("ref").GetString()); + Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs index 9039fcd6d..3910c301c 100644 --- a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs @@ -7,7 +7,7 @@ namespace ARSAS.Tests; public sealed class P62BFieldStabilityRegressionTests { [Fact] - public void EngineLock_PinsReviewedP62BEngine() + public void EngineLock_PreservesReviewedP62BPolicyAcrossLaterEnginePins() { var source = ReadRepoFile("engines/ARIEC61850.lock.json"); using var document = JsonDocument.Parse(source); @@ -15,9 +15,11 @@ public void EngineLock_PinsReviewedP62BEngine() Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); Assert.Equal("main", root.GetProperty("ref").GetString()); - Assert.Equal("249fb130e0e18e7a98e07e8894f24610bdb5642e", root.GetProperty("commit").GetString()); - Assert.Equal(89, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); + Assert.Contains("PR #89", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("quarantines automatic full dynamic DataSet activation", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("successful one-member NVL probation does not guarantee association survival", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("ambiguous structures remain raw", source, StringComparison.OrdinalIgnoreCase); }