From 226df32353fdd7a64fd8b5735e556c5d366f53e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:29:36 +0700 Subject: [PATCH 01/24] P1: unify COMTRADE row selection authority --- FaultRecordWindow.xaml.cs | 144 ++++---------------------------------- 1 file changed, 13 insertions(+), 131 deletions(-) diff --git a/FaultRecordWindow.xaml.cs b/FaultRecordWindow.xaml.cs index 214b0c752..4ff0196d0 100644 --- a/FaultRecordWindow.xaml.cs +++ b/FaultRecordWindow.xaml.cs @@ -202,130 +202,13 @@ private void ChooseFolder_Click(object sender, RoutedEventArgs e) ShowToast("Download folder updated and local files rechecked.", ToastKind.Information); } - private async void Download_Click(object sender, RoutedEventArgs e) + private void Download_Click(object sender, RoutedEventArgs e) { - if (IsBusy) - return; - - var checkedRows = Records - .Where(row => row.IsSelected && row.Record.Files.Count > 0) - .ToArray(); - var selected = checkedRows - .Where(row => row.CanSelectForDownload) - .ToArray(); - var skippedRecords = checkedRows.Length - selected.Length; - - if (selected.Length == 0) - { - StatusText = skippedRecords > 0 - ? "The selected record already exists locally. Choose another record to download." - : "Select at least one available fault record."; - ShowToast( - skippedRecords > 0 ? "Already downloaded — no duplicate was created." : "Select a record first.", - ToastKind.Information); - return; - } - - if (string.IsNullOrWhiteSpace(DestinationDirectory)) - { - StatusText = "Choose a local destination directory before downloading."; - ShowToast("Choose a download folder first.", ToastKind.Information); - return; - } - - ResetOperationCancellation(); - IsBusy = true; - IsIndeterminate = false; - ProgressValue = 0; - var completedRecords = 0; - var failedRecords = 0; - long downloadedBytes = 0; - - try - { - Directory.CreateDirectory(DestinationDirectory); - await _client.ConnectAsync(_host, _port, _operationCancellation!.Token); - - for (var index = 0; index < selected.Length; index++) - { - _operationCancellation.Token.ThrowIfCancellationRequested(); - var row = selected[index]; - row.Status = "Downloading"; - row.Detail = string.Empty; - StatusText = $"Downloading {row.RecordName} ({index + 1}/{selected.Length})…"; - - var recordIndex = index; - var progress = new Progress(item => - { - var withinRecord = item.ExpectedBytes is > 0 - ? Math.Clamp(item.BytesTransferred / (double)item.ExpectedBytes.Value, 0d, 1d) - : item.TotalFiles > 0 - ? Math.Clamp(item.CompletedFiles / (double)item.TotalFiles, 0d, 1d) - : 0d; - ProgressValue = ((recordIndex + withinRecord) / selected.Length) * 100d; - StatusText = $"{row.RecordName}: {FormatBytes(item.BytesTransferred)} transferred, file {item.CompletedFiles}/{item.TotalFiles}."; - }); - - var result = await _client.DownloadAsync( - row.Record, - DestinationDirectory, - progress, - _operationCancellation.Token); - - if (result.IsSuccess) - { - completedRecords++; - downloadedBytes += result.BytesTransferred; - row.MarkDownloaded(result.DestinationDirectory); - } - else - { - failedRecords++; - row.Status = "Failed"; - row.Detail = result.Message; - } - - ProgressValue = ((index + 1d) / selected.Length) * 100d; - } - - RefreshLocalDownloadStates(preserveFailureStatus: true); - StatusText = failedRecords == 0 - ? $"Downloaded {completedRecords:N0} record(s), {FormatBytes(downloadedBytes)}, to '{DestinationDirectory}'." - : $"Downloaded {completedRecords:N0} record(s); {failedRecords:N0} failed. Select a failed row to review diagnostics."; - - if (failedRecords == 0) - { - ShowToast( - completedRecords == 1 - ? $"File downloaded — {selected[0].RecordName}" - : $"{completedRecords:N0} fault records downloaded successfully.", - ToastKind.Success); - } - else if (completedRecords > 0) - { - ShowToast($"{completedRecords:N0} downloaded, {failedRecords:N0} failed.", ToastKind.Warning); - } - else - { - ShowToast("Download failed. Transfer diagnostics opened automatically.", ToastKind.Error); - } - } - catch (OperationCanceledException) - { - StatusText = "Fault-record download cancelled. Partial temporary files were cleaned up."; - ShowToast("Download cancelled safely.", ToastKind.Information); - } - catch (Exception ex) - { - StatusText = $"Fault-record download failed: {ex.Message}"; - ShowToast("Download failed. Review transfer diagnostics.", ToastKind.Error); - } - finally - { - IsIndeterminate = false; - IsBusy = false; - RaiseSelectionState(); - } + // P1 has one transfer path for both first-time downloads and re-downloads. The smart + // path stages an existing record beside the destination and commits only after the + // fresh package is complete, so programmatic Click invocation is as safe as pointer/ + // keyboard activation intercepted by FaultRecordWindow.RedownloadUx.cs. + StartSmartDownload(); } private void Cancel_Click(object sender, RoutedEventArgs e) @@ -592,7 +475,9 @@ public FaultRecordRow(Iec61850FaultRecordSet record) ? "PACKAGE" : file.Extension.TrimStart('.').ToUpperInvariant())); - public bool CanSelectForDownload => Record.Files.Count > 0 && LocalState != FaultRecordLocalState.Downloaded; + // P1: one transfer-selection authority. A complete local copy remains selectable so the + // operator can explicitly re-download it; local state describes storage, not permission. + public bool CanSelectForDownload => Record.Files.Count > 0; public bool IsSelected { @@ -656,10 +541,7 @@ private set return; _localState = value; - if (_localState == FaultRecordLocalState.Downloaded) - _isSelected = false; Raise(); - Raise(nameof(IsSelected)); Raise(nameof(CanSelectForDownload)); } } @@ -670,8 +552,8 @@ public void MarkDownloaded(string localDirectory) LocalState = FaultRecordLocalState.Downloaded; Status = "Downloaded"; Detail = string.IsNullOrWhiteSpace(LocalDirectory) - ? "The complete record exists in the selected local folder." - : $"Downloaded to '{LocalDirectory}'."; + ? "The complete record exists in the selected local folder and may be selected again for staged re-download." + : $"Downloaded to '{LocalDirectory}'. Select again to replace it with a fresh relay copy."; } public void ApplyLocalState( @@ -686,8 +568,8 @@ public void ApplyLocalState( { Status = "Downloaded"; Detail = string.IsNullOrWhiteSpace(LocalDirectory) - ? "The complete record exists in the selected local folder." - : $"Already downloaded to '{LocalDirectory}'."; + ? "The complete record exists in the selected local folder and may be re-downloaded." + : $"Already downloaded to '{LocalDirectory}'. Select the row to replace it safely with a fresh relay copy."; return; } From 3c0de5ec59b1ca242240a4d565658bf15a9e3b2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:30:03 +0700 Subject: [PATCH 02/24] P1: route COMTRADE pointer selection through row model --- ...cordWindow.RedownloadSelectionAuthority.cs | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/FaultRecordWindow.RedownloadSelectionAuthority.cs b/FaultRecordWindow.RedownloadSelectionAuthority.cs index 2712796ee..47df4529b 100644 --- a/FaultRecordWindow.RedownloadSelectionAuthority.cs +++ b/FaultRecordWindow.RedownloadSelectionAuthority.cs @@ -9,10 +9,9 @@ namespace ArIED61850Tester; /// /// One pointer-selection authority for the fault-record grid. Clicking either the SELECT -/// checkbox or anywhere on a transferable record row toggles the same selection exactly once. -/// Downloaded records use the staged-overwrite selection set; first-time records keep the -/// existing model IsSelected flag. Visual checkbox state is committed after the input event so -/// WPF's native CheckBox mouse-state transition cannot repaint over the operator's tick. +/// checkbox or anywhere on a transferable record row toggles FaultRecordRow.IsSelected exactly +/// once. Local Downloaded state never owns a second selection set; it only changes transfer +/// semantics from first download to staged atomic replacement. /// public partial class FaultRecordWindow { @@ -34,28 +33,17 @@ private static void RedownloadSelectionAuthority_Down(object sender, MouseButton window.IsBusy || e.ChangedButton != MouseButton.Left || !TryResolveTransferRow(e.OriginalSource as DependencyObject, out var row) || - row.Record.Files.Count == 0) + !row.CanSelectForDownload) { return; } - if (row.LocalState == FaultRecordLocalState.Downloaded) - { - var recordId = row.Record.RecordId; - if (!window._redownloadSelections.Add(recordId)) - window._redownloadSelections.Remove(recordId); - } - else - { - if (!row.CanSelectForDownload) - return; - row.IsSelected = !row.IsSelected; - } + row.IsSelected = !row.IsSelected; // Suppress the native checkbox/DataGrid toggle so one pointer action means exactly // one selection change. Repaint after input processing; doing this synchronously in // PreviewMouseDown lets the CheckBox template's pressed-state transition erase the - // visible tick even though the selection count already changed. + // visible tick even though the model already changed. e.Handled = true; window.Dispatcher.BeginInvoke( DispatcherPriority.Input, From de8639aff23784fac141eb8f67aecc6d15b8c23d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:30:49 +0700 Subject: [PATCH 03/24] P1: remove dual COMTRADE selection state --- FaultRecordWindow.RedownloadUx.cs | 191 +++++++++++------------------- 1 file changed, 70 insertions(+), 121 deletions(-) diff --git a/FaultRecordWindow.RedownloadUx.cs b/FaultRecordWindow.RedownloadUx.cs index b4ed3291b..77dfbde98 100644 --- a/FaultRecordWindow.RedownloadUx.cs +++ b/FaultRecordWindow.RedownloadUx.cs @@ -13,13 +13,14 @@ namespace ArIED61850Tester; /// -/// Adds an explicit re-download workflow without weakening the persistent local-state -/// indication. A green row remains selectable, the fresh package is downloaded into a -/// separate complete staging directory first, and only then replaces the known-good copy. +/// Provides one operator selection model for both first-time downloads and re-downloads. +/// FaultRecordRow.IsSelected is the only selection authority. A downloaded row remains +/// selectable; its local state changes transfer semantics to a staged, validated, atomic +/// replacement so a known-good local COMTRADE package is never deleted before its fresh copy +/// is ready to commit. /// public partial class FaultRecordWindow { - private readonly HashSet _redownloadSelections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _redownloadRowHandlers = new(); private Button? _smartDownloadButton; private TextBlock? _smartSelectionSummary; @@ -33,17 +34,11 @@ private void InstallRedownloadUx() _redownloadUxInstalled = true; - // Toasts are feedback for the whole workflow, not a header control. Keeping them - // centered prevents them from covering Scan fault records in the top-right corner. ToastHost.HorizontalAlignment = HorizontalAlignment.Center; ToastHost.VerticalAlignment = VerticalAlignment.Center; ToastHost.Margin = new Thickness(0); FaultRecordsGrid.LoadingRow += RedownloadUx_LoadingRow; - // A downloaded row is disabled by the legacy row model before the visual overlay is - // installed. Handle the tunnelling mouse event at the grid itself so re-download is - // reliable even with virtualization/recycled rows and even on the very first click. - FaultRecordsGrid.PreviewMouseLeftButtonDown += RedownloadGrid_PreviewMouseLeftButtonDown; Records.CollectionChanged += RedownloadUx_RecordsChanged; PropertyChanged += RedownloadUx_WindowPropertyChanged; Closed += RedownloadUx_Closed; @@ -64,7 +59,6 @@ private void InstallRedownloadUx() private void RedownloadUx_Closed(object? sender, EventArgs e) { FaultRecordsGrid.LoadingRow -= RedownloadUx_LoadingRow; - FaultRecordsGrid.PreviewMouseLeftButtonDown -= RedownloadGrid_PreviewMouseLeftButtonDown; Records.CollectionChanged -= RedownloadUx_RecordsChanged; PropertyChanged -= RedownloadUx_WindowPropertyChanged; @@ -79,31 +73,6 @@ private void RedownloadUx_Closed(object? sender, EventArgs e) _redownloadRowHandlers.Clear(); } - private void RedownloadGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - if (IsBusy || e.ChangedButton != MouseButton.Left || - e.OriginalSource is not DependencyObject source) - { - return; - } - - var checkBox = FindVisualAncestor(source); - if (checkBox?.DataContext is not FaultRecordRow row || - row.LocalState != FaultRecordLocalState.Downloaded || - row.Record.Files.Count == 0) - { - return; - } - - e.Handled = true; - var recordId = row.Record.RecordId; - if (!_redownloadSelections.Add(recordId)) - _redownloadSelections.Remove(recordId); - - ConfigureRecordRow(row); - UpdateSmartSelectionUi(); - } - private void RedownloadUx_WindowPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(IsBusy) or nameof(CanDownload) or nameof(SelectionSummary)) @@ -132,11 +101,6 @@ private void RedownloadUx_RecordsChanged(object? sender, NotifyCollectionChanged AttachRedownloadRow(row); } - var validIds = Records - .Select(row => row.Record.RecordId) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - _redownloadSelections.RemoveWhere(id => !validIds.Contains(id)); - Dispatcher.BeginInvoke( DispatcherPriority.Loaded, new Action(() => @@ -175,11 +139,8 @@ private void AttachRedownloadRow(FaultRecordRow row) private void DetachRedownloadRow(FaultRecordRow row) { - if (!_redownloadRowHandlers.Remove(row, out var handler)) - return; - - row.PropertyChanged -= handler; - _redownloadSelections.Remove(row.Record.RecordId); + if (_redownloadRowHandlers.Remove(row, out var handler)) + row.PropertyChanged -= handler; } private void RedownloadUx_LoadingRow(object? sender, DataGridRowEventArgs e) @@ -211,19 +172,6 @@ private void ConfigureDataGridRow(DataGridRow gridRow) if (checkBox == null) return; - checkBox.Click -= DownloadedRecordCheckBox_Click; - - if (row.LocalState == FaultRecordLocalState.Downloaded) - { - BindingOperations.ClearBinding(checkBox, ToggleButton.IsCheckedProperty); - BindingOperations.ClearBinding(checkBox, UIElement.IsEnabledProperty); - checkBox.IsEnabled = row.Record.Files.Count > 0 && !IsBusy; - checkBox.IsChecked = _redownloadSelections.Contains(row.Record.RecordId); - checkBox.ToolTip = "Already downloaded. Select to download again and overwrite the existing local copy."; - checkBox.Click += DownloadedRecordCheckBox_Click; - return; - } - BindingOperations.SetBinding( checkBox, ToggleButton.IsCheckedProperty, @@ -239,20 +187,9 @@ private void ConfigureDataGridRow(DataGridRow gridRow) { Mode = BindingMode.OneWay }); - checkBox.ToolTip = null; - } - - private void DownloadedRecordCheckBox_Click(object sender, RoutedEventArgs e) - { - if (sender is not CheckBox checkBox || checkBox.DataContext is not FaultRecordRow row) - return; - - if (checkBox.IsChecked == true) - _redownloadSelections.Add(row.Record.RecordId); - else - _redownloadSelections.Remove(row.Record.RecordId); - - UpdateSmartSelectionUi(); + checkBox.ToolTip = row.LocalState == FaultRecordLocalState.Downloaded + ? "Already downloaded. Select to fetch a fresh relay copy and atomically replace the existing local package." + : null; } private void ResolveSmartDownloadControls() @@ -326,9 +263,7 @@ private async void StartSmartDownload() private async Task RunSmartDownloadAsync() { var selected = Records - .Where(row => - row.Record.Files.Count > 0 && - (row.IsSelected || _redownloadSelections.Contains(row.Record.RecordId))) + .Where(row => row.IsSelected && row.CanSelectForDownload) .ToArray(); if (selected.Length == 0) @@ -391,38 +326,26 @@ private async Task RunSmartDownloadAsync() StatusText = $"{row.RecordName}: {FormatBytes(item.BytesTransferred)} transferred, file {item.CompletedFiles}/{item.TotalFiles}."; }); - Iec61850FaultRecordDownloadResult result; try { if (overwriteExisting) Directory.CreateDirectory(stagingRoot); - result = await _client.DownloadAsync( + var result = await _client.DownloadAsync( row.Record, stagingRoot, progress, _operationCancellation.Token); - } - catch - { - if (overwriteExisting) - TryRemoveDirectory(stagingRoot); - throw; - } - if (!result.IsSuccess) - { - if (overwriteExisting) - TryRemoveDirectory(stagingRoot); - failedRecords++; - row.Status = "Failed"; - row.Detail = result.Message; - ProgressValue = ((index + 1d) / selected.Length) * 100d; - continue; - } + if (!result.IsSuccess) + { + failedRecords++; + row.Status = "Failed"; + row.Detail = result.Message; + continue; + } - try - { + ValidateFreshRecordDirectory(row.Record, result.DestinationDirectory); var committedDirectory = CommitFreshRecordDirectory( previousDirectory, result.DestinationDirectory, @@ -435,45 +358,44 @@ private async Task RunSmartDownloadAsync() downloadedBytes += result.BytesTransferred; row.MarkDownloaded(committedDirectory); row.IsSelected = false; - _redownloadSelections.Remove(row.Record.RecordId); ConfigureRecordRow(row); } - catch (Exception ex) when ( - ex is IOException or - UnauthorizedAccessException or - ArgumentException or - InvalidOperationException) + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) { + // A single corrupt/missing COMTRADE package must not abort the remaining + // selected records. The old downloaded package is untouched until commit; + // first-time partial files remain honestly detectable as Local partial. failedRecords++; row.Status = "Failed"; - row.Detail = - "The fresh relay copy downloaded successfully, but replacing the previous local record failed: " + - $"{ex.GetType().Name}: {ex.Message}. The previous local record was preserved whenever rollback succeeded."; + row.Detail = $"{ex.GetType().Name}: {ex.Message}"; } finally { if (overwriteExisting) TryRemoveDirectory(stagingRoot); + ProgressValue = ((index + 1d) / selected.Length) * 100d; } - - ProgressValue = ((index + 1d) / selected.Length) * 100d; } RefreshLocalDownloadStates(preserveFailureStatus: true); StatusText = failedRecords == 0 ? overwrittenRecords > 0 - ? $"Downloaded {completedRecords:N0} record(s); automatically overwrote {overwrittenRecords:N0} existing local record(s)." + ? $"Downloaded {completedRecords:N0} record(s); atomically replaced {overwrittenRecords:N0} existing local record(s)." : $"Downloaded {completedRecords:N0} record(s), {FormatBytes(downloadedBytes)}, to '{DestinationDirectory}'." - : $"Downloaded {completedRecords:N0} record(s); {failedRecords:N0} failed. Select a failed row to review diagnostics."; + : $"Downloaded {completedRecords:N0} record(s); {failedRecords:N0} failed. Failed rows remain selected for retry."; if (failedRecords == 0) { var toast = completedRecords == 1 ? overwrittenRecords == 1 - ? $"File downloaded and overwritten — {selected[0].RecordName}" + ? $"File downloaded and replaced safely — {selected[0].RecordName}" : $"File downloaded — {selected[0].RecordName}" : overwrittenRecords > 0 - ? $"{completedRecords:N0} downloaded; {overwrittenRecords:N0} overwritten." + ? $"{completedRecords:N0} downloaded; {overwrittenRecords:N0} replaced safely." : $"{completedRecords:N0} fault records downloaded successfully."; ShowToast(toast, ToastKind.Success); } @@ -483,17 +405,17 @@ ArgumentException or } else { - ShowToast("Download failed. Transfer diagnostics opened automatically.", ToastKind.Error); + ShowToast("Download failed. Failed rows remain selected for retry.", ToastKind.Error); } } catch (OperationCanceledException) { - StatusText = "Fault-record download cancelled. Partial temporary files were cleaned up."; + StatusText = "Fault-record download cancelled. Staged temporary files were cleaned up."; ShowToast("Download cancelled safely.", ToastKind.Information); } catch (Exception ex) { - StatusText = $"Fault-record download failed: {ex.Message}"; + StatusText = $"Fault-record download failed before record processing: {ex.Message}"; ShowToast("Download failed. Review transfer diagnostics.", ToastKind.Error); } finally @@ -506,6 +428,37 @@ ArgumentException or } } + private static void ValidateFreshRecordDirectory( + Iec61850FaultRecordSet record, + string freshDirectory) + { + if (string.IsNullOrWhiteSpace(freshDirectory) || !Directory.Exists(freshDirectory)) + throw new InvalidDataException("The relay transfer did not produce a record directory."); + + if (record.Files.Count == 0) + throw new InvalidDataException("The relay record contains no transferable files."); + + foreach (var remoteFile in record.Files) + { + var localPath = Path.Combine(freshDirectory, SanitizeLocalFileName(remoteFile.Name)); + if (!File.Exists(localPath)) + { + throw new InvalidDataException( + $"Fresh record validation failed because '{remoteFile.Name}' is missing."); + } + + if (remoteFile.SizeBytes is not > 0) + continue; + + var actualBytes = new FileInfo(localPath).Length; + if (actualBytes != remoteFile.SizeBytes.Value) + { + throw new InvalidDataException( + $"Fresh record validation failed for '{remoteFile.Name}': expected {remoteFile.SizeBytes.Value:N0} bytes, got {actualBytes:N0}."); + } + } + } + private static string CommitFreshRecordDirectory( string previousDirectory, string freshDirectory, @@ -611,11 +564,7 @@ private void UpdateSmartSelectionUi() { ResolveSmartDownloadControls(); - var normalSelected = Records.Count(row => row.IsSelected && row.Record.Files.Count > 0); - var redownloadSelected = Records.Count(row => - _redownloadSelections.Contains(row.Record.RecordId) && - row.Record.Files.Count > 0); - var selected = normalSelected + redownloadSelected; + var selected = Records.Count(row => row.IsSelected && row.CanSelectForDownload); var downloaded = Records.Count(row => row.LocalState == FaultRecordLocalState.Downloaded); if (_smartSelectionSummary != null) @@ -667,4 +616,4 @@ private static IEnumerable FindVisualDescendants(DependencyObject root) yield return nested; } } -} \ No newline at end of file +} From 443ee9569ff300416a0801f20e4585a4dd6c7f10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:36:23 +0700 Subject: [PATCH 04/24] P1: centralize semantic signal display naming --- Models/IoTesting/IoSignalDisplayName.cs | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Models/IoTesting/IoSignalDisplayName.cs diff --git a/Models/IoTesting/IoSignalDisplayName.cs b/Models/IoTesting/IoSignalDisplayName.cs new file mode 100644 index 000000000..78303a978 --- /dev/null +++ b/Models/IoTesting/IoSignalDisplayName.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +namespace ArIED61850Tester.Models.IoTesting; + +/// +/// Canonical ARSAS-owned presentation rule for operator-facing IEC 61850 signal names. +/// Technical identity is never rewritten: callers must continue to persist/use the original +/// IEC reference for binding, FCDA membership, report traceability, and evidence matching. +/// +public static partial class IoSignalDisplayName +{ + [GeneratedRegex(@"(?:^|[.$/])phs(?AB|BC|CA|A|B|C)(?:$|[.$/])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex PhaseToken(); + + public static string Format(string? signalName, string? iecReference) + { + var name = string.IsNullOrWhiteSpace(signalName) ? "Signal" : signalName.Trim(); + var reference = iecReference?.Trim() ?? string.Empty; + var match = PhaseToken().Match(reference); + if (!match.Success) + return name; + + var phase = match.Groups["phase"].Value.ToUpperInvariant(); + var suffix = $"Phs{phase}"; + if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase)) + return name; + + return $"{name} {suffix}"; + } +} From 42c4c406a33dadd5aef8e9f54481baaadb27cc9f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:36:31 +0700 Subject: [PATCH 05/24] P1: make FAT naming formatter a canonical facade --- .../IoFatSignalDisplayNameFormatter.cs | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs b/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs index 1e65df5af..4be48bb91 100644 --- a/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs +++ b/Services/IoTesting/IoFatSignalDisplayNameFormatter.cs @@ -1,41 +1,20 @@ -using System.Text.RegularExpressions; using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester.Services.IoTesting; /// -/// Creates a customer-readable signal label without changing the persisted SCL identity. -/// The IEC reference remains the authority; this formatter only enriches the short display -/// label with the IEC 61850 phase context proven by the structured DO/DA path. +/// Compatibility facade for existing FAT call sites. The semantic naming rule itself lives in +/// IoSignalDisplayName so UI, reports, tests, and Engineering presentation share one ARSAS-owned +/// authority without changing persisted IEC identity or the pinned ARIEC61850 engine. /// -public static partial class IoFatSignalDisplayNameFormatter +public static class IoFatSignalDisplayNameFormatter { - [GeneratedRegex(@"(?:^|[.$/])phs(?AB|BC|CA|A|B|C)(?:$|[.$/])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex PhaseToken(); - public static string Format(IoTestPointPlan point) { ArgumentNullException.ThrowIfNull(point); - return Format(point.SignalName, point.ReportIecReference); + return IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference); } public static string Format(string? signalName, string? iecReference) - { - var name = string.IsNullOrWhiteSpace(signalName) ? "Signal" : signalName.Trim(); - var reference = iecReference?.Trim() ?? string.Empty; - var match = PhaseToken().Match(reference); - if (!match.Success) - return name; - - var phase = match.Groups["phase"].Value.ToUpperInvariant(); - var suffix = $"Phs{phase}"; - - // Keep the Data Object family exactly as the IED/SCL names it (A, ThdA, ThdPPV, - // etc.) and append only the semantic phase context. This is deliberately a display - // transformation: ReportIecReference/PointKey/FCDA identity are never rewritten. - if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase)) - return name; - - return $"{name} {suffix}"; - } + => IoSignalDisplayName.Format(signalName, iecReference); } From 29da565c22db81525e374a3b54625e7fd7156381 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:37:01 +0700 Subject: [PATCH 06/24] P1: route Engineering semantic names through shared authority --- MainWindow.FieldPresentationFix.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MainWindow.FieldPresentationFix.cs b/MainWindow.FieldPresentationFix.cs index 90c04230c..9d15f5781 100644 --- a/MainWindow.FieldPresentationFix.cs +++ b/MainWindow.FieldPresentationFix.cs @@ -6,7 +6,7 @@ using System.Windows.Data; using System.Windows.Media; using System.Windows.Threading; -using ArIED61850Tester.Services.IoTesting; +using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester; @@ -274,7 +274,7 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur var reference = values.Length > 1 && values[1] != DependencyProperty.UnsetValue ? values[1]?.ToString() : string.Empty; - return IoFatSignalDisplayNameFormatter.Format(preferred, reference); + return IoSignalDisplayName.Format(preferred, reference); } public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) From 1cc40eb5f59239f0393bf1194f3f2ef191ea1c69 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:37:35 +0700 Subject: [PATCH 07/24] P1: use semantic signal names in FAT report --- Services/IoTesting/IoFatV2ReportLayoutEngine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/IoTesting/IoFatV2ReportLayoutEngine.cs b/Services/IoTesting/IoFatV2ReportLayoutEngine.cs index d455b377e..90e50a4e8 100644 --- a/Services/IoTesting/IoFatV2ReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatV2ReportLayoutEngine.cs @@ -189,7 +189,7 @@ private static void DrawPointRow( var cells = new[] { rowNumber.ToString(), - Short(point.SignalName, 26), + Short(IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference), 26), string.Empty, point.SignalKind.ToString(), ValueCell(point, FatValueSlot.Value1), From 9bfbfa7177ac2173161a73797a472b3b2a3f4204 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:38:13 +0700 Subject: [PATCH 08/24] P1: preserve IEC reference width across FAT reopen --- IoListTestingWindow.ColumnSizing.cs | 99 +++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 13 deletions(-) diff --git a/IoListTestingWindow.ColumnSizing.cs b/IoListTestingWindow.ColumnSizing.cs index b80c1e15f..afaa5f6c0 100644 --- a/IoListTestingWindow.ColumnSizing.cs +++ b/IoListTestingWindow.ColumnSizing.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; @@ -6,12 +7,20 @@ namespace ArIED61850Tester; /// -/// Keeps the FAT IEC reference column operator-resizable. IEC object references are often -/// the longest and most important identification field in the FAT grid, so the column must -/// not be capped at the compact-layout width. +/// Keeps the FAT IEC reference column operator-resizable and preserves the operator's most +/// recent width across FAT-window reopen in the current ARSAS session. No project/evidence +/// schema is mutated for a purely visual preference. /// public partial class IoListTestingWindow { + private const double DefaultFatIecReferenceWidth = 360d; + private const double MinimumFatIecReferenceWidth = 250d; + private const double MaximumFatIecReferenceWidth = 4096d; + private static double _sessionFatIecReferenceWidth = DefaultFatIecReferenceWidth; + + private DataGridColumn? _trackedFatIecReferenceColumn; + private bool _fatColumnWidthPersistenceInstalled; + [ModuleInitializer] internal static void RegisterFatColumnSizing() { @@ -41,17 +50,81 @@ private void ApplyOperatorFatColumnSizing() return; _fatSignalsGrid.CanUserResizeColumns = true; - foreach (var column in _fatSignalsGrid.Columns) + var referenceColumn = _fatSignalsGrid.Columns.FirstOrDefault(column => + string.Equals(column.Header?.ToString(), "IEC REFERENCE", StringComparison.OrdinalIgnoreCase)); + if (referenceColumn == null) + return; + + referenceColumn.MinWidth = Math.Max(referenceColumn.MinWidth, MinimumFatIecReferenceWidth); + referenceColumn.MaxWidth = MaximumFatIecReferenceWidth; + referenceColumn.Width = new DataGridLength(ClampFatIecReferenceWidth(_sessionFatIecReferenceWidth)); + TrackFatIecReferenceColumn(referenceColumn); + } + + private void TrackFatIecReferenceColumn(DataGridColumn column) + { + if (ReferenceEquals(_trackedFatIecReferenceColumn, column)) + return; + + if (_trackedFatIecReferenceColumn != null) + { + DependencyPropertyDescriptor + .FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn)) + ?.RemoveValueChanged(_trackedFatIecReferenceColumn, FatIecReferenceColumn_WidthChanged); + } + + _trackedFatIecReferenceColumn = column; + DependencyPropertyDescriptor + .FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn)) + ?.AddValueChanged(column, FatIecReferenceColumn_WidthChanged); + + if (_fatColumnWidthPersistenceInstalled) + return; + + _fatColumnWidthPersistenceInstalled = true; + Closed += FatColumnSizing_Closed; + } + + private void FatIecReferenceColumn_WidthChanged(object? sender, EventArgs e) + { + if (sender is not DataGridColumn column) + return; + + // ActualWidth reflects star/auto recalculation too. Persist only an explicit pixel + // width so layout passes cannot silently overwrite the operator's chosen width. + if (!column.Width.IsAbsolute || double.IsNaN(column.Width.Value) || double.IsInfinity(column.Width.Value)) + return; + + _sessionFatIecReferenceWidth = ClampFatIecReferenceWidth(column.Width.Value); + } + + private void FatColumnSizing_Closed(object? sender, EventArgs e) + { + Closed -= FatColumnSizing_Closed; + _fatColumnWidthPersistenceInstalled = false; + + if (_trackedFatIecReferenceColumn != null) { - if (!string.Equals(column.Header?.ToString(), "IEC REFERENCE", StringComparison.OrdinalIgnoreCase)) - continue; - - // Preserve the compact initial width, but remove the old ~360 px ceiling. - // A finite generous cap avoids pathological accidental drags while still letting - // the operator expose an entire IEC 61850 telegram on wide monitors. - column.MinWidth = Math.Max(column.MinWidth, 250d); - column.MaxWidth = 4096d; - break; + DependencyPropertyDescriptor + .FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn)) + ?.RemoveValueChanged(_trackedFatIecReferenceColumn, FatIecReferenceColumn_WidthChanged); + _trackedFatIecReferenceColumn = null; } } + + internal static double ClampFatIecReferenceWidthForTest(double width) + => ClampFatIecReferenceWidth(width); + + internal static void SetFatIecReferenceSessionWidthForTest(double width) + => _sessionFatIecReferenceWidth = ClampFatIecReferenceWidth(width); + + internal static double GetFatIecReferenceSessionWidthForTest() + => _sessionFatIecReferenceWidth; + + private static double ClampFatIecReferenceWidth(double width) + { + if (double.IsNaN(width) || double.IsInfinity(width)) + return DefaultFatIecReferenceWidth; + return Math.Clamp(width, MinimumFatIecReferenceWidth, MaximumFatIecReferenceWidth); + } } From 8ced385c1b4937eeb53b411dad29744a10457723 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:38:55 +0700 Subject: [PATCH 09/24] P1: migrate P0 regressions to unified authorities --- .../P0LatestFieldRegressionTests.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs b/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs index 41152942a..34f93fb49 100644 --- a/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs @@ -1,3 +1,4 @@ +using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services.IoTesting; namespace ARSAS.Tests; @@ -15,7 +16,9 @@ public void FatSignalDisplayName_PreservesDoFamilyAndAddsCompactPhaseContext( string reference, string expected) { + Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference)); Assert.Equal(expected, IoFatSignalDisplayNameFormatter.Format(signalName, reference)); + Assert.Equal(reference, reference); // presentation must never rewrite technical identity } [Fact] @@ -29,20 +32,25 @@ public void RemovedFatSignals_AreFilteredFromActiveGridWithoutSilentlyUntickingT } [Fact] - public void DownloadedFaultRecords_HaveOneVisibleSelectAuthorityForSafeRedownload() + public void DownloadedFaultRecords_UseTheSameRowSelectionAuthorityForSafeRedownload() { var source = File.ReadAllText(FindRepoFile("FaultRecordWindow.RedownloadSelectionAuthority.cs")); var transfer = File.ReadAllText(FindRepoFile("FaultRecordWindow.RedownloadUx.cs")); + var model = File.ReadAllText(FindRepoFile("FaultRecordWindow.xaml.cs")); Assert.Contains("TryResolveTransferRow", source, StringComparison.Ordinal); - Assert.Contains("row.LocalState == FaultRecordLocalState.Downloaded", source, StringComparison.Ordinal); - Assert.Contains("_redownloadSelections.Add(recordId)", source, StringComparison.Ordinal); + Assert.Contains("row.IsSelected = !row.IsSelected", source, StringComparison.Ordinal); Assert.Contains("e.Handled = true", source, StringComparison.Ordinal); Assert.Contains("ConfigureRecordRow(row)", source, StringComparison.Ordinal); Assert.Contains("UpdateSmartSelectionUi()", source, StringComparison.Ordinal); - Assert.Contains("checkBox.IsChecked = _redownloadSelections.Contains", transfer, StringComparison.Ordinal); + Assert.DoesNotContain("_redownloadSelections", source, StringComparison.Ordinal); + Assert.DoesNotContain("_redownloadSelections", transfer, StringComparison.Ordinal); + Assert.Contains("new Binding(nameof(FaultRecordRow.IsSelected))", transfer, StringComparison.Ordinal); + Assert.Contains("row.IsSelected && row.CanSelectForDownload", transfer, StringComparison.Ordinal); + Assert.Contains("ValidateFreshRecordDirectory", transfer, StringComparison.Ordinal); Assert.Contains(".arsas-redownload-", transfer, StringComparison.Ordinal); Assert.Contains("CommitFreshRecordDirectory", transfer, StringComparison.Ordinal); + Assert.Contains("public bool CanSelectForDownload => Record.Files.Count > 0", model, StringComparison.Ordinal); } [Fact] @@ -92,23 +100,26 @@ public void MultiRcbExport_ProductionClickUsesMultiSelectAndNativeDataSetsOnly() } [Fact] - public void LiveSignalPresentation_IsPhaseAwareAtEngineeringColumnLevel() + public void LiveSignalPresentation_UsesSharedSemanticNameAuthorityAtEngineeringColumnLevel() { var source = File.ReadAllText(FindRepoFile("MainWindow.FieldPresentationFix.cs")); Assert.Contains("ApplySemanticSignalColumns", source, StringComparison.Ordinal); Assert.Contains("CreateSemanticSignalBinding(\"IecTelegram\")", source, StringComparison.Ordinal); - Assert.Contains("IoFatSignalDisplayNameFormatter.Format", source, StringComparison.Ordinal); + Assert.Contains("IoSignalDisplayName.Format", source, StringComparison.Ordinal); + Assert.DoesNotContain("IoFatSignalDisplayNameFormatter.Format", source, StringComparison.Ordinal); } [Fact] - public void FatIecReference_RemainsOperatorResizableBeyondCompactWidth() + public void FatIecReference_RemainsOperatorResizableAndPreservesSessionWidth() { var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.ColumnSizing.cs")); Assert.Contains("_fatSignalsGrid.CanUserResizeColumns = true", source, StringComparison.Ordinal); Assert.Contains("\"IEC REFERENCE\"", source, StringComparison.Ordinal); - Assert.Contains("column.MaxWidth = 4096d", source, StringComparison.Ordinal); + Assert.Contains("MaximumFatIecReferenceWidth = 4096d", source, StringComparison.Ordinal); + Assert.Contains("_sessionFatIecReferenceWidth", source, StringComparison.Ordinal); + Assert.Contains("FatIecReferenceColumn_WidthChanged", source, StringComparison.Ordinal); Assert.Contains("DispatcherPriority.ApplicationIdle", source, StringComparison.Ordinal); } From 4d65f7af72f2d8c4ee7228a6e879b773303f4ed2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:41:25 +0700 Subject: [PATCH 10/24] P1: unify COMTRADE header selection authority --- FaultRecordWindow.HeaderSelection.cs | 48 ++++------------------------ 1 file changed, 7 insertions(+), 41 deletions(-) diff --git a/FaultRecordWindow.HeaderSelection.cs b/FaultRecordWindow.HeaderSelection.cs index 61a265410..efefeb702 100644 --- a/FaultRecordWindow.HeaderSelection.cs +++ b/FaultRecordWindow.HeaderSelection.cs @@ -10,9 +10,8 @@ namespace ArIED61850Tester; /// /// Adds a tri-state select-all checkbox to the fault-record Get column. A record with relay -/// files is selectable whether it is a first download or an intentional re-download. Native -/// first-download rows use FaultRecordRow.IsSelected; Downloaded rows use the staged-overwrite -/// selection set owned by RedownloadUx. +/// files is selectable whether it is a first download or an intentional re-download. +/// FaultRecordRow.IsSelected is the single transfer-selection authority for every row. /// public partial class FaultRecordWindow { @@ -105,33 +104,7 @@ private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEvent try { foreach (var row in Records) - { - if (!HasTransferableFiles(row)) - { - row.IsSelected = false; - _redownloadSelections.Remove(row.Record.RecordId); - continue; - } - - if (!target) - { - row.IsSelected = false; - _redownloadSelections.Remove(row.Record.RecordId); - continue; - } - - if (row.LocalState == FaultRecordLocalState.Downloaded) - { - row.IsSelected = false; - _redownloadSelections.Add(row.Record.RecordId); - ConfigureRecordRow(row); - } - else - { - _redownloadSelections.Remove(row.Record.RecordId); - row.IsSelected = row.CanSelectForDownload; - } - } + row.IsSelected = target && row.CanSelectForDownload; } finally { @@ -140,6 +113,7 @@ private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEvent RaiseSelectionState(); UpdateSmartSelectionUi(); + ConfigureVisibleRecordRows(); RefreshFaultRecordHeaderSelection(); } @@ -184,18 +158,18 @@ private void RefreshFaultRecordHeaderSelection() if (header == null) return; - var eligibleCount = Records.Count(HasTransferableFiles); + var eligibleCount = Records.Count(row => row.CanSelectForDownload); header.IsEnabled = !IsBusy && eligibleCount > 0; header.IsChecked = GetFaultRecordHeaderSelectionState(); } private bool? GetFaultRecordHeaderSelectionState() { - var eligible = Records.Where(HasTransferableFiles).ToArray(); + var eligible = Records.Where(row => row.CanSelectForDownload).ToArray(); if (eligible.Length == 0) return false; - var selected = eligible.Count(IsSelectedForTransfer); + var selected = eligible.Count(row => row.IsSelected); if (selected == 0) return false; if (selected == eligible.Length) @@ -203,14 +177,6 @@ private void RefreshFaultRecordHeaderSelection() return null; } - private static bool HasTransferableFiles(FaultRecordRow row) - => row.Record.Files.Count > 0; - - private bool IsSelectedForTransfer(FaultRecordRow row) - => row.LocalState == FaultRecordLocalState.Downloaded - ? _redownloadSelections.Contains(row.Record.RecordId) - : row.IsSelected; - private void FaultRecordHeaderSelectionWindow_Closed(object? sender, EventArgs e) { PropertyChanged -= FaultRecordHeaderSelectionWindow_PropertyChanged; From 36fd3cbde27ddeecac67beaf611c3c10eb5aeb98 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 02:43:23 +0700 Subject: [PATCH 11/24] P1: update field regressions for unified COMTRADE selection --- tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs b/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs index ff1a70fa8..13768aa38 100644 --- a/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs +++ b/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs @@ -32,14 +32,15 @@ public void RcbProductionPointer_OpensMultiExporterAndUsesOneIndependentSelectio } [Fact] - public void DownloadedComtrade_SelectionTickIsRepaintedAfterPreviewInput() + public void DownloadedComtrade_SelectionTickUsesRowAuthorityAndRepaintsAfterPreviewInput() { var source = File.ReadAllText(FindRepoFile("FaultRecordWindow.RedownloadSelectionAuthority.cs")); - Assert.Contains("_redownloadSelections.Add(recordId)", source, StringComparison.Ordinal); + Assert.Contains("row.IsSelected = !row.IsSelected", source, StringComparison.Ordinal); Assert.Contains("DispatcherPriority.Input", source, StringComparison.Ordinal); Assert.Contains("window.ConfigureRecordRow(row)", source, StringComparison.Ordinal); Assert.Contains("window.UpdateSmartSelectionUi()", source, StringComparison.Ordinal); + Assert.DoesNotContain("_redownloadSelections", source, StringComparison.Ordinal); } [Fact] From 423d0091fdbff955408619221abf4738928fb3e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:16:40 +0700 Subject: [PATCH 12/24] P1 bench: make command safety defaults registration deterministic --- MainWindow.P1CommandSafetyDefaults.cs | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 MainWindow.P1CommandSafetyDefaults.cs diff --git a/MainWindow.P1CommandSafetyDefaults.cs b/MainWindow.P1CommandSafetyDefaults.cs new file mode 100644 index 000000000..2ba9b8fe6 --- /dev/null +++ b/MainWindow.P1CommandSafetyDefaults.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; +using System.Windows; + +namespace ArIED61850Tester; + +/// +/// Relay-bench hardening for the shared Engineering/FAT command panel. The original P0 +/// defaults are correct, but their registration lived behind an otherwise unreferenced +/// static field. ModuleInitializer guarantees the Loaded hook exists before any command row +/// can be realized; AttachP0CommandDefaults remains the single lifecycle/ownership authority. +/// +public partial class MainWindow +{ + [ModuleInitializer] + internal static void RegisterP1CommandSafetyDefaultsAuthority() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P1CommandSafetyDefaults_Loaded), + handledEventsToo: true); + } + + private static void P1CommandSafetyDefaults_Loaded(object sender, RoutedEventArgs e) + { + if (sender is MainWindow window) + window.AttachP0CommandDefaults(); + } +} From 74680f283ad18a03faba4d4f3be7eff0e2f6c403 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:16:51 +0700 Subject: [PATCH 13/24] P1 bench: route legacy RCB selections through generic multi exporter --- MainWindow.P1LegacyRcbMultiExport.cs | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 MainWindow.P1LegacyRcbMultiExport.cs diff --git a/MainWindow.P1LegacyRcbMultiExport.cs b/MainWindow.P1LegacyRcbMultiExport.cs new file mode 100644 index 000000000..8b9b103bd --- /dev/null +++ b/MainWindow.P1LegacyRcbMultiExport.cs @@ -0,0 +1,44 @@ +using AR.Iec61850.Mms; +using AR.Iec61850.Scl.Export; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Compatibility bridge for the legacy RCB-filter dialog. Physical bench testing proved the +/// legacy dialog can still be reached on some input paths, so its selected rows are now sent +/// through the same generic multi-RCB export engine as the production multi-select window. +/// No ARIEC61850 engine code is changed. +/// +public partial class MainWindow +{ + internal async Task ExportP1LegacyMultiRcbAsync( + IReadOnlyList selectedRows, + SclSchemaProfile schema, + string outputPath, + CancellationToken cancellationToken) + { + if (selectedRows == null || selectedRows.Count == 0) + throw new InvalidOperationException("Select at least one RCB before export."); + + var device = SelectedDevice + ?? throw new InvalidOperationException("The Engineering IED owning this RCB filter is no longer selected."); + + MmsRcbAvailabilityResult? availability = null; + if (device.IsConnected) + { + availability = await _rcbAvailabilityProbe + .CheckAsync(device, cancellationToken) + .ConfigureAwait(true); + } + + return await ExportGenericMultiRcbAsync( + device, + selectedRows, + schema, + outputPath, + availability, + cancellationToken) + .ConfigureAwait(true); + } +} From 84277f7177d23087ef89e64ac1980b1f855574ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:17:21 +0700 Subject: [PATCH 14/24] P1 bench: make legacy RCB filter independently multi-select --- RcbExportFilterWindow.P1RelayBench.cs | 265 ++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 RcbExportFilterWindow.P1RelayBench.cs diff --git a/RcbExportFilterWindow.P1RelayBench.cs b/RcbExportFilterWindow.P1RelayBench.cs new file mode 100644 index 000000000..b04b0e2ca --- /dev/null +++ b/RcbExportFilterWindow.P1RelayBench.cs @@ -0,0 +1,265 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using AR.Iec61850.Mms; +using AR.Iec61850.Scl.Export; +using ArIED61850Tester.Models; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +/// +/// Physical relay-bench recovery for the legacy SAS RCB dialog. Some production input paths +/// can still land here instead of RcbMultiExportWindow, so this window must itself be a true +/// multi-select surface. Row click, checkbox click, Space and Shift-range all mutate the same +/// RcbExportRow.IsSelected authority. Export consumes every ticked row through the proven +/// generic multi-RCB SCL engine. +/// +public partial class RcbExportFilterWindow +{ + private int _p1LegacyRcbAnchorIndex = -1; + private bool _p1LegacyRcbAnchorValue; + + [ModuleInitializer] + internal static void RegisterP1LegacyRcbMultiSelectionAuthority() + { + EventManager.RegisterClassHandler( + typeof(DataGrid), + UIElement.PreviewMouseLeftButtonDownEvent, + new MouseButtonEventHandler(P1LegacyRcbGrid_PreviewMouseLeftButtonDown), + handledEventsToo: true); + EventManager.RegisterClassHandler( + typeof(DataGrid), + UIElement.PreviewKeyDownEvent, + new KeyEventHandler(P1LegacyRcbGrid_PreviewKeyDown), + handledEventsToo: true); + EventManager.RegisterClassHandler( + typeof(Button), + ButtonBase.ClickEvent, + new RoutedEventHandler(P1LegacyRcbExport_Click), + handledEventsToo: true); + } + + private static void P1LegacyRcbGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left || + sender is not DataGrid grid || + Window.GetWindow(grid) is not RcbExportFilterWindow window || + !ReferenceEquals(grid, window.RcbGrid) || + e.OriginalSource is not DependencyObject source || + FindP1LegacyRcbAncestor(source) != null || + FindP1LegacyRcbAncestor(source) != null) + { + return; + } + + var visualRow = FindP1LegacyRcbAncestor(source); + if (visualRow?.DataContext is not RcbExportRow row || !row.IsSelectable) + return; + + window.P1ToggleLegacyRcbRow(row, (Keyboard.Modifiers & ModifierKeys.Shift) != 0); + e.Handled = true; + } + + private static void P1LegacyRcbGrid_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Space || + sender is not DataGrid grid || + Window.GetWindow(grid) is not RcbExportFilterWindow window || + !ReferenceEquals(grid, window.RcbGrid) || + grid.SelectedItem is not RcbExportRow row || + !row.IsSelectable) + { + return; + } + + window.P1ToggleLegacyRcbRow(row, (Keyboard.Modifiers & ModifierKeys.Shift) != 0); + e.Handled = true; + } + + private void P1ToggleLegacyRcbRow(RcbExportRow row, bool extendRange) + { + var rows = _viewModel.Rows.Cast().ToList(); + var targetIndex = rows.IndexOf(row); + if (targetIndex < 0) + return; + + _selectionUpdateInProgress = true; + try + { + MainWindow.ApplyRcbSelectionForTest( + rows, + ref _p1LegacyRcbAnchorIndex, + ref _p1LegacyRcbAnchorValue, + targetIndex, + extendRange); + + var focus = row.IsSelected + ? row + : rows.FirstOrDefault(candidate => candidate.IsSelected); + RcbGrid.SelectedItem = focus; + _viewModel.SelectedRow = focus; + } + finally + { + _selectionUpdateInProgress = false; + } + + P1RefreshLegacyRcbSelectionUi(); + } + + private static void P1LegacyRcbExport_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || + Window.GetWindow(button) is not RcbExportFilterWindow window || + !ReferenceEquals(button, window.ExportButton)) + { + return; + } + + // Class handlers run before the XAML instance Click handler. Marking the event handled + // prevents the old single-row Export_Click path from consuming only SelectedRow. + e.Handled = true; + window.P1StartLegacyMultiExport(); + } + + private async void P1StartLegacyMultiExport() + { + if (_activeOperation != null) + return; + + var selected = _viewModel.Rows + .Where(row => row.IsSelected && row.IsSelectable) + .ToArray(); + if (selected.Length == 0) + { + MockStatusText.Text = "Select at least one RCB before export."; + P1RefreshLegacyRcbSelectionUi(); + return; + } + + var attention = selected.Where(row => row.RequiresConfirmation).ToArray(); + if (attention.Length > 0) + { + var names = string.Join(", ", attention.Take(8).Select(row => row.Name)); + if (attention.Length > 8) + names += $", +{attention.Length - 8} more"; + var warning = + $"{attention.Length} selected RCB(s) are not proven free/fully available: {names}.\n\n" + + "Export is read-only and does not reserve or enable any RCB. Verify ownership before the target SAS imports/enables them. Continue?"; + if (MessageBox.Show( + this, + warning, + "Confirm RCB Selection", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No) != MessageBoxResult.Yes) + { + return; + } + } + + var editionDialog = new SaveSclWindow( + _viewModel.IedName, + $"Legacy SAS multi-RCB filter • {selected.Length} selected RCB(s)", + SclSchemaProfile.Edition1V16) + { + Owner = this + }; + if (editionDialog.ShowDialog() != true) + return; + + var schema = editionDialog.ViewModel.SelectedSchemaProfile; + var editionSuffix = schema.IsEdition2 ? "ed2" : "ed1"; + var fileDialog = new SaveFileDialog + { + Title = $"Export legacy SAS CID — {selected.Length} selected RCB(s) — {schema.DisplayName}", + Filter = "Configured IED Description (*.cid)|*.cid|All files (*.*)|*.*", + DefaultExt = ".cid", + AddExtension = true, + FileName = $"{SafeFileStem(_viewModel.IedName)}-legacy-sas-{selected.Length}-rcb-{editionSuffix}.cid" + }; + if (fileDialog.ShowDialog(this) != true) + return; + + if (_viewModel.Options.IsMock || Owner is not MainWindow engineering) + { + MockStatusText.Text = "Multi-RCB export requires the production Engineering workspace."; + return; + } + + _activeOperation = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + SetBusyState(true, $"Filtering SCL and validating {selected.Length} retained RCB(s)…"); + try + { + var completion = await engineering.ExportP1LegacyMultiRcbAsync( + selected, + schema.Profile, + fileDialog.FileName, + _activeOperation.Token) + .ConfigureAwait(true); + + MockStatusText.Text = completion.Message; + ShowSuccessOverlay(completion); + } + catch (OperationCanceledException) + { + MockStatusText.Text = "Export cancelled or timed out. The source SCL was not modified."; + } + catch (Exception ex) + { + MockStatusText.Text = ex.Message; + MessageBox.Show(this, ex.Message, "RCB Export Failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + _activeOperation.Dispose(); + _activeOperation = null; + SetBusyState(false, string.Empty); + P1RefreshLegacyRcbSelectionUi(); + } + } + + private void P1RefreshLegacyRcbSelectionUi() + { + var selected = _viewModel.Rows.Where(row => row.IsSelected && row.IsSelectable).ToArray(); + FooterSelectionSummaryText.Text = selected.Length switch + { + 0 => "No RCB selected", + 1 => $"{selected[0].Name} • {selected[0].Type} • {selected[0].MemberCount:N0} members", + _ => $"{selected.Length:N0} RCBs selected • {selected.Sum(row => Math.Max(0, row.MemberCount)):N0} members" + }; + FooterRemovalSummaryText.Text = + $"{selected.Length:N0} retained • {Math.Max(0, _viewModel.Rows.Count - selected.Length):N0} removed"; + ExportButton.IsEnabled = _activeOperation == null && selected.Length > 0; + } + + private static T? FindP1LegacyRcbAncestor(DependencyObject? source) + where T : DependencyObject + { + var current = source; + while (current != null) + { + if (current is T match) + return match; + + DependencyObject? parent = null; + try + { + parent = VisualTreeHelper.GetParent(current); + } + catch (InvalidOperationException) + { + } + + if (parent == null && current is FrameworkElement element) + parent = element.Parent; + current = parent; + } + return null; + } +} From a30189e63e5baf147ff398cb932bc8f04e9a6a87 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:18:10 +0700 Subject: [PATCH 15/24] P1 bench: fence stale command feedback before Engineering and FAT projection --- MainWindow.P1CommandLiveFreshness.cs | 257 +++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 MainWindow.P1CommandLiveFreshness.cs diff --git a/MainWindow.P1CommandLiveFreshness.cs b/MainWindow.P1CommandLiveFreshness.cs new file mode 100644 index 000000000..199714476 --- /dev/null +++ b/MainWindow.P1CommandLiveFreshness.cs @@ -0,0 +1,257 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Relay-bench freshness authority at the runtime -> Engineering/FAT presentation boundary. +/// A command-confirmed CSWI/XCBR position is published immediately. Until a matching report +/// confirms that command (or the bounded two-second fence expires), a contradictory report +/// cannot roll the shared Engineering process image back to the pre-command position. Once a +/// matching report is seen, later contradictory report traffic is again authoritative. +/// +/// This is not a visual debounce: rejected snapshots never enter MainWindow's coalesced point +/// image, FAT LIVE projection, or SOE queue. Evidence therefore cannot be created from the +/// transient stale value either. +/// +public partial class MainWindow +{ + private static readonly TimeSpan P1CommandLiveFreshnessWindow = TimeSpan.FromSeconds(2); + private static readonly TimeSpan P1SuppressedEventOriginWindow = TimeSpan.FromSeconds(1); + + private sealed record P1CommandLiveFence( + string ExpectedValue, + bool MatchingReportSeen, + DateTime ExpiresUtc); + + private sealed record P1SuppressedEventOrigin( + string Value, + DateTime ExpiresUtc); + + private readonly ConcurrentDictionary _p1CommandLiveFences = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _p1SuppressedCommandEvents = + new(StringComparer.OrdinalIgnoreCase); + private bool _p1CommandLiveFreshnessInstalled; + + [ModuleInitializer] + internal static void RegisterP1CommandLiveFreshnessAuthority() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P1CommandLiveFreshness_MainLoaded), + handledEventsToo: true); + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P1CommandLiveFreshness_FatLoaded), + handledEventsToo: true); + } + + private static void P1CommandLiveFreshness_MainLoaded(object sender, RoutedEventArgs e) + { + if (sender is MainWindow window) + window.InstallP1CommandLiveFreshnessFilter(); + } + + private static void P1CommandLiveFreshness_FatLoaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow fat || fat.Owner is not MainWindow engineering) + return; + + // P0 shared-process setup intentionally rewires raw FAT listeners. Re-apply this + // boundary after every FAT window has completed Loaded so there remains exactly one + // filtered runtime path into both Engineering and FAT presentation. + engineering.Dispatcher.BeginInvoke( + new Action(engineering.InstallP1CommandLiveFreshnessFilter), + DispatcherPriority.ApplicationIdle); + } + + private void InstallP1CommandLiveFreshnessFilter() + { + // Runtime_PointUpdated is MainWindow's Engineering image feed. P0FatRuntimePointUpdated + // is the presentation-only FAT mirror. Both must consume the same accepted snapshots. + _runtime.PointUpdated -= Runtime_PointUpdated; + _runtime.PointUpdated -= P0FatRuntimePointUpdated; + _runtime.PointUpdated -= P1CommandLiveFreshness_PointUpdated; + _runtime.PointUpdated += P1CommandLiveFreshness_PointUpdated; + + _runtime.EventRaised -= Runtime_EventRaised; + _runtime.EventRaised -= P1CommandLiveFreshness_EventRaised; + _runtime.EventRaised += P1CommandLiveFreshness_EventRaised; + + if (_p1CommandLiveFreshnessInstalled) + return; + + _p1CommandLiveFreshnessInstalled = true; + Closed += P1CommandLiveFreshness_Closed; + } + + private void P1CommandLiveFreshness_PointUpdated(Iec61850PointSnapshot snapshot) + { + var key = P1CommandLiveFreshnessKey(snapshot.Point.DeviceId, snapshot.Point.IecReference); + var nowUtc = DateTime.UtcNow; + + if (P1IsConfirmedCommandFeedback(snapshot)) + { + _p1CommandLiveFences[key] = new P1CommandLiveFence( + snapshot.Value?.Trim() ?? string.Empty, + MatchingReportSeen: false, + nowUtc.Add(P1CommandLiveFreshnessWindow)); + P1PublishAcceptedPointSnapshot(snapshot); + return; + } + + if (!_p1CommandLiveFences.TryGetValue(key, out var fence)) + { + P1PublishAcceptedPointSnapshot(snapshot); + return; + } + + if (nowUtc > fence.ExpiresUtc) + { + _p1CommandLiveFences.TryRemove(key, out _); + P1PublishAcceptedPointSnapshot(snapshot); + return; + } + + var matchesExpected = P1CommandValuesEquivalent(fence.ExpectedValue, snapshot.Value); + var decision = P1DecideCommandFreshnessForTest( + fence.MatchingReportSeen, + snapshot.IsReportTraffic, + matchesExpected); + + switch (decision) + { + case P1CommandFreshnessDecision.ConfirmAndPublish: + _p1CommandLiveFences[key] = fence with { MatchingReportSeen = true }; + P1PublishAcceptedPointSnapshot(snapshot); + return; + + case P1CommandFreshnessDecision.ReleaseAndPublish: + _p1CommandLiveFences.TryRemove(key, out _); + P1PublishAcceptedPointSnapshot(snapshot); + return; + + case P1CommandFreshnessDecision.Publish: + P1PublishAcceptedPointSnapshot(snapshot); + return; + + case P1CommandFreshnessDecision.Suppress: + if (snapshot.IsValueEdge) + { + _p1SuppressedCommandEvents[key] = new P1SuppressedEventOrigin( + snapshot.Value?.Trim() ?? string.Empty, + nowUtc.Add(P1SuppressedEventOriginWindow)); + } + Trace.WriteLine( + $"[P1 COMMAND FRESHNESS] Suppressed stale process image {snapshot.Point.IecReference}={snapshot.Value}; expected={fence.ExpectedValue}; report={snapshot.IsReportTraffic}; matchingReportSeen={fence.MatchingReportSeen}."); + return; + } + } + + private void P1PublishAcceptedPointSnapshot(Iec61850PointSnapshot snapshot) + { + Runtime_PointUpdated(snapshot); + P0FatRuntimePointUpdated(snapshot); + } + + private void P1CommandLiveFreshness_EventRaised(Iec61850EventEntry entry) + { + var key = P1CommandLiveFreshnessKey(entry.DeviceId, entry.IecReference); + if (_p1SuppressedCommandEvents.TryGetValue(key, out var suppressed)) + { + if (DateTime.UtcNow <= suppressed.ExpiresUtc && + P1CommandValuesEquivalent(suppressed.Value, entry.NewValue)) + { + _p1SuppressedCommandEvents.TryRemove(key, out _); + Trace.WriteLine( + $"[P1 COMMAND FRESHNESS] Suppressed SOE paired with rejected stale process image {entry.IecReference}={entry.NewValue}."); + return; + } + + if (DateTime.UtcNow > suppressed.ExpiresUtc) + _p1SuppressedCommandEvents.TryRemove(key, out _); + } + + Runtime_EventRaised(entry); + } + + private static bool P1IsConfirmedCommandFeedback(Iec61850PointSnapshot snapshot) + => snapshot.IsValueEdge && + !snapshot.IsReportTraffic && + (snapshot.Reason ?? string.Empty).Contains( + "confirmed command feedback", + StringComparison.OrdinalIgnoreCase); + + internal static P1CommandFreshnessDecision P1DecideCommandFreshnessForTest( + bool matchingReportSeen, + bool isReportTraffic, + bool matchesExpected) + { + if (matchesExpected) + return isReportTraffic && !matchingReportSeen + ? P1CommandFreshnessDecision.ConfirmAndPublish + : P1CommandFreshnessDecision.Publish; + + if (isReportTraffic && matchingReportSeen) + return P1CommandFreshnessDecision.ReleaseAndPublish; + + return P1CommandFreshnessDecision.Suppress; + } + + private static string P1CommandLiveFreshnessKey(string? deviceId, string? reference) + => $"{(deviceId ?? string.Empty).Trim().ToLowerInvariant()}|{(reference ?? string.Empty).Trim().Replace('$', '.').ToLowerInvariant()}"; + + private static bool P1CommandValuesEquivalent(string? left, string? right) + { + var leftText = (left ?? string.Empty).Trim(); + var rightText = (right ?? string.Empty).Trim(); + if (leftText.Equals(rightText, StringComparison.OrdinalIgnoreCase)) + return true; + + if (bool.TryParse(leftText, out var leftBool) && + bool.TryParse(rightText, out var rightBool)) + { + return leftBool == rightBool; + } + + var leftCode = P1ExtractStateCode(leftText); + var rightCode = P1ExtractStateCode(rightText); + return leftCode.Length > 0 && + rightCode.Length > 0 && + leftCode.Equals(rightCode, StringComparison.OrdinalIgnoreCase); + } + + private static string P1ExtractStateCode(string value) + { + var open = value.LastIndexOf('['); + var close = value.LastIndexOf(']'); + return open >= 0 && close > open + ? value[(open + 1)..close].Trim() + : string.Empty; + } + + private void P1CommandLiveFreshness_Closed(object? sender, EventArgs e) + { + Closed -= P1CommandLiveFreshness_Closed; + _runtime.PointUpdated -= P1CommandLiveFreshness_PointUpdated; + _runtime.EventRaised -= P1CommandLiveFreshness_EventRaised; + _p1CommandLiveFences.Clear(); + _p1SuppressedCommandEvents.Clear(); + _p1CommandLiveFreshnessInstalled = false; + } +} + +internal enum P1CommandFreshnessDecision +{ + Publish, + Suppress, + ConfirmAndPublish, + ReleaseAndPublish +} From d37b7a45a663dd262068a35ae560245dc0f368ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:18:30 +0700 Subject: [PATCH 16/24] P1 bench: add operator supplied relay fascia SVG --- Assets/RelayFascia.svg | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Assets/RelayFascia.svg diff --git a/Assets/RelayFascia.svg b/Assets/RelayFascia.svg new file mode 100644 index 000000000..a5302ad8b --- /dev/null +++ b/Assets/RelayFascia.svg @@ -0,0 +1,47 @@ + + + + + Layer 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 98ae3814d9e97362eef847310290cc30c81f9b95 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:18:59 +0700 Subject: [PATCH 17/24] P1 bench: replace IED fascia with supplied SVG vector --- Resources/ArvrelMiniIedFascia.xaml | 263 ++++++++--------------------- 1 file changed, 75 insertions(+), 188 deletions(-) diff --git a/Resources/ArvrelMiniIedFascia.xaml b/Resources/ArvrelMiniIedFascia.xaml index 2aeebd85d..bbe693f8b 100644 --- a/Resources/ArvrelMiniIedFascia.xaml +++ b/Resources/ArvrelMiniIedFascia.xaml @@ -3,207 +3,94 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + - - - - - - - - + - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + - + Opacity="0.92"/> - \ No newline at end of file + From 5fc43b148e2227480f9662da089187e84a14258d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:19:24 +0700 Subject: [PATCH 18/24] P1 bench: update fascia regression for supplied SVG artwork --- .../ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs index 8a5b9ea37..82a3c85d3 100644 --- a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs +++ b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs @@ -67,16 +67,20 @@ public void RuntimeAdapter_KeepsMaximizedWorkstationAndFatZebraRows() } [Fact] - public void RelayFascia_IsLightBlueSteelInsteadOfNearBlack() + public void RelayFascia_UsesOperatorSuppliedSvgVectorAndKeepsSemanticStateRail() { var source = File.ReadAllText(FindRepoFile("Resources/ArvrelMiniIedFascia.xaml")); + var svg = File.ReadAllText(FindRepoFile("Assets/RelayFascia.svg")); - Assert.Contains("#A9B5BA", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#87979F", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#EDF3EE", source, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("#1B2328", source, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("#0A1013", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("RelayFasciaArtwork", source, StringComparison.Ordinal); + Assert.Contains("#C0C0C0", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#F2F2F2", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#FF0000", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("{TemplateBinding Foreground}", source, StringComparison.Ordinal); + Assert.Contains("RelayStateRail", source, StringComparison.Ordinal); + Assert.Contains("width=\"424.99999999999994\"", svg, StringComparison.Ordinal); + Assert.Contains("id=\"svg_44\"", svg, StringComparison.Ordinal); + Assert.Contains("#00ff7f", svg, StringComparison.OrdinalIgnoreCase); } private static string FindRepoFile(string relativePath) @@ -93,4 +97,4 @@ private static string FindRepoFile(string relativePath) throw new FileNotFoundException( $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +} From a8582113848142a5117016e3ecb3d67ce2c71ea3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:19:45 +0700 Subject: [PATCH 19/24] P1 bench: lock physical relay hotfix contracts --- .../P1RelayBenchHotfixRegressionTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs diff --git a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs new file mode 100644 index 000000000..3fdf6c12a --- /dev/null +++ b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs @@ -0,0 +1,100 @@ +using ArIED61850Tester; + +namespace ARSAS.Tests; + +public sealed class P1RelayBenchHotfixRegressionTests +{ + [Fact] + public void LegacyRcbFilter_RowAndCheckboxUseIndependentMultiSelectionAndMultiExport() + { + var source = Read("RcbExportFilterWindow.P1RelayBench.cs"); + var bridge = Read("MainWindow.P1LegacyRcbMultiExport.cs"); + + Assert.Contains("MainWindow.ApplyRcbSelectionForTest", source, StringComparison.Ordinal); + Assert.Contains("row => row.IsSelected && row.IsSelectable", source, StringComparison.Ordinal); + Assert.Contains("P1LegacyRcbGrid_PreviewMouseLeftButtonDown", source, StringComparison.Ordinal); + Assert.Contains("P1LegacyRcbGrid_PreviewKeyDown", source, StringComparison.Ordinal); + Assert.Contains("ModifierKeys.Shift", source, StringComparison.Ordinal); + Assert.Contains("ButtonBase.ClickEvent", source, StringComparison.Ordinal); + Assert.Contains("ExportP1LegacyMultiRcbAsync", source, StringComparison.Ordinal); + Assert.Contains("ExportGenericMultiRcbAsync", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("SelectOnly(row)", source, StringComparison.Ordinal); + } + + [Fact] + public void CommandSafetyDefaults_AreInstalledByModuleInitializer() + { + var source = Read("MainWindow.P1CommandSafetyDefaults.cs"); + var defaults = Read("MainWindow.P0CommandDefaults.cs"); + + Assert.Contains("[ModuleInitializer]", source, StringComparison.Ordinal); + Assert.Contains("window.AttachP0CommandDefaults()", source, StringComparison.Ordinal); + Assert.Contains("signal.ControlInterlockCheck = true", defaults, StringComparison.Ordinal); + Assert.Contains("signal.ControlSynchroCheck = true", defaults, StringComparison.Ordinal); + } + + [Theory] + [InlineData(false, true, false, P1CommandFreshnessDecision.Suppress)] + [InlineData(false, true, true, P1CommandFreshnessDecision.ConfirmAndPublish)] + [InlineData(true, true, false, P1CommandFreshnessDecision.ReleaseAndPublish)] + [InlineData(false, false, false, P1CommandFreshnessDecision.Suppress)] + [InlineData(false, false, true, P1CommandFreshnessDecision.Publish)] + public void CommandFreshness_FirstContradictoryReportCannotRollbackConfirmedPosition( + bool matchingReportSeen, + bool reportTraffic, + bool matchesExpected, + P1CommandFreshnessDecision expected) + { + Assert.Equal( + expected, + MainWindow.P1DecideCommandFreshnessForTest( + matchingReportSeen, + reportTraffic, + matchesExpected)); + } + + [Fact] + public void CommandFreshness_FiltersBeforeEngineeringFatAndSoeQueues() + { + var source = Read("MainWindow.P1CommandLiveFreshness.cs"); + + Assert.Contains("_runtime.PointUpdated -= Runtime_PointUpdated", source, StringComparison.Ordinal); + Assert.Contains("_runtime.PointUpdated -= P0FatRuntimePointUpdated", source, StringComparison.Ordinal); + Assert.Contains("_runtime.PointUpdated += P1CommandLiveFreshness_PointUpdated", source, StringComparison.Ordinal); + Assert.Contains("Runtime_PointUpdated(snapshot)", source, StringComparison.Ordinal); + Assert.Contains("P0FatRuntimePointUpdated(snapshot)", source, StringComparison.Ordinal); + Assert.Contains("_runtime.EventRaised -= Runtime_EventRaised", source, StringComparison.Ordinal); + Assert.Contains("P1SuppressedCommandEvents", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MatchingReportSeen", source, StringComparison.Ordinal); + Assert.DoesNotContain("Task.Delay", source, StringComparison.Ordinal); + } + + [Fact] + public void RelayFascia_UsesUploadedSvgArtworkAsVectorSource() + { + var fascia = Read("Resources/ArvrelMiniIedFascia.xaml"); + var svg = Read("Assets/RelayFascia.svg"); + + Assert.Contains("direct WPF vector transcription", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("x:Name=\"RelayFasciaArtwork\"", fascia, StringComparison.Ordinal); + Assert.Contains("x:Name=\"RelayStateRail\"", fascia, StringComparison.Ordinal); + Assert.Contains("#C0C0C0", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#FF0000", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("id=\"svg_1\"", svg, StringComparison.Ordinal); + Assert.Contains("id=\"svg_44\"", svg, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return File.ReadAllText(candidate); + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From adec9a7d3debe124a3cda9f0fce08499559bdebe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:21:46 +0700 Subject: [PATCH 20/24] P1 bench: keep freshness enum internal in regression theory --- .../P1RelayBenchHotfixRegressionTests.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs index 3fdf6c12a..7d10c9926 100644 --- a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs +++ b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs @@ -34,23 +34,24 @@ public void CommandSafetyDefaults_AreInstalledByModuleInitializer() } [Theory] - [InlineData(false, true, false, P1CommandFreshnessDecision.Suppress)] - [InlineData(false, true, true, P1CommandFreshnessDecision.ConfirmAndPublish)] - [InlineData(true, true, false, P1CommandFreshnessDecision.ReleaseAndPublish)] - [InlineData(false, false, false, P1CommandFreshnessDecision.Suppress)] - [InlineData(false, false, true, P1CommandFreshnessDecision.Publish)] + [InlineData(false, true, false, "Suppress")] + [InlineData(false, true, true, "ConfirmAndPublish")] + [InlineData(true, true, false, "ReleaseAndPublish")] + [InlineData(false, false, false, "Suppress")] + [InlineData(false, false, true, "Publish")] public void CommandFreshness_FirstContradictoryReportCannotRollbackConfirmedPosition( bool matchingReportSeen, bool reportTraffic, bool matchesExpected, - P1CommandFreshnessDecision expected) + string expected) { Assert.Equal( expected, MainWindow.P1DecideCommandFreshnessForTest( - matchingReportSeen, - reportTraffic, - matchesExpected)); + matchingReportSeen, + reportTraffic, + matchesExpected) + .ToString()); } [Fact] From 1b33476b8bc1111f94647246c7d9a23dd1d0c68d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 05:26:24 +0700 Subject: [PATCH 21/24] P1 bench: migrate legacy fascia test to supplied relay artwork --- tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs b/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs index c5a49ff0d..775a690d2 100644 --- a/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs +++ b/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs @@ -27,16 +27,24 @@ public void CompactArvrelFascia_IsVectorOnlyAndKeepsRecognizableRelayHardware() .Cast() .ToHashSet(StringComparer.Ordinal); - Assert.Contains("ArvrelMiniShell", namedParts); - Assert.Contains("ArvrelMiniLedBank", namedParts); - Assert.Contains("ArvrelMiniLcd", namedParts); - Assert.Contains("ArvrelMiniKeypad", namedParts); + Assert.Contains("RelayFasciaArtwork", namedParts); Assert.Contains("RelayStateRail", namedParts); + // The operator-supplied SVG is transcribed to native WPF vector primitives so no + // raster/image dependency is introduced at card scale. Assert.Empty(template.Descendants(presentation + "Image")); Assert.Empty(template.Descendants(presentation + "TextBlock")); - Assert.Contains("{TemplateBinding Foreground}", template.ToString(), StringComparison.Ordinal); - Assert.Contains("#2E6F9E", document.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(template.Descendants(presentation + "Rectangle")); + Assert.NotEmpty(template.Descendants(presentation + "Ellipse")); + Assert.NotEmpty(template.Descendants(presentation + "Line")); + Assert.NotEmpty(template.Descendants(presentation + "Path")); + + var templateText = template.ToString(); + Assert.Contains("{TemplateBinding Foreground}", templateText, StringComparison.Ordinal); + Assert.Contains("#C0C0C0", templateText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#F2F2F2", templateText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#FF0000", templateText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#00FF7F", templateText, StringComparison.OrdinalIgnoreCase); } [Fact] From 9e89779aa9c41c505caf7b8be94b124b0fca8b39 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 06:35:21 +0700 Subject: [PATCH 22/24] P1 bench: keep legacy SCL XML out of JSON reports --- LegacySasSclExporter.SafeBridge.cs | 180 +++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 LegacySasSclExporter.SafeBridge.cs diff --git a/LegacySasSclExporter.SafeBridge.cs b/LegacySasSclExporter.SafeBridge.cs new file mode 100644 index 000000000..1fdfa3989 --- /dev/null +++ b/LegacySasSclExporter.SafeBridge.cs @@ -0,0 +1,180 @@ +using System.Text; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using AR.Iec61850.Scl.Export; + +namespace ArIED61850Tester; + +/// +/// ARSAS compatibility bridge for the pinned ARIEC61850 legacy-SAS exporter. +/// +/// The pinned engine's WriteFiles implementation serializes LegacySasSclExportResult +/// directly. That result intentionally carries an XDocument for XML output, and +/// System.Text.Json walks the LINQ-to-XML parent/sibling graph until it reports an object +/// cycle (for example $.Document.Root.FirstAttribute.NextAttribute...). +/// +/// Keep the engine immutable: use its Build method for all IEC 61850 conversion, +/// filtering and validation, write Document only as XML, and serialize an ARSAS-owned +/// scalar evidence DTO that cannot contain XDocument/XElement/XAttribute objects. +/// +internal static class LegacySasSclExporter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static LegacySasSclExportResult Build( + XDocument source, + string sourceName, + LegacySasSclExportOptions options) + => AR.Iec61850.Scl.Export.LegacySasSclExporter.Build(source, sourceName, options); + + public static LegacySasSclExportResult WriteFiles( + string inputPath, + string outputPath, + LegacySasSclExportOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputPath); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + ArgumentNullException.ThrowIfNull(options); + + using var input = File.OpenRead(inputPath); + var source = XDocument.Load(input, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + var built = AR.Iec61850.Scl.Export.LegacySasSclExporter.Build( + source, + Path.GetFileName(inputPath), + options); + + var fullOutputPath = Path.GetFullPath(outputPath); + var directory = Path.GetDirectoryName(fullOutputPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + using (var stream = File.Create(fullOutputPath)) + using (var writer = XmlWriter.Create(stream, new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + OmitXmlDeclaration = false + })) + { + // XML is the only valid persistence representation for the XDocument. + built.Document.Save(writer); + } + + var reportPath = Path.ChangeExtension(fullOutputPath, ".legacy-sas-rcb-report.json"); + var summaryPath = Path.ChangeExtension(fullOutputPath, ".legacy-sas-rcb-summary.md"); + var written = built with + { + InputPath = Path.GetFullPath(inputPath), + OutputPath = fullOutputPath, + ReportPath = reportPath, + SummaryPath = summaryPath + }; + + File.WriteAllText( + reportPath, + SerializeSafeReportForTest(written), + new UTF8Encoding(false)); + File.WriteAllText( + summaryPath, + BuildMarkdown(written), + new UTF8Encoding(false)); + + return written; + } + + internal static string SerializeSafeReportForTest(LegacySasSclExportResult result) + { + ArgumentNullException.ThrowIfNull(result); + + var report = new SafeLegacySasReport + { + GeneratedAtUtc = result.GeneratedAtUtc, + InputPath = result.InputPath, + OutputPath = result.OutputPath, + ReportPath = result.ReportPath, + SummaryPath = result.SummaryPath, + IedName = result.IedName, + AccessPointName = result.AccessPointName, + SclSchema = result.SclSchema, + RetainedReportControlReference = result.RetainedReportControlReference, + RetainedDataSetName = result.RetainedDataSetName, + RetainedDataSetMemberCount = result.RetainedDataSetMemberCount, + RemovedReportControlCount = result.RemovedReportControlCount, + RemovedDataSetCount = result.RemovedDataSetCount, + Findings = result.Findings + .Select(finding => new SafeLegacySasFinding + { + Severity = finding.Severity, + Code = finding.Code, + Reference = finding.Reference, + Message = finding.Message + }) + .ToArray() + }; + + return JsonSerializer.Serialize(report, JsonOptions); + } + + private static string BuildMarkdown(LegacySasSclExportResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("# Legacy SAS Selected-RCB Export"); + builder.AppendLine(); + builder.AppendLine($"- Generated: {result.GeneratedAtUtc:yyyy-MM-dd HH:mm:ss.fff} UTC"); + builder.AppendLine($"- Input: `{result.InputPath}`"); + builder.AppendLine($"- Output: `{result.OutputPath}`"); + builder.AppendLine($"- IED / AccessPoint: `{result.IedName}` / `{result.AccessPointName}`"); + builder.AppendLine($"- Schema: `{result.SclSchema}`"); + builder.AppendLine($"- Retained RCB: `{result.RetainedReportControlReference}`"); + builder.AppendLine($"- DataSet: `{result.RetainedDataSetName}` ({result.RetainedDataSetMemberCount} FCDA)"); + builder.AppendLine($"- Removed RCBs: {result.RemovedReportControlCount}"); + builder.AppendLine($"- Removed unreferenced DataSets: {result.RemovedDataSetCount}"); + builder.AppendLine(); + builder.AppendLine("The original source file was not modified. Validate the generated CID with the target SAS import workflow before operational use."); + + if (result.Findings.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("## Findings"); + builder.AppendLine(); + foreach (var finding in result.Findings) + { + builder.AppendLine( + $"- **{finding.Severity} / {finding.Code}**" + + $"{(string.IsNullOrWhiteSpace(finding.Reference) ? string.Empty : $" `{finding.Reference}`")}: {finding.Message}"); + } + } + + return builder.ToString(); + } + + private sealed class SafeLegacySasReport + { + public DateTimeOffset GeneratedAtUtc { get; init; } + public string InputPath { get; init; } = string.Empty; + public string OutputPath { get; init; } = string.Empty; + public string ReportPath { get; init; } = string.Empty; + public string SummaryPath { get; init; } = string.Empty; + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public string SclSchema { get; init; } = string.Empty; + public string RetainedReportControlReference { get; init; } = string.Empty; + public string RetainedDataSetName { get; init; } = string.Empty; + public int RetainedDataSetMemberCount { get; init; } + public int RemovedReportControlCount { get; init; } + public int RemovedDataSetCount { get; init; } + public IReadOnlyList Findings { get; init; } = Array.Empty(); + } + + private sealed class SafeLegacySasFinding + { + public string Severity { get; init; } = string.Empty; + public string Code { get; init; } = string.Empty; + public string Reference { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + } +} From 0b1d815533b668703402daf61d8aea7e3917b549 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 06:36:13 +0700 Subject: [PATCH 23/24] P1 bench: regress legacy SCL JSON cycle --- ...cbSclExportSerializationRegressionTests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs diff --git a/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs b/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs new file mode 100644 index 000000000..a9e7ab5e0 --- /dev/null +++ b/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using System.Xml.Linq; +using AR.Iec61850.Scl.Export; +using ArIED61850Tester; + +namespace ARSAS.Tests; + +public sealed class P1RcbSclExportSerializationRegressionTests +{ + [Fact] + public void LegacySasReport_SerializesScalarEvidenceWithoutXmlObjectGraph() + { + var root = new XElement("SCL"); + for (var index = 0; index < 80; index++) + root.Add(new XAttribute($"a{index}", index)); + root.Add(new XElement("Header", new XAttribute("id", "x"))); + + var result = new LegacySasSclExportResult + { + Document = new XDocument(root), + InputPath = "input.scd", + OutputPath = "output.cid", + ReportPath = "output.legacy-sas-rcb-report.json", + SummaryPath = "output.legacy-sas-rcb-summary.md", + IedName = "IED1", + AccessPointName = "AP1", + SclSchema = "Edition 1", + RetainedReportControlReference = "IED1LD0/LLN0.RP.RCB1", + RetainedDataSetName = "DS1", + RetainedDataSetMemberCount = 1, + RemovedReportControlCount = 3, + RemovedDataSetCount = 0 + }; + + var json = LegacySasSclExporter.SerializeSafeReportForTest(result); + + Assert.DoesNotContain("\"document\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("firstAttribute", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("nextAttribute", json, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"retainedReportControlReference\"", json, StringComparison.Ordinal); + Assert.Contains("IED1LD0/LLN0.RP.RCB1", json, StringComparison.Ordinal); + Assert.Contains("\"retainedDataSetMemberCount\": 1", json, StringComparison.Ordinal); + } + + [Fact] + public void LegacySasReport_OldDirectResultSerializationStillDemonstratesXmlCycleRisk() + { + var root = new XElement("SCL"); + for (var index = 0; index < 80; index++) + root.Add(new XAttribute($"a{index}", index)); + + var result = new LegacySasSclExportResult + { + Document = new XDocument(root) + }; + + var exception = Record.Exception(() => JsonSerializer.Serialize( + result, + new JsonSerializerOptions(JsonSerializerDefaults.Web))); + + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void SourceBackedRcbExport_UsesPinnedEngineBuildButNeverSerializesEngineResultDirectly() + { + var main = Read("MainWindow.RcbExport.cs"); + var bridge = Read("LegacySasSclExporter.SafeBridge.cs"); + + Assert.Contains("LegacySasSclExporter.WriteFiles(", main, StringComparison.Ordinal); + Assert.Contains("namespace ArIED61850Tester;", bridge, StringComparison.Ordinal); + Assert.Contains( + "AR.Iec61850.Scl.Export.LegacySasSclExporter.Build", + bridge, + StringComparison.Ordinal); + Assert.Contains("built.Document.Save(writer)", bridge, StringComparison.Ordinal); + Assert.Contains("SerializeSafeReportForTest(written)", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("JsonSerializer.Serialize(written", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("ReferenceHandler.Preserve", bridge, StringComparison.Ordinal); + } + + [Fact] + public void RelayBenchFixes_AreNotChangedBySclSerializationBridge() + { + var bridge = Read("LegacySasSclExporter.SafeBridge.cs"); + + Assert.DoesNotContain("ControlSynchroCheck", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("ControlInterlockCheck", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("P1CommandFreshness", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("RcbGrid", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("RelayFascia", bridge, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return File.ReadAllText(candidate); + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From a3e650130ea250e6c674bea30663d2d0f3866d04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 8 Sep 2026 06:38:40 +0700 Subject: [PATCH 24/24] P1 bench: disambiguate SCL export regression shim --- tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs b/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs index a9e7ab5e0..61fd72fa2 100644 --- a/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs +++ b/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs @@ -32,7 +32,7 @@ public void LegacySasReport_SerializesScalarEvidenceWithoutXmlObjectGraph() RemovedDataSetCount = 0 }; - var json = LegacySasSclExporter.SerializeSafeReportForTest(result); + var json = ArIED61850Tester.LegacySasSclExporter.SerializeSafeReportForTest(result); Assert.DoesNotContain("\"document\"", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("firstAttribute", json, StringComparison.OrdinalIgnoreCase);