feat(siteruntime): decode static List attributes to typed lists in InstanceActor (load/override/set)

This commit is contained in:
Joseph Doherty
2026-06-16 15:52:29 -04:00
parent 6ef6bab26e
commit 7f97780bb3
2 changed files with 259 additions and 6 deletions
@@ -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 ──────────────────────
/// <summary>
/// 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.
/// </summary>
[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<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
var list = Assert.IsType<List<string>>(response.Value);
Assert.Equal(new[] { "a", "b" }, list);
}
/// <summary>
/// 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().
/// </summary>
[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<SetStaticAttributeResponse>(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<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(getResponse.Found);
var list = Assert.IsType<List<string>>(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"]);
}
/// <summary>
/// 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.
/// </summary>
[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<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType<List<string>>(response.Value);
Assert.Equal(new[] { "p", "q" }, list);
}
/// <summary>
/// MV-7: a malformed stored List value must NOT crash the actor — it loads
/// with quality Bad and the actor stays alive and answering.
/// </summary>
[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<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 / restart during construction).
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
}
/// <summary>
/// MV-7 guard: a scalar static attribute is unaffected by the List decode
/// path — it still returns its raw string value.
/// </summary>
[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<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
Assert.Equal("Main Pump", response.Value);
}
}