diff --git a/IoListTestingWindow.LiveValueAuthority.cs b/IoListTestingWindow.LiveValueAuthority.cs
index d774a3b3c..b26c190d5 100644
--- a/IoListTestingWindow.LiveValueAuthority.cs
+++ b/IoListTestingWindow.LiveValueAuthority.cs
@@ -11,7 +11,7 @@
namespace ArIED61850Tester;
///
-/// 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.
///
@@ -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()
.FirstOrDefault(column =>
@@ -66,10 +64,11 @@ private void InstallAuthoritativeFatLiveValueColumn()
}
///
-/// 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.
///
public sealed class FatAuthoritativeLiveValueCell : StackPanel
{
@@ -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();
}
@@ -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(
diff --git a/tests/ARSAS.Tests/IoListFatLiveValueAuthorityRegressionTests.cs b/tests/ARSAS.Tests/IoListFatLiveValueAuthorityRegressionTests.cs
index 82bffab9d..c93b25a79 100644
--- a/tests/ARSAS.Tests/IoListFatLiveValueAuthorityRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoListFatLiveValueAuthorityRegressionTests.cs
@@ -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);
@@ -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]