using Akka.Actor; using Akka.TestKit; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// End-to-end test over the REAL waiter and DCL ingest path: /// the cache still holds the previous run's CompleteFlag = True, the script's reset /// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the /// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until /// the reset echo lands, then matches only on the device's fresh True. /// public class HandshakeResetEchoTests : TestKit, IDisposable { private const string Conn = "PLC"; private const string TriggerTag = "ns=3;s=MoveOutFlag"; private const string CompleteTag = "ns=3;s=MoveOutCompleteFlag"; private const string BatchTag = "ns=3;s=MoveOutBatchID"; private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; /// Initializes the test with a temporary LocalDb-backed site storage and script compilation services. public HandshakeResetEchoTests() { _localDb = TestLocalDb.CreateTemp("handshake-reset-echo"); _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService(NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance); } /// void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); var path = _localDb.Path; _localDb.Dispose(); TestLocalDb.DeleteFiles(path); } private static FlattenedConfiguration Config() => new() { InstanceUniqueName = "Z28061", Attributes = [ new ResolvedAttribute { CanonicalName = "MoveOutFlag", Value = "false", DataType = "Boolean", DataSourceReference = TriggerTag, BoundDataConnectionName = Conn }, new ResolvedAttribute { CanonicalName = "MoveOutCompleteFlag", Value = "false", DataType = "Boolean", DataSourceReference = CompleteTag, BoundDataConnectionName = Conn }, new ResolvedAttribute { CanonicalName = "MoveOutBatchID", Value = "0", DataType = "Int32", DataSourceReference = BatchTag, BoundDataConnectionName = Conn }, ] }; private (IActorRef Actor, TestProbe Dcl, ScriptRuntimeContext Ctx) Boot() { var dcl = CreateTestProbe(); var actor = ActorOf(Props.Create(() => new InstanceActor( "Z28061", JsonSerializer.Serialize(Config()), _storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger.Instance, dcl.Ref))); dcl.ExpectMsg(TimeSpan.FromSeconds(5)); // The previous run's completion is still in the cache: codec "True", exactly what the // response wait targets. actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow)); var ctx = new ScriptRuntimeContext( actor, _sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: "Z28061", logger: NullLogger.Instance); return (actor, dcl, ctx); } private static IReadOnlyDictionary Values(int batchId) => new Dictionary { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) }; /// /// Pins the PERMANENT legacy behaviour, not a bug awaiting a fix: the six-argument overload is /// deliberately unchanged (deployed scripts keep their semantics) and answers from the stale /// cache — callers migrate to the reset-aware overload instead. Never "fix" the fast-path in /// place; delete this test only if the legacy overload itself is removed. /// /// A task that represents the asynchronous operation. [Fact] public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue() { var (_, dcl, ctx) = Boot(); var call = ctx.WriteBatchAndWait( Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5)); var batch = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow)); // No device update at all — and the wait still "matches". Assert.True(await call); } /// Verifies that the reset-aware overload holds the batch until the reset echoes and then matches the fresh true value. /// A task that represents the asynchronous operation. [Fact] public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue() { var (actor, dcl, ctx) = Boot(); var call = ctx.WriteBatchResetAndWait( Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10), "MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromSeconds(10)); // Leg 1 reaches the device as a plain tag write; the DCL acks it but the device has // not echoed anything yet. var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(CompleteTag, reset.TagPath); dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow)); // The cache still says True → the reset wait must NOT fast-path, and no batch may go out. dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); Assert.False(call.IsCompleted); // The device echoes the reset. actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow)); // Only now the batch + trigger. var batch = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(TriggerTag, batch.TriggerTagPath); // TriggerValue may arrive boxed as bool or as its already-encoded string; normalise both sides. Assert.Equal(AttributeValueCodec.Encode(true), AttributeValueCodec.Encode(batch.TriggerValue)); Assert.True(batch.Values.ContainsKey(BatchTag)); dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow)); // Still pending: the cache now holds False, and nothing can complete the call until the // TagValueUpdate below is sent — no race here, only a documented intermediate state. Assert.False(call.IsCompleted); // The device completes THIS request. actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow)); var result = await call; Assert.True(result.Matched); Assert.Equal(HandshakePhase.Response, result.Phase); Assert.Equal(true, result.Value); Assert.Equal("Good", result.Quality); } /// Verifies that the reset-aware overload times out in the reset phase when no echo arrives and never writes the batch. /// A task that represents the asynchronous operation. [Fact] public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch() { var (_, dcl, ctx) = Boot(); var call = ctx.WriteBatchResetAndWait( Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10), "MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromMilliseconds(700)); var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow)); var result = await call; Assert.False(result.Matched); Assert.True(result.TimedOut); Assert.True(result.ResetFailed); dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } }