244 lines
13 KiB
C#
244 lines
13 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>
|
||
/// v3 Batch 4 (B4-WP3) — the driver <b>single-source fan-out</b> wired into
|
||
/// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
|
||
/// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
|
||
/// <c>RawTags</c> ∪ <c>UnsReferenceVariables</c> — resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
|
||
/// NodeId AND every referencing UNS NodeId, so <c>ForwardBatchToMux</c> lands ONE write on the raw node
|
||
/// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
|
||
/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
|
||
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
|
||
/// <para>
|
||
/// Driven over the BATCH ingress, because that is the only one a driver child has: since the
|
||
/// value-pipeline batching work <c>DriverInstanceActor.OnDataChangeForward</c> wraps even a single
|
||
/// data change in an <see cref="DriverInstanceActor.AttributeValueBatchPublished"/>, and the host
|
||
/// no longer receives the per-value message at all. These fan-out cases are therefore stated as
|
||
/// single-item bursts; what they pin is the ROUTING, while
|
||
/// <see cref="DriverHostActorBatchFanOutTests"/> pins the message counts of a multi-item burst.
|
||
/// </para>
|
||
/// <para>
|
||
/// Drives a real apply through <see cref="V3ArtifactSeeder"/>: the seeded artifact carries the v3
|
||
/// raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional
|
||
/// UnsTagReference projecting each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
|
||
/// <c>BeginDirectApply → HandleArtifactForApply → PushDesiredSubscriptionsFromParsed</c> pass that
|
||
/// builds the map; the sink and mux are
|
||
/// injected as <see cref="Akka.TestKit.TestProbe"/>s.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||
{
|
||
private static readonly NodeId TestNode = NodeId.Parse("driver-lv-test");
|
||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
|
||
|
||
/// <summary>The single-item burst a driver child emits for one data change — every value ingress into
|
||
/// the host is an AttributeValueBatchPublished, so the fan-out cases below state their input that
|
||
/// way.</summary>
|
||
private static DriverInstanceActor.AttributeValueBatchPublished Burst((string Ref, object Value) item) =>
|
||
new("drv-1", new[] { new DriverInstanceActor.AttributeValueItem(item.Ref, item.Value, OpcUaStatus.Good, Ts) });
|
||
|
||
/// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
|
||
/// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
|
||
[Fact]
|
||
public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value()
|
||
{
|
||
var db = NewInMemoryDbFactory();
|
||
var deploymentId = SeedV3Deployment(db, RevA,
|
||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||
|
||
var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
||
|
||
// The driver publishes by its wire-ref RawPath, in the single-item burst DriverInstanceActor
|
||
// emits for one data change.
|
||
actor.Tell(Burst(("Plant/Modbus/dev1/speed", 42.0)));
|
||
|
||
var byId = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget)
|
||
.Items.ToDictionary(u => u.NodeId);
|
||
|
||
byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
|
||
// Realm travels with each update so the sink resolves the right namespace.
|
||
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||
// Fan-out drift guard: identical value + quality + timestamp on BOTH node states.
|
||
foreach (var u in byId.Values)
|
||
{
|
||
u.Value.ShouldBe(42.0);
|
||
u.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||
u.SourceTimestampUtc.ShouldBe(Ts);
|
||
}
|
||
|
||
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
|
||
mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget)
|
||
.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed");
|
||
}
|
||
|
||
/// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
|
||
[Fact]
|
||
public void Unreferenced_raw_tag_fans_to_raw_node_only()
|
||
{
|
||
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(Burst(("Plant/Modbus/dev1/speed", 5.0)));
|
||
|
||
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget)
|
||
.Items.ShouldHaveSingleItem();
|
||
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||
only.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||
}
|
||
|
||
/// <summary>A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates,
|
||
/// identical value) — the 1:N fan-out.</summary>
|
||
[Fact]
|
||
public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns()
|
||
{
|
||
var db = NewInMemoryDbFactory();
|
||
var deploymentId = SeedV3Deployment(db, RevA,
|
||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||
unsRefs: new[]
|
||
{
|
||
(Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null),
|
||
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
|
||
});
|
||
|
||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
||
|
||
actor.Tell(Burst(("Plant/Modbus/dev1/speed", 7.0)));
|
||
|
||
// 3-wide fan-out inside ONE batch message — the batch path's own 1:N coverage.
|
||
var updates = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget).Items;
|
||
updates.Count.ShouldBe(3);
|
||
updates.Select(u => u.NodeId).ShouldBe(new[]
|
||
{
|
||
"Plant/Modbus/dev1/speed",
|
||
"filling/line1/station1/speed",
|
||
"filling/line1/station2/speed",
|
||
}, ignoreOrder: true);
|
||
updates.ShouldAllBe(u => (double)u.Value! == 7.0);
|
||
}
|
||
|
||
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
|
||
/// NodeId), but is still forwarded to the dependency mux.</summary>
|
||
[Fact]
|
||
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux()
|
||
{
|
||
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(Burst(("Plant/Modbus/dev1/nope", 99.0)));
|
||
|
||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||
mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget)
|
||
.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/nope");
|
||
}
|
||
|
||
// ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------
|
||
//
|
||
// #478 gave a scripted condition the worst quality of its inputs, but only for inputs whose driver
|
||
// PUBLISHES a Bad data change. A poll driver whose device goes unreachable publishes nothing — it
|
||
// raises ConnectivityChanged(false) and goes silent — so the engine held its last Good value and
|
||
// went on evaluating it. These pin the synthetic Bad that closes that hole.
|
||
|
||
/// <summary>Comms loss pushes a Bad for every value ref the driver owns — not one, and not the
|
||
/// alarm-bearing ones (those take the Part 9 quality path instead) — as ONE batch envelope rather than
|
||
/// one Tell per ref.</summary>
|
||
[Fact]
|
||
public void Driver_comms_loss_pushes_BadNoCommunication_into_the_mux_for_every_ref_it_owns()
|
||
{
|
||
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.ConnectivityChanged("drv-1", Connected: false));
|
||
|
||
// ONE envelope for the whole driver: a driver owning 5k tags cost 5k Tells before this was batched.
|
||
var fan = mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget);
|
||
fan.DriverInstanceId.ShouldBe("drv-1");
|
||
var pushes = fan.Items;
|
||
|
||
pushes.Select(p => p.FullReference).ShouldBe(
|
||
new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true);
|
||
|
||
foreach (var p in pushes)
|
||
{
|
||
// BadNoCommunication, not the bare Bad: #519's reasoning applies here too — the sub-code
|
||
// names the actual cause and reaches a client through the scripted condition's quality.
|
||
p.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||
// Null rather than the last-known value: the honest statement is "no current value". The
|
||
// engine's AreInputsReady gate holds the predicate on any Bad, so no script sees the null.
|
||
p.Value.ShouldBeNull();
|
||
}
|
||
|
||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||
}
|
||
|
||
/// <summary>Reconnect pushes NOTHING — and this is the falsifying control for the test above, since
|
||
/// both drive the same handler over the same populated map and only one may produce traffic.</summary>
|
||
[Fact]
|
||
public void Driver_reconnect_pushes_nothing_into_the_mux()
|
||
{
|
||
// Publishing Good here would assert a value quality the host has not observed. Recovery instead
|
||
// rides the driver's own republish: the Connected -> Reconnecting edge calls DetachSubscription,
|
||
// so the reconnect builds a FRESH subscription whose PollGroupEngine initial push runs with
|
||
// forceRaise: true and re-publishes every tag with its real quality. Without that detach the poll
|
||
// engine would compare against retained LastValues, see an unchanged value, raise nothing, and
|
||
// strand these refs Bad forever — the value-dedup trap from the v3 Batch 4 virtual-tag reassert.
|
||
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) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
||
|
||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
|
||
|
||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||
}
|
||
|
||
/// <summary>Comms loss for a driver this host knows nothing about pushes nothing — the fan-out is
|
||
/// scoped by DriverInstanceId, so one dead device cannot Bad-out another driver's tags.</summary>
|
||
[Fact]
|
||
public void Comms_loss_for_another_driver_pushes_nothing_into_the_mux()
|
||
{
|
||
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) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
||
|
||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
|
||
|
||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||
}
|
||
|
||
}
|