test(otopcua): tighten multi-device collapse assertion + clear warn-state on removal (follow-up E)

This commit is contained in:
Joseph Doherty
2026-06-26 15:16:59 -04:00
parent 50f08635ec
commit 0074f37a64
2 changed files with 61 additions and 3 deletions
@@ -770,6 +770,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses
// that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all
// shadowed by authored refs) contributes no entry.
// NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW
// StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device
// emits one consistent HostAddress string per device, so a partition is single-host either way (collapse
// fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be
// a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope
// already isolates them).
var plans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
foreach (var (host, nodes) in matchedNodes)
{
@@ -1534,6 +1540,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
if (plansByEquipment.Count == 0)
{
_discoveredByDriver.Remove(driverId);
// Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't
// leak a stale entry (log-level-only state; bounded by driver count — just tidiness).
_lastPartitionWarnSignature.Remove(driverId);
// FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its
// plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would
// otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only
@@ -233,10 +233,15 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run");
var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId;
var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId;
// Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries no host.
eqANodeId.ShouldStartWith("EQ-A/");
// Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries NO host
// segment and reads EQ-n/FOCAS/<leaf>/<name>. Assert the EXACT collapsed path (depth + leaf) so a
// collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
// fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
// (a lowercase-only "h1" check would miss a leaked raw "H1").
eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model"));
eqANodeId.ShouldNotContain("H1");
eqANodeId.ShouldNotContain("h1");
eqBNodeId.ShouldStartWith("EQ-B/");
eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run"));
eqBNodeId.ShouldNotContain("h2");
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
@@ -294,6 +299,50 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Warn-spam taming (follow-up E, part 2): an unmatched device-host partition WARNS exactly ONCE,
/// then the identical repeated re-discovery passes (the driver re-discovers ~15×/connect, re-sending the
/// same set) are quiet — proving <c>ShouldWarnPartition</c>'s per-driver signature dedup. The repeat is
/// logged at Debug, which the suite's <c>loglevel = WARNING</c> HOCON suppresses at source, so EventFilter
/// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on
/// pass 1; pass 2's matched routing is short-circuited by <c>PlansRoutingEqual</c>.)</summary>
[Fact]
public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
(Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
(Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
var discovered = new[]
{
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" },
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" },
BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
};
// Pass 1: the unmatched "h3" partition warns EXACTLY once.
EventFilter.Warning(contains: "discovered device-host partition(s) skipped")
.Expect(1, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)));
// Drain the matched EQ-A + EQ-B grafts from pass 1 so the assertion below is unambiguous.
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
// Pass 2: the IDENTICAL set (same revision) does NOT warn again — the repeat is Debug (suppressed by the
// suite's WARNING loglevel), so EventFilter sees ZERO further matching warnings.
EventFilter.Warning(contains: "discovered device-host partition(s) skipped")
.Expect(0, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)));
}
/// <summary>Guard: a <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving BEFORE any deployment
/// is applied (<c>_lastComposition</c> still null) is ignored — nothing is materialised on the publish
/// side (the equipment can't be resolved without a composition).</summary>