From 5841cec9587b7b7a7ff9cd8969e876224bf06bcc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 17:49:21 -0400 Subject: [PATCH] feat(siteruntime): normalize old-form List static overrides to native JSON on load --- .../Actors/InstanceActor.cs | 21 +++ .../Actors/InstanceActorTests.cs | 155 ++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index c8bd3afa..64c65d3c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -948,10 +948,31 @@ public class InstanceActor : ReceiveActor if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved) && IsListAttribute(resolved)) { + // NJ-4: decode the stored List override (both old array-of-strings + // and native-typed forms decode) and re-persist the native form if + // the stored value is still in the OLD form. Re-encoding the decoded + // list and comparing to the stored string detects old-form values + // (native → native is byte-identical, so a native value is a no-op). + // The re-persist is fire-and-forget and never throws into the actor. var decoded = DecodeAttributeValue(resolved, kvp.Value); _attributes[kvp.Key] = decoded; if (decoded is null && !string.IsNullOrEmpty(kvp.Value)) + { _attributeQualities[kvp.Key] = "Bad"; + } + else if (decoded is not null) + { + var native = AttributeValueCodec.Encode(decoded); + if (native != kvp.Value) // stored value was old-form → normalize on disk + { + var key = kvp.Key; + _storage.SetStaticOverrideAsync(_instanceUniqueName, key, native!) + .ContinueWith(t => _logger.LogWarning(t.Exception?.GetBaseException(), + "Failed to normalize static override {Instance}.{Attr} to native JSON", + _instanceUniqueName, key), + TaskContinuationOptions.OnlyOnFaulted); + } + } } else { 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 f93a238e..72dbb49c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -1016,4 +1016,159 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Single(overrides); Assert.Equal("[]", overrides["Labels"]); } + + // ── NJ-4: old-form List static override normalization on load ──────────── + + /// + /// NJ-4: an OLD array-of-strings static override (["10","20"]) for an + /// Int32 List attribute must be re-persisted in the native form ([10,20]) + /// when the actor loads it at startup. The in-memory read still returns the + /// typed list {10,20}; the on-disk value is normalized to native JSON. + /// + [Fact] + public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad() + { + await _storage.SetStaticOverrideAsync("Pump-OldForm", "Counts", "[\"10\",\"20\"]"); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-OldForm", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Counts", Value = "[1,2]", + DataType = "List", ElementDataType = "Int32" + } + ] + }; + + var actor = CreateInstanceActor("Pump-OldForm", config); + + // Wait for the async override load (PipeTo) + fire-and-forget normalization. + await Task.Delay(1000); + + // In-memory read returns the typed list, decoded from the old form. + actor.Tell(new GetAttributeRequest("corr-of", "Pump-OldForm", "Counts", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(response.Found); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { 10, 20 }, list); + + // The on-disk override has been normalized to the native form. + var overrides = await _storage.GetStaticOverridesAsync("Pump-OldForm"); + Assert.Single(overrides); + Assert.Equal("[10,20]", overrides["Counts"]); + } + + /// + /// NJ-4: a NATIVE-form static override ([10,20]) is already canonical, so + /// load-time normalization must be a no-op — the on-disk value is unchanged + /// (idempotent: native → native is byte-identical, so no re-persist occurs). + /// + [Fact] + public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad() + { + await _storage.SetStaticOverrideAsync("Pump-Native", "Counts", "[10,20]"); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-Native", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Counts", Value = "[1,2]", + DataType = "List", ElementDataType = "Int32" + } + ] + }; + + var actor = CreateInstanceActor("Pump-Native", config); + await Task.Delay(1000); + + actor.Tell(new GetAttributeRequest("corr-nat", "Pump-Native", "Counts", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(response.Found); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { 10, 20 }, list); + + // The native value is left untouched on disk. + var overrides = await _storage.GetStaticOverridesAsync("Pump-Native"); + Assert.Single(overrides); + Assert.Equal("[10,20]", overrides["Counts"]); + } + + /// + /// NJ-4: a scalar static override is unaffected by the List normalization path — + /// its on-disk value is left exactly as stored (no native re-encode). + /// + [Fact] + public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization() + { + await _storage.SetStaticOverrideAsync("Pump-ScalarOf", "Temperature", "200.0"); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-ScalarOf", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Temperature", Value = "100.0", DataType = "Double" } + ] + }; + + var actor = CreateInstanceActor("Pump-ScalarOf", config); + await Task.Delay(1000); + + actor.Tell(new GetAttributeRequest("corr-sof", "Pump-ScalarOf", "Temperature", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(response.Found); + Assert.Equal("200.0", response.Value); + + var overrides = await _storage.GetStaticOverridesAsync("Pump-ScalarOf"); + Assert.Single(overrides); + Assert.Equal("200.0", overrides["Temperature"]); + } + + /// + /// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the + /// actor and must NOT be re-persisted — it loads with Bad quality (as today), + /// the actor stays alive, and the poison on-disk value is left unchanged. + /// + [Fact] + public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted() + { + await _storage.SetStaticOverrideAsync("Pump-BadOf", "Counts", "[\"a\""); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-BadOf", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Counts", Value = "[1,2]", + DataType = "List", ElementDataType = "Int32" + } + ] + }; + + var actor = CreateInstanceActor("Pump-BadOf", config); + Watch(actor); + await Task.Delay(1000); + + actor.Tell(new GetAttributeRequest("corr-bof", "Pump-BadOf", "Counts", 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 from the normalization path. + ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500)); + + // The malformed value must NOT have been re-persisted (left exactly as stored). + var overrides = await _storage.GetStaticOverridesAsync("Pump-BadOf"); + Assert.Single(overrides); + Assert.Equal("[\"a\"", overrides["Counts"]); + } }