Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs
T
Joseph Doherty 2e0743ad25 fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3)
H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only.
A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare
strings; the bare-only key let a colliding raw+UNS pair route to the WRONG
driver ref (last-writer-wins). The realm the node manager resolves (RealmOf)
is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite ->
_driverRefByNodeId keyed by (realm, bareId). New regression test:
Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm.

M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes
hard-short-circuits (single enforcement point; _discoveredByDriver never
populates so the re-inject tail is inert too), with a clear log pointing at the
/raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3;
the 16+2 v2 injection scenarios re-pointed to an accurate skip reason
(DiscoveryInjectionDormantV3).

M2 (MEDIUM): realm-qualified dual-node self-correction tests —
Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched +
Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped).

L1: removed the = AddressSpaceRealm.Uns defaults from the consequential
node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/
EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/
RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the
AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit
realm; read-only accessors + internal builders retain their defaults.

L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the
retired EquipmentNodeIds.Variable).

Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity
round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 11:30:13 -04:00

108 lines
6.1 KiB
C#

using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using Xunit;
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.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Guard test: verifies that a <see cref="PeerOpcUaProbeActor.OpcUaProbeResult"/> published on the
/// <c>redundancy-state</c> DistributedPubSub topic does NOT produce a dead-letter in
/// <see cref="DriverHostActor"/>.
///
/// <para>
/// Background: <see cref="DriverHostActor"/> subscribes the <c>redundancy-state</c> topic so
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy.RedundancyStateChanged"/> snapshots
/// land and cache the node's role. The SAME topic also carries
/// <see cref="PeerOpcUaProbeActor.OpcUaProbeResult"/> messages (published by
/// <see cref="OpcUaPublishActor"/> peer-probes) which <see cref="DriverHostActor"/> does not
/// consume. Without an explicit drop handler the actor logs an unhandled-message warning and
/// the message becomes a dead-letter — noisy but benign. The fix adds an intentional-drop
/// <c>Receive</c> in every behaviour (<c>Steady</c>, <c>Applying</c>, <c>Stale</c>), mirroring
/// <see cref="PeerProbeSupervisor"/>.
/// </para>
///
/// <para>
/// The test uses <see cref="DriverHostActor"/>'s <c>Stale</c> path (bootstrapped with a
/// throwing DB factory) because it requires no deployment artifact or apply round-trip, making
/// the harness minimal while still exercising the subscription and the message-dispatch fix.
/// The Stale behaviour is one of the three that was patched; <c>Steady</c> and <c>Applying</c>
/// are covered by inspection + build (all three use the same <c>Receive</c> registration
/// pattern, so a compile-time pass on the Runtime project is the correctness gate for those).
/// </para>
/// </summary>
public sealed class DriverHostActorProbeResultDropTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("probe-drop-test");
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>
/// A <see cref="PeerOpcUaProbeActor.OpcUaProbeResult"/> published on the redundancy-state
/// topic must NOT produce an <see cref="AllDeadLetters"/> event on the
/// <see cref="ActorSystem.EventStream"/> after the fix.
/// </summary>
[Fact]
public void ProbeResult_on_redundancy_topic_does_not_dead_letter_in_DriverHostActor()
{
// Wire a dead-letter probe that ONLY passes through messages carrying an OpcUaProbeResult
// payload. Everything else (SubscribeAck, health-poll noise, etc.) is ignored so the probe
// mailbox is precise and ExpectNoMsg is race-free.
var deadLetterProbe = CreateTestProbe();
deadLetterProbe.IgnoreMessages(
m => m is not AllDeadLetters { Message: PeerOpcUaProbeActor.OpcUaProbeResult });
Sys.EventStream.Subscribe(deadLetterProbe.Ref, typeof(AllDeadLetters));
// Spin up the actor in Stale state (ThrowingDbFactory ⇒ Bootstrap's catch ⇒ Become(Stale)).
// Stale subscribes the redundancy-state topic in PreStart, so it will receive the probe
// message once the subscription is established.
var actor = Sys.ActorOf(DriverHostActor.Props(
new ThrowingDbFactory(), TestNode, CreateTestProbe().Ref,
localRoles: new HashSet<string> { "driver" }));
// Wait until the actor has subscribed to the redundancy-state topic. We do this by having a
// test probe subscribe to the same topic and waiting for its ack: once BOTH subscriptions are
// registered the mediator will fan-out any subsequent Publish to both subscribers. This avoids
// a fixed sleep and is the same barrier DriverHostActorNativeAlarmTests uses for the alerts topic.
var barrierProbe = CreateTestProbe();
DistributedPubSub.Get(Sys).Mediator.Tell(
new Subscribe(OpcUaPublishActor.RedundancyStateTopic, barrierProbe.Ref),
barrierProbe.Ref);
barrierProbe.ExpectMsg<SubscribeAck>(Timeout);
// Publish an OpcUaProbeResult to the redundancy-state topic. The mediator fans it out to
// every subscriber — including the DriverHostActor — so it lands on the actor's mailbox.
DistributedPubSub.Get(Sys).Mediator.Tell(
new Publish(OpcUaPublishActor.RedundancyStateTopic,
new PeerOpcUaProbeActor.OpcUaProbeResult(TestNode, Ok: true)));
// BARRIER: send a synchronous RouteNodeWrite (which Stale handles synchronously with an
// immediate fast-fail reply) and await its response. Once the reply arrives, the actor has
// drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled
// OpcUaProbeResult would already be on the EventStream before we assert.
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
// Assert no dead-letter carrying an OpcUaProbeResult was published.
deadLetterProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
/// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c>
/// path (the same stub used in <c>DriverHostActorWriteRoutingTests.Stale_host_fast_fails_route_node_write</c>).</summary>
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <inheritdoc />
public OtOpcUaConfigDbContext CreateDbContext() =>
throw new InvalidOperationException("config DB unreachable (test stub)");
}
}