using System; using System.Collections.Generic; 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 { [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); } [Fact] public void TryGet_WithUnknownHandle_ReturnsFalse() { MxAccessValueCache cache = new(); Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _)); } [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); } [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)); } /// /// Worker.Tests-020: pins the contract that TryWaitForUpdate /// returns false when the deadline has elapsed with no /// Set, yields a default CachedValue, and invokes /// pumpStep at least once so MXAccess Windows messages can /// be dispatched. Earlier revisions of this test asserted both an /// elapsed-time floor (stopwatch.ElapsedMilliseconds >= 60) /// and pumpCalls > 1 — the same wall-clock-floor race /// pattern Worker.Tests-003/004/013 corrected. To eliminate the /// timing dependency entirely (the equivalent of a manual time /// source for a DateTime.UtcNow-based deadline), the test /// now supplies a deadline already in the past: the loop pumps /// once, observes the passed deadline, and returns false /// deterministically without any Thread.Sleep. The /// deadline-honouring contract is what this test exists to pin; /// elapsed time and pump-iteration count are incidental. /// [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); } [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); } 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; } }