test(site-runtime): pin #36 — reset-aware handshake holds the batch until the reset echo lands
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #36 end-to-end over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
|
||||||
|
/// the cache still holds the previous run's <c>CompleteFlag = True</c>, 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 <c>True</c>.
|
||||||
|
/// </summary>
|
||||||
|
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<SiteStorageService>.Instance);
|
||||||
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||||
|
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.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<InstanceActor>.Instance, dcl.Ref)));
|
||||||
|
dcl.ExpectMsg<SubscribeTagsRequest>(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<string, string?> Values(int batchId)
|
||||||
|
=> new Dictionary<string, string?> { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) };
|
||||||
|
|
||||||
|
/// <summary>The defect, as documentation: the legacy overload answers from the stale cache.</summary>
|
||||||
|
[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<WriteTagBatchRequest>(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<WriteTagRequest>(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<WriteTagBatchRequest>(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<WriteTagRequest>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user