diff --git a/FaultRecordWindow.xaml b/FaultRecordWindow.xaml
index d01e0a3eb..131d93eff 100644
--- a/FaultRecordWindow.xaml
+++ b/FaultRecordWindow.xaml
@@ -1,10 +1,11 @@
+
+
+
+
-
-
+
+
+
+
+
-
+
-
+
-
+
-
+
-
+
@@ -91,83 +197,116 @@
-
-
+
+
+
-
-
-
+
-
+
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
+
+
+
-
+
-
+
-
+
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -178,7 +317,7 @@
-
+
@@ -195,17 +334,17 @@
+ Style="{StaticResource PillSecondaryButtonStyle}" Height="32" MinWidth="122" Padding="12,3"/>
-
+
@@ -219,15 +358,32 @@
-
-
-
+
+
+ Style="{StaticResource PillSecondaryButtonStyle}" IsEnabled="{Binding IsBusy, Mode=OneWay}" MinWidth="92"/>
+ Style="{StaticResource PillPrimaryButtonStyle}" IsEnabled="{Binding CanDownload, Mode=OneWay}" MinWidth="150"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FaultRecordWindow.xaml.cs b/FaultRecordWindow.xaml.cs
index 8c11ed044..214b0c752 100644
--- a/FaultRecordWindow.xaml.cs
+++ b/FaultRecordWindow.xaml.cs
@@ -4,6 +4,9 @@
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using System.Windows.Threading;
using AR.Iec61850.FaultRecords;
using ArIED61850Tester.Services;
using Microsoft.Win32;
@@ -13,12 +16,13 @@ namespace ArIED61850Tester;
public partial class FaultRecordWindow : Window, INotifyPropertyChanged
{
private readonly FaultRecordTransferClient _client = new();
+ private readonly DispatcherTimer _toastTimer = new();
private readonly string _host;
private readonly int _port;
private CancellationTokenSource? _operationCancellation;
private string _remoteDirectory = string.Empty;
private string _destinationDirectory;
- private string _statusText = "Ready to scan the relay file store.";
+ private string _statusText = "Opening the relay file store…";
private bool _isBusy;
private bool _isIndeterminate;
private double _progressValue;
@@ -35,6 +39,8 @@ public FaultRecordWindow(string deviceName, string host, int port)
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"ARSAS Fault Records");
+ _toastTimer.Interval = TimeSpan.FromSeconds(2.8);
+ _toastTimer.Tick += ToastTimer_Tick;
DataContext = this;
}
@@ -45,6 +51,8 @@ public FaultRecordWindow(string deviceName, string host, int port)
public string DeviceName { get; }
public string EndpointText => $"{_host}:{_port}";
+ // Retained internally for protocol diagnostics and compatibility. The compact UI
+ // intentionally scans from the relay file-store root without exposing this field.
public string RemoteDirectory
{
get => _remoteDirectory;
@@ -54,7 +62,13 @@ public string RemoteDirectory
public string DestinationDirectory
{
get => _destinationDirectory;
- set => Set(ref _destinationDirectory, value ?? string.Empty);
+ set
+ {
+ if (!Set(ref _destinationDirectory, value ?? string.Empty))
+ return;
+
+ RefreshLocalDownloadStates();
+ }
}
public string StatusText
@@ -90,14 +104,16 @@ public double ProgressValue
private set => Set(ref _progressValue, Math.Clamp(value, 0d, 100d));
}
- public bool CanDownload => !IsBusy && Records.Any(row => row.IsSelected && row.Record.Files.Count > 0);
+ public bool CanDownload =>
+ !IsBusy && Records.Any(row => row.IsSelected && row.CanSelectForDownload);
public string SelectionSummary
{
get
{
- var selected = Records.Count(row => row.IsSelected);
- return $"{Records.Count:N0} record(s) • {selected:N0} selected";
+ var selected = Records.Count(row => row.IsSelected && row.CanSelectForDownload);
+ var downloaded = Records.Count(row => row.LocalState == FaultRecordLocalState.Downloaded);
+ return $"{Records.Count:N0} record(s) • {selected:N0} selected • {downloaded:N0} downloaded";
}
}
@@ -116,20 +132,21 @@ private async Task ScanAsync()
IsBusy = true;
IsIndeterminate = true;
ProgressValue = 0;
- StatusText = $"Connecting a dedicated IEC 61850 file-transfer session to {EndpointText}…";
+ StatusText = $"Connecting to {EndpointText} and reading fault records…";
try
{
await _client.ConnectAsync(_host, _port, _operationCancellation!.Token);
- StatusText = string.IsNullOrWhiteSpace(RemoteDirectory)
- ? "Scanning the remote file-store root and bounded subdirectories…"
- : $"Scanning '{RemoteDirectory.Trim()}' and bounded subdirectories…";
+ StatusText = "Scanning the relay file store…";
var catalog = await _client.DiscoverAsync(
- RemoteDirectory,
+ remoteDirectory: null,
_operationCancellation.Token);
+ foreach (var existing in Records)
+ existing.PropertyChanged -= RecordRow_PropertyChanged;
Records.Clear();
+
foreach (var record in catalog.Records)
{
var row = new FaultRecordRow(record);
@@ -137,12 +154,14 @@ private async Task ScanAsync()
Records.Add(row);
}
+ RefreshLocalDownloadStates();
+ var downloaded = Records.Count(row => row.LocalState == FaultRecordLocalState.Downloaded);
StatusText = catalog.Records.Count == 0
- ? "No supported fault-record files were found. Try a known remote directory such as COMTRADE or DR."
- : $"{catalog.Summary} Select one or more available records to download; partial companion sets remain downloadable.";
+ ? "No supported fault records were found in the relay file store."
+ : $"Found {catalog.Records.Count:N0} fault record(s); {downloaded:N0} already exist in the selected local folder.";
if (catalog.Diagnostics.Count > 0)
- StatusText += $" {catalog.Diagnostics.Count} bounded browse diagnostic(s) were reported.";
+ StatusText += $" {catalog.Diagnostics.Count:N0} bounded browse diagnostic(s) were reported.";
ProgressValue = 100;
RaiseSelectionState();
@@ -154,6 +173,7 @@ private async Task ScanAsync()
catch (Exception ex)
{
StatusText = $"Fault-record scan failed: {ex.Message}";
+ ShowToast("Could not scan fault records. Open diagnostics for details.", ToastKind.Error);
}
finally
{
@@ -173,8 +193,13 @@ private void ChooseFolder_Click(object sender, RoutedEventArgs e)
Multiselect = false
};
- if (dialog.ShowDialog(this) == true)
- DestinationDirectory = dialog.FolderName;
+ if (dialog.ShowDialog(this) != true)
+ return;
+
+ DestinationDirectory = dialog.FolderName;
+ var downloaded = Records.Count(row => row.LocalState == FaultRecordLocalState.Downloaded);
+ StatusText = $"Local folder updated. {downloaded:N0} discovered record(s) already exist there.";
+ ShowToast("Download folder updated and local files rechecked.", ToastKind.Information);
}
private async void Download_Click(object sender, RoutedEventArgs e)
@@ -182,18 +207,29 @@ private async void Download_Click(object sender, RoutedEventArgs e)
if (IsBusy)
return;
- var selected = Records
+ 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 = "Select at least one record that contains an available remote file.";
+ 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;
}
@@ -215,6 +251,7 @@ private async void Download_Click(object sender, RoutedEventArgs e)
_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;
@@ -239,8 +276,7 @@ private async void Download_Click(object sender, RoutedEventArgs e)
{
completedRecords++;
downloadedBytes += result.BytesTransferred;
- row.Status = "Downloaded";
- row.LocalDirectory = result.DestinationDirectory;
+ row.MarkDownloaded(result.DestinationDirectory);
}
else
{
@@ -252,22 +288,43 @@ private async void Download_Click(object sender, RoutedEventArgs e)
ProgressValue = ((index + 1d) / selected.Length) * 100d;
}
+ RefreshLocalDownloadStates(preserveFailureStatus: true);
StatusText = failedRecords == 0
- ? $"Downloaded {completedRecords} record(s), {FormatBytes(downloadedBytes)}, to '{DestinationDirectory}'."
- : $"Downloaded {completedRecords} record(s); {failedRecords} failed. Select a failed row to review its status.";
+ ? $"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();
}
}
@@ -279,8 +336,12 @@ private void Cancel_Click(object sender, RoutedEventArgs e)
private void RecordRow_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName == nameof(FaultRecordRow.IsSelected))
+ if (e.PropertyName is nameof(FaultRecordRow.IsSelected) or
+ nameof(FaultRecordRow.LocalState) or
+ nameof(FaultRecordRow.Status))
+ {
RaiseSelectionState();
+ }
}
private void RaiseSelectionState()
@@ -289,6 +350,154 @@ private void RaiseSelectionState()
Raise(nameof(CanDownload));
}
+ private void RefreshLocalDownloadStates(bool preserveFailureStatus = false)
+ {
+ if (Records.Count == 0)
+ {
+ RaiseSelectionState();
+ return;
+ }
+
+ foreach (var row in Records)
+ {
+ var snapshot = DetectLocalDownload(row.Record, DestinationDirectory);
+ row.ApplyLocalState(snapshot.State, snapshot.Directory, preserveFailureStatus);
+ }
+
+ RaiseSelectionState();
+ }
+
+ private static LocalDownloadSnapshot DetectLocalDownload(
+ Iec61850FaultRecordSet record,
+ string destinationRoot)
+ {
+ if (string.IsNullOrWhiteSpace(destinationRoot) || !Directory.Exists(destinationRoot))
+ return new LocalDownloadSnapshot(FaultRecordLocalState.NotDownloaded, string.Empty);
+
+ var safeRecordName = SanitizeLocalFileName(record.BaseName);
+ string[] candidates;
+ try
+ {
+ candidates = Directory
+ .EnumerateDirectories(destinationRoot, $"{safeRecordName}*", SearchOption.TopDirectoryOnly)
+ .Where(path =>
+ {
+ var name = Path.GetFileName(path);
+ return name.Equals(safeRecordName, StringComparison.OrdinalIgnoreCase) ||
+ name.StartsWith(safeRecordName + "-", StringComparison.OrdinalIgnoreCase);
+ })
+ .OrderByDescending(path => Directory.GetLastWriteTimeUtc(path))
+ .ToArray();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
+ {
+ return new LocalDownloadSnapshot(FaultRecordLocalState.NotDownloaded, string.Empty);
+ }
+
+ string partialDirectory = string.Empty;
+ foreach (var candidate in candidates)
+ {
+ var matched = 0;
+ foreach (var remoteFile in record.Files)
+ {
+ var localPath = Path.Combine(candidate, SanitizeLocalFileName(remoteFile.Name));
+ if (!File.Exists(localPath))
+ continue;
+
+ try
+ {
+ var length = new FileInfo(localPath).Length;
+ var valid = remoteFile.SizeBytes is > 0
+ ? length == remoteFile.SizeBytes.Value
+ : true;
+ if (valid)
+ matched++;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+
+ if (matched == record.Files.Count && matched > 0)
+ return new LocalDownloadSnapshot(FaultRecordLocalState.Downloaded, candidate);
+
+ if (matched > 0 && string.IsNullOrWhiteSpace(partialDirectory))
+ partialDirectory = candidate;
+ }
+
+ return string.IsNullOrWhiteSpace(partialDirectory)
+ ? new LocalDownloadSnapshot(FaultRecordLocalState.NotDownloaded, string.Empty)
+ : new LocalDownloadSnapshot(FaultRecordLocalState.Partial, partialDirectory);
+ }
+
+ private static string SanitizeLocalFileName(string value)
+ {
+ var source = string.IsNullOrWhiteSpace(value) ? "fault-record" : value.Trim();
+ var invalid = Path.GetInvalidFileNameChars();
+ var characters = source
+ .Select(character => character < ' ' || invalid.Contains(character) ? '_' : character)
+ .ToArray();
+ var sanitized = new string(characters).Trim().TrimEnd('.');
+ return string.IsNullOrWhiteSpace(sanitized) ? "fault-record" : sanitized;
+ }
+
+ private void ShowToast(string message, ToastKind kind)
+ {
+ _toastTimer.Stop();
+ ToastHost.BeginAnimation(OpacityProperty, null);
+ ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, null);
+
+ ToastGlyph.Text = kind switch
+ {
+ ToastKind.Success => "✓",
+ ToastKind.Warning => "!",
+ ToastKind.Error => "×",
+ _ => "i"
+ };
+ ToastText.Text = message;
+ ToastHost.Background = CreateToastBrush(kind);
+ ToastHost.Visibility = Visibility.Visible;
+ ToastHost.Opacity = 0;
+ ToastTranslate.Y = -10;
+
+ var easing = new CubicEase { EasingMode = EasingMode.EaseOut };
+ ToastHost.BeginAnimation(
+ OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)) { EasingFunction = easing });
+ ToastTranslate.BeginAnimation(
+ System.Windows.Media.TranslateTransform.YProperty,
+ new DoubleAnimation(-10, 0, TimeSpan.FromMilliseconds(220)) { EasingFunction = easing });
+ _toastTimer.Start();
+ }
+
+ private void ToastTimer_Tick(object? sender, EventArgs e)
+ {
+ _toastTimer.Stop();
+ var fade = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(260))
+ {
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
+ };
+ fade.Completed += (_, _) =>
+ {
+ ToastHost.Visibility = Visibility.Collapsed;
+ ToastHost.Opacity = 0;
+ };
+ ToastHost.BeginAnimation(OpacityProperty, fade);
+ }
+
+ private static Brush CreateToastBrush(ToastKind kind)
+ {
+ var colors = kind switch
+ {
+ ToastKind.Success => (Color.FromRgb(40, 176, 112), Color.FromRgb(24, 132, 84)),
+ ToastKind.Warning => (Color.FromRgb(235, 166, 48), Color.FromRgb(193, 116, 24)),
+ ToastKind.Error => (Color.FromRgb(223, 82, 96), Color.FromRgb(173, 48, 64)),
+ _ => (Color.FromRgb(77, 132, 226), Color.FromRgb(48, 91, 177))
+ };
+
+ return new LinearGradientBrush(colors.Item1, colors.Item2, 25);
+ }
+
private void ResetOperationCancellation()
{
_operationCancellation?.Cancel();
@@ -298,6 +507,7 @@ private void ResetOperationCancellation()
private async void Window_Closed(object? sender, EventArgs e)
{
+ _toastTimer.Stop();
_operationCancellation?.Cancel();
_operationCancellation?.Dispose();
await _client.DisposeAsync();
@@ -332,6 +542,25 @@ private bool Set(ref T field, T value, [CallerMemberName] string? propertyNam
private void Raise([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ private readonly record struct LocalDownloadSnapshot(
+ FaultRecordLocalState State,
+ string Directory);
+
+ private enum ToastKind
+ {
+ Information,
+ Success,
+ Warning,
+ Error
+ }
+}
+
+public enum FaultRecordLocalState
+{
+ NotDownloaded,
+ Partial,
+ Downloaded
}
public sealed class FaultRecordRow : INotifyPropertyChanged
@@ -340,15 +569,12 @@ public sealed class FaultRecordRow : INotifyPropertyChanged
private string _status;
private string _detail = string.Empty;
private string _localDirectory = string.Empty;
+ private FaultRecordLocalState _localState;
public FaultRecordRow(Iec61850FaultRecordSet record)
{
Record = record ?? throw new ArgumentNullException(nameof(record));
- _status = record.Files.Count == 0
- ? "Unavailable"
- : record.IsComplete
- ? "Ready"
- : "Partial";
+ _status = DefaultRemoteStatus;
if (!record.IsComplete && record.Files.Count > 0)
_detail = $"Available file(s) can be downloaded; COMTRADE companion coverage: {record.Completeness}.";
}
@@ -366,15 +592,18 @@ public FaultRecordRow(Iec61850FaultRecordSet record)
? "PACKAGE"
: file.Extension.TrimStart('.').ToUpperInvariant()));
+ public bool CanSelectForDownload => Record.Files.Count > 0 && LocalState != FaultRecordLocalState.Downloaded;
+
public bool IsSelected
{
get => _isSelected;
set
{
- if (_isSelected == value)
+ var normalized = value && CanSelectForDownload;
+ if (_isSelected == normalized)
return;
- _isSelected = value;
+ _isSelected = normalized;
Raise();
}
}
@@ -418,6 +647,78 @@ public string LocalDirectory
}
}
+ public FaultRecordLocalState LocalState
+ {
+ get => _localState;
+ private set
+ {
+ if (_localState == value)
+ return;
+
+ _localState = value;
+ if (_localState == FaultRecordLocalState.Downloaded)
+ _isSelected = false;
+ Raise();
+ Raise(nameof(IsSelected));
+ Raise(nameof(CanSelectForDownload));
+ }
+ }
+
+ public void MarkDownloaded(string localDirectory)
+ {
+ LocalDirectory = localDirectory ?? string.Empty;
+ LocalState = FaultRecordLocalState.Downloaded;
+ Status = "Downloaded";
+ Detail = string.IsNullOrWhiteSpace(LocalDirectory)
+ ? "The complete record exists in the selected local folder."
+ : $"Downloaded to '{LocalDirectory}'.";
+ }
+
+ public void ApplyLocalState(
+ FaultRecordLocalState state,
+ string localDirectory,
+ bool preserveFailureStatus)
+ {
+ LocalDirectory = localDirectory ?? string.Empty;
+ LocalState = state;
+
+ if (state == FaultRecordLocalState.Downloaded)
+ {
+ Status = "Downloaded";
+ Detail = string.IsNullOrWhiteSpace(LocalDirectory)
+ ? "The complete record exists in the selected local folder."
+ : $"Already downloaded to '{LocalDirectory}'.";
+ return;
+ }
+
+ if (preserveFailureStatus && Status == "Failed")
+ return;
+
+ if (Status == "Downloading")
+ return;
+
+ if (state == FaultRecordLocalState.Partial)
+ {
+ Status = "Local partial";
+ Detail = string.IsNullOrWhiteSpace(LocalDirectory)
+ ? "Some companion files exist locally, but the record is incomplete."
+ : $"Some companion files exist in '{LocalDirectory}', but the local record is incomplete.";
+ return;
+ }
+
+ Status = DefaultRemoteStatus;
+ if (Record.IsComplete)
+ Detail = string.Empty;
+ else if (Record.Files.Count > 0)
+ Detail = $"Available file(s) can be downloaded; COMTRADE companion coverage: {Record.Completeness}.";
+ }
+
+ private string DefaultRemoteStatus => Record.Files.Count == 0
+ ? "Unavailable"
+ : Record.IsComplete
+ ? "Ready"
+ : "Partial";
+
private static string FormatBytes(long bytes)
{
string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
@@ -437,4 +738,4 @@ private static string FormatBytes(long bytes)
private void Raise([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
-}
+}
\ No newline at end of file
diff --git a/scripts/verify-fault-record-bindings.ps1 b/scripts/verify-fault-record-bindings.ps1
index a507e2ee4..0aac90594 100644
--- a/scripts/verify-fault-record-bindings.ps1
+++ b/scripts/verify-fault-record-bindings.ps1
@@ -25,13 +25,12 @@ $requiredOneWayBindings = @(
"SizeText",
"FilesText",
"Completeness",
- "RemoteDirectory",
- "Status",
"StatusText",
"ProgressValue",
"IsIndeterminate",
"IsBusy",
- "CanDownload"
+ "CanDownload",
+ "CanSelectForDownload"
)
foreach ($property in $requiredOneWayBindings) {
$pattern = "\{Binding\s+$([regex]::Escape($property))\s*,[^}]*Mode=OneWay[^}]*\}"
@@ -40,7 +39,16 @@ foreach ($property in $requiredOneWayBindings) {
}
}
-$requiredTwoWayBindings = @("RemoteDirectory", "DestinationDirectory", "IsSelected")
+# Status is used by DataTriggers and a read-only TextBlock. Those WPF bindings are
+# inherently OneWay; requiring a comma-form binding would reject valid trigger syntax.
+if ($xaml -notmatch '\{Binding\s+Status(?:\s*,[^}]*)?\}') {
+ throw "FaultRecordWindow must display the record Status."
+}
+
+# RemoteDirectory is intentionally not exposed by the compact fast workflow. The
+# window always starts discovery at the relay file-store root and keeps manual
+# refresh in the header.
+$requiredTwoWayBindings = @("DestinationDirectory", "IsSelected")
foreach ($property in $requiredTwoWayBindings) {
$pattern = "\{Binding\s+$([regex]::Escape($property))\s*,[^}]*Mode=TwoWay[^}]*\}"
if ($xaml -notmatch $pattern) {
@@ -48,4 +56,8 @@ foreach ($property in $requiredTwoWayBindings) {
}
}
-Write-Host "Fault record binding modes are explicit and valid." -ForegroundColor Green
+if ($xaml -match 'Text="Remote directory"' -or $xaml -match 'Header="Remote directory"') {
+ throw "FaultRecordWindow compact workflow must not expose the remote-directory input or grid column."
+}
+
+Write-Host "Fault record binding modes and compact workflow are explicit and valid." -ForegroundColor Green