feat(siteruntime): decode static List attributes to typed lists in InstanceActor (load/override/set)
This commit is contained in:
@@ -124,13 +124,29 @@ public class InstanceActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
foreach (var attr in _configuration.Attributes)
|
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
|
// MV-8: index resolved attributes for O(1) lookup on the hot
|
||||||
// TagValueUpdate ingest path (last-wins on duplicate names).
|
// TagValueUpdate ingest path (last-wins on duplicate names).
|
||||||
_resolvedAttributeByName[attr.CanonicalName] = attr;
|
_resolvedAttributeByName[attr.CanonicalName] = attr;
|
||||||
|
|
||||||
|
// MV-7: a STATIC List attribute's default is the canonical JSON
|
||||||
|
// array string. Decode it to a typed List<T> 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
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
|
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
|
// Publish attribute change to stream (WP-23) and notify children
|
||||||
var changed = new AttributeValueChanged(
|
var changed = new AttributeValueChanged(
|
||||||
@@ -499,6 +529,40 @@ public class InstanceActor : ReceiveActor
|
|||||||
Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
|
Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
|
||||||
&& dt == DataType.List;
|
&& dt == DataType.List;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="AttributeValueCodec.Decode"/> into a typed <c>List<T></c>
|
||||||
|
/// so scripts read a real collection; scalars pass through unchanged. This is
|
||||||
|
/// the authored counterpart to MV-8's <see cref="TryCoerceListValue"/> (which
|
||||||
|
/// coerces live OPC UA CLR arrays). An undecodable List value (malformed JSON,
|
||||||
|
/// bad element, missing element type) degrades to <see langword="null"/> + a
|
||||||
|
/// warning — the caller marks the attribute Bad quality. NEVER throws into the
|
||||||
|
/// actor.
|
||||||
|
/// </summary>
|
||||||
|
private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw)
|
||||||
|
{
|
||||||
|
DataType dataType = Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
|
||||||
|
? dt
|
||||||
|
: DataType.String;
|
||||||
|
DataType? elementType = string.IsNullOrEmpty(attr.ElementDataType)
|
||||||
|
? null
|
||||||
|
: (Enum.TryParse<DataType>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
|
/// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
|
||||||
/// into a typed <c>List<elementClrType></c> matching the attribute's
|
/// into a typed <c>List<elementClrType></c> matching the attribute's
|
||||||
@@ -825,7 +889,22 @@ public class InstanceActor : ReceiveActor
|
|||||||
|
|
||||||
foreach (var kvp in result.Overrides)
|
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(
|
_logger.LogDebug(
|
||||||
|
|||||||
@@ -666,4 +666,178 @@ public class InstanceActorTests : TestKit, IDisposable
|
|||||||
// (Liveness is also proven by the preceding successful GetAttributeResponse.)
|
// (Liveness is also proven by the preceding successful GetAttributeResponse.)
|
||||||
ExpectNoMsg(within);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user