test(otopcua): tighten multi-device collapse assertion + clear warn-state on removal (follow-up E)
This commit is contained in:
@@ -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
|
// 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
|
// that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all
|
||||||
// shadowed by authored refs) contributes no entry.
|
// 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);
|
var plans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
|
||||||
foreach (var (host, nodes) in matchedNodes)
|
foreach (var (host, nodes) in matchedNodes)
|
||||||
{
|
{
|
||||||
@@ -1534,6 +1540,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
if (plansByEquipment.Count == 0)
|
if (plansByEquipment.Count == 0)
|
||||||
{
|
{
|
||||||
_discoveredByDriver.Remove(driverId);
|
_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
|
// 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
|
// 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
|
// otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only
|
||||||
|
|||||||
+52
-3
@@ -233,10 +233,15 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run");
|
byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run");
|
||||||
var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId;
|
var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId;
|
||||||
var eqBNodeId = byEquipment["EQ-B"].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.
|
// Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries NO host
|
||||||
eqANodeId.ShouldStartWith("EQ-A/");
|
// 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");
|
eqANodeId.ShouldNotContain("h1");
|
||||||
eqBNodeId.ShouldStartWith("EQ-B/");
|
eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run"));
|
||||||
eqBNodeId.ShouldNotContain("h2");
|
eqBNodeId.ShouldNotContain("h2");
|
||||||
|
|
||||||
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
|
// (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));
|
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
|
/// <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
|
/// 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>
|
/// side (the equipment can't be resolved without a composition).</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user