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 @@
+
+
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;
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,
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
+}
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;
}
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);
+ }
}
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;
+ }
+}
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)
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
+}
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();
+ }
+}
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);
+ }
+}
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}";
+ }
+}
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;
+ }
+}
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
+
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);
}
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),
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]
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]
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);
}
diff --git a/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs b/tests/ARSAS.Tests/P1RcbSclExportSerializationRegressionTests.cs
new file mode 100644
index 000000000..61fd72fa2
--- /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 = ArIED61850Tester.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);
+ }
+}
diff --git a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs
new file mode 100644
index 000000000..7d10c9926
--- /dev/null
+++ b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs
@@ -0,0 +1,101 @@
+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, "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,
+ string expected)
+ {
+ Assert.Equal(
+ expected,
+ MainWindow.P1DecideCommandFreshnessForTest(
+ matchingReportSeen,
+ reportTraffic,
+ matchesExpected)
+ .ToString());
+ }
+
+ [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);
+ }
+}
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
+}