Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs
T

289 lines
12 KiB
C#

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;
/// <summary>
/// Unit tests for <see cref="MxAccessValueCache"/>. The cache is consumed by
/// <see cref="MxAccessSession.ReadBulk"/> 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.
/// </summary>
public sealed class MxAccessValueCacheTests
{
/// <summary>Verifies that cache returns the last value with incrementing versions.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>Verifies that TryGet returns false for unknown handles.</summary>
[Fact]
public void TryGet_WithUnknownHandle_ReturnsFalse()
{
MxAccessValueCache cache = new();
Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _));
}
/// <summary>Verifies that Remove drops entries and resets versions.</summary>
[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);
}
/// <summary>Verifies that CurrentVersion returns zero for unknown handles and the latest for known ones.</summary>
[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));
}
/// <summary>Verifies that TryWaitForUpdate returns false after the deadline expires.</summary>
[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);
}
/// <summary>Verifies that TryWaitForUpdate returns true when the cache is updated after the baseline.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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 <c>Thread.Sleep</c> 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.
/// </summary>
/// <remarks>
/// Scope: this proves the cadence bound, not the signal path. The clamp
/// alone would satisfy it, so it stays green if the <c>Set</c>-side
/// signal is deleted. <c>StaWaitHelperTests</c> 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.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[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.");
}
/// <summary>
/// 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.
/// </summary>
[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;
}
}