test+docs: mixed-deploy survival, STATUS/P1 close-out (R2-07 T14)
This commit is contained in:
+88
@@ -225,6 +225,94 @@ public sealed class SubscriptionSurvivalTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>R2-07 Phase 3 — a subscription on tag A survives a MIXED deploy (−B, +C in one apply): A
|
||||
/// stays alive, B is gone (BadNodeIdUnknown), and C is browsable + readable.</summary>
|
||||
[Fact]
|
||||
public async Task Subscription_survives_mixed_add_remove_deploy()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-subsurvive-mix-{Guid.NewGuid():N}");
|
||||
var port = AllocateFreePort();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
try
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = ServerUri,
|
||||
ApplicationUri = ServerUri + ".Mixed",
|
||||
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);
|
||||
|
||||
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");
|
||||
sink.WriteValue(nodeIdAString, 10, 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 receivedA = 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 };
|
||||
miA.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) receivedA.Add(v); };
|
||||
subscription.AddItem(miA);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
await WaitUntilAsync(() => { lock (gate) return receivedA.Count >= 1; }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// MIXED apply: remove B, add C (previous={A,B}, next={A,C}).
|
||||
var withAC = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null));
|
||||
var plan = AddressSpacePlanner.Compute(withAB, withAC);
|
||||
plan.RemovedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
lock (gate) receivedA.Clear();
|
||||
var outcome = applier.Apply(plan);
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild
|
||||
// Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce.
|
||||
applier.MaterialiseEquipmentTags(withAC);
|
||||
applier.AnnounceAddedNodes(plan);
|
||||
|
||||
// A stays alive.
|
||||
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.
|
||||
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
|
||||
(await session.ReadValueAsync(nodeIdB, ct)).StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// C is browsable + readable.
|
||||
var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns);
|
||||
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
var cRead = await session.ReadValueAsync(nodeIdC, ct);
|
||||
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
cRead.Value.ShouldBe(9);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) =>
|
||||
new(
|
||||
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||
|
||||
Reference in New Issue
Block a user