Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 86 additions & 7 deletions IoListTestingWindow.CommissioningStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;
using ArIED61850Tester.Models;
using ArIED61850Tester.Models.IoTesting;
using ArIED61850Tester.Services.IoTesting;

namespace ArIED61850Tester;

Expand Down Expand Up @@ -36,6 +38,11 @@ private void CommissioningConnectionBadgeTimer_Tick(object? sender, EventArgs e)
{
foreach (var plan in FatIedList.Items.OfType<IoTestIedPlan>())
{
// Older/saved FAT workspaces can contain a complete generic Value 1 / Value 2
// pair while Runtime.State still reflects the earlier transition-only contract.
// Re-assess only those complete generic digital pairs; raw evidence is untouched.
RefreshCurrentPairVerdicts(plan);

if (FatIedList.ItemContainerGenerator.ContainerFromItem(plan) is not ListBoxItem container)
continue;

Expand All @@ -44,13 +51,23 @@ private void CommissioningConnectionBadgeTimer_Tick(object? sender, EventArgs e)
if (badge == null || text == null)
continue;

// The FAT card must reflect the live Engineering runtime, not the last copied
// IoTestIedPlan flags. That removes the old Refresh dependency after an FO/network
// loss: as soon as the monitor marks the transport down, the card follows it.
var device = ResolveCommissioningRuntimeDevice(plan);
var state = plan.IsPreparing
? "CONNECTING"
: plan.IsLiveConnected
? "ONLINE"
: plan.IsLiveMonitoring
? "RECONNECTING"
: "OFFLINE";
: device != null
? device.IsConnected
? "ONLINE"
: device.IsMonitoring
? "RECONNECTING"
: "OFFLINE"
: plan.IsLiveConnected
? "ONLINE"
: plan.IsLiveMonitoring
? "RECONNECTING"
: "OFFLINE";

var palette = state switch
{
Expand All @@ -66,15 +83,77 @@ private void CommissioningConnectionBadgeTimer_Tick(object? sender, EventArgs e)
badge.Visibility = Visibility.Visible;
badge.Background = ConnectionBadgeBrushFromHex(palette.Background);
badge.BorderBrush = ConnectionBadgeBrushFromHex(palette.Border);
badge.ToolTip = string.IsNullOrWhiteSpace(plan.LiveStatusText)
var liveDetail = device == null ? plan.LiveStatusText : device.Status;
badge.ToolTip = string.IsNullOrWhiteSpace(liveDetail)
? state
: $"{state} · {plan.LiveStatusText}";
: $"{state} · {liveDetail}";
text.Text = state;
text.Foreground = ConnectionBadgeBrushFromHex(palette.Foreground);

if (FindNamedVisual<Control>(container, "RelayIcon") is { } relayIcon)
relayIcon.Foreground = ConnectionBadgeBrushFromHex(palette.Foreground);
}

// Keep Boolean presentation canonical without rewriting relay evidence or persisted
// RawValue. SetCurrentValue preserves the existing WPF binding, so a new sample can
// still replace the cell normally on the next runtime update.
if (_fatSignalsGrid != null)
NormalizeFatBooleanPresentation(_fatSignalsGrid);
}

private Iec61850MonitorDevice? ResolveCommissioningRuntimeDevice(IoTestIedPlan plan)
{
if (Owner is not MainWindow engineeringWindow)
return null;

if (!string.IsNullOrWhiteSpace(plan.LiveDeviceId))
{
var byId = engineeringWindow.Devices.FirstOrDefault(device =>
device.DeviceId.Equals(plan.LiveDeviceId, StringComparison.OrdinalIgnoreCase));
if (byId != null)
return byId;
}

return engineeringWindow.Devices.FirstOrDefault(device =>
device.IpAddress.Equals(plan.IpAddress, StringComparison.OrdinalIgnoreCase) &&
(device.Name.Equals(plan.IedName, StringComparison.OrdinalIgnoreCase) ||
device.SclIedName.Equals(plan.IedName, StringComparison.OrdinalIgnoreCase)))
?? engineeringWindow.Devices.FirstOrDefault(device =>
device.IpAddress.Equals(plan.IpAddress, StringComparison.OrdinalIgnoreCase));
Comment on lines +121 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject ambiguous IP-only runtime device matches

When LiveDeviceId is empty or stale and the name match fails, this fallback selects the first device sharing the plan's IP address. IoTestLiveBindingService.FindDevice deliberately accepts an IP-only match only when it is unique, so a multi-IED/shared-endpoint workspace that was correctly left unbound can nevertheless display another IED's ONLINE/OFFLINE state and status. Require a unique IP match or reuse the binding resolver's matching semantics.

Useful? React with 👍 / 👎.

}

private static void RefreshCurrentPairVerdicts(IoTestIedPlan plan)
{
foreach (var point in plan.TestPoints)
{
if (point.CaptureMode != FatCaptureMode.AutomaticTransition ||
point.Runtime.Value1Evidence == null ||
point.Runtime.Value2Evidence == null ||
point.Runtime.State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reassess terminal states restored from legacy workspaces

When an older workspace already stores a terminal legacy verdict, this guard prevents the advertised current-pair migration from running. IoTestProjectPersistenceService.RestorePoint restores Passed, Review, and Failed without validating the generic evidence, so a row can retain PASS even when its displayed Value 1/Value 2 pair now evaluates to REVIEW because the values are identical, out of order, or poor quality. Reassess every complete generic pair here and let FatCurrentEvidenceAssessmentService handle generation-preservation rules.

Useful? React with 👍 / 👎.

{
continue;
}

FatCurrentEvidenceAssessmentService.Apply(point);
}
}

private static void NormalizeFatBooleanPresentation(DependencyObject root)
{
var count = VisualTreeHelper.GetChildrenCount(root);
for (var index = 0; index < count; index++)
{
var child = VisualTreeHelper.GetChild(root, index);
if (child is TextBlock textBlock)
{
if (textBlock.Text.Equals("true", StringComparison.OrdinalIgnoreCase))
textBlock.SetCurrentValue(TextBlock.TextProperty, "True");
else if (textBlock.Text.Equals("false", StringComparison.OrdinalIgnoreCase))
textBlock.SetCurrentValue(TextBlock.TextProperty, "False");
}

NormalizeFatBooleanPresentation(child);
}
}

private static T? FindNamedVisual<T>(DependencyObject root, string name)
Expand Down
93 changes: 93 additions & 0 deletions tests/ARSAS.Tests/IoFatCurrentPairAndLiveBadgeRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using ArIED61850Tester.Models.IoTesting;
using ArIED61850Tester.Services.IoTesting;

namespace ARSAS.Tests;

public sealed class IoFatCurrentPairAndLiveBadgeRegressionTests
{
[Fact]
public void CompletedGenericDigitalPair_TrueThenFalse_IsPass()
{
var point = NewDigitalPoint();
point.Runtime.SetFatValueEvidence(Evidence(FatValueSlot.Value1, "True", 1));
point.Runtime.SetFatValueEvidence(Evidence(FatValueSlot.Value2, "false", 2));

var decision = FatCurrentEvidenceAssessmentService.Apply(point);

Assert.Equal(IoTestPointState.Passed, decision.State);
Assert.Equal(IoTestPointState.Passed, point.Runtime.State);
Assert.Equal("✔ PASS", point.FatResultText);
Assert.True(point.IsFatEvidenceComplete);
}

[Fact]
public void CommissioningCard_UsesActualEngineeringRuntime_NotCachedPlanFlags()
{
var source = ReadRepoFile("IoListTestingWindow.CommissioningStatus.cs");

Assert.Contains("engineeringWindow.Devices", source, StringComparison.Ordinal);
Assert.Contains("device.IsConnected", source, StringComparison.Ordinal);
Assert.Contains("device.IsMonitoring", source, StringComparison.Ordinal);
Assert.Contains("\"RECONNECTING\"", source, StringComparison.Ordinal);
Assert.Contains("RefreshCurrentPairVerdicts(plan);", source, StringComparison.Ordinal);
Assert.Contains("FatCurrentEvidenceAssessmentService.Apply(point);", source, StringComparison.Ordinal);
}

[Fact]
public void FatBooleanPresentation_CanonicalizesCaseWithoutRewritingEvidence()
{
var source = ReadRepoFile("IoListTestingWindow.CommissioningStatus.cs");

Assert.Contains("NormalizeFatBooleanPresentation", source, StringComparison.Ordinal);
Assert.Contains("SetCurrentValue(TextBlock.TextProperty, \"True\")", source, StringComparison.Ordinal);
Assert.Contains("SetCurrentValue(TextBlock.TextProperty, \"False\")", source, StringComparison.Ordinal);
Assert.Contains("without rewriting relay evidence", source, StringComparison.OrdinalIgnoreCase);
}

private static IoTestPointPlan NewDigitalPoint()
=> new()
{
TestPointId = "DI-PAIR",
IedName = "IED1",
IpAddress = "192.0.2.10",
SignalName = "TimeSynchrnz",
ObjectReference = "IED1LD0/GGIO1.TimeSynchrnz.stVal",
FunctionalConstraint = "ST",
ExpectedOnText = "TRUE",
ExpectedOffText = "FALSE",
DataType = "Boolean",
SignalKind = FatSignalKind.Discrete,
CaptureMode = FatCaptureMode.AutomaticTransition,
WorkspaceSelected = true,
TestEnabled = true,
ImportReady = true,
BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus
};

private static FatValueEvidence Evidence(FatValueSlot slot, string rawValue, long sequence)
=> new(
Guid.NewGuid(),
slot,
FatEvidenceCaptureKind.AutomaticValue,
rawValue,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow.AddMilliseconds(-2),
"Good",
"BRCB",
sequence,
1);

private static string ReadRepoFile(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).Replace("\r\n", "\n", StringComparison.Ordinal);
directory = directory.Parent;
}

throw new FileNotFoundException(relativePath);
}
}
Loading