Skip to content
Closed
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
72 changes: 45 additions & 27 deletions IoListTestingWindow.LiveValueAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
namespace ArIED61850Tester;

/// <summary>
/// FAT LIVE VALUE must be a presentation of the shared Engineering live objects, never a
/// FAT LIVE VALUE is a presentation of the shared Engineering live objects, never a
/// second cached process image. Evidence state (Value 1 / Value 2, transition history and
/// journal records) intentionally stays in IoTestPointRuntime and is not changed here.
/// </summary>
Expand Down Expand Up @@ -47,12 +47,10 @@ private void InstallAuthoritativeFatLiveValueColumn()
if (grid == null)
return;

// Recycling can display the previous row's text for one render frame while WPF is
// moving a cell to a new DataContext. Standard virtualization keeps large FAT lists
// virtualized but never reuses a realized row container for another test point.
VirtualizingPanel.SetIsVirtualizing(grid, true);
VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Standard);

// IMPORTANT: virtualization mode is a layout-time WPF property. Never mutate
// VirtualizingPanel.VirtualizationMode (or IsVirtualizing) here after Loaded/Measure.
// Doing so throws InvalidOperationException and is the direct cause of the blank/
// frozen FAT window seen by the recovery build. The baseline XAML owns virtualization.
var liveColumn = grid.Columns
.OfType<DataGridTemplateColumn>()
.FirstOrDefault(column =>
Expand All @@ -66,10 +64,11 @@ private void InstallAuthoritativeFatLiveValueColumn()
}

/// <summary>
/// One event-driven FAT cell. It resolves the exact live monitor point already proven by
/// the FAT binding service and, for an operable control DataObject, may display the shared
/// Engineering ControlCurrentValue because that is the direct status image used by the
/// Command Panel. No MMS read is initiated by this cell and no FAT evidence is mutated.
/// Event-driven FAT cell. It resolves the exact live monitor point already proven by the
/// FAT binding service. The live monitor point is authoritative because it is the actual
/// report/poll process image. Engineering ControlCurrentValue is only a fallback when no
/// live monitor value exists. No MMS read is initiated by this cell and no FAT evidence is
/// mutated.
/// </summary>
public sealed class FatAuthoritativeLiveValueCell : StackPanel
{
Expand Down Expand Up @@ -134,9 +133,10 @@ private void Cell_DataContextChanged(object sender, DependencyPropertyChangedEve
if (!_attached)
return;

// Clear before rebinding so a previous row can never be painted under a new row.
_valueText.Text = "—";
_qualityText.Text = "Unknown";
// Clear synchronously before rebinding so a recycled container can never expose the
// previous row. Rebind renders the new row in the same dispatcher turn.
SetTextIfChanged(_valueText, "—");
SetTextIfChanged(_qualityText, "Unknown");
RebindAuthoritativeSources();
}

Expand Down Expand Up @@ -288,30 +288,48 @@ private void Render()
if (!_attached)
return;

// For controllable DO rows, the same ControlCurrentValue shown in Engineering's
// Command Panel is preferred when initialized. It comes from the shared control
// backend; this cell does not trigger a read. All other rows display the exact
// Iec61850MonitorPoint current image. Runtime.CurrentValue is last-resort display
// fallback only and remains the separate FAT/evidence state machine image.
var controlValue = _controlSignal?.ControlCurrentValue;
// The monitor point is the current report/poll process image and therefore wins.
// ControlCurrentValue can lag after a relay changes externally, so it is fallback
// only. Runtime remains the final display fallback and separate FAT evidence state.
var liveValue = _livePoint?.Value;
var value = IsInitialized(controlValue)
? controlValue!
: IsInitialized(liveValue)
? liveValue!
var controlValue = _controlSignal?.ControlCurrentValue;
var rawValue = IsInitialized(liveValue)
? liveValue!
: IsInitialized(controlValue)
? controlValue!
: IsInitialized(_plan?.Runtime.CurrentValue)
? _plan!.Runtime.CurrentValue
: "—";

var liveQuality = _livePoint?.Quality;
var quality = IsInitialized(liveQuality)
var rawQuality = IsInitialized(liveQuality)
? liveQuality!
: IsInitialized(_plan?.Runtime.CurrentQuality)
? _plan!.Runtime.CurrentQuality
: "Unknown";

_valueText.Text = value;
_qualityText.Text = quality;
// Normalize equivalent boolean spellings and skip identical Text assignments. This
// prevents false -> False churn from repainting a realized FAT cell while scrolling.
SetTextIfChanged(_valueText, NormalizeDisplayValue(rawValue, "—"));
SetTextIfChanged(_qualityText, NormalizeDisplayValue(rawQuality, "Unknown"));
}

private static void SetTextIfChanged(TextBlock target, string value)
{
if (!string.Equals(target.Text, value, StringComparison.Ordinal))
target.Text = value;
}

private static string NormalizeDisplayValue(string? value, string fallback)
{
var text = (value ?? string.Empty).Trim();
if (!IsInitialized(text))
return fallback;

if (bool.TryParse(text, out var booleanValue))
return booleanValue ? bool.TrueString : bool.FalseString;

return text;
}

private static bool ExactControlReferenceMatch(
Expand Down
47 changes: 38 additions & 9 deletions tests/ARSAS.Tests/IoListFatLiveValueAuthorityRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ public void LiveValueColumn_UsesSharedEngineeringAuthorityInsteadOfOnlyRuntimeCo
var authority = File.ReadAllText(FindRepoFile("IoListTestingWindow.LiveValueAuthority.cs"));
var binding = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestLiveBindingService.cs"));

// Root cause guard: FAT v2 originally rendered the copied runtime image, while
// Root-cause guard: FAT v2 originally rendered the copied runtime image, while
// live binding copied Iec61850MonitorPoint only at binding time.
Assert.Contains("new Binding(\"Runtime.CurrentValue\")", legacyUx, StringComparison.Ordinal);
Assert.Contains("point.Runtime.CurrentValue = binding.LivePoint.Value;", binding, StringComparison.Ordinal);

// The installed field cell must instead subscribe to the shared Engineering
// monitor/control objects. Runtime remains only a display fallback and evidence
// state; the UI authority must not depend on another protocol read.
// The installed field cell subscribes to the shared Engineering monitor/control
// objects. It must not start another protocol read or mutate FAT evidence.
Assert.Contains("FatAuthoritativeLiveValueCell", authority, StringComparison.Ordinal);
Assert.Contains("_livePoint.PropertyChanged += LivePoint_PropertyChanged;", authority, StringComparison.Ordinal);
Assert.Contains("nameof(Iec61850MonitorPoint.Value)", authority, StringComparison.Ordinal);
Expand All @@ -28,15 +27,45 @@ public void LiveValueColumn_UsesSharedEngineeringAuthorityInsteadOfOnlyRuntimeCo
}

[Fact]
public void LiveValueGrid_DoesNotRecyclePreviousRowContentDuringScroll()
public void LiveValuePresentation_PrefersActualMonitorPointOverControlCache()
{
var authority = File.ReadAllText(FindRepoFile("IoListTestingWindow.LiveValueAuthority.cs"));

Assert.Contains("VirtualizingPanel.SetIsVirtualizing(grid, true);", authority, StringComparison.Ordinal);
Assert.Contains("VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Standard);", authority, StringComparison.Ordinal);
Assert.Contains("_valueText.Text = \"—\";", authority, StringComparison.Ordinal);
Assert.Contains("_qualityText.Text = \"Unknown\";", authority, StringComparison.Ordinal);
// A relay can change externally while ControlCurrentValue is still stale. The
// report/poll monitor point is the actual live process image and must win.
Assert.Contains("var liveValue = _livePoint?.Value;", authority, StringComparison.Ordinal);
Assert.Contains("var controlValue = _controlSignal?.ControlCurrentValue;", authority, StringComparison.Ordinal);
Assert.Contains("var rawValue = IsInitialized(liveValue)", authority, StringComparison.Ordinal);
Assert.Contains("? liveValue!", authority, StringComparison.Ordinal);
Assert.Contains(": IsInitialized(controlValue)", authority, StringComparison.Ordinal);
}

[Fact]
public void LiveValueGrid_NeverMutatesVirtualizationModeAfterLayout()
{
var authority = File.ReadAllText(FindRepoFile("IoListTestingWindow.LiveValueAuthority.cs"));

// WPF throws if VirtualizationMode is changed after the ItemsHost has measured.
// Virtualization remains owned by baseline XAML; the cell handles DataContext
// recycling synchronously instead.
Assert.DoesNotContain("VirtualizingPanel.SetVirtualizationMode(", authority, StringComparison.Ordinal);
Assert.DoesNotContain("VirtualizingPanel.SetIsVirtualizing(", authority, StringComparison.Ordinal);
Assert.Contains("Cell_DataContextChanged", authority, StringComparison.Ordinal);
Assert.Contains("SetTextIfChanged(_valueText, \"—\")", authority, StringComparison.Ordinal);
Assert.Contains("SetTextIfChanged(_qualityText, \"Unknown\")", authority, StringComparison.Ordinal);
}

[Fact]
public void LiveValuePresentation_NormalizesBooleanTextAndSkipsRedundantPaints()
{
var authority = File.ReadAllText(FindRepoFile("IoListTestingWindow.LiveValueAuthority.cs"));

// false / False / FALSE are semantically the same relay state. Canonicalize the
// display and do not assign Text when the rendered value is already identical.
Assert.Contains("bool.TryParse(text, out var booleanValue)", authority, StringComparison.Ordinal);
Assert.Contains("booleanValue ? bool.TrueString : bool.FalseString", authority, StringComparison.Ordinal);
Assert.Contains("if (!string.Equals(target.Text, value, StringComparison.Ordinal))", authority, StringComparison.Ordinal);
Assert.Contains("SetTextIfChanged(_valueText, NormalizeDisplayValue(rawValue, \"—\"));", authority, StringComparison.Ordinal);
}

[Fact]
Expand Down
Loading