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; /// /// 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. /// 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 must encode to a JSON array, not the garbage // "System.Collections.Generic.List`1[System.String]" that .ToString() produced. var list = new List { "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 { 1, 2, 3 }; var encoded = AttributeValueCodec.Encode(list); Assert.Equal("[1,2,3]", encoded); } } /// /// WaitAsync (spec §3-§5, acceptance §7.6) scope-resolution tests. Unlike the /// path-arithmetic tests above, these route a real /// against a TestProbe standing in for the Instance Actor, so they need a live /// ActorSystem — hence a TestKit-derived class. They assert that /// Attributes.WaitAsync applies /// (the composition prefix) to the key BEFORE the request is sent to the actor — /// the same contract Get/Set obey. /// 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.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(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 predicate = _ => true; _ = acc.WaitAsync("Level", predicate, TimeSpan.FromSeconds(30)); var req = probe.ExpectMsg(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(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(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 predicate = _ => true; var task = acc.WaitForAsync("Level", predicate, TimeSpan.FromSeconds(30)); var req = probe.ExpectMsg(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(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); } }