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:
Joseph Doherty
2026-07-16 11:30:13 -04:00
parent 77c39bf02d
commit 2e0743ad25
34 changed files with 616 additions and 265 deletions
@@ -1,6 +1,7 @@
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
@@ -24,7 +25,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
var probe = CreateTestProbe();
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
var routed = probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
routed.NodeId.ShouldBe("eq-1/speed");
@@ -43,7 +44,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
var probe = CreateTestProbe();
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary"));
@@ -63,7 +64,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
resolveDriverHost: () => probe.Ref, NullLogger.Instance,
askTimeout: TimeSpan.FromMilliseconds(200));
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldNotBeNull();
@@ -77,7 +78,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
{
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance);
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldBe("writes unavailable");
@@ -18,6 +18,18 @@ internal static class DarkAddressSpaceReasons
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
/// <summary>Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's
/// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
/// raw tags are authored explicitly via the Batch-2 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
/// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
/// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
public const string DiscoveryInjectionDormantV3 =
"v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " +
"v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " +
"browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " +
"re-migrating injection onto the raw device subtree is a separate follow-up.";
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired =
"v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" +
@@ -0,0 +1,84 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// v3 Batch 4 (Wave-B review recommendation) — the artifact-decode seam
/// (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) and the live compose seam
/// (<see cref="AddressSpaceComposer.Compose"/>) must produce BYTE-IDENTICAL <b>Raw</b> and <b>UNS</b> node
/// sets for the same seeded config. This is the guard that a future artifact-decode drift can't silently
/// diverge the deployed dual-namespace address space from what was authored/validated. The existing
/// <c>DeploymentArtifactEquipRefParityTests</c> covers <c>{{equip}}</c> script-path parity; this covers the
/// RawContainers / RawTags / UnsReferenceVariables sets (RawPaths, UNS NodeIds, Organizes backing paths,
/// writable + historian + array shape).
/// </summary>
public sealed class DeploymentArtifactRawUnsParityTests
{
[Fact]
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
{
// Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override,
// one plain read-only), an area/line/equipment, and a UNS reference with a display-name override.
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" };
var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null };
var speed = new Tag
{
TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}",
};
var run = new Tag
{
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" };
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { speed, run },
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1", ClusterId = "c1" } },
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1", ClusterId = "c1" } },
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, DeviceId = "dev-1", Name = "Fast" } },
Tags = new object[]
{
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" } },
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised).
composed.RawTags.Count.ShouldBe(2);
composed.UnsReferenceVariables.Count.ShouldBe(1);
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
// Byte-parity: the three dual-namespace node sets are record-equal between the two seams.
decoded.RawContainers.ShouldBe(composed.RawContainers);
decoded.RawTags.ShouldBe(composed.RawTags);
decoded.UnsReferenceVariables.ShouldBe(composed.UnsReferenceVariables);
}
}
@@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
/// overwrote the BadWaitingForInitialData seed.
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
{
var db = NewInMemoryDbFactory();
@@ -154,7 +154,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_and_value_survive_a_redeploy()
{
var db = NewInMemoryDbFactory();
@@ -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();
@@ -6,6 +6,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
@@ -51,7 +52,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -75,7 +76,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Reason.ShouldNotBe("not primary (role unknown)");
@@ -100,7 +101,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0), asker1.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref);
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Success.ShouldBeTrue(res.Reason);
@@ -109,7 +110,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
// Secondary snapshot ⇒ denied with the steady-state reason.
TellRole(actor, RedundancyRole.Secondary);
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0), asker2.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref);
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
denied.Success.ShouldBeFalse();
denied.Reason.ShouldBe("not primary");
@@ -126,7 +127,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
AwaitAssert(() =>
@@ -3,6 +3,7 @@ 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;
@@ -87,7 +88,7 @@ public sealed class DriverHostActorProbeResultDropTests : RuntimeActorTestBase
// 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), asker.Ref);
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.
@@ -8,6 +8,7 @@ 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.Messages.Redundancy;
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;
@@ -56,7 +57,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var asker = CreateTestProbe();
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
@@ -79,7 +80,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
@@ -110,7 +111,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
CorrelationId.NewId()));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -129,7 +130,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -149,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
localRoles: new HashSet<string> { "driver" }));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
result.Success.ShouldBeFalse();
@@ -157,6 +158,46 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
result.Reason!.ShouldContain("stale");
}
/// <summary>
/// Review H1 regression — a raw <c>RawPath</c> and a UNS path that share a BARE <c>s=</c> id but back
/// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag <c>A/B/C/D</c> on
/// <c>drv-1</c> and a UnsTagReference whose UNS NodeId is ALSO <c>A/B/C/D</c> (Area A / Line B / Equip C
/// / effective name D) but which backs a raw tag <c>X/Y/Z/W</c> on <c>drv-2</c>. A bare-only routing key
/// would collide (last-writer-wins → wrong device); the <c>(realm, bareId)</c> key keeps them distinct.
/// </summary>
[Fact]
public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm()
{
var db = NewInMemoryDbFactory();
var factory = new PerInstanceRecordingDriverFactory("Modbus");
var deploymentId = SeedCollisionDeployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, factory);
// Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D.
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref);
asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
// Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W.
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref);
asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
// drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222).
var w1 = factory.WritesFor("drv-1");
var w2 = factory.WritesFor("drv-2");
w1.Count.ShouldBe(1);
w1[0].FullReference.ShouldBe("A/B/C/D");
w1[0].Value.ShouldBe(111.0);
w2.Count.ShouldBe(1);
w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id
w2[0].Value.ShouldBe(222.0);
}, duration: Timeout);
}
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
/// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
/// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
@@ -214,6 +255,52 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
return id;
}
/// <summary>Seeds the review-H1 collision config: raw tag <c>A/B/C/D</c> on drv-1, and a UnsTagReference
/// (Area A / Line B / Equip C, effective name D → UNS NodeId <c>A/B/C/D</c>) backing raw tag <c>X/Y/Z/W</c>
/// on drv-2. Both drivers ENABLED (Modbus) so both children spawn.</summary>
private static DeploymentId SeedCollisionDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[]
{
new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" },
new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[]
{
new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" },
new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } },
// Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2.
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } },
});
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;
}
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
/// into the Stale path.</summary>
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
@@ -223,6 +310,41 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
throw new InvalidOperationException("config DB unreachable (test stub)");
}
/// <summary>Factory producing a SEPARATE <see cref="RecordingDriver"/> per driver instance id, so a test
/// with two drivers can assert which instance received a given write (used by the H1 collision test).</summary>
private sealed class PerInstanceRecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly Dictionary<string, RecordingDriver> _byId = new(StringComparer.Ordinal);
private readonly object _lock = new();
public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The writes the given driver instance received.</summary>
public IReadOnlyList<WriteRequest> WritesFor(string driverInstanceId)
{
lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty<WriteRequest>();
}
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
lock (_lock)
{
if (!_byId.TryGetValue(driverInstanceId, out var d))
{
d = new RecordingDriver();
d.Bind(driverInstanceId, driverType);
_byId[driverInstanceId] = d;
}
return d;
}
}
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
private sealed class RecordingDriverFactory : IDriverFactory
{
@@ -51,7 +51,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
NodeId: "ns=2;s=tag-1",
Value: 42,
Quality: OpcUaQuality.Good,
TimestampUtc: DateTime.UtcNow));
TimestampUtc: DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssertion(() =>
{
@@ -20,8 +20,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
{
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
@@ -43,8 +43,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssert(() =>
{
@@ -64,7 +64,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow));
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssert(() =>
{
@@ -138,7 +138,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
}
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true, Realm: AddressSpaceRealm.Uns) and an
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
[Fact]
public void Dependency_change_above_threshold_activates_alarm()
@@ -180,7 +180,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
}
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
/// — AlarmStateUpdate(Active=false, Realm: AddressSpaceRealm.Uns) + AlarmTransitionEvent("Cleared").</summary>
[Fact]
public void Dependency_change_below_threshold_clears_alarm()
{
@@ -279,7 +279,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
/// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is
/// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting
/// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
/// AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
/// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary>
[Fact]
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
@@ -573,7 +573,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
/// <summary>Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound
/// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active
/// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true)
/// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns)
/// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent).</summary>
[Fact]
public void Inbound_AlarmCommand_is_processed_regardless_of_role()