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]