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
This commit is contained in:
+43
-16
@@ -49,12 +49,39 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited.
|
||||
/// A driver reports a FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>, but the host must
|
||||
/// NOT materialise anything (no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> reaches the publish
|
||||
/// side) — discovered raw tags are authored via the <c>/raw</c> browse-commit flow, not injected at runtime.
|
||||
/// The skipped v2 injection scenarios below are the counterpart of this guard.</summary>
|
||||
[Fact]
|
||||
public void Discovered_nodes_are_ignored_dormant_in_v3()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var factory = new SubscribingDriverFactory("Modbus");
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
|
||||
{
|
||||
new DiscoveredNode(
|
||||
FolderPathSegments: new[] { "FOCAS", "Identity" },
|
||||
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
|
||||
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
|
||||
Writable: false, IsHistorized: false),
|
||||
}));
|
||||
|
||||
// Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise).
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
|
||||
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
|
||||
/// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the
|
||||
/// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
|
||||
/// live-value routing map was extended).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -121,7 +148,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver
|
||||
/// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with
|
||||
/// "no equipment/authored tags".</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -198,7 +225,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
|
||||
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
|
||||
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -264,7 +291,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here
|
||||
/// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only
|
||||
/// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -305,7 +332,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// 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(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -378,7 +405,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
|
||||
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
|
||||
/// are materialised.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -413,7 +440,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to
|
||||
/// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
|
||||
/// FixedTree still converges.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -469,7 +496,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached
|
||||
/// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's
|
||||
/// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Discovered_nodes_survive_a_redeploy_rebuild()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -536,7 +563,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a
|
||||
/// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell
|
||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Discovered_nodes_dropped_when_equipment_no_longer_resolves()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -578,7 +605,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
|
||||
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
|
||||
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -627,7 +654,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
|
||||
/// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
|
||||
/// under the new equipment EQ-2.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Config_unchanged_rebind_re_triggers_discovery_on_the_child()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -706,7 +733,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
|
||||
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
|
||||
/// pass.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void No_drop_redeploy_does_not_re_trigger_discovery()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -757,7 +784,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): the count rises by EXACTLY 1 across the
|
||||
/// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only
|
||||
/// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".)</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -823,7 +850,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
|
||||
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — a different message type the non-discovery
|
||||
/// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -885,7 +912,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// authored-only <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> per redeploy (the re-inject tail
|
||||
/// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's
|
||||
/// send. Observed via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
@@ -934,7 +961,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
|
||||
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
|
||||
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
|
||||
Reference in New Issue
Block a user