Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs
T

244 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}