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