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.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// Tests for the event-driven WaitForAttribute one-shot waiter registry in /// (Attributes.WaitAsync spec §3-§5). Covers the /// fast-path, change-match, timeout, no-leak (timeout-canceled-on-match), and /// predicate-overload acceptance criteria. /// public class InstanceActorWaitForAttributeTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; private readonly string _dbFile; public InstanceActorWaitForAttributeTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"instance-waitfor-test-{Guid.NewGuid():N}.db"); _storage = new SiteStorageService( $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); _options = new SiteRuntimeOptions(); } private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) { return ActorOf(Props.Create(() => new InstanceActor( instanceName, JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, // no stream manager in tests _options, NullLogger.Instance))); } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } // ── 1. Fast-path: attribute already at target ──────────────────────────── /// /// Acceptance §7.1: when the attribute already equals the target at the time /// the waiter registers, the actor must reply immediately with Matched=true /// (carrying the current value), without scheduling a timeout. /// [Fact] public void WaitForAttribute_FastPath_AlreadyAtTarget_RepliesMatchedImmediately() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Flag", Value = "true", DataType = "Boolean" } ] }; var actor = CreateInstanceActor("Pump1", config); actor.Tell(new WaitForAttributeRequest( "wfa-fast", "Pump1", "Flag", "true", null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Matched); Assert.False(response.TimedOut); Assert.Equal("wfa-fast", response.CorrelationId); Assert.Equal("true", response.Value?.ToString()); } // ── 2. Change-match: register first, then drive a value change ─────────── /// /// Acceptance §7.1/§7.4: registering when the value does NOT match, then /// driving the attribute to the target value (via a DCL TagValueUpdate) must /// produce a single Matched=true reply carrying the new value. /// [Fact] public void WaitForAttribute_ChangeMatch_RepliesMatchedWithNewValue() { const string tag = "ns=3;s=Recipe.Processed"; var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Processed", Value = "false", DataType = "Boolean", DataSourceReference = tag, BoundDataConnectionName = "PLC" } ] }; var dcl = CreateTestProbe(); var actor = ActorOf(Props.Create(() => new InstanceActor( "Pump1", JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, _options, NullLogger.Instance, dcl.Ref))); dcl.ExpectMsg(TimeSpan.FromSeconds(5)); // Register: current value "false" does not match the target. The value // arrives from the DCL as a boolean true, whose codec-encoded form is // "True" — so the target must be encoded the same way the accessor would // (AttributeValueCodec.Encode(true)), NOT the literal string "true". var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode(true); actor.Tell(new WaitForAttributeRequest( "wfa-change", "Pump1", "Processed", target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow)); // No reply yet — the value has not changed to the target. ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // Drive the value to the target through the DCL ingest path. actor.Tell(new TagValueUpdate("PLC", tag, true, QualityCode.Good, DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Matched); Assert.False(response.TimedOut); Assert.Equal("wfa-change", response.CorrelationId); Assert.Equal(true, response.Value); Assert.Equal("Good", response.Quality); } // ── 3. Timeout: value never matches ────────────────────────────────────── /// /// Acceptance §7.2: when the attribute never reaches the target within the /// timeout, the actor replies Matched=false, TimedOut=true (no throw). /// [Fact] public void WaitForAttribute_Timeout_RepliesNotMatchedTimedOut() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Flag", Value = "false", DataType = "Boolean" } ] }; var actor = CreateInstanceActor("Pump1", config); actor.Tell(new WaitForAttributeRequest( "wfa-timeout", "Pump1", "Flag", "true", null, TimeSpan.FromMilliseconds(300), DateTimeOffset.UtcNow)); // The scheduled timeout fires; allow a tolerant deadline. var response = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.False(response.Matched); Assert.True(response.TimedOut); Assert.Equal("wfa-timeout", response.CorrelationId); } // ── 4. No-leak: timeout canceled on match (no second reply) ────────────── /// /// Acceptance §7.5: after a successful change-match, the scheduled timeout /// must have been canceled and the waiter removed — so NO second (timeout) /// response arrives after the match. /// [Fact] public void WaitForAttribute_Match_CancelsTimeout_NoSecondReply() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Flag", Value = "false", DataType = "Boolean" } ] }; var actor = CreateInstanceActor("Pump1", config); // Register with a short timeout, then match BEFORE it would fire. actor.Tell(new WaitForAttributeRequest( "wfa-noleak", "Pump1", "Flag", "true", null, TimeSpan.FromMilliseconds(500), DateTimeOffset.UtcNow)); // Drive the static value to the target; the actor publishes via // HandleAttributeValueChanged, satisfying the waiter. actor.Tell(new SetStaticAttributeCommand( "set-flag", "Pump1", "Flag", "true", DateTimeOffset.UtcNow)); // First reply: the match. (A SetStaticAttributeResponse also arrives for // the set command — filter for the WaitForAttributeResponse.) var matched = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(matched.Matched); Assert.False(matched.TimedOut); // The set command's own ack — drain it so the no-msg assert below is clean. ExpectMsg(TimeSpan.FromSeconds(5)); // No second WaitForAttributeResponse (the timeout was canceled) for longer // than the original 500ms timeout window. ExpectNoMsg(TimeSpan.FromSeconds(1)); } // ── 5. Predicate overload ──────────────────────────────────────────────── /// /// Acceptance §7 (predicate form): registering with a site-local predicate and /// then flipping the value so the predicate passes must produce Matched=true. /// [Fact] public void WaitForAttribute_PredicateOverload_MatchesOnPredicatePass() { const string tag = "ns=3;s=Level"; var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Level", Value = "0", DataType = "Int32", DataSourceReference = tag, BoundDataConnectionName = "PLC" } ] }; var dcl = CreateTestProbe(); var actor = ActorOf(Props.Create(() => new InstanceActor( "Pump1", JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, _options, NullLogger.Instance, dcl.Ref))); dcl.ExpectMsg(TimeSpan.FromSeconds(5)); // Predicate: value > 50 (current is 0, so no immediate match). Func predicate = v => v is not null && int.TryParse(v.ToString(), out var n) && n > 50; actor.Tell(new WaitForAttributeRequest( "wfa-pred", "Pump1", "Level", null, predicate, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow)); ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // A value below the threshold must NOT satisfy the predicate. actor.Tell(new TagValueUpdate("PLC", tag, 25, QualityCode.Good, DateTimeOffset.UtcNow)); ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // A value above the threshold satisfies it. actor.Tell(new TagValueUpdate("PLC", tag, 75, QualityCode.Good, DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Matched); Assert.False(response.TimedOut); Assert.Equal(75, response.Value); } // ── 6. "any change" (null target + null predicate) ─────────────────────── /// /// Spec §4.1: a null TargetValueEncoded + null Predicate means "wait for any /// change" — the next value update on that attribute matches. /// [Fact] public void WaitForAttribute_AnyChange_MatchesOnNextUpdate() { const string tag = "ns=3;s=Speed"; var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Speed", Value = "0", DataType = "Int32", DataSourceReference = tag, BoundDataConnectionName = "PLC" } ] }; var dcl = CreateTestProbe(); var actor = ActorOf(Props.Create(() => new InstanceActor( "Pump1", JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, _options, NullLogger.Instance, dcl.Ref))); dcl.ExpectMsg(TimeSpan.FromSeconds(5)); // "any change" registers with a non-trivial timeout. The fast-path uses // `_ => true`, so a currently-present attribute matches immediately. actor.Tell(new WaitForAttributeRequest( "wfa-any", "Pump1", "Speed", null, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow)); // Speed=0 is already present, so the "any change" test (_ => true) matches // immediately on the fast path. var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Matched); Assert.False(response.TimedOut); } }