using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED /// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes /// the batch unless the reset echo was observed. The probe stands in for the InstanceActor /// so the order and the "nothing after a failed reset" property are directly observable. /// public class WriteBatchResetAndWaitTests : TestKit { private const string Instance = "Plant.Reactor1"; private static readonly IReadOnlyDictionary Values = new Dictionary { ["BatchID"] = "28950" }; private static ScriptRuntimeContext CreateContext(IActorRef instanceActor) => new(instanceActor, new SharedScriptLibrary( new ScriptCompilationService(NullLogger.Instance), NullLogger.Instance), currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: Instance, logger: NullLogger.Instance); [Fact] public async Task Legs_RunInOrder_AndReportTheResponsePhase() { var probe = CreateTestProbe(); var ctx = CreateContext(probe.Ref); var call = ctx.WriteBatchResetAndWait( Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); // Leg 1: the reset write, and nothing else yet. var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("MoveOutCompleteFlag", reset.AttributeName); Assert.Equal("False", reset.Value); probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); // Leg 2: the reset-echo wait targets the RESET value and the RESET timeout. var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("MoveOutCompleteFlag", resetWait.AttributeName); Assert.Equal("False", resetWait.TargetValueEncoded); Assert.Equal(TimeSpan.FromSeconds(10), resetWait.Timeout); Assert.False(resetWait.RequireGoodQuality); probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false)); // Leg 3: only now the batch + trigger. var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("MoveOutFlag", batch.TriggerAttribute); Assert.Equal("True", batch.TriggerEncodedValue); Assert.Equal("28950", batch.AttributeEncodedValues["BatchID"]); probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); // Leg 4: the response wait with the RESPONSE timeout. var respWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("MoveOutCompleteFlag", respWait.AttributeName); Assert.Equal("True", respWait.TargetValueEncoded); Assert.Equal(TimeSpan.FromSeconds(25), respWait.Timeout); probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, true, "Good", TimedOut: false)); var result = await call; Assert.True(result.Matched); Assert.Equal(HandshakePhase.Response, result.Phase); Assert.False(result.TimedOut); Assert.False(result.ResetFailed); Assert.Equal(true, result.Value); Assert.Equal("Good", result.Quality); Assert.Null(result.ErrorMessage); } [Fact] public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch() { var probe = CreateTestProbe(); var ctx = CreateContext(probe.Ref); var call = ctx.WriteBatchResetAndWait( Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true)); var result = await call; Assert.False(result.Matched); Assert.True(result.TimedOut); Assert.Equal(HandshakePhase.Reset, result.Phase); Assert.True(result.ResetFailed); Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage); // The property that fixes #36: no batch, no trigger, after a reset that was never echoed. probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public async Task ResponseTimeout_ReportsResponsePhase() { var probe = CreateTestProbe(); var ctx = CreateContext(probe.Ref); var call = ctx.WriteBatchResetAndWait( Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false)); var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); var respWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: false, null, null, TimedOut: true)); var result = await call; Assert.False(result.Matched); Assert.True(result.TimedOut); Assert.Equal(HandshakePhase.Response, result.Phase); Assert.False(result.ResetFailed); } [Fact] public async Task ResetWriteFailure_Throws_LikeTheBatchLeg() { var probe = CreateTestProbe(); var ctx = CreateContext(probe.Ref); var call = ctx.WriteBatchResetAndWait( Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", false, "device rejected", DateTimeOffset.UtcNow)); var ex = await Assert.ThrowsAsync(() => call); Assert.Contains("device rejected", ex.Message); probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// The bool overload is untouched: batch first, then the wait, no reset leg. [Fact] public async Task LegacyOverload_StillWritesTheBatchFirst() { var probe = CreateTestProbe(); var ctx = CreateContext(probe.Ref); var call = ctx.WriteBatchAndWait(Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25)); var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); var wait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, true, "Good", TimedOut: false)); Assert.True(await call); } }