feat(siteruntime): normalize old-form List static overrides to native JSON on load

This commit is contained in:
Joseph Doherty
2026-06-16 17:49:21 -04:00
parent bf80ca1388
commit 5841cec958
2 changed files with 176 additions and 0 deletions
@@ -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 ────────────
/// <summary>
/// NJ-4: an OLD array-of-strings static override (<c>["10","20"]</c>) for an
/// Int32 List attribute must be re-persisted in the native form (<c>[10,20]</c>)
/// 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.
/// </summary>
[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<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType<List<int>>(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"]);
}
/// <summary>
/// NJ-4: a NATIVE-form static override (<c>[10,20]</c>) 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).
/// </summary>
[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<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType<List<int>>(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"]);
}
/// <summary>
/// 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).
/// </summary>
[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<GetAttributeResponse>(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"]);
}
/// <summary>
/// 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.
/// </summary>
[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<GetAttributeResponse>(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"]);
}
}