diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs new file mode 100644 index 0000000..d0f3e89 --- /dev/null +++ b/IoListTestingWindow.ContextUx.cs @@ -0,0 +1,325 @@ +using System.ComponentModel; +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private bool _selectedIedContextInstalled; + + public bool SelectedCanStartWorkflow => + SelectedIed != null && !IsPreparingIed && Session.CanStart; + + public bool SelectedCanPause => + IsSelectedSessionIed && Session.CanPause; + + public bool SelectedCanResume => + IsSelectedSessionIed && Session.CanResume; + + public bool SelectedCanStop => + IsSelectedSessionIed && Session.CanStop; + + public string SelectedStartWorkflowText + { + get + { + if (SelectedIed == null) + return "Select IED"; + + if (_preparingIed != null) + { + return ReferenceEquals(_preparingIed, SelectedIed) + ? $"Connecting {SelectedIed.IedName}…" + : $"{_preparingIed.IedName} connecting…"; + } + + if (Session.IsSessionActive) + { + return IsSelectedSessionIed + ? "IED session active" + : $"{Session.ActiveIed?.IedName ?? "Another IED"} active"; + } + + var enabled = EnabledPoints(SelectedIed); + if (enabled.Count > 0 && enabled.All(point => point.Runtime.State == IoTestPointState.Passed)) + return "Reconnect / Retest"; + + return enabled.Any(point => point.Runtime.IsComplete) + ? "Connect & Continue IED" + : "Connect & Start IED"; + } + } + + public string SelectedFooterStatusText + { + get + { + if (SelectedIed == null) + return "Select an imported IED."; + + if (ReferenceEquals(_preparingIed, SelectedIed)) + return PreparationStatusText; + + if (IsSelectedSessionIed && Session.State != IoTestSessionState.Idle) + return Session.StatusText; + + var enabled = EnabledPoints(SelectedIed); + var complete = enabled.Count(point => point.Runtime.IsComplete); + var passed = enabled.Count(point => point.Runtime.State == IoTestPointState.Passed); + + if (enabled.Count > 0 && passed == enabled.Count) + return $"{SelectedIed.IedName} complete · all {enabled.Count} enabled points PASS · evidence protected."; + + if (complete > 0) + return $"{SelectedIed.IedName} selected · {complete}/{enabled.Count} complete · completed evidence will be preserved."; + + return $"{SelectedIed.IedName} selected · {SelectedIed.LiveStatusText}"; + } + } + + public string SelectedProgressText + { + get + { + if (SelectedIed == null) + return "0 / 0 complete"; + + var enabled = EnabledPoints(SelectedIed); + var complete = enabled.Count(point => point.Runtime.IsComplete); + var passed = enabled.Count(point => point.Runtime.State == IoTestPointState.Passed); + var review = enabled.Count(point => point.Runtime.State == IoTestPointState.Review); + var failed = enabled.Count(point => point.Runtime.State == IoTestPointState.Failed); + return $"{complete} / {enabled.Count} complete · {passed} PASS · {review} review · {failed} fail"; + } + } + + public int SelectedEvidenceCount => SelectedIed?.TestPoints.Sum(point => + (point.Runtime.OnEvidence == null ? 0 : 1) + + (point.Runtime.OffEvidence == null ? 0 : 1)) ?? 0; + + private bool IsSelectedSessionIed => + SelectedIed != null && ReferenceEquals(Session.ActiveIed, SelectedIed); + + private void IoListTestingWindow_ContentRendered(object? sender, EventArgs e) + { + Dispatcher.BeginInvoke( + new Action(InstallSelectedIedContext), + DispatcherPriority.ContextIdle); + } + + private void InstallSelectedIedContext() + { + AdoptWorkspacePreviewToggle(); + if (_selectedIedContextInstalled) + { + RaiseSelectedIedContextProperties(); + return; + } + + foreach (var ied in Project.Ieds) + ied.PropertyChanged += ContextIed_PropertyChanged; + + PropertyChanged += ContextWindow_PropertyChanged; + Session.PropertyChanged += ContextSession_PropertyChanged; + Closed += ContextWindow_Closed; + _selectedIedContextInstalled = true; + RaiseSelectedIedContextProperties(); + } + + private void AdoptWorkspacePreviewToggle() + { + if (_printPreviewToggle != null && !ReferenceEquals(_printPreviewToggle, WorkspacePreviewToggle)) + { + if (LogicalTreeHelper.GetParent(_printPreviewToggle) is Panel parent) + parent.Children.Remove(_printPreviewToggle); + } + + _printPreviewToggle = WorkspacePreviewToggle; + _printPreviewToggle.Content = _printPreviewActive ? "Signals View" : "Print Preview"; + _printPreviewToggle.ToolTip = "Toggle the selected IED between signal evidence and native print preview"; + } + + private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e) + { + if (IsPreparingIed) + return; + + var selectedIed = SelectedIed; + var preflight = IoTestSessionPreflight.Validate(selectedIed); + if (!preflight.Succeeded) + { + ShowActionResult(preflight, "FAT session scope is not ready"); + return; + } + + var enabledReady = selectedIed!.TestPoints + .Where(point => point.TestEnabled && point.ImportReady) + .ToList(); + var completedPoints = enabledReady + .Where(point => point.Runtime.IsComplete) + .ToList(); + var incompletePoints = enabledReady + .Where(point => !point.Runtime.IsComplete) + .ToList(); + + var explicitRetest = false; + if (completedPoints.Count > 0 && incompletePoints.Count == 0) + { + var answer = MessageBox.Show( + this, + $"All {completedPoints.Count} enabled rows for {selectedIed.IedName} already contain completed evidence.\n\nA normal Connect click will not erase them. Choose Yes only when you intentionally want to retest every completed row and replace its ON/OFF evidence.", + "Retest completed evidence?", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + { + PreparationStatusText = $"{selectedIed.IedName} evidence preserved · no retest started."; + RaiseSelectedIedContextProperties(); + return; + } + + explicitRetest = true; + } + + SetPreparingIed(selectedIed, $"Connecting {selectedIed.IedName} · {selectedIed.IpAddress}:102"); + try + { + if (Owner is MainWindow engineeringWindow) + { + var progress = new Progress(message => + { + selectedIed.SetPreparationState(true, message); + PreparationStatusText = message; + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + }); + var preparation = await engineeringWindow.PrepareIoTestIedForFatAsync( + Project, + selectedIed, + progress); + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + if (!preparation.Succeeded) + { + PreparationStatusText = preparation.Message; + ShowActionResult(preparation, "IED acquisition could not start"); + return; + } + } + + var protectedPoints = explicitRetest + ? Array.Empty() + : completedPoints.ToArray(); + IoTestSessionActionResult result; + try + { + foreach (var point in protectedPoints) + point.TestEnabled = false; + + result = Session.Start(selectedIed); + } + finally + { + foreach (var point in protectedPoints) + point.TestEnabled = true; + } + + ShowActionResult(result, "FAT evidence session could not start"); + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + if (result.Succeeded) + { + PreparationStatusText = protectedPoints.Length > 0 + ? $"{selectedIed.IedName} live · {protectedPoints.Length} completed row(s) preserved · waiting for pending OFF → ON → OFF tests" + : $"{selectedIed.IedName} live · waiting for OFF → ON → OFF"; + Storage?.ScheduleSave(); + } + else + { + PreparationStatusText = result.Message; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + PreparationStatusText = ex.Message; + MessageBox.Show( + this, + ex.Message, + "Connect and start IED failed", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + selectedIed.SetPreparationState(false, selectedIed.LiveStatusText); + SetPreparingIed(null, string.Empty); + RaiseSelectedIedContextProperties(); + } + } + + private void ContextWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(SelectedIed) or nameof(IsPreparingIed) or nameof(PreparationStatusText)) + RaiseSelectedIedContextProperties(); + } + + private void ContextSession_PropertyChanged(object? sender, PropertyChangedEventArgs e) + => RaiseSelectedIedContextProperties(); + + private void ContextIed_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (!ReferenceEquals(sender, SelectedIed)) + return; + + Raise(nameof(SelectedIedSummary)); + RaiseSelectedIedContextProperties(); + } + + private void RaiseSelectedIedContextProperties() + { + Raise(nameof(SelectedCanStartWorkflow)); + Raise(nameof(SelectedCanPause)); + Raise(nameof(SelectedCanResume)); + Raise(nameof(SelectedCanStop)); + Raise(nameof(SelectedStartWorkflowText)); + Raise(nameof(SelectedFooterStatusText)); + Raise(nameof(SelectedProgressText)); + Raise(nameof(SelectedEvidenceCount)); + } + + private void ContextWindow_Closed(object? sender, EventArgs e) + { + foreach (var ied in Project.Ieds) + ied.PropertyChanged -= ContextIed_PropertyChanged; + + PropertyChanged -= ContextWindow_PropertyChanged; + Session.PropertyChanged -= ContextSession_PropertyChanged; + Closed -= ContextWindow_Closed; + } + + private static List EnabledPoints(IoTestIedPlan ied) + => ied.TestPoints.Where(point => point.TestEnabled).ToList(); +} + +public sealed class IoFatAllPassedVisibilityConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var selectedCount = values.Length > 0 && values[0] is int count ? count : 0; + var passed = values.Length > 1 && values[1] is int passedCount ? passedCount : 0; + var allPassed = selectedCount > 0 && passed >= selectedCount; + if (string.Equals(parameter?.ToString(), "Inverse", StringComparison.OrdinalIgnoreCase)) + allPassed = !allPassed; + return allPassed ? Visibility.Visible : Visibility.Collapsed; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => targetTypes.Select(_ => Binding.DoNothing).ToArray(); +} diff --git a/IoListTestingWindow.RealPreparationProgress.cs b/IoListTestingWindow.RealPreparationProgress.cs new file mode 100644 index 0000000..98e6a6b --- /dev/null +++ b/IoListTestingWindow.RealPreparationProgress.cs @@ -0,0 +1,179 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private static readonly bool RealPreparationProgressRegistered = RegisterRealPreparationProgress(); + private readonly Dictionary _preparationDisplayStates = new(); + private DispatcherTimer? _preparationProgressTimer; + + private static bool RegisterRealPreparationProgress() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(RealPreparationProgress_Loaded)); + return true; + } + + private static void RealPreparationProgress_Loaded(object sender, RoutedEventArgs e) + { + if (sender is IoListTestingWindow window) + window.InstallRealPreparationProgress(); + } + + private void InstallRealPreparationProgress() + { + if (_preparationProgressTimer != null) + return; + + foreach (var ied in Project.Ieds) + _preparationDisplayStates[ied] = new PreparationDisplayState(); + + _preparationProgressTimer = new DispatcherTimer(DispatcherPriority.Render) + { + Interval = TimeSpan.FromMilliseconds(50) + }; + _preparationProgressTimer.Tick += PreparationProgressTimer_Tick; + _preparationProgressTimer.Start(); + Closed += RealPreparationProgress_Closed; + + Dispatcher.BeginInvoke( + new Action(ApplyDeterminateCardProgressBars), + DispatcherPriority.Loaded); + } + + private void PreparationProgressTimer_Tick(object? sender, EventArgs e) + { + foreach (var ied in Project.Ieds) + { + if (!_preparationDisplayStates.TryGetValue(ied, out var state)) + { + state = new PreparationDisplayState(); + _preparationDisplayStates[ied] = state; + } + + var active = ied.IsPreparing || ReferenceEquals(_preparingIed, ied); + if (active && !state.WasActive) + state.Reset(); + + state.WasActive = active; + if (!active) + continue; + + var snapshot = Owner is MainWindow engineeringWindow + ? engineeringWindow.GetIoFatPreparationProgressSnapshot(ied) + : BuildFallbackPreparationSnapshot(ied); + state.Target = Math.Max(state.Target, Math.Clamp(snapshot.Percent, 0d, 100d)); + state.Message = snapshot.Message; + state.StepText = snapshot.StepText; + state.AdvanceDisplay(); + } + + ApplyDeterminateCardProgressBars(); + } + + private void ApplyDeterminateCardProgressBars() + { + foreach (var progressBar in VisualDescendants(this) + .Where(progress => string.Equals(progress.Name, "CardProgress", StringComparison.Ordinal))) + { + // The XAML fallback is indeterminate so old project binaries remain safe. + // Once this behavior is installed every instantiated IED-card bar becomes + // determinate and is driven by real connection/discovery/acquisition state. + progressBar.IsIndeterminate = false; + progressBar.Minimum = 0d; + progressBar.Maximum = 100d; + + if (progressBar.DataContext is not IoTestIedPlan ied || + !_preparationDisplayStates.TryGetValue(ied, out var state)) + { + progressBar.Value = 0d; + continue; + } + + progressBar.Value = state.Display; + progressBar.ToolTip = string.Join( + " · ", + new[] + { + state.Message, + state.StepText, + $"{state.Display:0}%" + }.Where(value => !string.IsNullOrWhiteSpace(value))); + } + } + + private static IoFatPreparationProgressSnapshot BuildFallbackPreparationSnapshot(IoTestIedPlan ied) + { + var message = string.IsNullOrWhiteSpace(ied.PreparationStatusText) + ? $"Preparing {ied.IedName}" + : ied.PreparationStatusText; + var percent = message.Contains("live", StringComparison.OrdinalIgnoreCase) + ? 100d + : message.Contains("validating", StringComparison.OrdinalIgnoreCase) + ? 90d + : message.Contains("arming", StringComparison.OrdinalIgnoreCase) + ? 80d + : message.Contains("matching", StringComparison.OrdinalIgnoreCase) + ? 68d + : 8d; + return new IoFatPreparationProgressSnapshot(message, percent, "Preparing IED"); + } + + private void RealPreparationProgress_Closed(object? sender, EventArgs e) + { + Closed -= RealPreparationProgress_Closed; + if (_preparationProgressTimer == null) + return; + + _preparationProgressTimer.Stop(); + _preparationProgressTimer.Tick -= PreparationProgressTimer_Tick; + _preparationProgressTimer = null; + _preparationDisplayStates.Clear(); + } + + private sealed class PreparationDisplayState + { + public double Target { get; set; } + public double Display { get; private set; } + public string Message { get; set; } = string.Empty; + public string StepText { get; set; } = string.Empty; + public bool WasActive { get; set; } + + public void Reset() + { + Target = 0d; + Display = 0d; + Message = string.Empty; + StepText = string.Empty; + } + + public void AdvanceDisplay() + { + var remaining = Target - Display; + if (remaining <= 0.04d) + { + if (remaining > 0d) + Display = Target; + return; + } + + // Same visual principle as Engineering discovery cards: 20 FPS, + // ease-out movement, bounded minimum speed, and no artificial loop. + var completing = Target >= 99.9d; + var movement = Math.Clamp( + remaining * (completing ? 0.16d : 0.115d), + 0.10d, + completing ? 2.4d : 1.15d); + Display = Math.Min(Target, Display + movement); + } + } +} diff --git a/IoListTestingWindow.WorkspaceModeSwitch.cs b/IoListTestingWindow.WorkspaceModeSwitch.cs new file mode 100644 index 0000000..a36bfb8 --- /dev/null +++ b/IoListTestingWindow.WorkspaceModeSwitch.cs @@ -0,0 +1,119 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private const string FatWorkspaceModeTag = "ARSAS_FAT_WORKSPACE_MODE"; + private static readonly bool FatWorkspaceModeRegistered = RegisterFatWorkspaceMode(); + private bool _fatWorkspaceModeInstalled; + + private static bool RegisterFatWorkspaceMode() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatWorkspaceMode_Loaded)); + return true; + } + + private static void FatWorkspaceMode_Loaded(object sender, RoutedEventArgs e) + { + if (sender is IoListTestingWindow window) + window.InstallFatWorkspaceModeSwitch(); + } + + private void InstallFatWorkspaceModeSwitch() + { + if (_fatWorkspaceModeInstalled) + { + if (Owner is MainWindow existingOwner) + existingOwner.RegisterLoadedIoFatWindow(this); + return; + } + + var originalEngineeringButton = VisualDescendants