Merge #597/#599: real UnshelveTime, commented re-ack reports, native ack refused off-Primary, /alerts ack-path test; surgically-added raw nodes announced, equipment-with-references removal keeps references severed; Backing* parity guard

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:12:59 -04:00
16 changed files with 873 additions and 89 deletions
@@ -0,0 +1,81 @@
using Akka.Hosting;
using Akka.TestKit.Xunit2;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Gitea #597 — the AdminUI <c>/alerts</c> acknowledge / shelve path, from the page's client call down to
/// the command the cluster receives: <see cref="AdminOperationsClient"/> (what <c>Alerts.razor</c>'s
/// per-row buttons call) Asks the real <see cref="AdminOperationsActor"/> registered under
/// <see cref="AdminOperationsActorKey"/>, which maps the request to an <see cref="AlarmCommand"/> and hands
/// it to the mesh transport. Until now only the actor half had a test; the client seam the page goes
/// through had none.
/// </summary>
/// <remarks>
/// The razor click handler itself is not driven here — this repo has no bUnit. What it adds on top of the
/// client call is the operator name and <c>comment: null</c>, both reproduced below exactly as the page
/// passes them.
/// </remarks>
public sealed class AlertsAckPathTests : TestKit
{
private (AdminOperationsClient Client, Akka.TestKit.TestProbe Mesh) Wire()
{
var mesh = CreateTestProbe("mesh-router");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
AclScopeTestDb.Factory($"alerts-ack-{Guid.NewGuid():N}"),
coordinator: CreateTestProbe("coordinator").Ref,
probes: Enumerable.Empty<IDriverProbe>(),
meshRouter: mesh.Ref));
var registry = new ActorRegistry();
registry.Register<AdminOperationsActorKey>(actor);
return (new AdminOperationsClient(registry), mesh);
}
/// <summary>The page's Acknowledge reaches the cluster as an <c>Acknowledge</c> AlarmCommand on the
/// <c>alarm-commands</c> topic carrying the alarm id and the operator, and the page is told it was sent.</summary>
[Fact]
public async Task Acknowledge_from_the_alerts_page_dispatches_an_Acknowledge_command_and_reports_sent()
{
var (client, mesh) = Wire();
var result = await client.AcknowledgeAlarmAsync("alarm-42", "operator-jo", comment: null, CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Message.ShouldBeNull();
var sent = mesh.ExpectMsg<MeshCommand>(TimeSpan.FromSeconds(5));
sent.Topic.ShouldBe(AlarmCommandsTopic.Name);
var command = sent.Message.ShouldBeOfType<AlarmCommand>();
command.AlarmId.ShouldBe("alarm-42");
command.Operation.ShouldBe("Acknowledge");
command.User.ShouldBe("operator-jo");
command.Comment.ShouldBeNull();
command.UnshelveAtUtc.ShouldBeNull();
}
/// <summary>The page's timed shelve carries its expiry through to the command — the instant the engine
/// projects as the node's <c>UnshelveTime</c>.</summary>
[Fact]
public async Task Timed_shelve_from_the_alerts_page_carries_its_expiry_to_the_command()
{
var (client, mesh) = Wire();
var until = new DateTime(2026, 10, 8, 12, 30, 0, DateTimeKind.Utc);
var result = await client.ShelveAlarmAsync(
"alarm-7", "operator-kim", ShelveKind.Timed, unshelveAtUtc: until, comment: null, CancellationToken.None);
result.Ok.ShouldBeTrue();
var command = mesh.ExpectMsg<MeshCommand>(TimeSpan.FromSeconds(5)).Message.ShouldBeOfType<AlarmCommand>();
command.Operation.ShouldBe("TimedShelve");
command.UnshelveAtUtc.ShouldBe(until);
}
}
@@ -266,10 +266,11 @@ public sealed class ConditionMethodEventTests
/// reports exactly one unshelve event after the one shelve event.
/// </summary>
/// <remarks>
/// The method path applies the shelve with <c>shelvingTime 0</c>, as the projection does, so the expiry is
/// the engine's (computed from the command's <c>UnshelveAtUtc</c>), never the SDK timer's — a node timer
/// would only route an Unshelve back to the engine anyway. The stand-in engine here schedules its expiry
/// from the real <c>UnshelveAtUtc</c> the method handler computed.
/// The expiry is the engine's (computed from the command's <c>UnshelveAtUtc</c>). Since Gitea #597 the node
/// is shelved for the real duration, so the SDK's own timer fires at the same instant — but it only routes
/// an Unshelve back to the engine (ignored by this stand-in, a no-op on an unshelved engine alarm) and
/// changes no node state, so it adds no event. The stand-in engine here schedules its expiry from the real
/// <c>UnshelveAtUtc</c> the method handler computed.
/// </remarks>
[Fact]
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
@@ -326,17 +327,157 @@ public sealed class ConditionMethodEventTests
}
/// <summary>
/// Acknowledging an acknowledged condition reports nothing, and does not flip a scripted condition's
/// Confirmed out of step with its engine.
/// A scripted condition's events carry the real remaining shelve time in
/// <c>ShelvingState/UnshelveTime</c> — after a client TimedShelve, after the engine's re-projection of it,
/// and after an engine-driven timed shelve (the AdminUI <c>/alerts</c> path, which never calls the method).
/// </summary>
/// <remarks>
/// The SDK accepts a re-ack and sets Confirmed false again; the engine ignores it. The scripted leg
/// acknowledges and confirms first, so the flip would be visible on the sentinel.
/// Gitea #597: both paths applied the shelve with <c>shelvingTime 0</c>, which the SDK reads as "the
/// longest shelve allowed" — <c>MaxTimeShelved</c>, <c>int.MaxValue</c> ms — so every timed shelve
/// advertised ~24.8 days remaining while the engine expired it on time. Read through the event select
/// clause rather than a Read of the child node: the condition's children carry their type's
/// instance-declaration NodeIds (<c>i=9189</c> for UnshelveTime), so a direct Read reaches the type, not
/// this condition.
/// </remarks>
[Fact]
public async Task A_scripted_timed_shelve_reports_the_real_remaining_unshelve_time()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("CondUnshelveTime", encrypted: false, ct);
try
{
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.MaterialiseScripted();
var current = PreState("TimedShelve", native: false) with { Message = "base" };
rig.Nm.AlarmCommandRouter = cmd =>
{
if (cmd.Operation != "TimedShelve") return;
// The engine's answer carries its own expiry — what ScriptedAlarmHostActor projects.
current = current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = cmd.UnshelveAtUtc };
rig.ProjectLater(native: false, current);
};
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
double RemainingMs(string message) =>
(double)events.ConditionEvents(item, message)[^1][RecordedEvents.UnshelveTimeField].Value;
rig.Project(false, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
RemainingMs("base").ShouldBe(0d, "unshelved");
// The method's one event carries the shelve's remaining time …
await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 60_000);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
RemainingMs("base").ShouldBeInRange(50_000, 60_000, "the TimedShelve method event");
// … and so does a later transition, after the engine's re-projection re-applied the shelve.
rig.Project(false, current = current with { Message = "after-reprojection" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "after-reprojection"));
RemainingMs("after-reprojection").ShouldBeInRange(50_000, 60_000, "after the engine's re-projection");
// Engine-driven (the AdminUI path): unshelve, then a timed shelve that reaches the node only as a
// projection, never through the method.
rig.Project(false, current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null, Message = "unshelved" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "unshelved"));
RemainingMs("unshelved").ShouldBe(0d);
rig.Project(false, current with
{
Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = DateTime.UtcNow.AddSeconds(30), Message = "projected",
});
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "projected"));
RemainingMs("projected").ShouldBeInRange(20_000, 30_000, "a projected timed shelve");
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// On a node that is not the redundancy Primary, an OPC UA Acknowledge of a NATIVE condition is refused
/// with <c>BadInvalidState</c>: nothing is routed to the driver, nothing is applied, nothing is reported.
/// On the Primary the same call is accepted (the positive control).
/// </summary>
/// <remarks>
/// Gitea #597 decision. The ack used to be applied locally and reported, while <c>DriverHostActor</c>'s
/// Primary gate dropped the route upstream — so the driver never acknowledged, its next transition
/// projected <c>Acked = false</c>, and a client acking against the Secondary saw acked → unacked. An
/// honest refusal replaces an ack that visibly sticks and then reverts. Scripted conditions are
/// unaffected: their engine runs on both nodes, so the Secondary's ack is real.
/// </remarks>
[Fact]
public async Task A_native_acknowledge_on_a_non_primary_node_is_refused()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("CondNativeAckSecondary", encrypted: false, ct);
try
{
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.MaterialiseNative();
var current = PreState("Acknowledge", native: true) with { Message = "base" };
WireSimulatedOwners(rig, native: true, () => current, s => current = s);
var routed = 0;
var driverAck = rig.Nm.NativeAlarmAckRouter!;
rig.Nm.NativeAlarmAckRouter = ack => { Interlocked.Increment(ref routed); driverAck(ack); };
var primary = false;
rig.Nm.NativeAlarmAckPrimaryGate = () => primary;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
rig.Project(native: true, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
var condition = ConditionNodeId(session, native: true);
var refused = await Should.ThrowAsync<ServiceResultException>(
CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct));
refused.StatusCode.ShouldBe(StatusCodes.BadInvalidState);
routed.ShouldBe(0, "a refused ack reaches no driver");
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value
.ShouldBeFalse("a refused ack applies nothing");
// Positive control: the same call on the Primary is accepted, routed and reported.
primary = true;
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
await rig.DrainProjectionsAsync();
routed.ShouldBe(1);
rig.Project(native: true, current = current with { Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
var baseEvents = events.ConditionEvents(item, "base");
baseEvents.Count.ShouldBe(2, "the base transition plus the Primary's one ack event; the refusal reported nothing");
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Acknowledging an acknowledged condition WITHOUT a comment reports nothing, and does not flip a scripted
/// condition's Confirmed out of step with its engine. With a comment it reports exactly one event carrying
/// that comment — the comment is a Part 9 change in its own right, as AddComment is (Gitea #597) — and
/// still leaves Acked/Confirmed alone.
/// </summary>
/// <remarks>
/// The SDK accepts a re-ack, writes its comment, and sets Confirmed false again; the engine ignores it.
/// The scripted leg acknowledges and confirms first, so the flip would be visible on the sentinel. Before
/// #597 a commented re-ack wrote the comment to the node but reported nothing, so no subscriber ever saw
/// it.
/// </remarks>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_re_acknowledge_reports_nothing_and_changes_nothing(bool native)
[InlineData(false, null)]
[InlineData(true, null)]
[InlineData(false, "again")]
[InlineData(true, "again")]
public async Task A_re_acknowledge_reports_only_its_comment_and_changes_nothing_else(bool native, string? reAckComment)
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync($"CondReAck{native}", encrypted: false, ct);
@@ -347,6 +488,9 @@ public sealed class ConditionMethodEventTests
rig.MaterialiseScripted();
var current = PreState("Acknowledge", native) with { Message = "base" };
WireSimulatedOwners(rig, native, () => current, s => current = s);
var routedAcks = 0;
var driverAck = rig.Nm.NativeAlarmAckRouter!;
rig.Nm.NativeAlarmAckRouter = ack => { Interlocked.Increment(ref routedAcks); driverAck(ack); };
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
@@ -370,13 +514,32 @@ public sealed class ConditionMethodEventTests
}
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == expected);
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
var routedBefore = routedAcks;
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: reAckComment);
await rig.DrainProjectionsAsync();
rig.Project(native, current = current with { Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.ConditionEvents(item, "base").Count.ShouldBe(expected, "the re-ack must report nothing");
var baseEvents = events.ConditionEvents(item, "base");
if (reAckComment is null)
{
baseEvents.Count.ShouldBe(expected, "an uncommented re-ack must report nothing");
}
else
{
baseEvents.Count.ShouldBe(expected + 1, "a commented re-ack reports exactly one comment event");
var commented = baseEvents[^1];
(commented[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe(reAckComment);
commented[RecordedEvents.AckedField].Value.ShouldBe(true);
commented[RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the commented re-ack must not unconfirm the node");
}
// A native re-ack is still routed to the driver: the route is fire-and-forget, so a node showing
// Acked does not prove the upstream system acknowledged, and re-acking is the documented retry
// (docs/AlarmTracking.md, "Best-effort during reconnect"). Pinned so it is changed deliberately.
if (native) (routedAcks - routedBefore).ShouldBe(1);
var sentinel = events.ConditionEvents(item, "sentinel");
sentinel.Count.ShouldBe(1);
sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true);
@@ -467,11 +630,13 @@ public sealed class ConditionMethodEventTests
/// condition's method declarations — the shelve methods included); throws on a bad result.
/// </summary>
private static async Task CallAsync(
ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000)
ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000,
string? ackComment = "ack")
{
var (methodId, args) = method switch
{
"Acknowledge" => (MethodIds.AcknowledgeableConditionType_Acknowledge, new object[] { eventId!, new LocalizedText("ack") }),
"Acknowledge" => (MethodIds.AcknowledgeableConditionType_Acknowledge,
new object[] { eventId!, ackComment is null ? LocalizedText.Null : new LocalizedText(ackComment) }),
"Confirm" => (MethodIds.AcknowledgeableConditionType_Confirm, [eventId!, new LocalizedText("confirm")]),
"AddComment" => (MethodIds.ConditionType_AddComment, [eventId!, new LocalizedText("note")]),
"Enable" => (MethodIds.ConditionType_Enable, []),
@@ -310,6 +310,7 @@ internal sealed class RecordedEvents
public const int CommentField = 7;
public const int ChangesField = 8;
public const int SourceNodeField = 9;
public const int UnshelveTimeField = 10;
private readonly object _gate = new();
private readonly List<(uint Handle, VariantCollection Fields)> _events = [];
@@ -367,6 +368,8 @@ internal sealed class RecordedEvents
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Comment);
filter.AddSelectClause(ObjectTypeIds.GeneralModelChangeEventType, BrowseNames.Changes);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [BrowseNames.ShelvingState, BrowseNames.UnshelveTime]));
var item = new MonitoredItem(subscription.DefaultItem)
{
@@ -1459,6 +1459,62 @@ public sealed class AddressSpaceApplierTests
new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
}
/// <summary>
/// Gitea #599 (archreview 01/U-8 = 03/P6) — surgically-added RAW nodes are announced in the RAW realm.
/// A new tag under an existing device announces that device; a new TagGroup carrying a new tag under a
/// new Device announces only the Device's existing parent — the inner parents are themselves new, and a
/// client re-browsing the outer one discovers the whole new subtree. Before the fix only Uns-realm
/// parents were announced, so these adds were invisible to connected model-aware clients.
/// </summary>
[Fact]
public void AnnounceAddedNodes_announces_added_raw_nodes_in_the_raw_realm()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedRawContainers = new[]
{
new RawContainerNode("Plant/Modbus/dev2", "Plant/Modbus", "dev2", RawNodeKind.Device),
new RawContainerNode("Plant/Modbus/dev2/grp", "Plant/Modbus/dev2", "grp", RawNodeKind.TagGroup),
},
AddedRawTags = new[]
{
RawTag("Plant/Modbus/dev2/grp/speed") with { ParentNodeId = "Plant/Modbus/dev2/grp" },
RawTag("Plant/Modbus/dev1/temp"), // parent Plant/Modbus/dev1 already exists
},
// A UNS add in the same plan still announces in the Uns realm.
AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/speed", "speed") },
};
applier.AnnounceAddedNodes(plan);
sink.ModelChangeRealmQueue.OrderBy(x => x.NodeId, StringComparer.Ordinal).ToArray().ShouldBe(new[]
{
("Plant/Modbus", AddressSpaceRealm.Raw),
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
("eq-1", AddressSpaceRealm.Uns),
});
}
/// <summary>A top-level raw container (no parent: it hangs under the shared root, which has no
/// realm-qualified id) announces itself; duplicates collapse; the Uns-side computation is unaffected.</summary>
[Fact]
public void ComputeRawAddAnnouncements_top_level_container_announces_itself_and_dedups()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedRawContainers = new[] { new RawContainerNode("Plant2", null, "Plant2", RawNodeKind.Folder) },
AddedRawTags = new[] { RawTag("Plant/Modbus/dev1/a"), RawTag("Plant/Modbus/dev1/b") },
};
applier.ComputeRawAddAnnouncements(plan).ShouldBe(new[] { "Plant/Modbus/dev1", "Plant2" });
applier.ComputeAddAnnouncements(plan).ShouldBeEmpty();
}
// ----- Surgical v3 deltas: raw-tag attribute edits (Raw realm) + UNS-reference remove -----
// Task 4 taught AddressSpaceChangeClassifier that a non-alarm RawTagDelta and any UnsReferenceDelta are
// surgical-eligible; these pin the applier actually APPLYING them (before this, such a plan classified
@@ -1963,7 +2019,13 @@ public sealed class AddressSpaceApplierTests
public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
ModelChangeQueue.Enqueue(affectedNodeId);
ModelChangeRealmQueue.Enqueue((affectedNodeId, realm));
}
/// <summary>Gets the NodeAdded announcements WITH the realm each was raised in (Gitea #599).</summary>
public ConcurrentQueue<(string NodeId, AddressSpaceRealm Realm)> ModelChangeRealmQueue { get; } = new();
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
@@ -182,6 +182,104 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
await host.DisposeAsync();
}
// ---------- Gitea #599 (archreview 03/P5 + 03/S20): equipment-with-references removal ----------
/// <summary>
/// S20 — the subtree removal severs the inverse half of every cross-tree <c>Organizes</c> edge a removed
/// variable holds. The raw node survives a UNS-scoped removal, so without this it keeps a browsable,
/// unresolvable reference to a node that no longer exists — the hazard <c>RemoveVariableNode</c> was
/// already fixed for (2026-08-15 review I3), left open on the subtree path.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public async Task RemoveEquipmentSubtree_severs_the_inverse_Organizes_edge_on_the_surviving_raw_node()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/Modbus/dev1/temp", null, "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/Temp", "eq-1", "Temp", "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.AddReference("eq-1/Temp", AddressSpaceRealm.Uns, "Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw);
var mirror = nm.TryGetVariable("eq-1/Temp", AddressSpaceRealm.Uns)!.NodeId;
InverseOrganizesTo(nm, mirror).ShouldBeTrue(); // precondition: the edge really is there
nm.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
InverseOrganizesTo(nm, mirror).ShouldBeFalse();
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>
/// P5 — removing an equipment that holds UNS references is a scoped in-place removal, NOT a full rebuild,
/// and leaves the surviving raw node with no dangling inverse edge. Run for both id shapes: a reference
/// whose NodeId sits under the equipment id (so the subtree removal reaches it — before the fix the
/// reference loop then found it gone and ratcheted to a rebuild, severing every subscription on the
/// server), and the production shape, an <c>Area/Line/Equipment/Name</c> NodeId parented to an
/// id-named equipment folder (which the subtree's id-prefix scope does not reach).
/// </summary>
[Trait("Category", "Unit")]
[Theory]
[InlineData("eq-1/Temp")]
[InlineData("Area/Line/Eq/Temp")]
public async Task Removing_equipment_with_references_is_surgical_and_leaves_no_dangling_edge(string mirrorId)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/Modbus/dev1/temp", null, "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable(mirrorId, "eq-1", "Temp", "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.AddReference(mirrorId, AddressSpaceRealm.Uns, "Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw);
// A bystander whose value must survive (a rebuild would drop it).
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false, realm: AddressSpaceRealm.Uns);
var mirror = nm.TryGetVariable(mirrorId, AddressSpaceRealm.Uns)!.NodeId;
var plan = new AddressSpacePlan(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: new[] { new EquipmentNode("eq-1", "Equipment 1", "") },
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
{
RemovedUnsReferenceVariables = new[]
{
new UnsReferenceVariable("ref-1", "eq-1", mirrorId, "Temp", "Plant/Modbus/dev1/temp", "Float", Writable: false),
},
};
var outcome = new AddressSpaceApplier(new SdkAddressSpaceSink(nm),
Microsoft.Extensions.Logging.Abstractions.NullLogger<AddressSpaceApplier>.Instance).Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
nm.TryGetVariable(mirrorId, AddressSpaceRealm.Uns).ShouldBeNull();
nm.TryGetFolder("eq-1").ShouldBeNull();
nm.TryGetVariable("eq-2/S", AddressSpaceRealm.Uns).ShouldNotBeNull(); // positive control: no rebuild
nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
InverseOrganizesTo(nm, mirror).ShouldBeFalse();
}
finally
{
await host.DisposeAsync();
}
}
private static bool InverseOrganizesTo(OtOpcUaNodeManager nm, NodeId mirror) =>
nm.GetNodeReferences("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw)
.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && r.IsInverse && r.TargetId == mirror);
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
@@ -0,0 +1,158 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #599 / archreview 03/U11 — a mechanical guard on the <c>Backing*</c>-field parity invariant.
/// </summary>
/// <remarks>
/// <para>
/// A non-alarm raw-tag delta over the classifier's surgical whitelist is applied IN PLACE on the raw
/// node, and every referencing UNS mirror is repaired only because the same edit makes its
/// <see cref="UnsReferenceVariable"/> record unequal — the planner then emits a coincident
/// <c>UnsReferenceDelta</c>. That works only while every whitelisted raw-tag field the mirror inherits
/// has a counterpart on <see cref="UnsReferenceVariable"/> (same name, or <c>Backing</c> + name).
/// Omitting one is silent: the raw node updates, the mirror stays stale, and nothing fails. Until this
/// test the invariant was enforced by comments alone.
/// </para>
/// <para>
/// The whitelist is DISCOVERED, not restated: each <see cref="RawTagPlan"/> property is perturbed in
/// isolation and asked of <see cref="AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible"/>. So
/// widening the classifier whitelist without mirroring the field fails here, and so does adding a
/// <c>Backing*</c> field that names no raw-tag field.
/// </para>
/// </remarks>
public sealed class UnsReferenceBackingParityTests
{
/// <summary>
/// Surgically-eligible raw-tag fields that a UNS mirror deliberately does NOT inherit, each with the
/// reason. Anything else the classifier lets through surgically must be mirrored.
/// </summary>
private static readonly IReadOnlyDictionary<string, string> NotInheritedByMirror = new Dictionary<string, string>
{
// Node-inert for a value tag: it only drives native-alarm notifier wiring, and an alarm-bearing delta
// never takes the surgical path. The mirror is the referencing side, so it has nothing to inherit.
[nameof(RawTagPlan.ReferencingEquipmentPaths)] = "notifier wiring only; the mirror is the referencing side",
};
private static RawTagPlan Baseline() => new(
TagId: "tag-1",
NodeId: "MAIN/modbus/dev/grp/temp",
ParentNodeId: "MAIN/modbus/dev/grp",
DriverInstanceId: "drv-1",
Name: "temp",
DataType: "Float",
Writable: false,
Alarm: null,
ReferencingEquipmentPaths: new[] { "Area/Line/Eq" },
IsHistorized: false,
HistorianTagname: null,
IsArray: false,
ArrayLength: null,
Realm: AddressSpaceRealm.Raw);
/// <summary>The raw-tag fields the classifier lets through surgically, found by perturbing each one.</summary>
private static IReadOnlyList<PropertyInfo> SurgicallyEligibleRawTagFields()
{
var baseline = Baseline();
var eligible = new List<PropertyInfo>();
foreach (var p in RecordProperties(typeof(RawTagPlan)))
{
var current = Perturb(baseline, p);
if (AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible(new AddressSpacePlan.RawTagDelta(baseline, current)))
eligible.Add(p);
}
return eligible;
}
/// <summary>
/// Every raw-tag field the classifier applies surgically is mirrored on the UNS reference record (or is
/// explicitly listed as not inherited) — with the same type, so the planner's record equality sees the
/// edit.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public void Every_surgically_eligible_raw_tag_field_is_mirrored_on_the_UNS_reference_record()
{
var eligible = SurgicallyEligibleRawTagFields();
// Positive control: the perturbation machinery really does find the whitelist. If it found nothing,
// the loop below would pass vacuously.
eligible.Select(p => p.Name).ShouldBe(
new[]
{
nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths),
nameof(RawTagPlan.IsHistorized), nameof(RawTagPlan.HistorianTagname), nameof(RawTagPlan.IsArray),
nameof(RawTagPlan.ArrayLength),
},
ignoreOrder: true);
var mirror = RecordProperties(typeof(UnsReferenceVariable)).ToDictionary(p => p.Name, StringComparer.Ordinal);
foreach (var p in eligible)
{
if (NotInheritedByMirror.ContainsKey(p.Name)) continue;
var counterpart = mirror.GetValueOrDefault(p.Name) ?? mirror.GetValueOrDefault("Backing" + p.Name);
counterpart.ShouldNotBeNull(
$"RawTagPlan.{p.Name} is applied surgically on the raw node, but UnsReferenceVariable has neither " +
$"'{p.Name}' nor 'Backing{p.Name}' — a raw-side edit of it would leave every referencing UNS mirror stale.");
counterpart.PropertyType.ShouldBe(p.PropertyType, $"UnsReferenceVariable.{counterpart.Name} vs RawTagPlan.{p.Name}");
}
}
/// <summary>Every <c>Backing*</c> field on the UNS reference record names a real raw-tag field of the same
/// type — a misspelt or orphaned mirror field repairs nothing.</summary>
[Trait("Category", "Unit")]
[Fact]
public void Every_Backing_field_names_a_raw_tag_field_of_the_same_type()
{
var raw = RecordProperties(typeof(RawTagPlan)).ToDictionary(p => p.Name, StringComparer.Ordinal);
var backing = RecordProperties(typeof(UnsReferenceVariable))
.Where(p => p.Name.StartsWith("Backing", StringComparison.Ordinal) && p.Name != nameof(UnsReferenceVariable.BackingRawPath))
.ToList();
backing.ShouldNotBeEmpty();
foreach (var b in backing)
{
var source = raw.GetValueOrDefault(b.Name["Backing".Length..]);
source.ShouldNotBeNull($"UnsReferenceVariable.{b.Name} names no RawTagPlan field");
source.PropertyType.ShouldBe(b.PropertyType, $"UnsReferenceVariable.{b.Name} vs RawTagPlan.{source.Name}");
}
}
/// <summary>The allow-list stays honest: every entry must still be a surgically-eligible raw-tag field.</summary>
[Trait("Category", "Unit")]
[Fact]
public void Not_inherited_allow_list_names_only_surgically_eligible_fields()
{
var eligible = SurgicallyEligibleRawTagFields().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
foreach (var name in NotInheritedByMirror.Keys) eligible.ShouldContain(name);
}
private static IEnumerable<PropertyInfo> RecordProperties(Type t) =>
t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite && p.Name != "EqualityContract");
/// <summary>A copy of <paramref name="source"/> differing ONLY in <paramref name="property"/>.</summary>
private static RawTagPlan Perturb(RawTagPlan source, PropertyInfo property)
{
var clone = (RawTagPlan)typeof(RawTagPlan).GetMethod("<Clone>$")!.Invoke(source, null)!;
var value = property.GetValue(source);
object? changed = property.PropertyType switch
{
var t when t == typeof(bool) => !(bool)value!,
var t when t == typeof(string) => (string?)value + "-changed",
var t when t == typeof(uint?) => ((uint?)value ?? 0u) + 1u,
var t when t == typeof(IReadOnlyList<string>) => ((IReadOnlyList<string>)value!).Append("Other/Eq").ToArray(),
var t when t == typeof(AddressSpaceRealm) => (AddressSpaceRealm)value! == AddressSpaceRealm.Raw ? AddressSpaceRealm.Uns : AddressSpaceRealm.Raw,
var t when t == typeof(EquipmentTagAlarmInfo) => new EquipmentTagAlarmInfo("OffNormalAlarm", 500),
var t => throw new InvalidOperationException(
$"RawTagPlan.{property.Name} has type {t.Name}, which this guard cannot perturb — add a case so the field is checked."),
};
property.SetValue(clone, changed);
return clone;
}
}