From 7f97780bb30b573ea74750ec41dbeddb791708bb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:52:29 -0400 Subject: [PATCH] feat(siteruntime): decode static List attributes to typed lists in InstanceActor (load/override/set) --- .../Actors/InstanceActor.cs | 91 ++++++++- .../Actors/InstanceActorTests.cs | 174 ++++++++++++++++++ 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index d6424d98..62cdafd0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -124,13 +124,29 @@ public class InstanceActor : ReceiveActor { foreach (var attr in _configuration.Attributes) { - _attributes[attr.CanonicalName] = attr.Value; - _attributeQualities[attr.CanonicalName] = - string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; - // MV-8: index resolved attributes for O(1) lookup on the hot // TagValueUpdate ingest path (last-wins on duplicate names). _resolvedAttributeByName[attr.CanonicalName] = attr; + + // MV-7: a STATIC List attribute's default is the canonical JSON + // array string. Decode it to a typed List for in-memory reads + // so scripts see a real collection. Scalars store their raw + // string unchanged. A malformed List default decodes to null and + // is marked Bad quality rather than crashing the actor. + if (IsListAttribute(attr)) + { + var decoded = DecodeAttributeValue(attr, attr.Value); + _attributes[attr.CanonicalName] = decoded; + _attributeQualities[attr.CanonicalName] = + decoded is null && !string.IsNullOrEmpty(attr.Value) ? "Bad" + : string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; + } + else + { + _attributes[attr.CanonicalName] = attr.Value; + _attributeQualities[attr.CanonicalName] = + string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; + } } } @@ -319,7 +335,21 @@ public class InstanceActor : ReceiveActor /// private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command) { - _attributes[command.AttributeName] = command.Value; + // MV-7: command.Value is the canonical form — a plain string for scalars, + // a JSON array string for List attributes. For a List attribute we store + // the DECODED typed list in memory (so scripts read a real collection) but + // persist + publish the canonical JSON string UNCHANGED below. Scalars + // store the string verbatim. (HandleSetStaticAttribute already rejected + // unknown attributes, so resolved is non-null here, but guard defensively.) + if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved) + && IsListAttribute(resolved)) + { + _attributes[command.AttributeName] = DecodeAttributeValue(resolved, command.Value); + } + else + { + _attributes[command.AttributeName] = command.Value; + } // Publish attribute change to stream (WP-23) and notify children var changed = new AttributeValueChanged( @@ -499,6 +529,40 @@ public class InstanceActor : ReceiveActor Enum.TryParse(attr.DataType, ignoreCase: true, out var dt) && dt == DataType.List; + /// + /// MV-7: decodes a STATIC (authored / overridden) attribute's canonical value + /// for in-memory storage. List attributes carry a canonical JSON array string + /// (config default or persisted override) which is decoded via + /// into a typed List<T> + /// so scripts read a real collection; scalars pass through unchanged. This is + /// the authored counterpart to MV-8's (which + /// coerces live OPC UA CLR arrays). An undecodable List value (malformed JSON, + /// bad element, missing element type) degrades to + a + /// warning — the caller marks the attribute Bad quality. NEVER throws into the + /// actor. + /// + private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw) + { + DataType dataType = Enum.TryParse(attr.DataType, ignoreCase: true, out var dt) + ? dt + : DataType.String; + DataType? elementType = string.IsNullOrEmpty(attr.ElementDataType) + ? null + : (Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var et) ? et : null); + + try + { + return AttributeValueCodec.Decode(raw, dataType, elementType); + } + catch (FormatException ex) + { + _logger.LogWarning(ex, + "Attribute '{Attr}' on '{Instance}' has an undecodable List value; marking Bad quality", + attr.CanonicalName, _instanceUniqueName); + return null; // caller marks quality Bad + } + } + /// /// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable) /// into a typed List<elementClrType> matching the attribute's @@ -825,7 +889,22 @@ public class InstanceActor : ReceiveActor foreach (var kvp in result.Overrides) { - _attributes[kvp.Key] = kvp.Value; + // MV-7: persisted override values are canonical strings — a JSON array + // string for List attributes, a plain string for scalars. Decode List + // overrides to a typed list (matching the config-default load), set + // Bad quality on a malformed stored value, and never crash the actor. + if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved) + && IsListAttribute(resolved)) + { + var decoded = DecodeAttributeValue(resolved, kvp.Value); + _attributes[kvp.Key] = decoded; + if (decoded is null && !string.IsNullOrEmpty(kvp.Value)) + _attributeQualities[kvp.Key] = "Bad"; + } + else + { + _attributes[kvp.Key] = kvp.Value; + } } _logger.LogDebug( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index ae7dcfa2..a92ac484 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -666,4 +666,178 @@ public class InstanceActorTests : TestKit, IDisposable // (Liveness is also proven by the preceding successful GetAttributeResponse.) ExpectNoMsg(within); } + + // ── MV-7: static (authored) List attribute decode ────────────────────── + + /// + /// MV-7: a STATIC List attribute carries its default as the canonical JSON + /// array string. On load the actor must decode it to a typed list so a + /// script reading the attribute receives a real collection, not the raw + /// JSON string. + /// + [Fact] + public void InstanceActor_StaticListAttribute_LoadsAsTypedList() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-StaticList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-StaticList", config); + + actor.Tell(new GetAttributeRequest("corr-sl", "Pump-StaticList", "Labels", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { "a", "b" }, list); + } + + /// + /// MV-7: a SetStaticAttribute write on a List attribute decodes the canonical + /// JSON value into a typed list for in-memory reads, but the PERSISTED form + /// (SQLite static override) must remain the canonical JSON string — never a + /// CLR-list .ToString(). + /// + [Fact] + public async Task InstanceActor_SetStaticListAttribute_ReadsTypedList_PersistsJsonString() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-SetList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-SetList", config); + + actor.Tell(new SetStaticAttributeCommand( + "corr-set-list", "Pump-SetList", "Labels", "[\"x\",\"y\"]", DateTimeOffset.UtcNow)); + var setResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(setResponse.Success); + + // In-memory read returns a typed list. + actor.Tell(new GetAttributeRequest("corr-get-list", "Pump-SetList", "Labels", DateTimeOffset.UtcNow)); + var getResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(getResponse.Found); + var list = Assert.IsType>(getResponse.Value); + Assert.Equal(new[] { "x", "y" }, list); + + // The persisted form is the canonical JSON string, NOT a CLR-list .ToString(). + await Task.Delay(500); + var overrides = await _storage.GetStaticOverridesAsync("Pump-SetList"); + Assert.Single(overrides); + Assert.Equal("[\"x\",\"y\"]", overrides["Labels"]); + } + + /// + /// MV-7: a persisted static override for a List attribute is a canonical JSON + /// string in SQLite; on load it must be decoded to a typed list, the same as + /// the config default. + /// + [Fact] + public async Task InstanceActor_StaticListOverride_LoadsAsTypedList() + { + await _storage.SetStaticOverrideAsync("Pump-OverrideList", "Labels", "[\"p\",\"q\"]"); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-OverrideList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-OverrideList", config); + + // Wait for the async override load (PipeTo) to apply. + await Task.Delay(1000); + + actor.Tell(new GetAttributeRequest("corr-ol", "Pump-OverrideList", "Labels", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { "p", "q" }, list); + } + + /// + /// MV-7: a malformed stored List value must NOT crash the actor — it loads + /// with quality Bad and the actor stays alive and answering. + /// + [Fact] + public void InstanceActor_StaticListAttribute_Malformed_LoadsBadQuality_ActorAlive() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-BadList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\"", // truncated JSON + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-BadList", config); + Watch(actor); + + actor.Tell(new GetAttributeRequest("corr-bl", "Pump-BadList", "Labels", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Bad", response.Quality); + Assert.Null(response.Value); + + // The actor must still be alive (no crash / restart during construction). + ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500)); + } + + /// + /// MV-7 guard: a scalar static attribute is unaffected by the List decode + /// path — it still returns its raw string value. + /// + [Fact] + public void InstanceActor_StaticScalarAttribute_UnaffectedByListDecode() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-StaticScalar", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" } + ] + }; + + var actor = CreateInstanceActor("Pump-StaticScalar", config); + + actor.Tell(new GetAttributeRequest("corr-ss", "Pump-StaticScalar", "Label", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + Assert.Equal("Main Pump", response.Value); + } }