using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Unit tests for . The cache is consumed by /// to satisfy "current value" /// requests for already-advised tags without touching the existing /// subscription, so its contract is exercised in isolation here before any /// STA / COM plumbing gets layered on top. /// public sealed class MxAccessValueCacheTests { /// Verifies that cache returns the last value with incrementing versions. [Fact] public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion() { MxAccessValueCache cache = new(); Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc)); cache.Set(serverHandle: 7, itemHandle: 21, BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp)); Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue first)); Assert.Equal(1UL, first.Version); Assert.Equal(100, first.Value.Int32Value); Assert.Equal(192, first.Quality); Assert.Equal(sourceTimestamp, first.SourceTimestamp); // A second Set on the same key bumps the version and overwrites the // payload. Different keys remain isolated. cache.Set(7, 21, BuildEvent(7, 21, intValue: 200, quality: 192, sourceTimestamp)); cache.Set(7, 22, BuildEvent(7, 22, intValue: 999, quality: 192, sourceTimestamp)); Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue second)); Assert.Equal(2UL, second.Version); Assert.Equal(200, second.Value.Int32Value); Assert.True(cache.TryGet(7, 22, out MxAccessValueCache.CachedValue other)); Assert.Equal(1UL, other.Version); Assert.Equal(999, other.Value.Int32Value); } /// /// Verifies that Set stores an independent deep-copied snapshot: mutating /// the source event's protobuf sub-messages after caching does not alter /// the cached value. WRK-11 stopped the event sink cloning before enqueue, /// so the same MxEvent instance now flows to the outbound queue; the cache /// must own its own copy so the two never share mutable state. /// [Fact] public void Set_StoresIndependentSnapshot_UnaffectedByLaterEventMutation() { MxAccessValueCache cache = new(); Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc)); MxEvent mxEvent = BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp); cache.Set(7, 21, mxEvent); // Mutate the event in place after it was cached — as if it kept flowing // through the (unrelated) outbound path. None of this must reach the cache. mxEvent.Value.Int32Value = 999; mxEvent.Quality = 0; mxEvent.SourceTimestamp = Timestamp.FromDateTime(new(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); mxEvent.Statuses[0].Category = MxStatusCategory.SecurityError; Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue cached)); Assert.Equal(100, cached.Value.Int32Value); Assert.Equal(192, cached.Quality); Assert.Equal(sourceTimestamp, cached.SourceTimestamp); Assert.Single(cached.Statuses); Assert.Equal(MxStatusCategory.Ok, cached.Statuses[0].Category); } /// Verifies that TryGet returns false for unknown handles. [Fact] public void TryGet_WithUnknownHandle_ReturnsFalse() { MxAccessValueCache cache = new(); Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _)); } /// Verifies that Remove drops entries and resets versions. [Fact] public void Remove_DropsEntryAndResetsVersion() { MxAccessValueCache cache = new(); cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); cache.Remove(7, 21); Assert.False(cache.TryGet(7, 21, out _)); // After Remove, a subsequent Set restarts the per-handle version from 1 // — the cache must not serve a stale "version 3" entry that would race // against a reused MXAccess item handle. cache.Set(7, 21, BuildEvent(7, 21, intValue: 3, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue reset)); Assert.Equal(1UL, reset.Version); } /// Verifies that CurrentVersion returns zero for unknown handles and the latest for known ones. [Fact] public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown() { MxAccessValueCache cache = new(); Assert.Equal(0UL, cache.CurrentVersion(7, 21)); cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); Assert.Equal(2UL, cache.CurrentVersion(7, 21)); } /// Verifies that TryWaitForUpdate returns false after the deadline expires. [Fact] public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs() { MxAccessValueCache cache = new(); int pumpCalls = 0; // Deadline already in the past — eliminates the wall-clock-floor // race. The loop must pump once (so MXAccess messages can dispatch // on the calling thread even when the deadline has just expired) // and then immediately observe the passed deadline. DateTime expiredDeadlineUtc = DateTime.UtcNow.AddMilliseconds(-1); bool result = cache.TryWaitForUpdate( serverHandle: 7, itemHandle: 21, sinceVersion: 0, deadlineUtc: expiredDeadlineUtc, pumpStep: () => Interlocked.Increment(ref pumpCalls), out MxAccessValueCache.CachedValue value, pollIntervalMs: 5); Assert.False(result); Assert.Equal(default, value.Value); Assert.Equal(1, pumpCalls); } /// Verifies that TryWaitForUpdate returns true when the cache is updated after the baseline. /// A task that represents the asynchronous operation. [Fact] public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion() { MxAccessValueCache cache = new(); Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow); // Baseline is "no entry yet" → wait for the first Set to land. Task<(bool ok, MxAccessValueCache.CachedValue value)> waitTask = Task.Run(() => { bool ok = cache.TryWaitForUpdate( serverHandle: 7, itemHandle: 21, sinceVersion: 0, deadlineUtc: DateTime.UtcNow.AddSeconds(2), pumpStep: () => { }, out MxAccessValueCache.CachedValue v, pollIntervalMs: 5); return (ok, v); }); // Race a Set against the wait loop. The cache's lock guarantees the // wait observes the new version before TryGet returns it. await Task.Delay(20); cache.Set(7, 21, BuildEvent(7, 21, intValue: 4242, quality: 192, sourceTimestamp)); (bool ok, MxAccessValueCache.CachedValue value) = await waitTask; Assert.True(ok); Assert.Equal(4242, value.Value.Int32Value); Assert.Equal(1UL, value.Version); } /// /// Verifies the wait slice stays bounded no matter what poll interval the /// caller asks for: with a 10 s interval and a 3 s deadline, a value set /// 50 ms in is still returned in time, because every slice is capped at /// the 50 ms fallback tick. The blind Thread.Sleep this replaced /// would have slept the full interval — on the STA, 10 s with no Windows /// messages dispatched, so the OnDataChange being waited for could not /// have arrived at all — and then reported a timeout. /// /// /// Scope: this proves the cadence bound, not the signal path. The clamp /// alone would satisfy it, so it stays green if the Set-side /// signal is deleted. StaWaitHelperTests owns the signal path, /// where a five-second wait with no clamp in play makes the handle the /// only thing that can end it early. /// /// A task that represents the asynchronous operation. [Fact] public async Task TryWaitForUpdate_CompletesWithinDeadline_WhenPollIntervalExceedsTheFallbackTick() { MxAccessValueCache cache = new(); Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow); using ManualResetEventSlim waitEntered = new(false); Task setter = Task.Run(async () => { // Handshake so the value cannot land before the wait starts — // otherwise the first check would satisfy it and prove nothing. waitEntered.Wait(TimeSpan.FromSeconds(5)); await Task.Delay(50, CancellationToken.None); cache.Set(7, 21, BuildEvent(7, 21, intValue: 8080, quality: 192, sourceTimestamp)); }); Stopwatch elapsed = Stopwatch.StartNew(); bool found = cache.TryWaitForUpdate( serverHandle: 7, itemHandle: 21, sinceVersion: 0, deadlineUtc: DateTime.UtcNow.AddSeconds(3), pumpStep: () => waitEntered.Set(), out MxAccessValueCache.CachedValue value, pollIntervalMs: 10_000); elapsed.Stop(); await setter; Assert.True(found); Assert.Equal(8080, value.Value.Int32Value); Assert.True( elapsed.Elapsed < TimeSpan.FromSeconds(2), $"The wait should have re-checked within the fallback tick, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms."); } /// /// Verifies the pump step keeps running throughout a wait that nothing /// ever signals: the caller's poll interval is capped at the 50 ms /// fallback tick, so a timing-out wait still pumps repeatedly instead of /// once. This is what keeps the STA dispatching COM events in a process /// whose message queue never wakes the wait, and it is the safety net /// behind the ReadBulk per-tag timeout. /// [Fact] public void TryWaitForUpdate_KeepsPumping_WhenPollIntervalExceedsTheFallbackTick() { MxAccessValueCache cache = new(); int pumpCalls = 0; bool found = cache.TryWaitForUpdate( serverHandle: 7, itemHandle: 21, sinceVersion: 0, deadlineUtc: DateTime.UtcNow.AddMilliseconds(400), pumpStep: () => Interlocked.Increment(ref pumpCalls), out _, pollIntervalMs: 10_000); Assert.False(found); Assert.True(pumpCalls >= 3, $"Expected repeated pumping across the 400 ms wait, saw {pumpCalls} calls."); } private static MxEvent BuildEvent( int serverHandle, int itemHandle, int intValue, int quality, Timestamp sourceTimestamp) { MxEvent mxEvent = new() { Family = MxEventFamily.OnDataChange, ServerHandle = serverHandle, ItemHandle = itemHandle, Quality = quality, SourceTimestamp = sourceTimestamp, Value = new MxValue { DataType = MxDataType.Integer, VariantType = "VT_I4", Int32Value = intValue, }, OnDataChange = new OnDataChangeEvent(), }; mxEvent.Statuses.Add(new MxStatusProxy { Category = MxStatusCategory.Ok, }); return mxEvent; } }