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), // 30s: the legs Ask the InstanceActor with their own bounds (the batch's internal 35s, // the waits' own timeouts); a short ambient Ask timeout would race those and make the // reset/response phases indistinguishable from an AskTimeoutException. currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(30), 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); // The message names the attribute that did not answer and the bound it blew. Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage); Assert.Contains("25", result.ErrorMessage); } /// /// A reset wait that fails WITHOUT timing out (a predicate blew up in the waiter) surfaces the /// actor's own message verbatim rather than a synthesized one, and still writes nothing further. /// [Fact] public async Task ResetWaitError_PassesTheActorsMessageThrough() { 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: false, ErrorMessage: "predicate threw")); var result = await call; Assert.False(result.Matched); Assert.False(result.TimedOut); Assert.True(result.ResetFailed); Assert.Equal("predicate threw", result.ErrorMessage); probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// /// The batch leg keeps throwing after a successful reset โ€” a device-rejected batch is a hard /// failure, not a the script could mistake for a timeout. /// [Fact] public async Task BatchFailureAfterASuccessfulReset_StillThrows() { 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, false, "gateway rejected")); var ex = await Assert.ThrowsAsync(() => call); Assert.Contains("gateway rejected", ex.Message); Assert.Contains("Batch write failed", ex.Message); } [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); } }