diff --git a/IpConnectWizardWindow.xaml b/IpConnectWizardWindow.xaml
index 929418871..486907ea8 100644
--- a/IpConnectWizardWindow.xaml
+++ b/IpConnectWizardWindow.xaml
@@ -1,76 +1,95 @@
-
-
-
-
-
-
+ Title="Add IEC 61850 IED" Width="700" MinWidth="640" MaxHeight="620"
+ SizeToContent="Height" WindowStartupLocation="CenterOwner"
+ ResizeMode="NoResize" ShowInTaskbar="False"
+ Background="{DynamicResource TaskDialogCanvas}"
+ FontFamily="{DynamicResource AppFontFamily}" Icon="Assets/app-icon.ico">
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index f56bb7c56..fb24c3a7f 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -345,18 +345,34 @@ await OpenSignalSelectionWizardAsync(
}
catch (Exception ex)
{
- AddLog("ERROR", "SCL", $"Could not open {sourceLabel}: {ex.Message}");
- SetStatus($"{sourceLabel}: SCL open failed. Diagnostics is marked with !.");
+ // Opening a valid SCL also opens a WPF action chooser. A broken XAML
+ // resource after model import is not an ARIEC61850 parser failure.
+ var presentationFailure = IsSclPresentationFailure(ex);
+ AddLog("ERROR", "SCL", $"Could not finish opening {sourceLabel}: {ex}");
+ SetStatus(presentationFailure
+ ? $"{sourceLabel}: SCL action window failed to open; the model may already be loaded."
+ : $"{sourceLabel}: SCL open failed. Diagnostics is marked with !.");
MarkDiagnosticAlert();
- MessageBox.Show(
- this,
- $"ARSAS could not open this SCL file through the ARIEC61850 engine.\n\n{ex.Message}",
- "Open SCL",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ var message = presentationFailure
+ ? $"ARSAS could not display the SCL action window. The imported model may already be available in Engineering.\n\n{ex.GetBaseException().Message}\n\nSee Diagnostics for details."
+ : $"ARSAS could not open this SCL file through the ARIEC61850 engine.\n\n{ex.Message}";
+ MessageBox.Show(this, message, "Open SCL",
+ MessageBoxButton.OK, MessageBoxImage.Error);
}
}
+ private static bool IsSclPresentationFailure(Exception exception)
+ {
+ for (Exception? cause = exception; cause is not null; cause = cause.InnerException)
+ {
+ if (cause is System.Windows.Markup.XamlParseException ||
+ cause is ResourceReferenceKeyNotFoundException ||
+ cause.Message.Contains("StaticResourceExtension", StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ return false;
+ }
+
private void ApplySclWorkspaceToDevice(
Iec61850MonitorDevice device,
SclWorkspaceDocument document,
diff --git a/SclSignalSelectionModeWindow.xaml b/SclSignalSelectionModeWindow.xaml
index 1365a1f40..6ad1a6d63 100644
--- a/SclSignalSelectionModeWindow.xaml
+++ b/SclSignalSelectionModeWindow.xaml
@@ -1,121 +1,136 @@
-
-
-
-
-
-
-
-
+ ShowInTaskbar="False" Background="{DynamicResource TaskDialogCanvas}"
+ FontFamily="{DynamicResource AppFontFamily}" Icon="Assets/app-icon.ico">
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/SclSignalSelectionModeWindow.xaml.cs b/SclSignalSelectionModeWindow.xaml.cs
index f7a7fa089..bac183cdd 100644
--- a/SclSignalSelectionModeWindow.xaml.cs
+++ b/SclSignalSelectionModeWindow.xaml.cs
@@ -15,10 +15,10 @@ public SclSignalSelectionModeWindow(int iedCount, Iec61850MonitorDevice? targetD
InitializeComponent();
DialogTitle = targetDevice is null ? "SCL Quick Start" : $"IED Actions — {targetDevice.Name}";
- ContextHeading = targetDevice is null ? "Workspace opened offline" : $"IED actions — {targetDevice.Name}";
+ ContextHeading = targetDevice is null ? "Workspace opened offline" : "IED Actions";
ContextSubtitle = targetDevice is null
- ? "Choose a task. ARSAS only connects when that task needs the IED."
- : "Choose what to do with this IED. Closing this window changes nothing.";
+ ? "Choose a monitoring or engineering task."
+ : "Choose how to work with this IED.";
ImportScopeText = targetDevice is not null
? targetDevice.Name
: iedCount == 1
@@ -31,7 +31,7 @@ public SclSignalSelectionModeWindow(int iedCount, Iec61850MonitorDevice? targetD
if (targetDevice is null)
{
CanUseStaticDataSet = true;
- StaticDataSetAvailabilityText = "Select + monitor";
+ StaticDataSetAvailabilityText = "Start report monitoring";
}
else
{
@@ -42,9 +42,9 @@ public SclSignalSelectionModeWindow(int iedCount, Iec61850MonitorDevice? targetD
CanUseStaticDataSet = dataSetCount > 0;
StaticDataSetAvailabilityText = dataSetCount == 0
- ? "No static DataSet"
+ ? "No static DataSets"
: reportBackedCount > 0
- ? $"{dataSetCount} DataSet(s) • report ready"
+ ? $"{dataSetCount} DataSet(s) ready"
: $"{dataSetCount} DataSet(s) • no configured RCB";
}
diff --git a/Styles/IedTaskDialogStyles.xaml b/Styles/IedTaskDialogStyles.xaml
new file mode 100644
index 000000000..c052cd180
--- /dev/null
+++ b/Styles/IedTaskDialogStyles.xaml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ARSAS.Tests/IedTaskDialogVisualRegressionTests.cs b/tests/ARSAS.Tests/IedTaskDialogVisualRegressionTests.cs
new file mode 100644
index 000000000..6e1d6f90d
--- /dev/null
+++ b/tests/ARSAS.Tests/IedTaskDialogVisualRegressionTests.cs
@@ -0,0 +1,192 @@
+using System.Globalization;
+using System.Xml.Linq;
+
+namespace ARSAS.Tests;
+
+///
+/// Read-only XAML contract checks: the field screenshots exposed dark text on dark
+/// backgrounds and a fixed-height connect wizard clipping its final panel.
+/// This suite does not simulate IEC 61850 sessions or alter action authority.
+///
+public sealed class IedTaskDialogVisualRegressionTests
+{
+ private static readonly XNamespace Presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+ private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+ [Fact]
+ public void BothDialogs_UseIsolatedHighContrastSurfaceAndBoundedNaturalHeight()
+ {
+ foreach (var file in new[] { "SclSignalSelectionModeWindow.xaml", "IpConnectWizardWindow.xaml" })
+ {
+ var document = XDocument.Load(Find(file));
+ var root = Assert.IsType(document.Root);
+ Assert.Equal("Window", root.Name.LocalName);
+ Assert.Equal("Height", (string?)root.Attribute("SizeToContent"));
+ Assert.Equal("TaskDialogCanvas", ResourceRef((string?)root.Attribute("Background"), "DynamicResource"));
+ Assert.True(double.TryParse((string?)root.Attribute("MaxHeight"),
+ NumberStyles.Float, CultureInfo.InvariantCulture, out var max) && max <= 700);
+ Assert.Null(root.Attribute("Height"));
+ Assert.Contains(root.Descendants(Presentation + "ResourceDictionary"),
+ element => element.Attribute("Source")?.Value == "Styles/IedTaskDialogStyles.xaml");
+ Assert.Contains(root.Descendants(Presentation + "ScrollViewer"),
+ element => element.Attribute("VerticalScrollBarVisibility")?.Value == "Auto");
+ Assert.DoesNotContain(root.Descendants(Presentation + "RowDefinition"),
+ element => element.Attribute("Height")?.Value == "*");
+ Assert.DoesNotContain(root.Descendants(Presentation + "TextBlock"),
+ element => element.Attribute("Foreground")?.Value == "{StaticResource Muted}" ||
+ element.Attribute("Foreground")?.Value == "{StaticResource Ink}");
+ }
+ }
+
+ [Fact]
+ public void DialogStyles_AreSelfContained_AndBothWindowsInitializeOnSta()
+ {
+ var dictionary = XDocument.Load(Find("Styles/IedTaskDialogStyles.xaml"));
+ var declared = dictionary.Descendants()
+ .Attributes(Xaml + "Key")
+ .Select(attribute => attribute.Value)
+ .ToHashSet(StringComparer.Ordinal);
+ var resourceReferences = dictionary.Descendants()
+ .Attributes()
+ .Select(attribute => ResourceRef(attribute.Value))
+ .Where(value => value is not null)
+ .Cast()
+ .ToArray();
+
+ // ResourceDictionary.Source is loaded on its own; inheritance from an
+ // application-level style can throw StaticResourceExtension at runtime
+ // even when compiled XAML and text-based CI contracts all pass.
+ Assert.All(resourceReferences, key => Assert.Contains(key, declared));
+ Assert.Contains("TaskDialogButtonBase", declared);
+ Assert.Contains("TaskDialogPrimaryAction", declared);
+ Assert.Contains("TaskDialogSubmitAction", declared);
+
+ if (!OperatingSystem.IsWindows())
+ return;
+
+ Exception? failure = null;
+ var thread = new System.Threading.Thread(() =>
+ {
+ try
+ {
+ var choice = new ArIED61850Tester.SclSignalSelectionModeWindow(1);
+ var connect = new ArIED61850Tester.IpConnectWizardWindow("127.0.0.1");
+ Assert.NotNull(choice.TryFindResource("TaskDialogPrimaryAction"));
+ Assert.NotNull(connect.TryFindResource("TaskDialogSubmitAction"));
+ Assert.NotNull(choice.Background);
+ Assert.NotNull(connect.Background);
+ choice.Close();
+ connect.Close();
+ }
+ catch (Exception error)
+ {
+ failure = error;
+ }
+ });
+ thread.SetApartmentState(System.Threading.ApartmentState.STA);
+ thread.Start();
+ Assert.True(thread.Join(TimeSpan.FromSeconds(20)), "WPF dialog initialization timed out.");
+ Assert.True(failure is null, failure?.ToString());
+ }
+
+ [Fact]
+ public void SharedDialogPalette_MeetsReadableForegroundContrast()
+ {
+ var document = XDocument.Load(Find("Styles/IedTaskDialogStyles.xaml"));
+ string Color(string key) => document.Descendants(Presentation + "SolidColorBrush")
+ .Single(element => element.Attribute(Xaml + "Key")?.Value == key)
+ .Attribute("Color")!.Value;
+ var background = Color("TaskDialogCanvas");
+ var card = Color("TaskDialogCard");
+ var ink = Color("TaskDialogInk");
+ var muted = Color("TaskDialogMuted");
+ Assert.True(Contrast(ink, background) >= 7.0);
+ Assert.True(Contrast(muted, background) >= 4.5);
+ Assert.True(Contrast(ink, card) >= 7.0);
+ Assert.True(Contrast(muted, card) >= 4.5);
+ }
+
+ [Fact]
+ public void IedActions_RemainsTaskScoped_AndTechnicalNotesAreInTooltips()
+ {
+ var xml = File.ReadAllText(Find("SclSignalSelectionModeWindow.xaml"));
+ var code = File.ReadAllText(Find("SclSignalSelectionModeWindow.xaml.cs"));
+ foreach (var action in new[]
+ {
+ "MonitorStaticDataSet_Click", "MonitorManual_Click",
+ "RcbEngineering_Click", "DownloadComtrade_Click", "BrowseOffline_Click"
+ })
+ Assert.Contains($"Click=\"{action}\"", xml, StringComparison.Ordinal);
+
+ Assert.Contains("IsEnabled=\"{Binding CanUseStaticDataSet}\"", xml, StringComparison.Ordinal);
+ Assert.Contains("StaticDataSetAvailabilityText", xml, StringComparison.Ordinal);
+ Assert.Contains("ContextHeading", xml, StringComparison.Ordinal);
+ Assert.Contains("ImportScopeText", xml, StringComparison.Ordinal);
+ Assert.Contains("reportBackedCount > 0", code, StringComparison.Ordinal);
+ Assert.Contains("UseStaticDataSet => _useStaticDataSet", code, StringComparison.Ordinal);
+ Assert.DoesNotContain("Signal selection is shared with FAT, but", xml, StringComparison.Ordinal);
+ Assert.DoesNotContain("task-scoped MMS file-transfer connection", xml, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AddIed_PreservesSavedIpBindingValidationFeedbackAndKeyboardActions()
+ {
+ var xml = File.ReadAllText(Find("IpConnectWizardWindow.xaml"));
+ var source = File.ReadAllText(Find("IpConnectWizardWindow.xaml.cs"));
+ Assert.Contains("x:Name=\"RelayIpBox\"", xml, StringComparison.Ordinal);
+ Assert.Contains("RecentRelayIps", xml, StringComparison.Ordinal);
+ Assert.Contains("RelayIpAddress", xml, StringComparison.Ordinal);
+ Assert.Contains("MmsPortText", xml, StringComparison.Ordinal);
+ Assert.Contains("x:Name=\"WizardStatusText\"", xml, StringComparison.Ordinal);
+ Assert.Contains("Style=\"{StaticResource TaskDialogError}\"", xml, StringComparison.Ordinal);
+ Assert.Contains("IsDefault=\"True\"", xml, StringComparison.Ordinal);
+ Assert.Contains("IsCancel=\"True\"", xml, StringComparison.Ordinal);
+ Assert.Contains("Click=\"Connect_Click\"", xml, StringComparison.Ordinal);
+ Assert.Contains("Click=\"Cancel_Click\"", xml, StringComparison.Ordinal);
+ Assert.DoesNotContain("Flow: TCP/TPKT/COTP", xml, StringComparison.Ordinal);
+ Assert.Contains("UserPreferenceStore.LoadRecentEndpoints()", source, StringComparison.Ordinal);
+ Assert.Contains("System.Net.IPAddress.TryParse", source, StringComparison.Ordinal);
+ Assert.Contains("port is <= 0 or > 65535", source, StringComparison.Ordinal);
+ }
+
+ private static string? ResourceRef(string? value, string extension = "StaticResource")
+ {
+ var prefix = "{" + extension + " ";
+ return value?.StartsWith(prefix, StringComparison.Ordinal) == true &&
+ value.EndsWith("}", StringComparison.Ordinal)
+ ? value[prefix.Length..^1] : null;
+ }
+
+ private static double Contrast(string first, string second)
+ {
+ static double Luminance(string value)
+ {
+ var c = value.TrimStart('#');
+ Assert.Equal(6, c.Length);
+ static double Channel(string hex)
+ {
+ var s = int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture) / 255d;
+ return s <= 0.04045 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4);
+ }
+ return Channel(c[..2]) * 0.2126 +
+ Channel(c[2..4]) * 0.7152 +
+ Channel(c[4..6]) * 0.0722;
+ }
+ var a = Luminance(first);
+ var b = Luminance(second);
+ return (Math.Max(a, b) + 0.05) / (Math.Min(a, b) + 0.05);
+ }
+
+ private static string Find(string path)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ var full = Path.Combine(directory.FullName, path);
+ if (File.Exists(full))
+ return full;
+ directory = directory.Parent;
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs
index fd76fc1a6..9d5c7acda 100644
--- a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs
+++ b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs
@@ -25,20 +25,27 @@ public void SclImport_UsesOneSelectionAuthorityAcrossEngineeringAndFat()
{
var workflow = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs"));
var selectionWindow = File.ReadAllText(FindRepoFile("SclSignalSelectionModeWindow.xaml"));
+ var selectionCode = File.ReadAllText(FindRepoFile("SclSignalSelectionModeWindow.xaml.cs"));
+ var dialogStyles = File.ReadAllText(FindRepoFile("Styles/IedTaskDialogStyles.xaml"));
var engineering = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs"));
var fat = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs"));
var fatProjection = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs"));
Assert.Contains("new SclSignalSelectionModeWindow", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("MessageBox.Show", workflow, StringComparison.Ordinal);
- Assert.Contains("Workspace opened offline", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("Workspace opened offline", selectionCode, StringComparison.Ordinal);
+ Assert.Contains("ContextHeading", selectionWindow, StringComparison.Ordinal);
Assert.Contains("Static DataSet", selectionWindow, StringComparison.Ordinal);
Assert.Contains("Select Signals", selectionWindow, StringComparison.Ordinal);
Assert.Contains("RCB Engineering", selectionWindow, StringComparison.Ordinal);
Assert.Contains("Download COMTRADE", selectionWindow, StringComparison.Ordinal);
Assert.Contains("Browse Offline", selectionWindow, StringComparison.Ordinal);
- Assert.Contains("PrimaryButton", selectionWindow, StringComparison.Ordinal);
- Assert.Contains("SoftButton", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("TaskDialogPrimaryAction", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("TaskDialogSecondaryAction", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("x:Key=\"TaskDialogButtonBase\"", dialogStyles, StringComparison.Ordinal);
+ Assert.Contains("BasedOn=\"{StaticResource TaskDialogButtonBase}\"", dialogStyles, StringComparison.Ordinal);
+ Assert.DoesNotContain("BasedOn=\"{StaticResource PrimaryButton}\"", dialogStyles, StringComparison.Ordinal);
+ Assert.DoesNotContain("BasedOn=\"{StaticResource SoftButton}\"", dialogStyles, StringComparison.Ordinal);
Assert.Contains("_sharedSclSelectionAuthorityDeviceIds", workflow, StringComparison.Ordinal);
Assert.Contains("ApplyStaticDataSetSelection", engineering, StringComparison.Ordinal);
Assert.Contains("selectionAlreadyApplied: true", engineering, StringComparison.Ordinal);