diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index e8bd1b30..1c5c448d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -1206,7 +1206,18 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers { var values = new Dictionary(); for (var i = 0; i < names.Count; i++) - values[names[i]] = t.Result[i].Found ? t.Result[i].Value : null; + // Each attribute value crosses the Central↔Site PROCESS boundary inside + // this response. For a List-typed (static or coerced) attribute the value + // is a concrete generic List — a non-primitive shape Akka's cross-process + // serializer cannot reliably round-trip, which would silently drop the reply + // and hang the caller's Route.To().GetAttributes() Ask (same risk class as + // RouteInboundApiCall / RouteInboundApiWaitForAttribute). Project each value + // through the same normalizer so the wire carries a plain CLR graph + // (Dictionary/List/primitive). Keys are preserved; scalars/strings/null pass + // through unchanged. + values[names[i]] = t.Result[i].Found + ? NormalizeRoutedReturnValue(t.Result[i].Value) + : null; return new RouteToGetAttributesResponse( request.CorrelationId, values, true, null, DateTimeOffset.UtcNow); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index 318dcb1f..9e822a4b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -102,6 +102,31 @@ public class DeploymentManagerActorTests : TestKit, IDisposable return JsonSerializer.Serialize(config); } + /// + /// Builds a config carrying BOTH a static List attribute (whose JSON-array default + /// the Instance Actor decodes into a typed List<int>) and a static scalar + /// attribute. Used to drive a routed that + /// exercises #162: the List value must be normalized for cross-process transport, + /// while the scalar must pass through the normalizer unchanged. + /// + private static string MakeConfigWithListAndScalarAttributesJson(string instanceName) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = instanceName, + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Setpoints", Value = "[10,20,30]", + DataType = "List", ElementDataType = "Int32" + }, + new ResolvedAttribute { CanonicalName = "Mode", Value = "Auto", DataType = "String" } + ] + }; + return JsonSerializer.Serialize(config); + } + /// /// Builds a config carrying a single callable (no-trigger) script that /// returns a constant — enough for an inbound @@ -515,6 +540,82 @@ public class DeploymentManagerActorTests : TestKit, IDisposable list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture))); } + // ── #162: routed RouteToGetAttributesRequest → InstanceActor (List normalization) ── + + [Fact] + public async Task RouteInboundApiGetAttributes_ListValuedAttribute_ReturnsSerializerSafeValueAndScalarUnchanged() + { + // #162: a routed GetAttributes read of a List-typed static attribute comes back + // from the Instance Actor as a concrete generic List — the SAME non-primitive + // shape the cross-process (Newtonsoft) serializer cannot reliably round-trip that + // WS-4 fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and + // the caller's Route.To().GetAttributes() Ask hangs to timeout. The handler must + // project EACH value through NormalizeRoutedReturnValue so the List survives the + // wire — while leaving scalar/string/null values unchanged (no regression). + var actor = CreateDeploymentManager(); + await Task.Delay(500); // empty startup + + // Static List attribute "Setpoints" = [10,20,30] (decoded to List, Good) + // alongside a scalar string attribute "Mode" = "Auto". + actor.Tell(new DeployInstanceCommand( + "dep-getattr-list", "GetAttrPumpList", "sha256:getattr-list", + MakeConfigWithListAndScalarAttributesJson("GetAttrPumpList"), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(1000); // let the InstanceActor spin up + decode the List default + + // Request the List attribute, the scalar attribute, and a name that does not + // exist (must come back null — the Found:false path) in one round-trip. + actor.Tell(new RouteToGetAttributesRequest( + "getattr-corr-list", "GetAttrPumpList", + ["Setpoints", "Mode", "DoesNotExist"], DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("getattr-corr-list", response.CorrelationId); + Assert.True(response.Success, $"Routed GetAttributes failed: {response.ErrorMessage}"); + + // Keys preserved: all three requested names present. + Assert.True(response.Values.ContainsKey("Setpoints")); + Assert.True(response.Values.ContainsKey("Mode")); + Assert.True(response.Values.ContainsKey("DoesNotExist")); + + // The List value must have been projected to a serializer-safe graph: a plain + // List of primitives — NOT the Instance Actor's concrete List + // (which Akka's cross-process serializer drops). The JSON round-trip re-materializes + // the numbers as boxed primitives; assert numeric equality without pinning the exact + // CLR numeric type (long vs double is an artifact of the round-trip). + var listValue = Assert.IsType>(response.Values["Setpoints"]); + Assert.Equal( + new[] { 10.0, 20.0, 30.0 }, + listValue.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture))); + + // Scalar/string value passes through the normalizer UNCHANGED (no regression). + Assert.Equal("Auto", response.Values["Mode"]); + + // A not-found attribute remains null (the Found:false path is untouched by + // normalization — NormalizeRoutedReturnValue(null) is a no-op). + Assert.Null(response.Values["DoesNotExist"]); + } + + [Fact] + public async Task RouteInboundApiGetAttributes_UnknownInstance_RepliesNotFound() + { + // A routed GetAttributes for an instance never deployed to this site must come + // back Success:false with a not-found message (routing-level outcome), mirroring + // the other RouteTo* unknown-instance paths. + var actor = CreateDeploymentManager(); + await Task.Delay(500); + + actor.Tell(new RouteToGetAttributesRequest( + "getattr-corr-nf", "NeverDeployedGetAttr", ["Setpoints"], DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("getattr-corr-nf", response.CorrelationId); + Assert.False(response.Success); + Assert.Empty(response.Values); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase); + } + // ── M2.11: Debug-view routing — unknown-instance not-found signal ── [Fact]