using Akka.Actor; using Akka.TestKit; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; 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.SiteRuntime.Streaming; using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// Flow Debugger design 2026-09-05 §5.3 — the Instance Actor trace arm: a TRACED write to a /// data-sourced attribute stamps the next DCL echo for that attribute with the writer's /// SourceExecutionId and Trace; the arm is consumed once and expires unconsumed. /// Untraced writes never arm (the production-unchanged pin). A traced static write publishes /// the flag directly. /// public class InstanceActorTraceArmTests : TestKit, IDisposable { private const string Tag = "ns=3;s=Recipe.Processed"; private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; public InstanceActorTraceArmTests() { _localDb = TestLocalDb.CreateTemp("instance-tracearm-test"); _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() { Shutdown(); var path = _localDb.Path; _localDb.Dispose(); TestLocalDb.DeleteFiles(path); } private static FlattenedConfiguration Config(string instance) => new() { InstanceUniqueName = instance, Attributes = [ new ResolvedAttribute { CanonicalName = "Processed", Value = "false", DataType = "Boolean", DataSourceReference = Tag, BoundDataConnectionName = "PLC" }, new ResolvedAttribute { CanonicalName = "Status", Value = "Idle", DataType = "String" }, ] }; private (IActorRef Actor, TestProbe Dcl, TestProbe Subscriber) Create(string instance, int armWindowSeconds = 30) { var options = new SiteRuntimeOptions { StreamBufferSize = 100, TraceArmWindowSeconds = armWindowSeconds }; var streamManager = new SiteStreamManager(options, NullLogger.Instance); streamManager.Initialize(Sys); var dcl = CreateTestProbe(); var actor = ActorOf(Props.Create(() => new InstanceActor( instance, JsonSerializer.Serialize(Config(instance)), _storage, _compilationService, _sharedScriptLibrary, streamManager, options, NullLogger.Instance, dcl.Ref))); var subscriber = CreateTestProbe(); streamManager.Subscribe(instance, subscriber.Ref); return (actor, dcl, subscriber); } private static WriteTagRequest ExpectWrite(TestProbe dcl) => dcl.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); private void Write(IActorRef actor, TestProbe dcl, string instance, Guid? source, bool trace) { actor.Tell(new SetStaticAttributeCommand("c", instance, "Processed", "true", DateTimeOffset.UtcNow, SourceExecutionId: source, Trace: trace)); var write = ExpectWrite(dcl); Assert.Equal(Tag, write.TagPath); dcl.Reply(new WriteTagResponse(write.CorrelationId, true, null, DateTimeOffset.UtcNow)); Assert.True(ExpectMsg(TimeSpan.FromSeconds(5)).Success); } /// Same as but the DCL rejects the write, so the arm must be dropped. private void FailingWrite(IActorRef actor, TestProbe dcl, string instance, Guid? source, bool trace) { actor.Tell(new SetStaticAttributeCommand("c", instance, "Processed", "true", DateTimeOffset.UtcNow, SourceExecutionId: source, Trace: trace)); var write = ExpectWrite(dcl); Assert.Equal(Tag, write.TagPath); dcl.Reply(new WriteTagResponse(write.CorrelationId, false, "rejected", DateTimeOffset.UtcNow)); Assert.False(ExpectMsg(TimeSpan.FromSeconds(5)).Success); } private static AttributeValueChanged Echo(IActorRef actor, TestProbe subscriber, object? value) { actor.Tell(new TagValueUpdate("PLC", Tag, value, QualityCode.Good, DateTimeOffset.UtcNow)); return subscriber.FishForMessage( m => m.AttributeName == "Processed" && Equals(m.Value, value), TimeSpan.FromSeconds(10)); } [Fact] public void TracedDataWrite_ThenEcho_PublishesChangeStampedWithSourceAndTrace() { var (actor, dcl, sub) = Create("P1"); var writer = Guid.NewGuid(); Write(actor, dcl, "P1", writer, trace: true); // The device clamps/echoes whatever it wants — the stamp does not depend on the value. var published = Echo(actor, sub, "clamped"); Assert.Equal(writer, published.SourceExecutionId); Assert.True(published.Trace); } [Fact] public void UntracedDataWrite_ThenEcho_PublishesUnstampedChange() { var (actor, dcl, sub) = Create("P2"); // Stamped writer but NOT traced — the ordinary script-write path. Write(actor, dcl, "P2", Guid.NewGuid(), trace: false); var published = Echo(actor, sub, true); Assert.Null(published.SourceExecutionId); Assert.False(published.Trace); } /// /// Arch-review F6: the arm is recorded when the write is ISSUED, not when the DCL acknowledges /// it. A write the device rejects must drop the arm, otherwise the next unrelated subscription /// update on that attribute is stamped with the debugger's execution id for the rest of the /// 30 s window — the Flow Debugger would draw a "device echo" of a write that never landed. /// [Fact] public void TracedDataWrite_ThatFails_DisarmsTheAttribute_SoTheNextEchoIsUnstamped() { var (actor, dcl, sub) = Create("P6"); FailingWrite(actor, dcl, "P6", Guid.NewGuid(), trace: true); var published = Echo(actor, sub, "unrelated"); Assert.Null(published.SourceExecutionId); Assert.False(published.Trace); } /// /// A failed write must only drop ITS OWN arm — a newer traced write that succeeded in the /// meantime owns the arm and keeps it (the disarm is keyed on the writer's execution id). /// [Fact] public void FailedWrite_DoesNotDisarm_AnArmTakenByALaterWriter() { var (actor, dcl, sub) = Create("P7"); var loser = Guid.NewGuid(); var winner = Guid.NewGuid(); // Issue the traced write that will fail, but hold its DCL reply back. The reply goes to the // Ask's temporary ref, so capture it now — a later Reply() would answer the second write. actor.Tell(new SetStaticAttributeCommand("c1", "P7", "Processed", "true", DateTimeOffset.UtcNow, SourceExecutionId: loser, Trace: true)); var first = ExpectWrite(dcl); var firstAsker = dcl.LastSender; // A second traced write lands and succeeds — it now owns the arm. Write(actor, dcl, "P7", winner, trace: true); // Only now does the first write fail; its disarm must not steal the winner's arm. firstAsker.Tell(new WriteTagResponse(first.CorrelationId, false, "rejected", DateTimeOffset.UtcNow), dcl.Ref); Assert.False(ExpectMsg(TimeSpan.FromSeconds(5)).Success); var published = Echo(actor, sub, "echo"); Assert.Equal(winner, published.SourceExecutionId); Assert.True(published.Trace); } [Fact] public void Arm_IsConsumedOnce() { var (actor, dcl, sub) = Create("P3"); var writer = Guid.NewGuid(); Write(actor, dcl, "P3", writer, trace: true); var first = Echo(actor, sub, "one"); var second = Echo(actor, sub, "two"); Assert.Equal(writer, first.SourceExecutionId); Assert.True(first.Trace); Assert.Null(second.SourceExecutionId); Assert.False(second.Trace); } [Fact] [Trait("Category", "Slow")] public void Arm_Expired_IsIgnored() { var (actor, dcl, sub) = Create("P4", armWindowSeconds: 1); Write(actor, dcl, "P4", Guid.NewGuid(), trace: true); Thread.Sleep(1100); var published = Echo(actor, sub, true); Assert.Null(published.SourceExecutionId); Assert.False(published.Trace); } [Fact] public void StaticWrite_Traced_PublishesTraceFlag() { var (actor, _, sub) = Create("P5"); var writer = Guid.NewGuid(); actor.Tell(new SetStaticAttributeCommand("c", "P5", "Status", "Running", DateTimeOffset.UtcNow, SourceExecutionId: writer, Trace: true)); Assert.True(ExpectMsg(TimeSpan.FromSeconds(5)).Success); var published = sub.FishForMessage( m => m.AttributeName == "Status" && Equals(m.Value, "Running"), TimeSpan.FromSeconds(10)); Assert.Equal(writer, published.SourceExecutionId); Assert.True(published.Trace); // And an untraced static write publishes Trace=false (pin). actor.Tell(new SetStaticAttributeCommand("c2", "P5", "Status", "Idle2", DateTimeOffset.UtcNow, SourceExecutionId: writer)); ExpectMsg(TimeSpan.FromSeconds(5)); var plain = sub.FishForMessage( m => m.AttributeName == "Status" && Equals(m.Value, "Idle2"), TimeSpan.FromSeconds(10)); Assert.False(plain.Trace); } }