75ffa09b8f
Adds InstanceActor one-shot waiter registry (fast-path + change-match + scheduled timeout self-eviction), threads per-script timeout token through ScriptRuntimeContext, and exposes Attributes.WaitAsync(value|predicate, timeout). Replaces handshake busy-poll. Implements spec docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md §3-§5; §6 routed variant + WaitForAsync + quality-only mode deferred.
223 lines
8.1 KiB
C#
223 lines
8.1 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
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.Commons.Types.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// Phase 1 of the script-scope rollout: verify path arithmetic for the new
|
|
/// Attributes / Children / Parent accessors. The actor-mediated reads/writes
|
|
/// are exercised end-to-end in Phase 2 once flattening carries scope info.
|
|
/// </summary>
|
|
public class ScopeAccessorTests
|
|
{
|
|
[Fact]
|
|
public void Root_SelfPath_Empty()
|
|
{
|
|
Assert.Equal("", ScriptScope.Root.SelfPath);
|
|
Assert.Null(ScriptScope.Root.ParentPath);
|
|
Assert.False(ScriptScope.Root.HasParent);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompositionScope_HasParent()
|
|
{
|
|
var scope = new ScriptScope("TempSensor", "");
|
|
Assert.True(scope.HasParent);
|
|
Assert.Equal("", scope.ParentPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeAccessor_RootScope_ResolvesBareKey()
|
|
{
|
|
var acc = new AttributeAccessor(null!, "");
|
|
Assert.Equal("Temperature", acc.Resolve("Temperature"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeAccessor_ComposedScope_PrependsPath()
|
|
{
|
|
var acc = new AttributeAccessor(null!, "TempSensor");
|
|
Assert.Equal("TempSensor.Temperature", acc.Resolve("Temperature"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeAccessor_NestedScope_ChainsPath()
|
|
{
|
|
var acc = new AttributeAccessor(null!, "Motor.TempSensor");
|
|
Assert.Equal("Motor.TempSensor.Temperature", acc.Resolve("Temperature"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CompositionAccessor_AttributesShareScope()
|
|
{
|
|
var comp = new CompositionAccessor(null!, "TempSensor");
|
|
Assert.Equal("TempSensor", comp.Path);
|
|
Assert.Equal("TempSensor", comp.Attributes.ScopePrefix);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompositionAccessor_ResolveScript_PrependsPath()
|
|
{
|
|
var comp = new CompositionAccessor(null!, "TempSensor");
|
|
Assert.Equal("TempSensor.Sample", comp.ResolveScript("Sample"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CompositionAccessor_EmptyPath_LeavesScriptNameBare()
|
|
{
|
|
var comp = new CompositionAccessor(null!, "");
|
|
Assert.Equal("Sample", comp.ResolveScript("Sample"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ChildrenAccessor_FromRoot_GivesUnpathedChild()
|
|
{
|
|
var children = new ChildrenAccessor(null!, "");
|
|
var temp = children["TempSensor"];
|
|
Assert.Equal("TempSensor", temp.Path);
|
|
}
|
|
|
|
[Fact]
|
|
public void ChildrenAccessor_FromComposition_PrefixesChild()
|
|
{
|
|
var children = new ChildrenAccessor(null!, "Motor");
|
|
var temp = children["TempSensor"];
|
|
Assert.Equal("Motor.TempSensor", temp.Path);
|
|
}
|
|
|
|
// --- AttributeAccessor encoding contract ----------------------------------
|
|
//
|
|
// AttributeAccessor.this[key].set and SetAsync both route through
|
|
// ScriptRuntimeContext.SetAttribute(name, encodedString), which requires
|
|
// a live Akka IActorRef; ScriptRuntimeContext has no virtual members and
|
|
// its constructor cannot be satisfied without a real ActorSystem, so a
|
|
// full-round-trip unit test through the accessor+context is not viable
|
|
// without a heavy Akka harness.
|
|
//
|
|
// Instead we test the encoding decision directly: AttributeAccessor is now
|
|
// documented to delegate value serialisation to AttributeValueCodec.Encode.
|
|
// These tests verify that contract at the codec level, which is exactly what
|
|
// the fix makes the accessor invoke.
|
|
|
|
[Fact]
|
|
public void AttributeValueCodec_Encode_List_ProducesJsonArray()
|
|
{
|
|
// A List<string> must encode to a JSON array, not the garbage
|
|
// "System.Collections.Generic.List`1[System.String]" that .ToString() produced.
|
|
var list = new List<string> { "a", "b" };
|
|
var encoded = AttributeValueCodec.Encode(list);
|
|
Assert.Equal("[\"a\",\"b\"]", encoded);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeValueCodec_Encode_Scalar_PassesThrough()
|
|
{
|
|
// A plain string scalar must be returned unchanged (byte-identical to
|
|
// the historical value?.ToString() path for strings).
|
|
var encoded = AttributeValueCodec.Encode("x");
|
|
Assert.Equal("x", encoded);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeValueCodec_Encode_Null_ReturnsNull()
|
|
{
|
|
// AttributeAccessor coalesces null → "" at the call site,
|
|
// but the codec itself must return null for null input.
|
|
Assert.Null(AttributeValueCodec.Encode(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeValueCodec_Encode_IntList_ProducesJsonArray()
|
|
{
|
|
// Integer list elements encode as native-typed JSON numbers (NJ-1):
|
|
// [1,2,3], not the old quoted-element form ["1","2","3"].
|
|
var list = new List<int> { 1, 2, 3 };
|
|
var encoded = AttributeValueCodec.Encode(list);
|
|
Assert.Equal("[1,2,3]", encoded);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// WaitAsync (spec §3-§5, acceptance §7.6) scope-resolution tests. Unlike the
|
|
/// path-arithmetic tests above, these route a real <see cref="ScriptRuntimeContext"/>
|
|
/// against a TestProbe standing in for the Instance Actor, so they need a live
|
|
/// ActorSystem — hence a TestKit-derived class. They assert that
|
|
/// <c>Attributes.WaitAsync</c> applies <see cref="AttributeAccessor.Resolve"/>
|
|
/// (the composition prefix) to the key BEFORE the request is sent to the actor —
|
|
/// the same contract Get/Set obey.
|
|
/// </summary>
|
|
public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
|
{
|
|
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
|
|
new(
|
|
instanceActor,
|
|
instanceActor,
|
|
sharedScriptLibrary: null!,
|
|
currentCallDepth: 0,
|
|
maxCallDepth: 10,
|
|
askTimeout: TimeSpan.FromSeconds(2),
|
|
instanceName: "Pump1",
|
|
logger: NullLogger<ScriptRuntimeContext>.Instance);
|
|
|
|
void IDisposable.Dispose() => Shutdown();
|
|
|
|
[Fact]
|
|
public void WaitAsync_Value_AppliesScopeResolution_BeforeSendingRequest()
|
|
{
|
|
var probe = CreateTestProbe();
|
|
var ctx = MakeContext(probe.Ref);
|
|
|
|
// Composed scope "TempSensor" — Resolve("Flag") => "TempSensor.Flag".
|
|
var acc = new AttributeAccessor(ctx, "TempSensor");
|
|
|
|
// Fire-and-forget; the assertion is on the message the actor receives.
|
|
_ = acc.WaitAsync("Flag", true, TimeSpan.FromSeconds(30));
|
|
|
|
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("TempSensor.Flag", req.AttributeName);
|
|
// The value overload encodes the target via AttributeValueCodec.Encode and
|
|
// sends a null predicate. bool true encodes to "True" (capital T).
|
|
Assert.Equal(AttributeValueCodec.Encode(true), req.TargetValueEncoded);
|
|
Assert.Equal("True", req.TargetValueEncoded);
|
|
Assert.Null(req.Predicate);
|
|
Assert.Equal("Pump1", req.InstanceName);
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitAsync_Predicate_AppliesScopeResolution_AndSendsPredicate()
|
|
{
|
|
var probe = CreateTestProbe();
|
|
var ctx = MakeContext(probe.Ref);
|
|
|
|
var acc = new AttributeAccessor(ctx, "Motor.TempSensor");
|
|
|
|
Func<object?, bool> predicate = _ => true;
|
|
_ = acc.WaitAsync("Level", predicate, TimeSpan.FromSeconds(30));
|
|
|
|
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("Motor.TempSensor.Level", req.AttributeName);
|
|
// The predicate overload sends the delegate and a null encoded target.
|
|
Assert.Null(req.TargetValueEncoded);
|
|
Assert.NotNull(req.Predicate);
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitAsync_RootScope_LeavesKeyBare()
|
|
{
|
|
var probe = CreateTestProbe();
|
|
var ctx = MakeContext(probe.Ref);
|
|
|
|
var acc = new AttributeAccessor(ctx, "");
|
|
_ = acc.WaitAsync("Flag", true, TimeSpan.FromSeconds(30));
|
|
|
|
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("Flag", req.AttributeName);
|
|
}
|
|
}
|