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 legacy behaviour when the run did NOT write a reset itself: the six-argument /// overload answers from the cache (level-triggered, the caller owns the reset). The waiter's /// fast-path is not "fixed" in place — the implicit reset echo (Gitea #36, 2026-11-10) engages /// only when this run's own SetAttribute reset the response attribute; see /// . /// /// 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); } private async Task ResetThroughScript(TestProbe dcl, ScriptRuntimeContext ctx) { // The MES script's own reset: `rx.Attributes["MoveOutCompleteFlag"] = false;` — a device // write the DCL acks, with NO echo yet (the cache still holds the previous run's True). var set = ctx.SetAttribute("MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!); var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(CompleteTag, reset.TagPath); dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow)); await set; } /// /// Gitea #36 regression (MesMoveOut, QA 2026-09-10): the script resets CompleteFlag and then /// calls the SIX-argument overload. Before the fix the response wait fast-pathed on the previous /// run's cached True (0 ms) and the script read stale results. Now the batch is held until the /// reset echoes, and only the device's fresh True completes the call. /// /// A task that represents the asynchronous operation. [Fact] public async Task LegacyOverload_AfterTheScriptsOwnReset_WaitsForTheEcho_ThenMatchesTheFreshTrue() { var (actor, dcl, ctx) = Boot(); await ResetThroughScript(dcl, ctx); var call = ctx.WriteBatchAndWait( Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10)); // Stale True in the cache, reset not echoed → no batch, no completion. dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); Assert.False(call.IsCompleted); actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow)); var batch = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(TriggerTag, batch.TriggerTagPath); dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow)); // The cache holds the echoed False — only THIS request's completion may finish the call. dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); Assert.False(call.IsCompleted); actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow)); Assert.True(await call); } /// /// Gitea #36: a reset that never echoes makes the legacy handshake report a timeout and write /// NOTHING — it must not complete from the stale cached True, nor send the trigger. /// /// A task that represents the asynchronous operation. [Fact] public async Task LegacyOverload_AfterTheScriptsOwnReset_NoEcho_ReturnsFalse_AndNeverWritesTheBatch() { var (_, dcl, ctx) = Boot(); await ResetThroughScript(dcl, ctx); var matched = await ctx.WriteBatchAndWait( Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromMilliseconds(700)); Assert.False(matched); dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// /// Gitea #36: the pending reset is consumed by the handshake that observed it — a second legacy /// handshake in the same run (no new reset) keeps the plain level-triggered behaviour instead /// of waiting for an echo of a reset that already happened. /// /// A task that represents the asynchronous operation. [Fact] public async Task LegacyOverload_ResetIsConsumedByTheHandshakeThatObservedIt() { var (actor, dcl, ctx) = Boot(); await ResetThroughScript(dcl, ctx); var first = ctx.WriteBatchAndWait( Values(1), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10)); actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow)); var b1 = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); dcl.Reply(new WriteTagBatchResponse(b1.CorrelationId, true, null, DateTimeOffset.UtcNow)); actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow)); Assert.True(await first); var second = ctx.WriteBatchAndWait( Values(2), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5)); var b2 = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); dcl.Reply(new WriteTagBatchResponse(b2.CorrelationId, true, null, DateTimeOffset.UtcNow)); Assert.True(await second); } /// /// Gitea #36: the reset-aware overload's own reset write is observed by its reset leg, so it /// must not be left pending for a later six-argument handshake in the same run (which would /// otherwise wait for an echo of a reset that already happened and time out). /// /// A task that represents the asynchronous operation. [Fact] public async Task ResetAwareOverload_ConsumesItsReset_SoALaterLegacyHandshakeIsNotGated() { var (actor, dcl, ctx) = Boot(); var first = ctx.WriteBatchResetAndWait( Values(1), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10), "MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromSeconds(10)); var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow)); actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow)); var b1 = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); dcl.Reply(new WriteTagBatchResponse(b1.CorrelationId, true, null, DateTimeOffset.UtcNow)); actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow)); Assert.True((await first).Matched); var second = ctx.WriteBatchAndWait( Values(2), "MoveOutFlag", AttributeValueCodec.Encode(true), "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(3)); var b2 = dcl.ExpectMsg(TimeSpan.FromSeconds(2)); dcl.Reply(new WriteTagBatchResponse(b2.CorrelationId, true, null, DateTimeOffset.UtcNow)); Assert.True(await second); } /// 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)); } }