199 lines
10 KiB
C#
199 lines
10 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Batched value fan-out in <see cref="DriverHostActor"/>: a driver child's
|
|
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> burst costs exactly ONE mux hop and
|
|
/// ONE OPC UA publish hop, regardless of how many values it carries — the point of the batching work.
|
|
/// <para>
|
|
/// The routing itself is unchanged from the per-value path: each item's <c>FullReference</c>
|
|
/// (== the tag's RawPath) resolves through the host's <c>_nodeIdByDriverRef</c> map to the RAW NodeId
|
|
/// AND every referencing UNS NodeId, each carrying IDENTICAL value/status/timestamp (v3 Batch 4
|
|
/// single-source fan-out), and an unmapped ref is dropped rather than faulting the burst. These pin
|
|
/// the message COUNT as well as the contents, because a per-item unwrap satisfies the contents alone.
|
|
/// </para>
|
|
/// <para>
|
|
/// Arrangement mirrors <see cref="DriverHostActorLiveValueTests"/> — a real apply through the seeded
|
|
/// v3 artifact (<see cref="V3ArtifactSeeder"/>) builds the map; sink + mux are
|
|
/// <see cref="Akka.TestKit.TestProbe"/>s.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-batch-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('b', 64));
|
|
private static readonly DateTime TsA = new(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
|
|
private static readonly DateTime TsB = new(2026, 8, 15, 9, 0, 1, DateTimeKind.Utc);
|
|
|
|
/// <summary>A two-item burst whose first item is referenced by one equipment lands as a SINGLE
|
|
/// AttributeValueBatchUpdate carrying all three (raw, uns, raw) writes verbatim — including a non-Good
|
|
/// sub-code (#519), which rides through unbucketed.</summary>
|
|
[Fact]
|
|
public void Batch_fans_out_to_all_node_realm_refs_in_one_publish_message()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var deploymentId = SeedV3Deployment(db, RevA,
|
|
rawTags: new[]
|
|
{
|
|
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
|
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
|
|
},
|
|
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
|
|
|
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
|
|
|
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
|
|
{
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.BadNoCommunication, TsB),
|
|
}));
|
|
|
|
var batch = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
|
|
batch.Items.Count.ShouldBe(3);
|
|
|
|
var byId = batch.Items.ToDictionary(w => w.NodeId);
|
|
byId.Keys.ShouldBe(new[]
|
|
{
|
|
"Plant/Modbus/dev1/speed",
|
|
"filling/line1/station1/speed",
|
|
"Plant/Modbus/dev1/temp",
|
|
}, ignoreOrder: true);
|
|
|
|
// Item A: raw + UNS, identical value/quality/timestamp (fan-out drift guard).
|
|
foreach (var id in new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" })
|
|
{
|
|
byId[id].Value.ShouldBe(42.0);
|
|
byId[id].StatusCode.ShouldBe(OpcUaStatus.Good);
|
|
byId[id].SourceTimestampUtc.ShouldBe(TsA);
|
|
}
|
|
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
|
|
|
|
// Item B: raw only, and its own status sub-code + timestamp — not item A's.
|
|
byId["Plant/Modbus/dev1/temp"].Value.ShouldBe(7.5);
|
|
byId["Plant/Modbus/dev1/temp"].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
|
byId["Plant/Modbus/dev1/temp"].SourceTimestampUtc.ShouldBe(TsB);
|
|
byId["Plant/Modbus/dev1/temp"].Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
|
|
// ONE message, not one per value.
|
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
/// <summary>An item whose ref is bound to no NodeId is dropped; the rest of the burst still lands in the
|
|
/// one message and nothing faults.</summary>
|
|
[Fact]
|
|
public void Unmapped_items_are_dropped_not_faulted()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var deploymentId = SeedV3Deployment(db, RevA,
|
|
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
|
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
|
|
|
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
|
|
|
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
|
|
{
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, TsA),
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
|
|
}));
|
|
|
|
var batch = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
|
|
batch.Items.Count.ShouldBe(1);
|
|
batch.Items[0].NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
|
batch.Items[0].Value.ShouldBe(42.0);
|
|
|
|
// The host is still alive and routing after the unmapped item.
|
|
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
|
|
{
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 43.0, OpcUaStatus.Good, TsB),
|
|
}));
|
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget)
|
|
.Items[0].Value.ShouldBe(43.0);
|
|
}
|
|
|
|
/// <summary>The dependency mux gets ONE envelope carrying the whole burst — not N per-item messages.
|
|
/// The mux filters per item itself.</summary>
|
|
[Fact]
|
|
public void Batch_reaches_mux_once()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var deploymentId = SeedV3Deployment(db, RevA,
|
|
rawTags: new[]
|
|
{
|
|
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
|
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
|
|
},
|
|
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
|
|
|
var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
|
|
|
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
|
|
{
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.Good, TsB),
|
|
}));
|
|
|
|
var fwd = mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget);
|
|
fwd.DriverInstanceId.ShouldBe("drv-1");
|
|
fwd.Items.Select(i => i.FullReference).ShouldBe(
|
|
new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" });
|
|
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
/// <summary>A burst in which NOTHING resolves sends no publish message at all — an empty batch would be
|
|
/// wasted work at the sink — while the mux still gets its one envelope (virtual-tag inputs are keyed by
|
|
/// ref, independent of whether the ref is materialised as a node).</summary>
|
|
[Fact]
|
|
public void All_unmapped_batch_sends_nothing_to_publish_actor()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var deploymentId = SeedV3Deployment(db, RevA,
|
|
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
|
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
|
|
|
var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
|
|
|
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
|
|
{
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/nope", 1.0, OpcUaStatus.Good, TsA),
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/alsonope", 2.0, OpcUaStatus.Good, TsB),
|
|
}));
|
|
|
|
mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget)
|
|
.Items.Count.ShouldBe(2);
|
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
/// <summary>A host with NO OPC UA publish actor wired (the ctor default — the dev/Mac shape) still
|
|
/// makes the mux hop: the batch fan-out returns early on a null sink, and returning early BEFORE the
|
|
/// mux Tell would silently strand virtual-tag and continuous-historization inputs.</summary>
|
|
[Fact]
|
|
public void Mux_still_receives_batch_when_no_publish_actor_is_wired()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var deploymentId = SeedV3Deployment(db, RevA,
|
|
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
|
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
|
|
|
var (actor, mux) = SpawnHostAndApplyWithoutPublishActor(this, db, TestNode, deploymentId, RevA);
|
|
|
|
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
|
|
{
|
|
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
|
|
}));
|
|
|
|
var fwd = mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget);
|
|
fwd.DriverInstanceId.ShouldBe("drv-1");
|
|
fwd.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed");
|
|
}
|
|
}
|