diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index edaeef9a3..f42746a37 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -230,7 +230,9 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) return; } - var bindingIndex = new IedBrowserReadBindingIndex(device); + // Build after a possible cached-model connect; connection may replace + // the catalog and invalidate a pre-connect binding snapshot. + IedBrowserReadBindingIndex? bindingIndex = null; var cts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); // Read All may include thousands of attributes. It is cancellable by the // operator, never a new continuous acquisition/polling process. @@ -257,8 +259,9 @@ void DrainReadResults() readValues.Drain(result => { if (!ReferenceEquals(_iedBrowserReadOperation, cts) || - sourceDevice.LiveValues.Association != readAssociation) - return; // new association or replaced read flight + sourceDevice.LiveValues.Association != readAssociation || + bindingIndex is not { IsCurrent: true }) + return; // superseded association, catalog or read flight if (result.Error.Length != 0 || string.IsNullOrWhiteSpace(result.Value)) { failures++; @@ -296,7 +299,14 @@ void DrainReadResults() throw new InvalidOperationException("MMS connection was not established."); } + // A cached connect may rebuild schema/model. Never execute Read + // targets from a superseded model against the new association. + if (!ReferenceEquals(model, + sourceDevice.LiveDiscoveryModel ?? sourceDevice.SclWorkspace?.DesignModel)) + throw new InvalidOperationException( + "Browser model changed during connection; select the current model and retry Read."); readAssociation = sourceDevice.LiveValues.Association; + bindingIndex = new IedBrowserReadBindingIndex(sourceDevice); var readProgress = new Progress(p => { if (ReferenceEquals(_iedBrowserReadOperation, cts) && @@ -314,6 +324,9 @@ await _runtime.ReadBrowserValuesAsync(device, targets, // All producers have returned: consume the final queued results // synchronously before calculating counts and closing this flight. DrainReadResults(); + if (bindingIndex is not { IsCurrent: true }) + throw new InvalidOperationException( + "Browser catalog changed during Read; obsolete result rows were not admitted."); if (!cts.IsCancellationRequested && ReferenceEquals(SelectedDevice, sourceDevice)) { device.ApplyBrowserReadProgress(targets.Count, targets.Count); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index ed62bf0c8..ec9c88103 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -26,7 +26,7 @@ public partial class MainWindow : Window, INotifyPropertyChanged private readonly CancellationTokenSource _applicationCancellation = new(); private readonly Dictionary> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _signalOwners = new(); - private readonly ConcurrentDictionary _pendingPointSnapshots = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _pendingPointSnapshots = new(StringComparer.Ordinal); // Monitoring-lifetime O(1) immutable reference/FC lookup for SignalDefinition. // Rebuilt after the selected signal/point model is materialized. private readonly Dictionary _canonicalSignalProjections = @@ -2373,7 +2373,7 @@ private void EnsureOfflinePointProjection(Iec61850MonitorDevice device) private void Runtime_PointUpdated(Iec61850PointSnapshot snapshot) { _pendingPointSnapshots.AddOrUpdate( - snapshot.Point.PointKey, + CanonicalPointSnapshotAuthority.UiQueueKey(snapshot.Point), _ => new CanonicalPointSnapshotBatch(snapshot, snapshot.IsValueEdge), (_, current) => CanonicalPointSnapshotAuthority.Merge(current, snapshot)); } diff --git a/Models/CanonicalPointSnapshotAuthority.cs b/Models/CanonicalPointSnapshotAuthority.cs index 4e36fffb4..848ebfba4 100644 --- a/Models/CanonicalPointSnapshotAuthority.cs +++ b/Models/CanonicalPointSnapshotAuthority.cs @@ -16,6 +16,19 @@ public sealed record CanonicalPointSnapshotBatch( public static class CanonicalPointSnapshotAuthority { + /// + /// Exact IEC reference + FC key for coalescing observational snapshots. + /// Legacy PointKey intentionally has display/search normalization and is + /// NOT an authority key: it lowercases references and omits FC. + /// + public static string UiQueueKey(Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(point); + return point.DeviceId + "|" + + IedBrowserLiveValues.CanonicalReference(point.IecReference) + "|" + + (point.FunctionalConstraint ?? "").Trim().ToUpperInvariant(); + } + public static Iec61850PointSnapshot NewerSnapshot( Iec61850PointSnapshot previous, Iec61850PointSnapshot incoming) { diff --git a/Models/IedBrowserReadBindingIndex.cs b/Models/IedBrowserReadBindingIndex.cs index 78f71f26a..3e311d864 100644 --- a/Models/IedBrowserReadBindingIndex.cs +++ b/Models/IedBrowserReadBindingIndex.cs @@ -10,6 +10,11 @@ namespace ArIED61850Tester.Models; public sealed class IedBrowserReadBindingIndex { private readonly Iec61850MonitorDevice _device; + private readonly long _projectionEpoch; + + // Fail closed when the engineering catalog or its bound rows are replaced. + // Comparing one monotonic epoch costs O(1) on each Report/Read projection. + public bool IsCurrent => _projectionEpoch == _device.BrowserProjectionEpoch; private readonly Dictionary _signals; private readonly Dictionary _points; @@ -45,6 +50,7 @@ public IedBrowserReadBindingIndex(Iec61850MonitorDevice device) StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + _projectionEpoch = device.BrowserProjectionEpoch; } private static void Add(Dictionary> index, @@ -69,7 +75,7 @@ public int Apply(IedBrowserReadResult read, IedBrowserLiveValues live, { ArgumentNullException.ThrowIfNull(read); ArgumentNullException.ThrowIfNull(live); - if (_device.IsMonitoring || !_device.IsConnected || + if (!IsCurrent || _device.IsMonitoring || !_device.IsConnected || !string.IsNullOrWhiteSpace(read.Error) || string.IsNullOrWhiteSpace(read.Value) || live.Association != association || @@ -118,7 +124,7 @@ public int ProjectReportedSignals(IedBrowserLiveValues live, long association, string reference, string fc) { ArgumentNullException.ThrowIfNull(live); - if (!_device.IsMonitoring || !_device.IsConnected || + if (!IsCurrent || !_device.IsMonitoring || !_device.IsConnected || association != live.Association || live.IsOffline || string.IsNullOrWhiteSpace(reference) || string.IsNullOrWhiteSpace(fc) || !_signals.TryGetValue(Key(reference, fc), out var signals) || diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 5caf4f014..5d62b7b64 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -56,6 +56,21 @@ public sealed class Iec61850MonitorDevice : ObservableObject // identical reference/FC keys; not persisted across associations. [JsonIgnore] public IedBrowserLiveValues LiveValues { get; } = new(); + + // Collection-change notifications (including same-count ReplaceAll/Reset) + // and schema replacement invalidate captured reference/FC projection indexes. + // One monotonic revision avoids scans or per-row event subscriptions on ingress. + private long _browserProjectionEpoch; + internal long BrowserProjectionEpoch => + System.Threading.Interlocked.Read(ref _browserProjectionEpoch); + + public Iec61850MonitorDevice() + { + Signals.CollectionChanged += (_, _) => + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); + Points.CollectionChanged += (_, _) => + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); + } public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new(); [JsonIgnore] @@ -83,6 +98,7 @@ public SclIedWorkspace? SclWorkspace { if (ReferenceEquals(_sclWorkspace, value)) return; _sclWorkspace = value; + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); RefreshAuthoritativeSclComparison(); RefreshComputed(); } @@ -95,6 +111,7 @@ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel { if (ReferenceEquals(_liveDiscoveryModel, value)) return; _liveDiscoveryModel = value; + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); RefreshAuthoritativeSclComparison(); RefreshComputed(); } @@ -932,7 +949,10 @@ private static string NormalizeSemanticValue(string? value) return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); } - private static string NormalizeReference(string? reference) + // Legacy presentation/planner PointKey normalization. This is NOT a + // canonical IEC identity: case and FC are intentionally not encoded. + // Startup collision validation protects runtime dictionaries from aliasing. + internal static string NormalizeReference(string? reference) => (reference ?? string.Empty).Trim().Replace('$', '.').Replace("..", ".").ToLowerInvariant(); internal static string StripIedNamePrefix(string? reference, string? iedName) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 212c7cf2e..ed7067290 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -619,9 +619,15 @@ public async Task> StartMonitoringAsync( throw new InvalidOperationException($"{device.Name} is not connected. Connect and discover this IED first."); var staticDataSetReportOnly = Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device); - var selected = selectedSignals + var eligible = selectedSignals .Where(signal => signal.IsSelected && signal.CanPublishToRuntime) .Where(signal => !staticDataSetReportOnly || !string.IsNullOrWhiteSpace(signal.DataSetReference)) + .ToArray(); + // PointKey is a historical normalized reference without FC. Validate + // BEFORE grouping or stopping the active monitor: two distinct IEC + // identities must never overwrite each other in runtime state maps. + EnsureDistinctRuntimePointIdentities(eligible); + var selected = eligible .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) .Select(group => group .OrderByDescending(signal => !string.IsNullOrWhiteSpace(signal.DataSetReference)) @@ -2899,6 +2905,36 @@ private static bool IsDegradedIecQuality(string? quality) normalized.Contains("reserved", StringComparison.Ordinal); } + // O(selected signals), once at explicit Start; no per-Report scans. + // Preserve legacy PointKey consumers until a coordinated migration can + // update SOE, planners, point lookup, persistence and UI atomically. + internal static void EnsureDistinctRuntimePointIdentities( + IEnumerable selectedSignals) + { + ArgumentNullException.ThrowIfNull(selectedSignals); + var seen = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var signal in selectedSignals) + { + var reference = IedBrowserLiveValues.CanonicalReference(signal.ObjectReference); + var fc = (signal.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); + var legacyKey = Iec61850MonitorPoint.NormalizeReference(signal.ObjectReference); + if (seen.TryGetValue(legacyKey, out var prior)) + { + if (!string.Equals(prior.Reference, reference, StringComparison.Ordinal) || + !string.Equals(prior.Fc, fc, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Cannot monitor ambiguous IEC identities: {prior.Reference} [{prior.Fc}] and " + + $"{reference} [{fc}] share legacy point key '{legacyKey}'. " + + "Review selection; values cannot be safely routed to separate points."); + } + else + { + seen.Add(legacyKey, (reference, fc)); + } + } + } + private static void IndexPointReference(DeviceSession session, Iec61850MonitorPoint point) { foreach (var key in GetReferenceKeys(point.IecReference)) diff --git a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs new file mode 100644 index 000000000..970454214 --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// M2.13F: same-size model swaps, Read/reconnect and exact IEC identity. +public sealed class CanonicalLiveValueSsotM213FTests +{ + private static IedBrowserReadResult Read(string reference, string value = "999") + => new(new IedBrowserReadTarget(reference, "MX", "FLOAT32"), + value, "", "", ""); + + [Fact] + public void SameCountCatalogReloadCannotUpdateDetachedReadSignalsOrPoints() + { + const string reference = "LD/MMXU1.Hz.mag.f"; + var device = new Iec61850MonitorDevice { IsConnected = true }; + var oldSignal = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + var oldPoint = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, IecReference = reference, + FunctionalConstraint = "MX" + }; + device.Signals.Add(oldSignal); + device.Points.Add(oldPoint); + var oldIndex = new IedBrowserReadBindingIndex(device); + var association = device.LiveValues.Association; + Assert.True(oldIndex.IsCurrent); + // Replace with the SAME number of elements and SAME IEC reference. + // Count-based invalidation would silently retain the detached objects. + var replacementSignal = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + var replacementPoint = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, IecReference = reference, + FunctionalConstraint = "MX" + }; + device.Signals.ReplaceAll([replacementSignal]); + device.Points.ReplaceAll([replacementPoint]); + Assert.False(oldIndex.IsCurrent); + Assert.True(device.LiveValues.PublishForAssociation(association, reference, "MX", + "50", "Good", "T1", "MMS Read")); + Assert.Equal(0, oldIndex.Apply(Read(reference), device.LiveValues, association)); + Assert.Equal("-", oldSignal.Value); + Assert.Equal("-", replacementSignal.Value); + Assert.Equal("-", oldPoint.Value); + Assert.Equal("-", replacementPoint.Value); + + var current = new IedBrowserReadBindingIndex(device); + Assert.True(current.IsCurrent); + Assert.Equal(2, current.Apply(Read(reference), device.LiveValues, association)); + Assert.Equal("50", replacementSignal.Value); + Assert.Equal("50", replacementPoint.Value); + Assert.Equal("-", oldSignal.Value); + } + + [Fact] + public void SameCountCatalogReloadCannotReanimateOldReportSignal() + { + const string reference = "LD/MMXU1.A.phsA.cVal.mag.f"; + var device = new Iec61850MonitorDevice { IsConnected = true, IsMonitoring = true }; + var previous = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + device.Signals.Add(previous); + var oldIndex = new IedBrowserReadBindingIndex(device); + var replacement = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + device.Signals.ReplaceAll([replacement]); + var association = device.LiveValues.Association; + Assert.True(device.LiveValues.PublishForAssociation(association, reference, "MX", + "10", "Good", "T1", "Report")); + Assert.Equal(0, oldIndex.ProjectReportedSignals(device.LiveValues, association, reference, "MX")); + Assert.Equal("-", previous.Value); + Assert.Equal("-", replacement.Value); + var newIndex = new IedBrowserReadBindingIndex(device); + Assert.Equal(1, newIndex.ProjectReportedSignals(device.LiveValues, association, reference, "MX")); + Assert.Equal("10", replacement.Value); + Assert.Equal("-", previous.Value); + } + + [Fact] + public void ExactCaseAndFcRemainDistinctUnderBurstCoalescing() + { + var live = new IedBrowserLiveValues(); + var association = live.BeginAssociation(); + var upper = "LD/CSWI1.Pos.stVal"; + var lower = "LD/CSWI1.pos.stVal"; + Assert.True(live.PublishForAssociation(association, upper, "ST", "Open", "", "", "Report")); + Assert.True(live.PublishForAssociation(association, lower, "ST", "Closed", "", "", "Report")); + Assert.True(live.PublishForAssociation(association, upper, "MX", "22", "", "", "Report")); + Assert.True(live.TryGet(upper, "ST", out var a)); + Assert.True(live.TryGet(lower, "ST", out var b)); + Assert.True(live.TryGet(upper, "MX", out var c)); + Assert.Equal("Open", a.Value); + Assert.Equal("Closed", b.Value); + Assert.Equal("22", c.Value); + + var batches = new ConcurrentDictionary(StringComparer.Ordinal); + foreach (var reference in new[] { upper, lower }) + { + var point = new Iec61850MonitorPoint { DeviceId = "IED", IecReference = reference, FunctionalConstraint = "ST" }; + var snapshot = new Iec61850PointSnapshot + { + Point = point, ObservationAssociation = association, + SnapshotRevision = 1, Sequence = 1, Value = reference + }; + batches.AddOrUpdate(CanonicalPointSnapshotAuthority.UiQueueKey(point), + _ => new CanonicalPointSnapshotBatch(snapshot, false), + (_, prior) => CanonicalPointSnapshotAuthority.Merge(prior, snapshot)); + } + Assert.Equal(2, batches.Count); + var mx = new Iec61850MonitorPoint + { + DeviceId = "IED", IecReference = upper, FunctionalConstraint = "MX" + }; + var st = new Iec61850MonitorPoint + { + DeviceId = "IED", IecReference = upper, FunctionalConstraint = "ST" + }; + Assert.NotEqual(CanonicalPointSnapshotAuthority.UiQueueKey(st), + CanonicalPointSnapshotAuthority.UiQueueKey(mx)); + Assert.NotEqual(CanonicalPointSnapshotAuthority.UiQueueKey(st), + CanonicalPointSnapshotAuthority.UiQueueKey(new Iec61850MonitorPoint + { + DeviceId = "IED", IecReference = lower, FunctionalConstraint = "ST" + })); + + // Protect the real WPF receive seam rather than only a test dictionary. + var source = FindSource("MainWindow.xaml.cs"); + Assert.Contains("_pendingPointSnapshots = new(StringComparer.Ordinal);", source); + Assert.Contains("CanonicalPointSnapshotAuthority.UiQueueKey(snapshot.Point)", source); + } + + [Fact] + public void ReadBindsAfterConnectAndFencesCatalogBeforeEvidenceAdmission() + { + var source = FindSource("MainWindow.IedBrowser.cs"); + Assert.Contains("bindingIndex = new IedBrowserReadBindingIndex(sourceDevice);", source); + Assert.Contains("bindingIndex is not { IsCurrent: true }", source); + Assert.Contains("Browser model changed during connection", source); + Assert.Contains("Browser catalog changed during Read", source); + } + + private static string FindSource(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir != null) + { + var file = Path.Combine(dir.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/CanonicalRuntimePointIdentityM213GTests.cs b/tests/ARSAS.Tests/CanonicalRuntimePointIdentityM213GTests.cs new file mode 100644 index 000000000..face6c5c2 --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalRuntimePointIdentityM213GTests.cs @@ -0,0 +1,74 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class CanonicalRuntimePointIdentityM213GTests +{ + private static SignalDefinition Signal(string reference, string fc) => new() + { + ObjectReference = reference, FunctionalConstraint = fc + }; + + [Fact] + public void SameLegacyKeyWithDistinctIecCaseFailsClosedBeforeStateConstruction() + { + var upper = Signal("LD/CSWI1.Pos.stVal", "ST"); + var lower = Signal("LD/CSWI1.pos.stVal", "ST"); + Assert.NotEqual( + IedBrowserLiveValues.CanonicalReference(upper.ObjectReference), + IedBrowserLiveValues.CanonicalReference(lower.ObjectReference)); + Assert.Equal( + Iec61850MonitorPoint.NormalizeReference(upper.ObjectReference), + Iec61850MonitorPoint.NormalizeReference(lower.ObjectReference)); + var ex = Assert.Throws(() => + Iec61850MonitorRuntime.EnsureDistinctRuntimePointIdentities([upper, lower])); + Assert.Contains("ambiguous IEC identities", ex.Message); + Assert.Contains("[ST]", ex.Message); + } + + [Fact] + public void DifferentFcOnSameObjectCannotAliasOneRuntimePoint() + { + var first = Signal("LD/MMXU1.Hz.mag.f", "MX"); + var second = Signal("LD/MMXU1.Hz.mag.f", "CF"); + var ex = Assert.Throws(() => + Iec61850MonitorRuntime.EnsureDistinctRuntimePointIdentities([first, second])); + Assert.Contains("[MX]", ex.Message); + Assert.Contains("[CF]", ex.Message); + } + + [Fact] + public void SameExactIdentityCanAppearTwiceWithoutFalseConflict() + { + Iec61850MonitorRuntime.EnsureDistinctRuntimePointIdentities([ + Signal("LD/MMXU1.Hz.mag.f", "mx"), + Signal("LD/MMXU1.Hz.mag.f", "MX"), + Signal("LD/MMXU1.A.phsA.cVal.mag.f", "MX"), + Signal("LD/MMXU1.A.phsB.cVal.mag.f", "MX") + ]); + } + + [Fact] + public void CollisionDetectionPrecedesDestructiveMonitorRestart() + { + var source = Source("Services/Iec61850MonitorRuntime.cs"); + var validate = source.IndexOf("EnsureDistinctRuntimePointIdentities(eligible);", + StringComparison.Ordinal); + var stop = source.IndexOf("await StopMonitoringSessionAsync(session, preservePointDefinitions: false)", + StringComparison.Ordinal); + Assert.True(validate >= 0 && stop > validate); + } + + private static string Source(string path) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var file = Path.Combine(directory.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + directory = directory.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs index a0f25d5a2..5fdcc46b8 100644 --- a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs +++ b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs @@ -76,9 +76,18 @@ public void UiReadHandlerUsesExplicitInboxCompletionBeforeResultsSubtitle() Assert.DoesNotContain("new Progress", source); // Checkout on Windows uses CRLF; source-based contracts are newline-neutral. var normalized = source.Replace("\r\n", "\n", StringComparison.Ordinal); - var finalDrain = normalized.IndexOf("DrainReadResults();\n if (!cts.IsCancellationRequested", StringComparison.Ordinal); + // Guarding a replaced model after the synchronous drain is valid and + // mandatory; the final drain must precede the success summary, not + // necessarily be adjacent to the success conditional. + var finishedProducers = normalized.IndexOf( + "// All producers have returned:", StringComparison.Ordinal); + var finalDrain = normalized.IndexOf( + " DrainReadResults();", finishedProducers, StringComparison.Ordinal); + var catalogFence = normalized.IndexOf( + "if (bindingIndex is not { IsCurrent: true })", finalDrain, StringComparison.Ordinal); var summary = normalized.IndexOf("Read complete ยท", StringComparison.Ordinal); - Assert.True(finalDrain >= 0 && summary > finalDrain); + Assert.True(finishedProducers >= 0 && finalDrain > finishedProducers && + catalogFence > finalDrain && summary > catalogFence); Assert.Contains("sourceDevice.LiveValues.Association != readAssociation", source); } }