test(opcua): subscription-survival on pure-remove + live-verify note (R2-07 T12)

This commit is contained in:
Joseph Doherty
2026-07-13 12:19:36 -04:00
parent 502d7650d0
commit f4ea834fac
3 changed files with 92 additions and 3 deletions
@@ -137,6 +137,94 @@ public sealed class SubscriptionSurvivalTests
}
}
/// <summary>R2-07 Phase 2 — a subscription on tag A survives a PURE-REMOVE deploy that removes tag B; B's
/// monitored item observes a final Bad and the node then disappears (a re-read returns BadNodeIdUnknown),
/// while A stays alive.</summary>
[Fact]
public async Task Subscription_on_surviving_tag_survives_pure_remove_of_another_tag()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-subsurvive-rm-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = ServerUri,
ApplicationUri = ServerUri + ".Remove",
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Materialise A + B and seed both Good.
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
var nodeIdBString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var nodeIdA = new NodeId(nodeIdAString, ns);
var nodeIdB = new NodeId(nodeIdBString, ns);
var receivedA = new List<DataValue>();
var receivedB = new List<DataValue>();
var gate = new object();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var miA = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeIdA, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10 };
var miB = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeIdB, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10 };
miA.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) receivedA.Add(v); };
miB.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) receivedB.Add(v); };
subscription.AddItem(miA);
subscription.AddItem(miB);
await subscription.ApplyChangesAsync(ct);
await WaitUntilAsync(() => { lock (gate) return receivedA.Count >= 1 && receivedB.Count >= 1; }, TimeSpan.FromSeconds(5));
// Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}).
var withA = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(withAB, withA);
plan.RemovedEquipmentTags.Count.ShouldBe(1);
lock (gate) { receivedA.Clear(); receivedB.Clear(); }
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // PureRemove — no full rebuild
// B's monitored item observes a final Bad; the node is then gone from the address space.
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
// A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read returns BadNodeIdUnknown.
var bRead = await session.ReadValueAsync(nodeIdB, ct);
bRead.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
}
finally
{
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
}
}
private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) =>
new(
UnsAreas: Array.Empty<UnsAreaProjection>(),