From da3aa249edd9fd71ab4960d17bdafcb935cd5f71 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 07:30:45 +0700 Subject: [PATCH 1/7] fix(diagnostics): key probe evidence by device identity, not endpoint text (#446) --- Services/DiagnosticReportBuilder.cs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 45e7c8c14..44737abd7 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -11,7 +11,7 @@ namespace ArIED61850Tester.Services; internal static class DiagnosticReportBuilder { - private sealed record TcpProbeResult(string Endpoint, string Result, string Detail); + private sealed record TcpProbeResult(string DeviceId, string Endpoint, string Result, string Detail); private sealed record AdapterIpv4(string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); private sealed record RouteAnalysis(string Source, string MatchingAdapters, string AdapterMatrix, string Note); @@ -24,7 +24,14 @@ public static async Task BuildAsync( var appAssembly = Assembly.GetEntryAssembly() ?? typeof(DiagnosticReportBuilder).Assembly; var engineAssembly = typeof(AR.Iec61850.Mms.MmsClientSession).Assembly; var probes = await Task.WhenAll(devices.Select(device => ProbeDeviceAsync(device, cancellationToken))).ConfigureAwait(false); - var probeByEndpoint = probes.ToDictionary(result => result.Endpoint, StringComparer.OrdinalIgnoreCase); + + // Diagnostics must remain available even when the workspace itself is malformed, + // duplicated, offline-only or contains multiple cards with the same endpoint text + // (for example "No endpoint"). DeviceId is the stable per-card identity; endpoint + // text is evidence, not a dictionary key. + var probeByDeviceId = probes + .GroupBy(result => result.DeviceId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase); var builder = new StringBuilder(64 * 1024); builder.AppendLine("ARSAS Diagnostic Report"); @@ -69,7 +76,7 @@ public static async Task BuildAsync( { cancellationToken.ThrowIfCancellationRequested(); var diagnostic = device.LastDiagnosticSnapshot ?? new Iec61850DeviceDiagnosticSnapshot(); - probeByEndpoint.TryGetValue(device.EndpointText, out var probe); + probeByDeviceId.TryGetValue(device.DeviceId, out var probe); builder.AppendLine($"IED : {device.Name}"); builder.AppendLine($"Endpoint : {device.EndpointText}"); @@ -274,10 +281,10 @@ private static async Task ProbeDeviceAsync( CancellationToken cancellationToken) { if (device.IsConnected) - return new TcpProbeResult(device.EndpointText, "SKIPPED", "active MMS session already connected"); + return new TcpProbeResult(device.DeviceId, device.EndpointText, "SKIPPED", "active MMS session already connected"); if (string.IsNullOrWhiteSpace(device.IpAddress) || device.Port is <= 0 or > 65535) - return new TcpProbeResult(device.EndpointText, "INVALID", "invalid endpoint"); + return new TcpProbeResult(device.DeviceId, device.EndpointText, "INVALID", "invalid endpoint"); using var client = new TcpClient(); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -287,7 +294,7 @@ private static async Task ProbeDeviceAsync( { await client.ConnectAsync(device.IpAddress, device.Port, timeout.Token).ConfigureAwait(false); stopwatch.Stop(); - return new TcpProbeResult(device.EndpointText, "OPEN", $"TCP accepted in {stopwatch.Elapsed.TotalMilliseconds:0} ms"); + return new TcpProbeResult(device.DeviceId, device.EndpointText, "OPEN", $"TCP accepted in {stopwatch.Elapsed.TotalMilliseconds:0} ms"); } catch (SocketException ex) { @@ -300,12 +307,12 @@ private static async Task ProbeDeviceAsync( catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { stopwatch.Stop(); - return new TcpProbeResult(device.EndpointText, "TIMEOUT", $"> {stopwatch.Elapsed.TotalMilliseconds:0} ms"); + return new TcpProbeResult(device.DeviceId, device.EndpointText, "TIMEOUT", $"> {stopwatch.Elapsed.TotalMilliseconds:0} ms"); } catch (Exception ex) { stopwatch.Stop(); - return new TcpProbeResult(device.EndpointText, "FAILED", $"{ex.GetType().Name}: {ex.Message}"); + return new TcpProbeResult(device.DeviceId, device.EndpointText, "FAILED", $"{ex.GetType().Name}: {ex.Message}"); } } From 237880d9733f5c8da5ccde75228cac04bd67b4db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 07:30:59 +0700 Subject: [PATCH 2/7] feat(scl): restore known MMS endpoint by exact IED identity (#446) --- Services/UserPreferenceStore.cs | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Services/UserPreferenceStore.cs b/Services/UserPreferenceStore.cs index 8947ba57f..9be74665e 100644 --- a/Services/UserPreferenceStore.cs +++ b/Services/UserPreferenceStore.cs @@ -79,6 +79,51 @@ public static void RecordSuccessfulEndpoint(string ipAddress, int port, string i File.WriteAllText(PreferencesPath, JsonSerializer.Serialize(prefs, Options)); } + public static bool TryLoadSuccessfulEndpointForIed( + string iedName, + out string ipAddress, + out int port) + { + ipAddress = string.Empty; + port = 102; + + var nameKey = NormalizeName(iedName); + if (string.IsNullOrWhiteSpace(nameKey)) + return false; + + var prefs = LoadPreferences(); + var endpoint = prefs.SuccessfulRelays + .Where(item => + !string.IsNullOrWhiteSpace(item.IpAddress) && + NormalizeName(item.IedName).Equals(nameKey, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.LastConnectedUtc) + .FirstOrDefault(); + + if (endpoint != null) + { + ipAddress = endpoint.IpAddress.Trim(); + port = endpoint.MmsPort <= 0 ? 102 : endpoint.MmsPort; + return true; + } + + // Older preference files may have the IEDName-to-endpoint relation only in the + // signal-selection profile. This is still user-local, previously successful intent; + // use it only as a fallback when no successful-relay record exists. + var profile = prefs.SignalSelectionProfiles + .Where(item => + !string.IsNullOrWhiteSpace(item.IpAddress) && + NormalizeName(item.IedName).Equals(nameKey, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.LastSavedUtc) + .FirstOrDefault(); + + if (profile == null) + return false; + + ipAddress = profile.IpAddress.Trim(); + port = profile.Port <= 0 ? 102 : profile.Port; + return true; + } + public static IReadOnlyCollection LoadSignalSelectionProfile(string iedName, string ipAddress, int port) { var prefs = LoadPreferences(); From f55762b291ad4964bedbe372d5ccbcb8e6aa7437 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 07:32:00 +0700 Subject: [PATCH 3/7] fix(scl): project one Engineering card per IED and preserve known endpoint (#446) --- MainWindow.xaml.cs | 136 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 10 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index ca8225c82..c925bc006 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -199,15 +199,21 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) } importedSources.Add((sourcePath, document.SourceSha256)); - offlineCount += document.Ieds.Count(item => item.CanBrowseOffline); - endpointCount += document.Ieds.Count(item => !item.RequiresEndpointBinding); + var workspaces = SelectPrimarySclWorkspaces(document); + offlineCount += workspaces.Count(item => item.CanBrowseOffline); + endpointCount += workspaces.Count(item => !item.RequiresEndpointBinding); - foreach (var workspace in document.Ieds) + if (workspaces.Count < document.Ieds.Count) { - var device = Devices.FirstOrDefault(item => - item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) && - item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) && - item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase)); + AddLog( + "INFO", + "SCL", + $"{sourceName}: collapsed {document.Ieds.Count} AccessPoint workspace(s) into {workspaces.Count} logical IED card(s); one Engineering card is owned per IEDName."); + } + + foreach (var workspace in workspaces) + { + var device = FindReusableSclDevice(document, workspace); if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring)) { @@ -230,6 +236,8 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) } ApplySclWorkspaceToDevice(device, document, workspace, signals); + RestoreKnownSclEndpointIfAvailable(device, workspace); + RemoveRedundantOfflineSclCards(device, document.SourceSha256, workspace.IedName); firstImported ??= device; importedDevices.Add(device); } @@ -377,12 +385,17 @@ private void ApplySclWorkspaceToDevice( device.SclIedName = workspace.IedName; device.SclAccessPointName = workspace.AccessPointName; device.HasDiscoveryCache = signals.Count > 0; - device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready"; + + // The SCL may omit a direct MMS address while this exact IEDName already has a + // previously proven endpoint in the current workspace. Do not erase that runtime + // identity merely because the design file is offline-only. + var requiresEndpointBinding = string.IsNullOrWhiteSpace(device.IpAddress); + device.Status = requiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready"; device.Detail = allowDynamicReporting - ? (workspace.RequiresEndpointBinding + ? (requiresEndpointBinding ? "LD/LN/DO/DA are available offline. Static report coverage is incomplete; after signal selection and endpoint binding, ARSAS will create an association-scoped dynamic DataSet and use a safe free RCB before polling fallback." : "LD/LN/DO/DA were loaded offline. Static report coverage is incomplete; ARSAS will use static coverage where available and create an association-scoped dynamic DataSet for uncovered selected signals before polling fallback.") - : (workspace.RequiresEndpointBinding + : (requiresEndpointBinding ? "LD/LN/DO/DA are available offline. Press Play to bind an MMS endpoint; no discovery traffic was sent while opening the file." : "LD/LN/DO/DA were loaded offline. Play performs a fast MMS association; Re-scan performs full design-versus-live verification."); device.AcquisitionMode = allowDynamicReporting @@ -401,6 +414,109 @@ private void ApplySclWorkspaceToDevice( ScheduleGooseBindingRefreshFromWorkspace(); } + private IReadOnlyList SelectPrimarySclWorkspaces(SclWorkspaceDocument document) + { + return document.Ieds + .GroupBy(workspace => workspace.IedName, StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(workspace => workspace.PreferredEndpoint?.HasUsableAddress == true) + .ThenByDescending(workspace => document.EngineeringProfile.AccessPoints.Any(accessPoint => + accessPoint.IedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) && + accessPoint.Name.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase) && + accessPoint.HasServer)) + .ThenByDescending(workspace => workspace.ReportControls.Count) + .ThenByDescending(workspace => workspace.DataSets.Count) + .ThenByDescending(workspace => workspace.DesignModel.Coverage.LogicalDeviceCount) + .ThenBy(workspace => workspace.AccessPointName, StringComparer.OrdinalIgnoreCase) + .First()) + .OrderBy(workspace => workspace.IedName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private Iec61850MonitorDevice? FindReusableSclDevice( + SclWorkspaceDocument document, + SclIedWorkspace workspace) + { + var exactSource = Devices + .Where(item => + item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) && + item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.IsConnected || item.IsMonitoring) + .ThenByDescending(item => !string.IsNullOrWhiteSpace(item.IpAddress)) + .FirstOrDefault(); + if (exactSource != null) + return exactSource; + + var sclEndpoint = workspace.PreferredEndpoint; + return Devices + .Where(item => item.Name.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase)) + .Where(item => + sclEndpoint?.HasUsableAddress != true || + string.IsNullOrWhiteSpace(item.IpAddress) || + (item.IpAddress.Equals(sclEndpoint.IpAddress, StringComparison.OrdinalIgnoreCase) && + item.Port == sclEndpoint.Port)) + .OrderByDescending(item => item.IsConnected || item.IsMonitoring) + .ThenByDescending(item => !string.IsNullOrWhiteSpace(item.IpAddress)) + .ThenByDescending(item => item.HasDiscoveryCache) + .FirstOrDefault(); + } + + private void RestoreKnownSclEndpointIfAvailable( + Iec61850MonitorDevice device, + SclIedWorkspace workspace) + { + if (!string.IsNullOrWhiteSpace(device.IpAddress) || + workspace.PreferredEndpoint?.HasUsableAddress == true) + { + return; + } + + if (!UserPreferenceStore.TryLoadSuccessfulEndpointForIed( + workspace.IedName, + out var ipAddress, + out var port)) + { + return; + } + + device.IpAddress = ipAddress; + device.Port = port; + device.Status = "SCL model ready • remembered endpoint"; + device.Detail = + $"The SCL does not declare a direct MMS IP for this AccessPoint. ARSAS restored {ipAddress}:{port} from the most recent successful session for the exact IEDName '{workspace.IedName}'. Play will validate it with a fresh MMS association before any live operation."; + device.RefreshComputed(); + AddLog( + "INFO", + device.Name, + $"SCL has no direct MMS endpoint; restored previously successful endpoint {ipAddress}:{port} for exact IEDName {workspace.IedName}. Live use still requires a fresh association."); + } + + private void RemoveRedundantOfflineSclCards( + Iec61850MonitorDevice keeper, + string sourceSha256, + string iedName) + { + var redundant = Devices + .Where(item => + !ReferenceEquals(item, keeper) && + !item.IsConnected && + !item.IsMonitoring && + !item.IsBusy && + item.SclSourceSha256.Equals(sourceSha256, StringComparison.OrdinalIgnoreCase) && + item.SclIedName.Equals(iedName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + foreach (var item in redundant) + { + DetachSignalHandlers(item.Signals); + Devices.Remove(item); + AddLog( + "INFO", + "SCL", + $"Removed redundant offline AccessPoint card {item.SclIedName}/{item.SclAccessPointName}; logical IED ownership remains on one Engineering card."); + } + } + private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace) { var coverage = workspace.DesignModel.Coverage; From 31a57c0932e88062d0c98c45a626b55a40d744c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 07:32:36 +0700 Subject: [PATCH 4/7] feat(diagnostics): add probe-free emergency report fallback (#446) --- Services/DiagnosticReportBuilder.cs | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 44737abd7..1c352de73 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -15,6 +15,56 @@ private sealed record TcpProbeResult(string DeviceId, string Endpoint, string Re private sealed record AdapterIpv4(string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); private sealed record RouteAnalysis(string Source, string MatchingAdapters, string AdapterMatrix, string Note); + public static string BuildEmergency( + IReadOnlyCollection devices, + IReadOnlyCollection logs, + Iec61850MonitorDevice? selectedDevice, + Exception collectionFailure) + { + ArgumentNullException.ThrowIfNull(collectionFailure); + + var appAssembly = Assembly.GetEntryAssembly() ?? typeof(DiagnosticReportBuilder).Assembly; + var engineAssembly = typeof(AR.Iec61850.Mms.MmsClientSession).Assembly; + var builder = new StringBuilder(16 * 1024); + builder.AppendLine("ARSAS Emergency Diagnostic Report"); + builder.AppendLine("Diagnostic collection degraded, but support evidence was preserved."); + builder.AppendLine(new string('=', 72)); + builder.AppendLine($"Generated local : {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz}"); + builder.AppendLine($"App version : {GetAssemblyVersion(appAssembly)}"); + builder.AppendLine($"Engine version : {GetAssemblyVersion(engineAssembly)}"); + builder.AppendLine($"Collection error: {collectionFailure.GetType().Name}: {collectionFailure.Message}"); + builder.AppendLine($"IED count : {devices.Count}"); + builder.AppendLine($"Selected IED : {selectedDevice?.Name ?? "none"}"); + builder.AppendLine(); + + builder.AppendLine("IED SNAPSHOT"); + builder.AppendLine(new string('-', 72)); + foreach (var device in devices) + { + builder.AppendLine( + $"{device.DeviceId} | {device.Name} | endpoint={device.EndpointText} | connected={device.IsConnected} | monitoring={device.IsMonitoring} | status={device.Status}"); + } + if (devices.Count == 0) + builder.AppendLine("No IED card is present."); + + builder.AppendLine(); + builder.AppendLine("RECENT DIAGNOSTICS"); + builder.AppendLine(new string('-', 72)); + foreach (var entry in logs.OrderBy(item => item.Time).TakeLast(200)) + { + builder.AppendLine( + $"{entry.Time:yyyy-MM-dd HH:mm:ss.fff} | {entry.Level} | {entry.Source} | {entry.Message}"); + } + if (logs.Count == 0) + builder.AppendLine("No diagnostic entries."); + + builder.AppendLine(); + builder.AppendLine("NOTE"); + builder.AppendLine(new string('-', 72)); + builder.AppendLine("This fallback report intentionally skips network probes and complex aggregation so diagnostics remain copyable even when the normal collector encounters malformed workspace state."); + return builder.ToString(); + } + public static async Task BuildAsync( IReadOnlyCollection devices, IReadOnlyCollection logs, From 4cfa39d860216ef040980772770719513e703c75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 07:32:58 +0700 Subject: [PATCH 5/7] fix(diagnostics): always fall back to copyable emergency evidence (#446) --- MainWindow.xaml.cs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index c925bc006..28a101b8b 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -2458,6 +2458,9 @@ private async void CopyDiagnostics_Click(object sender, RoutedEventArgs e) { var button = sender as Button; var previousContent = button?.Content; + Iec61850MonitorDevice[] devices = Array.Empty(); + DiagnosticEntry[] logs = Array.Empty(); + try { if (button != null) @@ -2469,8 +2472,8 @@ private async void CopyDiagnostics_Click(object sender, RoutedEventArgs e) // Include any protocol entries that are still waiting for the normal // 100 ms UI batch before taking an immutable support snapshot. UiFlushTimer_Tick(null, EventArgs.Empty); - var devices = Devices.ToArray(); - var logs = Logs.ToArray(); + devices = Devices.ToArray(); + logs = Logs.ToArray(); var report = await DiagnosticReportBuilder.BuildAsync( devices, logs, @@ -2487,8 +2490,36 @@ private async void CopyDiagnostics_Click(object sender, RoutedEventArgs e) } catch (Exception ex) { - AddLog("ERROR", "Diagnostics", $"Copy Diagnostic failed: {ex.GetType().Name}: {ex.Message}"); - SetStatus("Copy Diagnostic failed. The error is recorded in Diagnostics."); + // Support evidence must remain available precisely when workspace state is + // broken. Fall back to a probe-free report instead of turning diagnostics + // into a second failure that hides the original defect. + try + { + if (devices.Length == 0) + devices = Devices.ToArray(); + if (logs.Length == 0) + logs = Logs.ToArray(); + + var fallback = DiagnosticReportBuilder.BuildEmergency( + devices, + logs, + SelectedDevice, + ex); + Clipboard.SetText(fallback, TextDataFormat.UnicodeText); + AddLog( + "WARN", + "Diagnostics", + $"Normal diagnostic collection degraded ({ex.GetType().Name}: {ex.Message}); emergency support report copied instead."); + SetStatus($"Emergency diagnostic report copied ({fallback.Length:N0} characters). Paste it into the support conversation."); + } + catch (Exception fallbackError) + { + AddLog( + "ERROR", + "Diagnostics", + $"Copy Diagnostic failed after emergency fallback: {fallbackError.GetType().Name}: {fallbackError.Message}; original={ex.GetType().Name}: {ex.Message}"); + SetStatus("Copy Diagnostic could not access the Windows clipboard. The collection error remains recorded in Diagnostics."); + } } finally { From 2621d5a0592c627dabcf892352146d81b7350bf6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 07:33:45 +0700 Subject: [PATCH 6/7] test(scl): lock one-card identity and diagnostic resilience (#446) --- ...eAndDiagnosticResilienceRegressionTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/ARSAS.Tests/SclWorkspaceAndDiagnosticResilienceRegressionTests.cs diff --git a/tests/ARSAS.Tests/SclWorkspaceAndDiagnosticResilienceRegressionTests.cs b/tests/ARSAS.Tests/SclWorkspaceAndDiagnosticResilienceRegressionTests.cs new file mode 100644 index 000000000..2a03c6331 --- /dev/null +++ b/tests/ARSAS.Tests/SclWorkspaceAndDiagnosticResilienceRegressionTests.cs @@ -0,0 +1,121 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclWorkspaceAndDiagnosticResilienceRegressionTests +{ + [Fact] + public async Task DiagnosticReport_AllowsMultipleOfflineCardsWithSameEndpointText() + { + var first = new Iec61850MonitorDevice + { + Name = "IED-A", + IpAddress = string.Empty, + Port = 102, + Status = "SCL model ready — bind endpoint" + }; + var second = new Iec61850MonitorDevice + { + Name = "IED-A", + IpAddress = string.Empty, + Port = 102, + Status = "SCL model ready — bind endpoint" + }; + + var report = await DiagnosticReportBuilder.BuildAsync( + new[] { first, second }, + Array.Empty(), + first, + CancellationToken.None); + + Assert.Contains($"IED count : 2", report, StringComparison.Ordinal); + Assert.Equal(2, Count(report, "Endpoint : No endpoint")); + } + + [Fact] + public void EmergencyDiagnostic_RemainsCopyableWithoutNetworkOrAggregation() + { + var device = new Iec61850MonitorDevice + { + Name = "IED-A", + IpAddress = string.Empty, + Status = "Broken workspace state" + }; + + var report = DiagnosticReportBuilder.BuildEmergency( + new[] { device }, + Array.Empty(), + device, + new ArgumentException("duplicate endpoint")); + + Assert.Contains("ARSAS Emergency Diagnostic Report", report, StringComparison.Ordinal); + Assert.Contains("ArgumentException: duplicate endpoint", report, StringComparison.Ordinal); + Assert.Contains(device.DeviceId, report, StringComparison.Ordinal); + Assert.Contains("endpoint=No endpoint", report, StringComparison.Ordinal); + } + + [Fact] + public void OpenScl_ProjectsOneEngineeringCardPerIed_AndRestoresKnownEndpoint() + { + var source = Read("MainWindow.xaml.cs"); + + Assert.Contains("SelectPrimarySclWorkspaces(document)", source, StringComparison.Ordinal); + Assert.Contains(".GroupBy(workspace => workspace.IedName", source, StringComparison.Ordinal); + Assert.Contains("one Engineering card is owned per IEDName", source, StringComparison.Ordinal); + Assert.Contains("FindReusableSclDevice(document, workspace)", source, StringComparison.Ordinal); + Assert.Contains("RestoreKnownSclEndpointIfAvailable(device, workspace)", source, StringComparison.Ordinal); + Assert.Contains("TryLoadSuccessfulEndpointForIed", source, StringComparison.Ordinal); + Assert.Contains("RemoveRedundantOfflineSclCards", source, StringComparison.Ordinal); + + Assert.DoesNotContain( + "foreach (var workspace in document.Ieds)", + source, + StringComparison.Ordinal); + } + + [Fact] + public void CopyDiagnostic_HasEmergencyFallbackInsteadOfSecondFailure() + { + var source = Read("MainWindow.xaml.cs"); + var builder = Read("Services/DiagnosticReportBuilder.cs"); + + Assert.Contains("DiagnosticReportBuilder.BuildEmergency", source, StringComparison.Ordinal); + Assert.Contains("Emergency diagnostic report copied", source, StringComparison.Ordinal); + Assert.Contains("probe-free report", source, StringComparison.Ordinal); + Assert.Contains("GroupBy(result => result.DeviceId", builder, StringComparison.Ordinal); + Assert.DoesNotContain( + "probes.ToDictionary(result => result.Endpoint", + builder, + StringComparison.Ordinal); + } + + private static int Count(string text, string value) + { + var count = 0; + var offset = 0; + while ((offset = text.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From 6e99b1a4b5282d2ca6d3683b739a661cd4064a5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:14:45 +0700 Subject: [PATCH 7/7] fix(diagnostics): include device identity in socket-error probe result (#446) --- Services/DiagnosticReportBuilder.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 1c352de73..29936f986 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -350,6 +350,7 @@ private static async Task ProbeDeviceAsync( { stopwatch.Stop(); return new TcpProbeResult( + device.DeviceId, device.EndpointText, ex.SocketErrorCode == SocketError.ConnectionRefused ? "REFUSED" : "SOCKET_ERROR", $"SocketError={ex.SocketErrorCode}; {ex.Message}; {stopwatch.Elapsed.TotalMilliseconds:0} ms");