feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -444,4 +445,182 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
}
|
||||
|
||||
// ── MV-8: data-sourced List attribute coercion ─────────────────────────
|
||||
|
||||
private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl)
|
||||
{
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
// On startup the actor subscribes its data-sourced tags through the DCL.
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
return actor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: when a data-sourced attribute is declared <c>DataType.List</c>, an
|
||||
/// incoming OPC UA array value (a CLR array surfaces from the SDK) must be
|
||||
/// coerced into a typed <c>List<int></c> whose elements match the
|
||||
/// attribute's ElementDataType. The stored value must be a real list — not a
|
||||
/// JSON string — so scripts read a typed collection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListAttribute_CoercesArrayToTypedList()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Setpoints";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-List",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Setpoints", Value = null,
|
||||
DataType = "List", ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-List", config, dcl);
|
||||
|
||||
// OPC UA delivers an array value (CLR array) for the List-typed tag.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 10, 20, 30 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-list", "Pump-List", "Setpoints", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
var list = Assert.IsType<List<int>>(response.Value);
|
||||
Assert.Equal(new[] { 10, 20, 30 }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: array elements coming in as a different CLR type (here, strings that
|
||||
/// are valid integers) must still coerce to the declared element type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListAttribute_CoercesElementTypes()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Levels";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ListCoerce",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Levels", Value = null,
|
||||
DataType = "List", ElementDataType = "Double",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-ListCoerce", config, dcl);
|
||||
|
||||
// Elements arrive as ints/strings but the attribute is List<double>.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "2.5", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-coerce", "Pump-ListCoerce", "Levels", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
var list = Assert.IsType<List<double>>(response.Value);
|
||||
Assert.Equal(new[] { 1.0, 2.5, 3.0 }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: an element that cannot be coerced to the declared element type must
|
||||
/// set the attribute quality to <c>Bad</c> and must NOT crash the actor (it
|
||||
/// stays alive and continues to answer queries).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListAttribute_ElementMismatch_SetsBadQuality_ActorAlive()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Bad";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ListBad",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = null,
|
||||
DataType = "List", ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-ListBad", config, dcl);
|
||||
Watch(actor);
|
||||
|
||||
// "not-a-number" cannot be coerced to int → Bad quality, no crash.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "not-a-number", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-bad", "Pump-ListBad", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
|
||||
// The actor must still be alive (no crash / restart) and serving.
|
||||
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing
|
||||
/// pass-through behaviour — a scalar value is stored unchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedScalarAttribute_UnchangedByListPath()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Speed";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-Scalar",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Speed", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-Scalar", config, dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-scalar", "Pump-Scalar", "Speed", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
Assert.Equal(1450, response.Value);
|
||||
}
|
||||
|
||||
private void ExpectNoTerminated(IActorRef actor, TimeSpan within)
|
||||
{
|
||||
// The actor is Watch()ed; assert no Terminated arrives in the window.
|
||||
ExpectNoMsg(within);
|
||||
Assert.False(actor.IsNobody());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user