diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 915ce49f1..c61e4a778 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -524,23 +524,32 @@ await Task.Delay(remaining, _applicationCancellation.Token) // This observer never drives MMS IO, GI or a cyclic fallback. LogStaticRcbImageTriage(device); - if (trafficObserved && device.HasReportStream) + // The per-RCB decoder-backed stream evidence is authoritative. + // The UI pulse can be false (e.g. event lost at monitor startup) + // even after all seven RCBs delivered genuine InformationReports. + var exactRcbRows = ArIED61850Tester.Services.StaticDataSetRcbImageTriage.Evaluate( + device.Points, device.ReportContinuityEvidence, maxGroups: 16); + var routedRcbs = exactRcbRows.Count(row => row.HasActualReportFrames); + if (routedRcbs > 0) { var reportPoints = device.Points.Count(point => IsReportSource(point.SourceMode) && !point.SourceMode.Contains("pending", StringComparison.OrdinalIgnoreCase)); - var firstReportLatency = DateTime.UtcNow - startedUtc; AddLog( "INFO", device.Name, - $"Static DataSet report evidence: actual InformationReport traffic observed after {firstReportLatency.TotalMilliseconds:0} ms; report-backed runtime point(s)={reportPoints}. At least one RCB delivered a frame; each other RCB remains independently unverified until its actual ingress is observed."); + $"Static DataSet report evidence: actual InformationReport traffic observed within {proofWindow.TotalSeconds:0.#} s; " + + $"exact routed RCBs={routedRcbs}/{exactRcbRows.Count}; report-backed runtime point(s)={reportPoints}. " + + "Each RCB remains independently verified by its own frames; zero-frame peers are not assumed healthy."); return; } AddLog( "WARN", device.Name, - $"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. Activation remains armed/report-pending; absence of traffic alone is not treated as RCB activation failure and ARSAS will NOT switch process values to cyclic MMS polling."); + $"Static DataSet report evidence: no exact routed RCB InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s " + + $"(uiPulse={trafficObserved && device.HasReportStream}, configuredGroups={exactRcbRows.Count}). " + + "Activation remains armed/report-pending; lack of exact routed ingress is not proof of IED silence and ARSAS will NOT switch process values to cyclic MMS polling."); MarkDiagnosticAlert(); } catch (OperationCanceledException) diff --git a/Models/Iec61850ValueStatePresentation.cs b/Models/Iec61850ValueStatePresentation.cs index 012cf4842..0584b4874 100644 --- a/Models/Iec61850ValueStatePresentation.cs +++ b/Models/Iec61850ValueStatePresentation.cs @@ -25,6 +25,11 @@ public static string Classify(string? value, string? dataType = null) var normalized = text.ToLowerInvariant(); + // A raw typed BIT STRING's bits are data, not a DPC/Boolean assertion. + // In particular Bits [10] must not inherit the DPC Closed/Active tone. + if (normalized.StartsWith("bits [", StringComparison.Ordinal)) + return Neutral; + // DPC state codes are process states, not health/severity. Intermediate and // bad are the only forms that deserve the amber attention tone. if (normalized.Contains("intermediate", StringComparison.Ordinal) || @@ -96,6 +101,9 @@ public static string TypeToken(string? dataType) return "F"; } + if (normalized is "BITSTRING" or "BITS") + return "BIT"; + if (normalized.StartsWith("UINT", StringComparison.Ordinal) || normalized is "INT8U" or "INT16U" or "INT24U" or "INT32U" or "INT64U") { diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 4888f59eb..87f047b02 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -567,7 +567,8 @@ public async Task> ConnectAndDiscoverAsync( try { progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.OpeningTcp, $"Opening TCP {device.IpAddress}:{device.Port}…", 8d, 2, 15)); - await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false); + await ConnectInitialDiscoveryAssociationWithRetryAsync( + session, cancellationToken).ConfigureAwait(false); if (!session.Client.IsConnected) { device.Status = "Connection failed"; @@ -3036,55 +3037,93 @@ private void EmitStatusSnapshot( }); } - private async Task ConnectCachedAssociationWithRetryAsync( - DeviceSession session, - CancellationToken cancellationToken) + // Both initial full discovery and cached reconnect use ONE association + // executor; only their bounded per-attempt timeout differs. Reporting, + // discovery and control operations stay strictly outside this lifecycle. + private Task ConnectInitialDiscoveryAssociationWithRetryAsync( + DeviceSession session, CancellationToken cancellationToken) + => ConnectAssociationWithRetryAsync(session, cachedModel: false, cancellationToken); + + private Task ConnectCachedAssociationWithRetryAsync( + DeviceSession session, CancellationToken cancellationToken) + => ConnectAssociationWithRetryAsync(session, cachedModel: true, cancellationToken); + + private async Task ConnectAssociationWithRetryAsync( + DeviceSession session, bool cachedModel, CancellationToken cancellationToken) { - const int maxAttempts = 2; - for (var attempt = 1; attempt <= maxAttempts; attempt++) + var phase = cachedModel ? "Fast saved-model" : "Initial discovery"; + for (var attempt = 1; + attempt <= SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts; + attempt++) { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(SmartReconnectPolicy.ConnectBudget); + cancellationToken.ThrowIfCancellationRequested(); + + // Cached association already has a strict two-second attempt budget. + // Initial discovery retains the engine's own eight-second budget. + using var budget = cachedModel + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; + if (budget is not null) + budget.CancelAfter(SmartReconnectPolicy.ConnectBudget); + var attemptToken = budget?.Token ?? cancellationToken; + + var started = Stopwatch.GetTimestamp(); + var nativeDeadlineCancelled = false; try { await session.Client.ConnectAsync( session.Device.IpAddress, session.Device.Port, - timeout.Token).ConfigureAwait(false); + attemptToken).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - $"Fast saved-model connect attempt #{attempt} timed out"); + // Only the attempt-local budget or native internal timeout. + // Explicit user cancellation still propagates and is not retried. + nativeDeadlineCancelled = true; } - + var elapsed = Stopwatch.GetElapsedTime(started); if (session.Client.IsConnected) { if (attempt > 1) - { Log("INFO", session.Device.Name, - $"Fast saved-model connection recovered automatically on attempt #{attempt}; no manual Play retry was required."); - } + $"{phase} MMS connection recovered on attempt #{attempt} " + + $"after {elapsed.TotalMilliseconds:0} ms; discovery and RCB setup remain single-flight."); return; } - if (attempt >= maxAttempts) - return; + var failureKind = nativeDeadlineCancelled + ? "NATIVE_ASSOCIATION_CANCELLED" + : session.Client.LastConnectionFailureKind; + var failureDetail = nativeDeadlineCancelled + ? "Association attempt deadline expired before MMS was ready." + : string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) + ? $"native state={session.Client.NativeState}" + : session.Client.LastErrorMessage; + session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + $"{phase} MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms"); - var failure = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) - ? $"native state={session.Client.NativeState}" - : session.Client.LastErrorMessage; + // Fail closed on unclassified errors and ACSE/MMS refusal. Do not + // repeat a confirmed protocol rejection just to satisfy a retry count. + var retry = attempt < SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts && + SmartReconnectPolicy.CanRetryInitialDiscoveryAssociation(failureKind); Log("WARN", session.Device.Name, - $"Fast saved-model connection attempt #{attempt} did not establish MMS ({failure}). Retrying once after {SmartReconnectPolicy.InitialAssociationRetryDelay.TotalMilliseconds:0} ms."); + $"{phase} MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms " + + $"(kind={failureKind}, retry={retry}). {failureDetail}"); - var staleClient = session.Client; + if (!retry) + return; + + // A new client gives the next TCP/COTP/ACSE/MMS attempt a new + // ownership scope. Never recycle a failed protocol session. + var oldClient = session.Client; session.Client = new NativeIec61850Client(); await DisposeClientForReconnectAsync( - staleClient, - session.Device.Name, - SmartReconnectPolicy.ClientCleanupBudget, + oldClient, session.Device.Name, + SmartReconnectPolicy.ClientCleanupBudget, cancellationToken).ConfigureAwait(false); + await Task.Delay( + SmartReconnectPolicy.InitialAssociationRetryDelay, cancellationToken).ConfigureAwait(false); - await Task.Delay(SmartReconnectPolicy.InitialAssociationRetryDelay, cancellationToken).ConfigureAwait(false); } } diff --git a/Services/Iec61850TwoBitStatusPresentation.cs b/Services/Iec61850TwoBitStatusPresentation.cs new file mode 100644 index 000000000..da61fd9f7 --- /dev/null +++ b/Services/Iec61850TwoBitStatusPresentation.cs @@ -0,0 +1,58 @@ +namespace ArIED61850Tester.Services; + +/// +/// Single, side-effect-free operator presentation boundary for two-bit MMS +/// status values. The raw association ledger, quality flags, event ordering and +/// typed decoder are not changed. DPC positions use their own CDC-aware path. +/// +internal static class Iec61850TwoBitStatusPresentation +{ + internal static bool TryFormat( + string wireValue, + string? dataType, + string? category, + string? reference, + out string display) + { + display = string.Empty; + + // Metadata authority is required. A Measurement that happens to be two + // bits must not inherit a process-state meaning from its byte shape. + if (!string.Equals(category?.Trim(), "Status", StringComparison.OrdinalIgnoreCase)) + return false; + + var path = reference.AsSpan().Trim(); + if (!path.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) && + !path.EndsWith("$stVal", StringComparison.OrdinalIgnoreCase)) + return false; + + // Fail closed when declared MMS/IEC type contradicts a two-bit status. + // Unknown is a legacy incomplete-model state, not a vendor exception. + if (!IsBitStringOrIncompleteType(dataType)) + return false; + + if (!Iec61850ValueFormatter.TryNormalizeTwoBitWireStatus(wireValue, out var code)) + return false; + + display = code switch + { + 0 => "Bits [00]", + 1 => "Bits [01]", + 2 => "Bits [10]", + 3 => "Bits [11]", + _ => string.Empty + }; + return display.Length != 0; + } + + private static bool IsBitStringOrIncompleteType(string? dataType) + { + var type = dataType?.Trim(); + return string.IsNullOrEmpty(type) || + type.Equals("Unknown", StringComparison.OrdinalIgnoreCase) || + type.Equals("BitString", StringComparison.OrdinalIgnoreCase) || + type.Equals("Bit String", StringComparison.OrdinalIgnoreCase) || + type.Equals("Bits", StringComparison.OrdinalIgnoreCase) || + type.Equals("MmsBitString", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Services/Iec61850ValueFormatter.cs b/Services/Iec61850ValueFormatter.cs index 2c1646b60..8c75b2d7e 100644 --- a/Services/Iec61850ValueFormatter.cs +++ b/Services/Iec61850ValueFormatter.cs @@ -86,6 +86,14 @@ public static string FormatReportProcessValue( return boolean ? "True [1]" : "False [0]"; } + // The protocol decoder owns raw MMS truth. Only a typed operator + // status projection may render a two-bit status; never mutate MMS + // measurements, quality evidence or an unknown semantic field. + if (normalizedValue is string renderedBits && + Iec61850TwoBitStatusPresentation.TryFormat( + renderedBits, dataType, category, reference, out var displayBits)) + return displayBits; + return FormatNormalizedValue(normalizedValue, dataType, unit); } diff --git a/Services/SmartReconnectPolicy.cs b/Services/SmartReconnectPolicy.cs index bc410baea..308acae0b 100644 --- a/Services/SmartReconnectPolicy.cs +++ b/Services/SmartReconnectPolicy.cs @@ -11,6 +11,14 @@ public static class SmartReconnectPolicy public static TimeSpan ClientCleanupBudget => TimeSpan.FromMilliseconds(500); public static TimeSpan ConnectBudget => TimeSpan.FromSeconds(2); public static TimeSpan InitialAssociationRetryDelay => TimeSpan.FromMilliseconds(500); + + // First full discovery had no retry, while a cached-model connection already + // had a bounded transient retry. Never replay discovery or any RCB operation: + // this policy only permits one extra attempt to establish TCP/ACSE/MMS. + public const int InitialDiscoveryAssociationMaxAttempts = 2; + + public static bool CanRetryInitialDiscoveryAssociation(string? failureKind) + => failureKind is "TCP_TIMEOUT" or "TCP_TRANSPORT_CLOSED" or "TCP_CONNECTION_REFUSED" or "NATIVE_ASSOCIATION_CANCELLED"; public static TimeSpan ReportRearmDelay => TimeSpan.FromSeconds(1); public static TimeSpan ReportRearmDeadline => TimeSpan.FromSeconds(3); public static TimeSpan RecoveryWarmupDuration => TimeSpan.FromSeconds(3); diff --git a/docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md b/docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md new file mode 100644 index 000000000..a89b256b6 --- /dev/null +++ b/docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md @@ -0,0 +1,19 @@ +# P8 SIEBCU first-connect / BIT STRING / false reporting warning audit (2026-11-05) + +## Operator evidence (P8 trial, SHA 37165a0c / engine ae1ce23d) + +Diagnostics generated at 10:53:49 WIB (2026-11-05) for SIEBCU 192.16.1.192:102. Log starts association at 10:53:25.055 and again at 10:53:35.128, with MMS initiated at 10:53:35.182. This establishes two attempts, **not the cause of the first failure**; the retained log does not include a first-attempt exception/rejection. The accepted route is SAS-Port 3 with 192.16.1.92/24, same subnet. The successful smart discovery took ~1756 ms for 211 requests. The trial received 14 Report frames across seven exactly routed RCBs, with seven visible selected values. + +At 10:53:42.741, an observer still logged "no InformationReport traffic" despite decoder-based per-RCB evidence showing 7/7 streams with frames at 10:53:41. This is an application diagnostic contradiction, not a proof of wire SOE loss. + +The UI displayed `USER2.LOCREM.stVal` as `bits(80, unused=6)`. This raw MMS renderer means one byte 0x80 with six unused padding bits, therefore two significant bits `10`. It does **not** establish a vendor-specific Local/Remote label nor general DPC/Close semantics. + +## Source-supported defects and changes + +1. Full discovery started its MMS association only once; the cached reconnect route already had a bounded retry. Initial discovery now retries at most once **only** after TCP timeout, connection refused, transport closure, or internal native association cancellation with the parent request still active. It uses a new native client, observes a 500 ms backoff, preserves failure-kind and elapsed-attempt logs, and performs discovery only after the successful association. Explicit cancellation, ACSE rejection, unknown failure and unreachable-network errors do not provoke hidden retries. No reporting/RCB write or control is triggered by retry. +2. The report value formatter already understood valid `bits(HH, unused=6)` but used the decoding only for DPC/position. Exact two-bit non-position values now show `Bits [10]`, without a guessed semantic label; DPC positions retain `Close [10]`. Malformed padding and non-two-bit representations remain unmodified. The optional `BIT` badge is based only on an explicit BitString data-type declaration. +3. The three-second static-report observer used `trafficObserved && HasReportStream` to decide whether to log "no traffic", even when exact per-RCB continuity evidence contained real frames. Now it examines exact routed frame evidence for each RCB in the current association before deciding the verdict; zero-frame peers remain independently unverified. It performs no MMS I/O. + +## Safety and acceptance + +Changes are scoped to the existing P8 trial branch consumer; ARSAS main, integrated RC, stable v1.6.42, pinned engine baseline, and ARIEC staging/P9 remain unchanged. This change does not prove the first attempt's physical cause, instant first-attempt success, uninterrupted SOE, or SG7 77/77, 4/4 recovery. Validate exact-head CI, then create a separately identifiable Windows nonrelease trial before field retest. Retain raw MMS typed evidence alongside operator display. diff --git a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs index bb3308120..83e7df424 100644 --- a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs +++ b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs @@ -83,6 +83,73 @@ public void FormatReportProcessValue_LeavesNumericMeasurementUnchanged() Assert.Equal("123.45 V", formatted); } + [Theory] + [InlineData("bits(80, unused=6)", "Bits [10]")] + [InlineData("bits(40, unused=6)", "Bits [01]")] + [InlineData("bits(00, unused=6)", "Bits [00]")] + [InlineData("bits(C0, unused=6)", "Bits [11]")] + public void ReportTwoBitStatus_ShowsActualSignificantBits_WithoutInventingLocalRemote( + string wireValue, string expected) + { + Assert.Equal(expected, Iec61850ValueFormatter.FormatReportProcessValue( + wireValue, "BitString", "", "Status", "SIEBCUUD1/USER2.LOCREM.stVal")); + } + + [Theory] + [InlineData("Measurement", "IEDLD/GGIO1.RawBits")] + [InlineData("Status", "IEDLD/GGIO1.RawBits")] + [InlineData("Status", "IEDLD/GGIO1.RawBits.q")] + [InlineData("Status", "IEDLD/GGIO1.RawBits.subVal")] + [InlineData("Quality", "IEDLD/GGIO1.RawBits.stVal")] + public void TwoBitRawEvidence_OutsideExactStatusStVal_IsNotMutated( + string category, string reference) + { + const string wire = "bits(80,unused=6)"; + Assert.Equal(wire, Iec61850ValueFormatter.FormatReportProcessValue( + wire, "BitString", "", category, reference)); + } + + [Theory] + [InlineData("Float32")] + [InlineData("Quality")] + [InlineData("Boolean")] + [InlineData("Enum")] + [InlineData("INT32")] + public void TwoBitStatus_ContradictingDeclaredType_MustStayRaw(string declaredType) + { + const string raw = "bits(80,unused=6)"; + Assert.Equal(raw, Iec61850ValueFormatter.FormatReportProcessValue( + raw, declaredType, "", "Status", "IEDLD/GGIO1.Flag.stVal")); + } + + [Theory] + [InlineData("bits(00,unused=6)", "Bits [00]")] + [InlineData("bits(40,unused=6)", "Bits [01]")] + [InlineData("bits(80,unused=6)", "Bits [10]")] + [InlineData("bits(C0,unused=6)", "Bits [11]")] + public void TwoBitStatus_IncompleteModelRetainsTruthfulSignificantBits(string raw, string expected) + => Assert.Equal(expected, Iec61850ValueFormatter.FormatReportProcessValue( + raw, "Unknown", "", "Status", "IEDLD/GGIO1.Mode.stVal")); + + [Fact] + public void TwoBitStatus_IsOnlyDpcWhenExplicitPositionSemanticsArePresent() + { + Assert.Equal("Close [10]", Iec61850ValueFormatter.FormatReportProcessValue( + "bits(80, unused=6)", "DPC", "", "Position", "SIEBCUCB3/CSWI1.Pos.stVal")); + Assert.Equal("Bits [10]", Iec61850ValueFormatter.FormatReportProcessValue( + "bits(80, unused=6)", "Unknown", "", "Status", "SIEBCUUD1/USER2.LOCREM.stVal")); + } + + [Theory] + [InlineData("bits(81, unused=6)")] + [InlineData("bits(80, unused=5)")] + [InlineData("bits(FFFF, unused=6)")] + public void MalformedWireBitString_RemainsUnmodifiedAndCannotFabricateStatus(string raw) + { + Assert.Equal(raw, Iec61850ValueFormatter.FormatReportProcessValue( + raw, "BitString", "", "Status", "IED/GGIO1.VendorState.stVal")); + } + [Fact] public void Format_Extracts_Boolean_StVal_From_Legacy_Report_Structure() { diff --git a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs index 11e1c1847..a4f5cfcbb 100644 --- a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs +++ b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs @@ -15,6 +15,54 @@ public void RetryBackoff_IsBoundedAndDeterministic(int failureCount, int expecte TimeSpan.FromMilliseconds(expectedMilliseconds), SmartReconnectPolicy.GetRetryDelay(failureCount)); + [Theory] + [InlineData("TCP_TIMEOUT", true)] + [InlineData("TCP_TRANSPORT_CLOSED", true)] + [InlineData("TCP_CONNECTION_REFUSED", true)] + [InlineData("NATIVE_ASSOCIATION_CANCELLED", true)] + [InlineData("NETWORK_UNREACHABLE", false)] + [InlineData("ACSE_MMS_ASSOCIATION_REJECTED", false)] + [InlineData("IEC61850_CONNECTION_FAILED", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void InitialDiscovery_RetriesOnlyKnownTransientTransportFailures( + string? failureKind, bool expected) + { + Assert.Equal(expected, SmartReconnectPolicy.CanRetryInitialDiscoveryAssociation(failureKind)); + Assert.Equal(2, SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts); + } + + [Fact] + public void AssociationRecovery_UsesOneExecutorAndNeverReplaysDiscoveryOrControl() + { + var runtime = ReadRepoFile("Services/Iec61850MonitorRuntime.cs"); + var fullDiscoveryCall = runtime.IndexOf( + "await ConnectInitialDiscoveryAssociationWithRetryAsync(", StringComparison.Ordinal); + var discoveryCall = runtime.IndexOf( + "await session.Client.DiscoverSignalsAsync(", StringComparison.Ordinal); + var cachedCall = runtime.IndexOf( + "await ConnectCachedAssociationWithRetryAsync(", StringComparison.Ordinal); + var executor = runtime.IndexOf( + "private async Task ConnectAssociationWithRetryAsync(", StringComparison.Ordinal); + var next = runtime.IndexOf( + "private static async Task ResetAssociationControlAuthorityAsync(", StringComparison.Ordinal); + Assert.True(fullDiscoveryCall >= 0 && fullDiscoveryCall < discoveryCall); + Assert.True(cachedCall >= 0 && executor >= 0 && next > executor); + + var owner = runtime[executor..next]; + Assert.DoesNotContain("DiscoverSignalsAsync(", owner, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReport", owner, StringComparison.Ordinal); + Assert.DoesNotContain("WriteReport", owner, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControlAsync(", owner, StringComparison.Ordinal); + Assert.Contains("new NativeIec61850Client()", owner, StringComparison.Ordinal); + Assert.Contains("InitialAssociationRetryDelay", owner, StringComparison.Ordinal); + Assert.Contains("ClientCleanupBudget", owner, StringComparison.Ordinal); + Assert.Contains("cancellationToken.ThrowIfCancellationRequested()", owner, StringComparison.Ordinal); + Assert.Contains("CanRetryInitialDiscoveryAssociation(failureKind)", owner, StringComparison.Ordinal); + Assert.Contains("cachedModel ? \"Fast saved-model\" : \"Initial discovery\"", owner, StringComparison.Ordinal); + Assert.Contains("budget.CancelAfter(SmartReconnectPolicy.ConnectBudget)", owner, StringComparison.Ordinal); + } + [Fact] public void RecoveryWarmup_RemainsResponsiveForCommissioningReconnect() { diff --git a/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs index 54c59134c..32386b150 100644 --- a/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs +++ b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs @@ -55,7 +55,12 @@ public void StaticTrafficProof_WaitsForPropertyChangeInsteadOfFixedSleep() Assert.Contains("WaitForReportStreamAsync", source, StringComparison.Ordinal); Assert.Contains("device.PropertyChanged += handler;", source, StringComparison.Ordinal); Assert.Contains(".WaitAsync(timeout, cancellationToken)", source, StringComparison.Ordinal); - Assert.Contains("firstReportLatency.TotalMilliseconds", source, StringComparison.Ordinal); + // A timed UI pulse cannot disprove actual decoder-backed RCB frames. + // This was the false-negative cause in the physical SIEBCU trial. + Assert.Contains("StaticDataSetRcbImageTriage.Evaluate(", source, StringComparison.Ordinal); + Assert.Contains("row.HasActualReportFrames", source, StringComparison.Ordinal); + Assert.Contains("if (routedRcbs > 0)", source, StringComparison.Ordinal); + Assert.DoesNotContain("if (trafficObserved && device.HasReportStream)", source, StringComparison.Ordinal); Assert.DoesNotContain( "await Task.Delay(proofWindow", source, diff --git a/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs b/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs index bba5c4fea..99c23cd90 100644 --- a/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs +++ b/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs @@ -18,6 +18,19 @@ public sealed class ProcessValueStatePresentationTests public void DiscreteStates_AreClassifiedWithoutSeveritySemantics(string value, string dataType, string expected) => Assert.Equal(expected, Iec61850ValueStatePresentation.Classify(value, dataType)); + [Theory] + [InlineData("Bits [10]", "BitString")] + [InlineData("Bits [11]", "BitString")] + [InlineData("Bits [00]", "Unknown")] + public void RawTwoBitNonDpc_NeverAcquiresPositionTone(string value, string dataType) + { + Assert.Equal(Iec61850ValueStatePresentation.Neutral, + Iec61850ValueStatePresentation.Classify(value, dataType)); + Assert.Equal(Iec61850ValueStatePresentation.Neutral, + Iec61850ValueStatePresentation.ClassifyVisualKind( + value, dataType, "Status", "SIEBCUUD1/USER2.LOCREM.stVal")); + } + [Theory] [InlineData("1", "Boolean", Iec61850ValueStatePresentation.Active)] [InlineData("0", "Boolean", Iec61850ValueStatePresentation.Inactive)] @@ -74,6 +87,8 @@ public void VisualKind_DoesNotTurnGenericEnumOffIntoPosition() [InlineData("Enum", "E")] [InlineData("Dbpos", "DP")] [InlineData("DPC", "DP")] + [InlineData("BitString", "BIT")] + [InlineData("BITS", "BIT")] [InlineData("DPS", "DP")] [InlineData("Counter", "")] [InlineData("Unknown", "")] diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index bb56d251e..e19318344 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -194,7 +194,11 @@ public void SharedStaticFat_EmitsCausalReportEvidenceWithoutMmsFallback() Assert.Contains("LogStaticRcbImageTriage(device);", source, StringComparison.Ordinal); Assert.Contains("waiting for actual InformationReport traffic", source, StringComparison.Ordinal); Assert.Contains("actual InformationReport traffic observed", source, StringComparison.Ordinal); - Assert.Contains("no InformationReport traffic was observed", source, StringComparison.Ordinal); + Assert.Contains("no exact routed RCB InformationReport traffic was observed", source, StringComparison.Ordinal); + Assert.Contains("var exactRcbRows = ArIED61850Tester.Services.StaticDataSetRcbImageTriage.Evaluate(", source, StringComparison.Ordinal); + Assert.Contains("var routedRcbs = exactRcbRows.Count(row => row.HasActualReportFrames)", source, StringComparison.Ordinal); + Assert.Contains("if (routedRcbs > 0)", source, StringComparison.Ordinal); + Assert.DoesNotContain("if (trafficObserved && device.HasReportStream)", source, StringComparison.Ordinal); Assert.Contains("will NOT switch process values to cyclic MMS polling", source, StringComparison.Ordinal); }