Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs
T
Joseph Doherty 17d5d3e639 fix(scripts): reset-aware handshake refuses a null reset value; document additive timeouts (#36 review)
A null resetValue encoded to null, which wrote an empty string and then handed the
waiter a null target (= any change), fast-pathing on the stale cache and reinstating
the race the overload exists to close. The context parameter is now non-nullable and
the accessor throws ArgumentException before any write. Also: additive-timeout note on
all surfaces, batch-failure message no longer names the bool overload, and the
error-message/batch-failure branches are pinned.

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:35:50 -04:00

374 lines
15 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,
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);
}
// ── WaitForAsync (spec §3): scope resolution + populated WaitResult ───────
[Fact]
public async Task WaitForAsync_Value_AppliesScopeResolution_AndSurfacesPopulatedWaitResult()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
// Composed scope "TempSensor" — Resolve("Flag") => "TempSensor.Flag".
var acc = new AttributeAccessor(ctx, "TempSensor");
var task = acc.WaitForAsync("Flag", true, TimeSpan.FromSeconds(30));
// The actor receives the scope-resolved, codec-encoded request.
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("TempSensor.Flag", req.AttributeName);
Assert.Equal(AttributeValueCodec.Encode(true), req.TargetValueEncoded);
Assert.Null(req.Predicate);
Assert.False(req.RequireGoodQuality);
// Reply with a matched response — the accessor must surface the full WaitResult.
probe.Reply(new WaitForAttributeResponse(
req.CorrelationId, Matched: true, Value: true, Quality: "Good", TimedOut: false));
var result = await task;
Assert.True(result.Matched);
Assert.Equal(true, result.Value);
Assert.Equal("Good", result.Quality);
Assert.False(result.TimedOut);
}
[Fact]
public async Task WaitForAsync_Predicate_AppliesScopeResolution_AndSurfacesWaitResult()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var acc = new AttributeAccessor(ctx, "Motor.TempSensor");
Func<object?, bool> predicate = _ => true;
var task = acc.WaitForAsync("Level", predicate, TimeSpan.FromSeconds(30));
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("Motor.TempSensor.Level", req.AttributeName);
Assert.Null(req.TargetValueEncoded);
Assert.NotNull(req.Predicate);
probe.Reply(new WaitForAttributeResponse(
req.CorrelationId, Matched: true, Value: 42, Quality: "Good", TimedOut: false));
var result = await task;
Assert.True(result.Matched);
Assert.Equal(42, result.Value);
}
[Fact]
public async Task WaitForAsync_RequireGoodQuality_ThreadsFlagIntoRequest()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var acc = new AttributeAccessor(ctx, "");
var task = acc.WaitForAsync("Flag", true, TimeSpan.FromSeconds(30), requireGoodQuality: true);
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.True(req.RequireGoodQuality);
probe.Reply(new WaitForAttributeResponse(
req.CorrelationId, Matched: false, Value: null, Quality: null, TimedOut: true));
var result = await task;
Assert.False(result.Matched);
Assert.True(result.TimedOut);
Assert.Null(result.Value);
}
// ── Reset-aware WriteBatchAndWaitAsync overload (Gitea #36) ──────────────
/// <summary>
/// The reset-aware overload must scope-resolve and codec-encode the reset key/value
/// exactly like every other accessor, must default <c>resetTimeout</c> to
/// <c>timeout</c>, and — when the reset echo never arrives — must return a
/// <see cref="HandshakeResult.ResetFailed"/> result WITHOUT writing the batch or
/// the trigger (the ExpectNoMsg at the end is that assertion).
/// </summary>
[Fact]
public async Task WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
// Composed scope "RightMESReceiver" — every key is prefixed before it leaves the accessor.
var acc = new AttributeAccessor(ctx, "RightMESReceiver");
var call = acc.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", false);
// Leg 1 — the reset write.
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", reset.AttributeName);
Assert.Equal(AttributeValueCodec.Encode(false), reset.Value);
probe.Reply(new SetStaticAttributeResponse(
reset.CorrelationId, "Pump1", reset.AttributeName, true, null, DateTimeOffset.UtcNow));
// Leg 2 — the reset echo wait, bounded by the defaulted resetTimeout.
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", resetWait.AttributeName);
Assert.Equal(AttributeValueCodec.Encode(false), resetWait.TargetValueEncoded);
Assert.Equal(TimeSpan.FromSeconds(25), resetWait.Timeout); // defaulted to `timeout`
probe.Reply(new WaitForAttributeResponse(
resetWait.CorrelationId, Matched: false, Value: null, Quality: null, TimedOut: true));
var result = await call;
Assert.True(result.ResetFailed);
Assert.False(result.Matched);
Assert.True(result.TimedOut);
// Nothing else was written: no batch, no trigger.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Gitea #36 review: a null <c>resetValue</c> would encode to null, which writes an empty
/// string on leg 1 and then hands the waiter a null target — "any change" — which fast-paths
/// on the stale cached value and reinstates exactly the race this overload exists to close.
/// The accessor rejects it before anything reaches the InstanceActor.
/// </summary>
[Fact]
public void WriteBatchAndWaitAsync_ResetAware_NullResetValue_ThrowsBeforeAnyWrite()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var acc = new AttributeAccessor(ctx, "RightMESReceiver");
// Block body (not an expression lambda) so this binds the Action overload: the throw is
// SYNCHRONOUS — a faulted task would leave nothing for Assert.Throws to catch.
var ex = Assert.Throws<ArgumentException>(() =>
{
_ = acc.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", null);
});
Assert.Equal("resetValue", ex.ParamName);
// Thrown before leg 1: the reset write never left the accessor.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}