diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 42222d6d..e56b1c31 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -48,8 +48,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// Children (DriverInstance/VirtualTag/etc.) are fully wired: DispatchDeployment drives /// which runs the spawn-plan via , /// maintains the FullName→NodeId live-value routing map, and forwards results to the -/// OPC UA publish actor via ForwardToMux (per value) and ForwardBatchToMux (one whole -/// driver production burst per message — the primary path for a batch-capable driver). +/// OPC UA publish actor via ForwardBatchToMux — one whole driver production burst per message, +/// and the ONLY value ingress from driver children ( wraps even a +/// single data change in an AttributeValueBatchPublished). The per-value +/// AttributeValuePublished survives only as the message +/// fabricates straight to the dependency mux; the host itself no longer receives it. /// public sealed class DriverHostActor : ReceiveActor, IWithTimers { @@ -186,7 +189,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// /// Driver live-value routing map: (DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged). /// Rebuilt every apply by from the composition's RawTags - /// ∪ UnsReferenceVariables, and resolved in so a driver value + /// ∪ UnsReferenceVariables, and resolved in so a driver value /// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical /// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set /// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups. @@ -924,7 +927,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers { Receive(HandleDispatchFromSteady); Receive(HandleGetDiagnostics); - Receive(ForwardToMux); Receive(ForwardBatchToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); @@ -959,7 +961,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // then Become(Applying)); completing the apply transitions back to Steady. Receive(HandleFetchedForApply); Receive(HandleGetDiagnostics); - Receive(ForwardToMux); Receive(ForwardBatchToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); @@ -977,16 +978,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } /// - /// Routes one batched driver production burst — the batched analogue of - /// , and the primary value ingress for a batch-capable driver. + /// Routes one batched driver production burst — the ONLY value ingress from a driver child, since + /// wraps even a single data change in an + /// . /// - /// The routing is identical per item: each FullReference (the tag's RawPath) resolves - /// through to the v3 Batch 4 single-source fan-out — the RAW - /// NodeId AND every referencing UNS NodeId, each carrying IDENTICAL value/status/timestamp — and - /// an unmapped ref is Debug-logged and dropped without disturbing the rest of the burst. What - /// changes is the delivery: the whole burst costs ONE hop to the dependency mux and ONE + /// Each item's FullReference (the tag's RawPath) resolves through + /// — rebuilt every apply from the composition's RawTags + /// ∪ UnsReferenceVariables — to the v3 Batch 4 single-source fan-out: the RAW + /// NodeId () AND every referencing UNS NodeId + /// (), each carrying IDENTICAL value/status/timestamp, so + /// there is no independent buffer to drift. An unmapped ref is Debug-logged and dropped without + /// disturbing the rest of the burst. + /// + /// + /// The whole burst costs ONE hop to the dependency mux (which filters per item itself, feeding + /// VirtualTag inputs keyed by RawPath) and ONE /// to - /// the publish actor, instead of one message per value. + /// the publish actor — never one message per value. Both sinks are optional: without a mux, + /// VirtualTagActor evaluation can't fire (the dev/Mac path, no virtual tags registered); + /// production binds both via the RuntimeActors extension. /// /// private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg) @@ -998,13 +1008,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // Indexed for-loop, not foreach: IReadOnlyList enumerates through an interface // enumerator that would allocate once per burst on the hottest routing seam in the server. + var count = msg.Items.Count; List? writes = null; - for (var idx = 0; idx < msg.Items.Count; idx++) + for (var idx = 0; idx < count; idx++) { var item = msg.Items[idx]; if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, item.FullReference), out var nodeIds)) { - writes ??= new List(msg.Items.Count * 2); + // Sized for the dominant case — every item resolving to its raw node alone, no UNS + // reference — where this is exact and the list never grows. A burst that does fan out + // pays at most a doubling or two, which beats over-allocating on every burst that doesn't. + writes ??= new List(count); foreach (var n in nodeIds) writes.Add(new ValueWrite(n.NodeId, item.Value, item.StatusCode, item.TimestampUtc, n.Realm)); } @@ -1020,42 +1034,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueBatchUpdate(writes)); } - private void ForwardToMux(DriverInstanceActor.AttributeValuePublished msg) - { - // Pass driver-published values to the dependency mux when one is wired (VirtualTag inputs, - // keyed by FullReference). Without a mux, VirtualTagActor evaluation can't fire — that's the - // dev/Mac path (no virtual tags registered); production binds the mux via the RuntimeActors - // extension. KEEP this unchanged — VirtualTag inputs are still keyed by FullReference. - _dependencyMux?.Tell(msg); - - if (_opcUaPublishActor is null) return; - - // Route the value to the OPC UA sink at the variable's ACTUAL NodeId. Equipment-tag variables - // are materialised with folder-scoped NodeIds (EquipmentId/FolderPath/Name), while the driver - // publishes keyed by its wire-ref FullName (FullReference). The _nodeIdByDriverRef map — built - // each apply from the composition's EquipmentTags — resolves (DriverInstanceId, FullName) to - // the folder-scoped NodeId(s) the materialiser placed the variable(s) at, so the value lands - // instead of leaving the variable at BadWaitingForInitialData. One driver ref can back several - // equipment variables (identical machines sharing a register), hence the fan-out. - if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds)) - { - // v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND - // every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/status/timestamp. - foreach (var n in nodeIds) - _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate( - n.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, n.Realm)); - } - else - { - _log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped", - _localNode, msg.DriverInstanceId, msg.FullReference); - } - } - /// /// Routes a native alarm transition (published by a driver child as /// ) to its materialised Part 9 condition - /// node(s). The alarm path analogue of : the transition's ConditionId + /// node(s). The alarm path analogue of : the transition's ConditionId /// (the dotted alarm full-reference, which equals the authored equipment-tag FullName — NOT the bare /// SourceNodeId owning object) is resolved by the map — /// built each apply from the alarm-bearing EquipmentTags — to the folder-scoped condition NodeId(s) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs index 69fb5bb4..cf06b01f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs @@ -13,11 +13,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// subscriber. /// /// DriverHostActor forwards driver-child value publishes to this mux (one mux per driver-role -/// node). The PRIMARY ingress is the batched +/// node). The ONLY driver ingress is the batched /// — one envelope per driver -/// production burst, which the mux filters per item — while the per-value +/// production burst, which the mux filters per item. The per-value /// receive stays for the comms-loss fan -/// (DriverHostActor.FanCommsLossToDependencyMux fabricates it) and per-tag drivers. Both +/// ALONE: DriverHostActor.FanCommsLossToDependencyMux fabricates that message and Tells this +/// mux directly, never through the host's own receive. Both /// produce the same per interested subscriber. /// The mux is deliberately not a DPS subscriber — virtual-tag evaluation is local to each node and /// would over-emit if it spanned the cluster. @@ -116,12 +117,19 @@ public sealed class DependencyMuxActor : ReceiveActor /// Filters one batched driver production burst per item — the batched analogue of /// . Items nobody registered for are dropped (the common case: /// only a fraction of an address space feeds virtual-tag expressions), so a burst of N values costs one - /// message plus one DependencyValueChanged per genuinely interested (item, subscriber) pair. + /// message plus one DependencyValueChanged per genuinely interested (item, subscriber) pair. + /// The registered set is NOT just virtual-tag inputs: ContinuousHistorizationRecorder + /// registers interest in every historized ref, so on a historizing node can be + /// large and a sizeable fraction of each burst resolves. private void OnAttributeValueBatchPublished(DriverInstanceActor.AttributeValueBatchPublished msg) { + // Nobody has registered anything — skip the whole burst rather than probing an empty map per item. + if (_byRef.Count == 0) return; + // Indexed for-loop, not foreach: this is the hot value ingress and an IReadOnlyList enumerator // would allocate once per burst. - for (var i = 0; i < msg.Items.Count; i++) + var count = msg.Items.Count; + for (var i = 0; i < count; i++) { var item = msg.Items[i]; if (!_byRef.TryGetValue(item.FullReference, out var subscribers) || subscribers.Count == 0) continue; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorBatchFanOutTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorBatchFanOutTests.cs index 8205e02b..51e3ee87 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorBatchFanOutTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorBatchFanOutTests.cs @@ -1,18 +1,12 @@ -using System.Text.Json; using Akka.Actor; -using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Engines; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; 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; @@ -29,7 +23,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// /// Arrangement mirrors — a real apply through the seeded -/// v3 artifact builds the map; sink + mux are s. +/// v3 artifact () builds the map; sink + mux are +/// s. /// /// public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase @@ -54,7 +49,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase }, unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) }); - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId); + var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { @@ -62,7 +57,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.BadNoCommunication, TsB), })); - var batch = publish.ExpectMsg(TimeSpan.FromSeconds(5)); + var batch = publish.ExpectMsg(PresenceBudget); batch.Items.Count.ShouldBe(3); var byId = batch.Items.ToDictionary(w => w.NodeId); @@ -103,7 +98,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase 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(db, deploymentId); + var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { @@ -111,7 +106,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA), })); - var batch = publish.ExpectMsg(TimeSpan.FromSeconds(5)); + var batch = publish.ExpectMsg(PresenceBudget); batch.Items.Count.ShouldBe(1); batch.Items[0].NodeId.ShouldBe("Plant/Modbus/dev1/speed"); batch.Items[0].Value.ShouldBe(42.0); @@ -121,7 +116,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase { new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 43.0, OpcUaStatus.Good, TsB), })); - publish.ExpectMsg(TimeSpan.FromSeconds(5)) + publish.ExpectMsg(PresenceBudget) .Items[0].Value.ShouldBe(43.0); } @@ -139,7 +134,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase }, unsRefs: Array.Empty<(string, string, string, string, string?)>()); - var (actor, _, mux) = SpawnHostAndApply(db, deploymentId); + var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { @@ -147,7 +142,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.Good, TsB), })); - var fwd = mux.ExpectMsg(TimeSpan.FromSeconds(5)); + var fwd = mux.ExpectMsg(PresenceBudget); fwd.DriverInstanceId.ShouldBe("drv-1"); fwd.Items.Select(i => i.FullReference).ShouldBe( new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }); @@ -165,7 +160,7 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase 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(db, deploymentId); + var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { @@ -173,104 +168,31 @@ public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/alsonope", 2.0, OpcUaStatus.Good, TsB), })); - mux.ExpectMsg(TimeSpan.FromSeconds(5)) + mux.ExpectMsg(PresenceBudget) .Items.Count.ShouldBe(2); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } - /// Spawns the host with publish + mux probes, dispatches the deployment, and waits for the - /// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes. - private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply( - IDbContextFactory db, DeploymentId deploymentId) + /// 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. + [Fact] + public void Mux_still_receives_batch_when_no_publish_actor_is_wired() { - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var mux = CreateTestProbe(); - var vtHost = CreateTestProbe(); + 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 = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - localRoles: new HashSet { "driver" }, - dependencyMux: mux.Ref, - opcUaPublishActor: publish.Ref, - virtualTagEvaluator: NullVirtualTagEvaluator.Instance, - virtualTagHostOverride: vtHost.Ref)); + var (actor, mux) = SpawnHostAndApplyWithoutPublishActor(this, db, TestNode, deploymentId, RevA); - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(TimeSpan.FromSeconds(5)); - - return (actor, publish, mux); - } - - /// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" → - /// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an - /// Area/Line/Equipment — the same shape seeds. - private static DeploymentId SeedV3Deployment( - IDbContextFactory db, RevisionHash rev, - (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags, - (string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs) - { - var drivers = rawTags - .Select(t => (t.Driver, t.DriverName)) - .Distinct() - .Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false }) - .ToArray(); - var devices = rawTags - .Select(t => (t.Driver, t.Device)) - .Distinct() - .Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" }) - .ToArray(); - var tags = rawTags.Select(t => new + actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { - TagId = t.Tag, - DeviceId = $"{t.Driver}:{t.Device}", - TagGroupId = (string?)null, - Name = t.Tag, - DataType = t.DataType, - AccessLevel = t.Writable ? 1 : 0, - TagConfig = "{}", - }).ToArray(); + new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA), + })); - var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal) - .Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray(); - var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct() - .Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray(); - var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct() - .Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray(); - var unsTagReferences = unsRefs.Select((r, i) => new - { - UnsTagReferenceId = $"ref-{i}", - EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}", - TagId = r.BackingTag, - DisplayNameOverride = r.Effective, - }).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, - DriverInstances = drivers, - Devices = devices, - TagGroups = Array.Empty(), - Tags = tags, - UnsAreas = areas, - UnsLines = lines, - Equipment = equipment, - UnsTagReferences = unsTagReferences, - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; + var fwd = mux.ExpectMsg(PresenceBudget); + fwd.DriverInstanceId.ShouldBe("drv-1"); + fwd.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs index df113425..d86e4ce9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs @@ -1,19 +1,12 @@ -using System.Text.Json; using Akka.Actor; -using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Engines; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; 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; @@ -22,14 +15,22 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// . The driver publishes a value keyed by its wire-ref (== the tag's /// RawPath); the host's _nodeIdByDriverRef map — rebuilt each apply from the composition's /// RawTags ∪ UnsReferenceVariables — resolves (DriverInstanceId, RawPath) to the RAW -/// NodeId AND every referencing UNS NodeId, so ForwardToMux lands ONE publish on the raw node +/// NodeId AND every referencing UNS NodeId, so ForwardBatchToMux lands ONE write on the raw node /// () plus every UNS node () 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. /// -/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain -/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting -/// each raw tag into an equipment. DispatchDeployment triggers the +/// Driven over the BATCH ingress, because that is the only one a driver child has: since the +/// value-pipeline batching work DriverInstanceActor.OnDataChangeForward wraps even a single +/// data change in an , 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 +/// pins the message counts of a multi-item burst. +/// +/// +/// Drives a real apply through : the seeded artifact carries the v3 +/// raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional +/// UnsTagReference projecting each raw tag into an equipment. DispatchDeployment triggers the /// ApplyAndAck → PushDesiredSubscriptions pass that builds the map; the sink and mux are /// injected as s. /// @@ -40,6 +41,12 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase 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); + /// 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. + private static DriverInstanceActor.AttributeValueBatchPublished Burst((string Ref, object Value) item) => + new("drv-1", new[] { new DriverInstanceActor.AttributeValueItem(item.Ref, item.Value, OpcUaStatus.Good, Ts) }); + /// 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. [Fact] @@ -50,15 +57,14 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase 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(db, deploymentId); + var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); - // The driver publishes by its wire-ref RawPath. - actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, Ts)); + // 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 a = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - var b = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - var byId = new[] { a, b }.ToDictionary(u => u.NodeId); + var byId = publish.ExpectMsg(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. @@ -69,12 +75,12 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase { u.Value.ShouldBe(42.0); u.StatusCode.ShouldBe(OpcUaStatus.Good); - u.TimestampUtc.ShouldBe(Ts); + u.SourceTimestampUtc.ShouldBe(Ts); } // The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath. - mux.ExpectMsg(TimeSpan.FromSeconds(5)) - .FullReference.ShouldBe("Plant/Modbus/dev1/speed"); + mux.ExpectMsg(PresenceBudget) + .Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed"); } /// A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm). @@ -86,12 +92,12 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase 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(db, deploymentId); + var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); - actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaStatus.Good, Ts)); + actor.Tell(Burst(("Plant/Modbus/dev1/speed", 5.0))); - var only = publish.ExpectMsg(TimeSpan.FromSeconds(5)); + var only = publish.ExpectMsg(PresenceBudget) + .Items.ShouldHaveSingleItem(); only.NodeId.ShouldBe("Plant/Modbus/dev1/speed"); only.Realm.ShouldBe(AddressSpaceRealm.Raw); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); @@ -111,17 +117,13 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase (Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null), }); - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId); + var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); - actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaStatus.Good, Ts)); + actor.Tell(Burst(("Plant/Modbus/dev1/speed", 7.0))); - var updates = new[] - { - publish.ExpectMsg(TimeSpan.FromSeconds(5)), - publish.ExpectMsg(TimeSpan.FromSeconds(5)), - publish.ExpectMsg(TimeSpan.FromSeconds(5)), - }; + // 3-wide fan-out inside ONE batch message — the batch path's own 1:N coverage. + var updates = publish.ExpectMsg(PresenceBudget).Items; + updates.Count.ShouldBe(3); updates.Select(u => u.NodeId).ShouldBe(new[] { "Plant/Modbus/dev1/speed", @@ -141,14 +143,13 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase 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(db, deploymentId); + var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); - actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, Ts)); + actor.Tell(Burst(("Plant/Modbus/dev1/nope", 99.0))); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - mux.ExpectMsg(TimeSpan.FromSeconds(5)) - .FullReference.ShouldBe("Plant/Modbus/dev1/nope"); + mux.ExpectMsg(PresenceBudget) + .Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/nope"); } // ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------ @@ -172,14 +173,14 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase }, unsRefs: Array.Empty<(string, string, string, string, string?)>()); - var (actor, _, mux) = SpawnHostAndApply(db, deploymentId); + var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false)); var pushes = new[] { - mux.ExpectMsg(TimeSpan.FromSeconds(5)), - mux.ExpectMsg(TimeSpan.FromSeconds(5)), + mux.ExpectMsg(PresenceBudget), + mux.ExpectMsg(PresenceBudget), }; pushes.Select(p => p.FullReference).ShouldBe( @@ -215,7 +216,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase 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(db, deploymentId); + var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true)); @@ -232,110 +233,11 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase 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(db, deploymentId); + var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false)); mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } - /// Spawns the host with publish + mux probes, dispatches the deployment, and waits for the - /// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a - /// value. A VirtualTag-host probe is injected so the real host isn't spawned. - private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply( - IDbContextFactory db, DeploymentId deploymentId) - { - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var mux = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - localRoles: new HashSet { "driver" }, - dependencyMux: mux.Ref, - opcUaPublishActor: publish.Ref, - virtualTagEvaluator: NullVirtualTagEvaluator.Instance, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(TimeSpan.FromSeconds(5)); - - return (actor, publish, mux); - } - - /// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" → - /// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an - /// Area/Line/Equipment. Each raw tag's RawPath is Plant/{DriverName}/{Device}/{Tag}; each UNS - /// reference's NodeId is {Area}/{Line}/{Equip}/{Effective ?? backing tag Name}. Enums serialize - /// numerically (AccessLevel: ReadWrite = 1). - private static DeploymentId SeedV3Deployment( - IDbContextFactory db, RevisionHash rev, - (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags, - (string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs) - { - var drivers = rawTags - .Select(t => (t.Driver, t.DriverName)) - .Distinct() - .Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false }) - .ToArray(); - var devices = rawTags - .Select(t => (t.Driver, t.Device)) - .Distinct() - .Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" }) - .ToArray(); - var tags = rawTags.Select(t => new - { - TagId = t.Tag, // tag id == name for test simplicity (unique per driver here) - DeviceId = $"{t.Driver}:{t.Device}", - TagGroupId = (string?)null, - Name = t.Tag, - DataType = t.DataType, - AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1 - TagConfig = "{}", - }).ToArray(); - - // UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip). - var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal) - .Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray(); - var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct() - .Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray(); - var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct() - .Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray(); - var unsTagReferences = unsRefs.Select((r, i) => new - { - UnsTagReferenceId = $"ref-{i}", - EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}", - TagId = r.BackingTag, - DisplayNameOverride = r.Effective, - }).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, - DriverInstances = drivers, - Devices = devices, - TagGroups = Array.Empty(), - Tags = tags, - UnsAreas = areas, - UnsLines = lines, - Equipment = equipment, - UnsTagReferences = unsTagReferences, - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs index be6fd400..c547321f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs @@ -27,8 +27,11 @@ public abstract class RuntimeActorTestBase : TestKit /// idle machine are comfortable in isolation and marginal under that contention: three consecutive /// 30-run verification rounds each surfaced a different test failing on one, in three /// different files. Fixing them one at a time was chasing a distribution rather than a defect. + /// Public rather than protected so Harness helpers that are not themselves + /// subclasses — — budget their waits from the + /// same constant instead of re-deriving one. /// - protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15); + public static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15); /// Gets the Akka test HOCON configuration string for single-node cluster setup. protected static string AkkaTestHocon => @" diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs new file mode 100644 index 00000000..4ffc9a1d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using Akka.Actor; +using Akka.TestKit; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +/// +/// Shared v3 deployment fixture for the value-routing suites: seeds a +/// Sealed artifact carrying the raw-tag chain (+ optional UNS references) and spawns a host that has +/// actually applied it, so _nodeIdByDriverRef is populated before the test publishes anything. +/// +/// Kept here rather than in either test class because the per-value and batched fan-out suites +/// assert different deliveries over the SAME arrangement — duplicating the seeder let the two +/// copies drift. Deliberately narrow: the assembly's other artifact fixtures seed their own +/// shapes and are not folded in. +/// +/// +public static class V3ArtifactSeeder +{ + /// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" → + /// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an + /// Area/Line/Equipment. Each raw tag's RawPath is Plant/{DriverName}/{Device}/{Tag}; each UNS + /// reference's NodeId is {Area}/{Line}/{Equip}/{Effective ?? backing tag Name}. Enums serialize + /// numerically (AccessLevel: ReadWrite = 1). + /// The in-memory config-db factory to seed. + /// The revision hash the deployment is sealed under. + /// The raw tags to materialise, one per (driver, device, tag). + /// UNS references projecting a raw tag (by BackingTag) into an equipment. + /// The seeded deployment's id. + public static DeploymentId SeedV3Deployment( + IDbContextFactory db, RevisionHash rev, + (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags, + (string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs) + { + var drivers = rawTags + .Select(t => (t.Driver, t.DriverName)) + .Distinct() + .Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false }) + .ToArray(); + var devices = rawTags + .Select(t => (t.Driver, t.Device)) + .Distinct() + .Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" }) + .ToArray(); + var tags = rawTags.Select(t => new + { + TagId = t.Tag, // tag id == name for test simplicity (unique per driver here) + DeviceId = $"{t.Driver}:{t.Device}", + TagGroupId = (string?)null, + Name = t.Tag, + DataType = t.DataType, + AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1 + TagConfig = "{}", + }).ToArray(); + + // UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip). + var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal) + .Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray(); + var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct() + .Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray(); + var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct() + .Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray(); + var unsTagReferences = unsRefs.Select((r, i) => new + { + UnsTagReferenceId = $"ref-{i}", + EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}", + TagId = r.BackingTag, + DisplayNameOverride = r.Effective, + }).ToArray(); + + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = drivers, + Devices = devices, + TagGroups = Array.Empty(), + Tags = tags, + UnsAreas = areas, + UnsLines = lines, + Equipment = equipment, + UnsTagReferences = unsTagReferences, + }); + + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, + RevisionHash = rev.Value, + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + + /// Spawns the host with publish + mux probes, dispatches the deployment, and waits for the + /// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a + /// value. A VirtualTag-host probe is injected so the real host isn't spawned. + /// The calling TestKit, supplying the ActorSystem + probes. + /// The factory whose deployment was seeded by . + /// The host's local node id. + /// The deployment to dispatch. + /// The revision hash the deployment was sealed under. + /// The host actor plus the publish and mux probes wired into it. + public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply( + TestKitBase kit, IDbContextFactory db, + NodeId localNode, DeploymentId deploymentId, RevisionHash rev) + { + var publish = kit.CreateTestProbe(); + var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref); + + Dispatch(actor, coordinator, deploymentId, rev); + publish.ExpectMsg(RuntimeActorTestBase.PresenceBudget); + + return (actor, publish, mux); + } + + /// The same arrangement with NO OPC UA publish actor wired (the ctor default) — the dev/Mac + /// shape, where the mux hop must still happen. Returns only the mux probe, since there is no sink. + /// The calling TestKit, supplying the ActorSystem + probes. + /// The factory whose deployment was seeded by . + /// The host's local node id. + /// The deployment to dispatch. + /// The revision hash the deployment was sealed under. + /// The host actor plus the mux probe wired into it. + public static (IActorRef Actor, TestProbe Mux) SpawnHostAndApplyWithoutPublishActor( + TestKitBase kit, IDbContextFactory db, + NodeId localNode, DeploymentId deploymentId, RevisionHash rev) + { + var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null); + Dispatch(actor, coordinator, deploymentId, rev); + return (actor, mux); + } + + private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn( + TestKitBase kit, IDbContextFactory db, NodeId localNode, + IActorRef? opcUaPublishActor) + { + var coordinator = kit.CreateTestProbe(); + var mux = kit.CreateTestProbe(); + var vtHost = kit.CreateTestProbe(); + + var actor = kit.Sys.ActorOf(DriverHostActor.Props( + db, localNode, coordinator.Ref, + localRoles: new HashSet { "driver" }, + dependencyMux: mux.Ref, + opcUaPublishActor: opcUaPublishActor, + virtualTagEvaluator: NullVirtualTagEvaluator.Instance, + virtualTagHostOverride: vtHost.Ref)); + + return (actor, mux, coordinator); + } + + private static void Dispatch(IActorRef actor, TestProbe coordinator, DeploymentId deploymentId, RevisionHash rev) + { + actor.Tell(new DispatchDeployment(deploymentId, rev, CorrelationId.NewId())); + coordinator.ExpectMsg(RuntimeActorTestBase.PresenceBudget) + .Outcome.ShouldBe(ApplyAckOutcome.Applied); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs index fccaebc4..0ea82630 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs @@ -124,13 +124,14 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } - /// Verifies that the DriverHostActor forwards AttributeValuePublished messages through the dependency mux. + /// Verifies that the DriverHostActor forwards a driver burst through the dependency mux to the + /// interested subscriber. [Fact] - public void DriverHostActor_forwards_AttributeValuePublished_through_to_mux() + public void DriverHostActor_forwards_AttributeValueBatchPublished_through_to_mux() { - // Spin a mux + a stand-in for DriverHostActor that wraps the real DriverHostActor's - // forward path. We feed it AttributeValuePublished directly to verify the routing — - // exercising the actual DriverHost spawn would require a deployment artifact + DI. + // Spin a mux + a real DriverHostActor and feed it the batch a driver child would send — + // exercising a full DriverHost apply would require a deployment artifact + DI, and the mux hop + // does not depend on one (it precedes the NodeId resolution). var mux = Sys.ActorOf(DependencyMuxActor.Props()); var subscriber = CreateTestProbe(); mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "ref-1" }, subscriber.Ref)); @@ -142,9 +143,10 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase ackRouter: hostProbe.Ref, dependencyMux: mux)); - // Tell the host an AttributeValuePublished — it should fan out to the mux + subscriber. - hostActor.Tell(new DriverInstanceActor.AttributeValuePublished( - "driver-1", "ref-1", 42, OpcUaStatus.Good, DateTime.UtcNow)); + hostActor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("driver-1", new[] + { + new DriverInstanceActor.AttributeValueItem("ref-1", 42, OpcUaStatus.Good, DateTime.UtcNow), + })); subscriber.ExpectMsg().TagId.ShouldBe("ref-1"); } @@ -168,7 +170,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase new DriverInstanceActor.AttributeValueItem("tag-x", 10, OpcUaStatus.BadNoCommunication, tsX), })); - var dep = sub.ExpectMsg(TimeSpan.FromSeconds(3)); + var dep = sub.ExpectMsg(PresenceBudget); dep.TagId.ShouldBe("tag-x"); dep.Value.ShouldBe(10); dep.TimestampUtc.ShouldBe(tsX);