From 03c8a01dd16edb458aecb9857f5417ec89edf54d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 10 Sep 2026 15:26:37 -0400 Subject: [PATCH] =?UTF-8?q?test(site-runtime):=20pin=20#36=20=E2=80=94=20r?= =?UTF-8?q?eset-aware=20handshake=20holds=20the=20batch=20until=20the=20re?= =?UTF-8?q?set=20echo=20lands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV --- .../Actors/HandshakeResetEchoTests.cs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs new file mode 100644 index 00000000..173ffa43 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs @@ -0,0 +1,166 @@ +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; + +/// +/// Gitea #36 end-to-end 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; + + 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) }; + + /// The defect, as documentation: the legacy overload answers from the stale cache. + [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); + } + + [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); + 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. + 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); + } + + [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)); + } +}