Merge R2-07 Surgical pure-adds (arch-review round 2) [PR #436]
Finding 03/P1: surgical in-place address-space adds/removals skip full rebuilds (AddressSpaceChangeClassifier default-closed to Rebuild). 3 new ISurgicalAddressSpaceSink remove members, guard-first + forwarded through DeferredAddressSpaceSink (F10b inertness net green). T5/T6/T12/T14 over-the-wire + docker-dev gates deferred. Auto-merged OtOpcUaNodeManager.cs with R2-08; verified OpcUaServer.Tests 341/341 + forwarding guard 3/3. Build clean.
This commit is contained in:
@@ -114,6 +114,44 @@ public class DeferredAddressSpaceSinkTests
|
||||
surgical.FolderRenameCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------- R2-07 Phase 2: surgical remove forwarding ----------
|
||||
|
||||
[Fact]
|
||||
public void Remove_members_return_false_for_non_surgical_inner()
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_members_forward_to_surgical_inner()
|
||||
{
|
||||
var surgical = new SpySurgicalSink();
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(surgical);
|
||||
|
||||
sink.RemoveVariableNode("v-1").ShouldBeTrue();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
||||
|
||||
surgical.RemoveVariableCalled.ShouldBeTrue();
|
||||
surgical.RemoveAlarmCalled.ShouldBeTrue();
|
||||
surgical.RemoveSubtreeCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_SetSink_remove_members_return_false()
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------- SetSink(null) reverts to null sink ----------
|
||||
|
||||
[Fact]
|
||||
@@ -183,5 +221,13 @@ public class DeferredAddressSpaceSinkTests
|
||||
FolderRenameCalled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveVariableCalled { get; private set; }
|
||||
public bool RemoveAlarmCalled { get; private set; }
|
||||
public bool RemoveSubtreeCalled { get; private set; }
|
||||
|
||||
public bool RemoveVariableNode(string variableNodeId) { RemoveVariableCalled = true; return true; }
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveAlarmCalled = true; return true; }
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveSubtreeCalled = true; return true; }
|
||||
}
|
||||
}
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-07 03/P1 — the HEADLINE proof: an over-the-wire subscription on an existing tag SURVIVES a
|
||||
/// pure-add deploy (one new tag), instead of being killed by the full-rebuild teardown that pre-R2-07
|
||||
/// every topology change forced. Boots the real <see cref="OtOpcUaSdkServer"/>, materialises tag A,
|
||||
/// opens a real client subscription + monitored item on A, then drives a PURE-ADD apply adding tag B
|
||||
/// (classifier ⇒ PureAdd ⇒ no rebuild ⇒ the idempotent Materialise passes create B and leave A's
|
||||
/// NodeState untouched), and asserts:
|
||||
/// <list type="bullet">
|
||||
/// <item>A's monitored item still delivers a subsequent value write (the item is alive — no
|
||||
/// <c>BadNodeIdUnknown</c>, no dead subscription);</item>
|
||||
/// <item>B is browsable + readable (the add landed).</item>
|
||||
/// </list>
|
||||
/// Written against the master (pre-T2) behaviour this FAILS (the rebuild recreates A as a new NodeState
|
||||
/// and the monitored item goes dead); it passes once the classifier routing (T2) + the publish-actor
|
||||
/// announce (T4b) land.
|
||||
/// <para><b>Run note:</b> this is a heavy in-process server+client integration test — run it in the
|
||||
/// serial integration pass (it is NOT part of the lightweight unit sweep). The <see cref="OtOpcUaNodeManager"/>
|
||||
/// namespace URI is <see cref="OtOpcUaNodeManager.DefaultNamespaceUri"/>; the client resolves the
|
||||
/// folder-scoped variable NodeIds against it.</para>
|
||||
/// </summary>
|
||||
public sealed class SubscriptionSurvivalTests
|
||||
{
|
||||
private const string ServerUri = "urn:OtOpcUa.SubscriptionSurvival";
|
||||
|
||||
/// <summary>A subscription on tag A survives a pure-add deploy adding tag B; B becomes readable.</summary>
|
||||
[Fact]
|
||||
public async Task Subscription_on_existing_tag_survives_pure_add_of_another_tag()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-subsurvive-{Guid.NewGuid():N}");
|
||||
var port = AllocateFreePort();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
try
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = ServerUri,
|
||||
ApplicationUri = ServerUri,
|
||||
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 tag A (equipment eq-1, tag "A") and seed it a Good value. ---
|
||||
var withA = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
|
||||
applier.MaterialiseHierarchy(withA);
|
||||
applier.MaterialiseEquipmentTags(withA);
|
||||
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
|
||||
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
|
||||
// --- Open a client session + subscription + monitored item on A. ---
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||
ns.ShouldBeGreaterThan((ushort)0);
|
||||
var nodeIdA = new NodeId(nodeIdAString, ns);
|
||||
|
||||
var received = new List<DataValue>();
|
||||
var gate = new object();
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
|
||||
var monitored = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = nodeIdA,
|
||||
AttributeId = Attributes.Value,
|
||||
SamplingInterval = 50,
|
||||
QueueSize = 10,
|
||||
};
|
||||
monitored.Notification += (item, _) =>
|
||||
{
|
||||
foreach (var v in item.DequeueValues())
|
||||
lock (gate) received.Add(v);
|
||||
};
|
||||
subscription.AddItem(monitored);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
// Baseline: the initial value (41) is delivered.
|
||||
await WaitUntilAsync(() => { lock (gate) return received.Count >= 1; }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). ---
|
||||
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));
|
||||
var plan = AddressSpacePlanner.Compute(withA, withAB);
|
||||
plan.AddedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown
|
||||
|
||||
// Materialise passes (as the publish actor runs them) then announce — over the FULL composition.
|
||||
applier.MaterialiseHierarchy(withAB);
|
||||
applier.MaterialiseEquipmentTags(withAB);
|
||||
applier.AnnounceAddedNodes(plan);
|
||||
|
||||
// --- A's monitored item must still be alive: a new write to A is delivered. ---
|
||||
lock (gate) received.Clear();
|
||||
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
|
||||
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
|
||||
lock (gate)
|
||||
{
|
||||
received.ShouldContain(v => Equals(v.Value, 42));
|
||||
received.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
// --- B is browsable + readable (the add landed). ---
|
||||
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
|
||||
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
var bValue = await session.ReadValueAsync(nodeIdB, ct);
|
||||
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
|
||||
bValue.Value.ShouldBe(7);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>(),
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Equipment 1", "") },
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = tags,
|
||||
};
|
||||
|
||||
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUa.SubscriptionSurvivalClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.SubscriptionSurvivalClient.{Guid.NewGuid():N}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier(),
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
configuredEndpoint,
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "SubscriptionSurvivalTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()),
|
||||
preferredLocales: null,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
condition().ShouldBeTrue("condition not met within timeout");
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -23,7 +23,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
var sink = new ConfigurableThrowingSink { ThrowOnRebuild = true };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var outcome = applier.Apply(AddedEquipmentPlan("new"));
|
||||
var outcome = applier.Apply(RebuildingPlan("eq-1"));
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildFailed.ShouldBeTrue();
|
||||
@@ -36,7 +36,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
var sink = new ConfigurableThrowingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var outcome = applier.Apply(AddedEquipmentPlan("new"));
|
||||
var outcome = applier.Apply(RebuildingPlan("eq-1"));
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildFailed.ShouldBeFalse();
|
||||
@@ -121,10 +121,18 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
EquipmentScriptedAlarms = alarms,
|
||||
};
|
||||
|
||||
private static AddressSpacePlan AddedEquipmentPlan(string id) => new(
|
||||
AddedEquipment: new[] { new EquipmentNode(id, id, "line-1") },
|
||||
// A rebuild-forcing plan: a node-affecting ChangedEquipment delta classifies as Rebuild (R2-07), so
|
||||
// these tests exercise the SafeRebuild path (RebuildCalled/RebuildFailed) regardless of the pure-add
|
||||
// routing. A pure-add plan would no longer rebuild, so the failure-surface tests must use a change.
|
||||
private static AddressSpacePlan RebuildingPlan(string id) => new(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
ChangedEquipment: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentDelta(
|
||||
new EquipmentNode(id, id, "line-1"),
|
||||
new EquipmentNode(id, id + "-renamed", "line-1")),
|
||||
},
|
||||
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||
|
||||
+6
-4
@@ -64,7 +64,8 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; provisioning still fires
|
||||
outcome.AddedNodes.ShouldBeGreaterThan(0);
|
||||
|
||||
// Fire-and-forget: await the capturing double's signal so the assertion is deterministic.
|
||||
await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
@@ -108,7 +109,7 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
var outcome = applier.Apply(PlanWithAddedTags(
|
||||
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
|
||||
}
|
||||
|
||||
/// <summary>The default ctor (no provisioner) binds the no-op <see cref="NullHistorianProvisioning"/>
|
||||
@@ -121,7 +122,8 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
var outcome = applier.Apply(PlanWithAddedTags(
|
||||
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; default no-op provisioning still safe
|
||||
outcome.AddedNodes.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>An added historized tag whose DataType string is not a <see cref="DriverDataType"/> is
|
||||
@@ -319,7 +321,7 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
var outcome = applier.Apply(PlanWithAddedTags(
|
||||
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
|
||||
}
|
||||
|
||||
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||
|
||||
@@ -25,9 +25,12 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removed equipment writes inactive alarm state and triggers rebuild.</summary>
|
||||
/// <summary>R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal
|
||||
/// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via
|
||||
/// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the
|
||||
/// pre-R2-07 "removed equipment ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
|
||||
public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -36,16 +39,20 @@ public sealed class AddressSpaceApplierTests
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RemovedNodes.ShouldBe(2);
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
// Terminal "no-event" condition state written per id (inactive + acked + confirmed).
|
||||
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
|
||||
// Removed nodes are reset to the "no-event" state: inactive + acked + confirmed + enabled.
|
||||
sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
// Each equipment torn down as a subtree.
|
||||
sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that added equipment triggers rebuild without writing alarm state.</summary>
|
||||
/// <summary>R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
|
||||
/// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm
|
||||
/// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Added_equipment_triggers_rebuild_without_alarm_writes()
|
||||
public void Added_equipment_is_pure_add_and_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -63,10 +70,10 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
|
||||
@@ -675,11 +682,11 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.ModelChangeCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
|
||||
/// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no
|
||||
/// longer a silent no-op).</summary>
|
||||
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
|
||||
/// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Added_equipment_tags_trigger_rebuild()
|
||||
public void Added_equipment_tags_are_pure_add_and_skip_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -694,16 +701,16 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that added Equipment VirtualTags in an otherwise-empty plan trigger an
|
||||
/// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags,
|
||||
/// so a VirtualTag-only deploy is no longer a silent no-op).</summary>
|
||||
/// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒
|
||||
/// rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Added_equipment_virtual_tags_trigger_rebuild()
|
||||
public void Added_equipment_virtual_tags_are_pure_add_and_skip_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -719,8 +726,289 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on
|
||||
/// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The
|
||||
/// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is
|
||||
/// surgical-eligible); the applier still runs the F10b surgical pass.</summary>
|
||||
[Fact]
|
||||
public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
ChangedEquipmentTags = new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
call.Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node
|
||||
/// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is
|
||||
/// preserved through the classifier routing).</summary>
|
||||
[Fact]
|
||||
public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink { SurgicalReturns = false };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
ChangedEquipmentTags = new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the
|
||||
/// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2
|
||||
/// phase pin.)</summary>
|
||||
[Fact]
|
||||
public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
|
||||
// Terminal Bad written to the removed variable BEFORE the removal.
|
||||
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing
|
||||
/// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION
|
||||
/// node in place (not a value variable).</summary>
|
||||
[Fact]
|
||||
public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
|
||||
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
|
||||
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
|
||||
var write = sink.AlarmWrites.ShouldHaveSingleItem();
|
||||
write.NodeId.ShouldBe(nodeId);
|
||||
(!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue();
|
||||
// Removed as a condition node, not a value variable.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId));
|
||||
sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
|
||||
/// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
|
||||
[Fact]
|
||||
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
RemovedEquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
// ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1"));
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier
|
||||
/// falls back to a full rebuild (the one-way ratchet) and attempts no further surgical removes.</summary>
|
||||
[Fact]
|
||||
public void Pure_remove_with_remove_returns_false_falls_back_to_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink { RemoveReturns = false };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
// Ratchet: stopped at the FIRST failed remove — did not attempt the second.
|
||||
sink.RemoveCalls.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — a PureRemove on a sink lacking the surgical capability falls back to a full
|
||||
/// rebuild (safe default), same as the F10b attribute path.</summary>
|
||||
[Fact]
|
||||
public void Pure_remove_on_non_surgical_sink_rebuilds()
|
||||
{
|
||||
var sink = new PlainRecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE
|
||||
/// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's
|
||||
/// passes. (Supersedes the T2 phase pin.)</summary>
|
||||
[Fact]
|
||||
public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", EquipmentNodeIds.Variable("eq-1", "", "Old")));
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the
|
||||
/// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the
|
||||
/// recreate is the publish actor's job, a fresh NodeState). No rebuild.</summary>
|
||||
[Fact]
|
||||
public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Slot");
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet.</summary>
|
||||
[Fact]
|
||||
public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink { RemoveReturns = false };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -1163,9 +1451,14 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
outcome.RemovedNodes.ShouldBe(2); // both removals counted (was 0 before the fix)
|
||||
// R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in
|
||||
// place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted.
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
outcome.RemovedNodes.ShouldBe(2); // both removals counted
|
||||
sink.RemoveCalls.Count.ShouldBe(2);
|
||||
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed")));
|
||||
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency")));
|
||||
}
|
||||
|
||||
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
|
||||
@@ -1496,11 +1789,12 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.ChangedNodes.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>F10b — a surgical-eligible tag delta MIXED with another change (here an added equipment)
|
||||
/// must still rebuild: the rebuild is forced by the OTHER change. The surgical path is taken ONLY when
|
||||
/// the tag deltas are the sole change. No surgical call is made (the rebuild materialises everything).</summary>
|
||||
/// <summary>R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd:
|
||||
/// the added equipment is created by the Materialise passes and the surgical tag change is applied IN
|
||||
/// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07
|
||||
/// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.)</summary>
|
||||
[Fact]
|
||||
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_rebuilds()
|
||||
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -1529,9 +1823,9 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot
|
||||
@@ -1632,11 +1926,12 @@ public sealed class AddressSpaceApplierTests
|
||||
call.DisplayName.ShouldBe("Cell B");
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 — a folder rename MIXED with a structural change (here an added equipment)
|
||||
/// must rebuild: the rebuild + MaterialiseHierarchy re-create every folder with the new names, so no
|
||||
/// separate surgical folder call is made. The rename is covered by the rebuild for free.</summary>
|
||||
/// <summary>R2-07 T2 — a folder rename MIXED with an added equipment is now a PureAdd: the added
|
||||
/// equipment is created by the Materialise passes and the rename is applied IN PLACE via the surgical
|
||||
/// sink, so NO rebuild fires and exactly one folder-rename call lands. (Supersedes the pre-R2-07
|
||||
/// "rename-mixed-with-add ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Folder_rename_mixed_with_added_equipment_rebuilds()
|
||||
public void Folder_rename_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -1647,7 +1942,8 @@ public sealed class AddressSpaceApplierTests
|
||||
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||
// Area renamed AND a brand-new equipment node — the structural add forces a rebuild.
|
||||
// Area renamed AND a brand-new equipment node — an add no longer forces a rebuild; the rename
|
||||
// rides the in-place surgical path alongside the PureAdd.
|
||||
var next = new AddressSpaceComposition(
|
||||
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
@@ -1661,9 +1957,9 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.FolderRenameCalls.ShouldBeEmpty(); // no surgical folder call — rebuild covers it
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.FolderRenameCalls.ShouldHaveSingleItem().ShouldBe(("area-1", "South"));
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement
|
||||
@@ -1709,6 +2005,105 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
|
||||
}
|
||||
|
||||
// ----- R2-07 T3: pure-add NodeAdded announcements -----
|
||||
|
||||
/// <summary>Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the
|
||||
/// equipment id.</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
|
||||
}
|
||||
|
||||
/// <summary>A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment.</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") });
|
||||
}
|
||||
|
||||
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_added_alarm_announces_equipment()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") },
|
||||
};
|
||||
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" });
|
||||
}
|
||||
|
||||
/// <summary>An added equipment WITH a UnsLineId announces its parent line; WITHOUT one announces its own
|
||||
/// new folder id (a valid Part 3 NodeAdded of the folder itself; the root has no announceable id).</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_added_equipment_announces_line_or_self()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var withLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-7") } };
|
||||
applier.ComputeAddAnnouncements(withLine).ShouldBe(new[] { "line-7" });
|
||||
|
||||
var noLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "") } };
|
||||
applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" });
|
||||
}
|
||||
|
||||
/// <summary>A plan with no additions yields no announcements.</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_empty_add_plan_yields_none()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected
|
||||
/// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped.</summary>
|
||||
[Fact]
|
||||
public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.AnnounceAddedNodes(plan);
|
||||
|
||||
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
|
||||
new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
|
||||
}
|
||||
|
||||
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
|
||||
new(
|
||||
areas, Array.Empty<UnsLineProjection>(), Array.Empty<EquipmentNode>(),
|
||||
@@ -1798,6 +2193,35 @@ public sealed class AddressSpaceApplierTests
|
||||
return FolderRenameReturns;
|
||||
}
|
||||
|
||||
/// <summary>Gets the queue of surgical remove calls (kind + node id) in call order (R2-07 Phase 2).</summary>
|
||||
public ConcurrentQueue<(string Kind, string NodeId)> RemoveQueue { get; } = new();
|
||||
/// <summary>Gets the list of recorded surgical remove calls.</summary>
|
||||
public List<(string Kind, string NodeId)> RemoveCalls => RemoveQueue.ToList();
|
||||
/// <summary>When false, the Remove* members report the node missing (return false), driving the
|
||||
/// applier's rebuild fallback. Defaults to true (node present, removal succeeds).</summary>
|
||||
public bool RemoveReturns { get; init; } = true;
|
||||
|
||||
/// <summary>Records a surgical variable-node removal; returns <see cref="RemoveReturns"/>.</summary>
|
||||
public bool RemoveVariableNode(string variableNodeId)
|
||||
{
|
||||
RemoveQueue.Enqueue(("var", variableNodeId));
|
||||
return RemoveReturns;
|
||||
}
|
||||
|
||||
/// <summary>Records a surgical alarm-condition-node removal; returns <see cref="RemoveReturns"/>.</summary>
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
||||
{
|
||||
RemoveQueue.Enqueue(("alarm", alarmNodeId));
|
||||
return RemoveReturns;
|
||||
}
|
||||
|
||||
/// <summary>Records a surgical equipment-subtree removal; returns <see cref="RemoveReturns"/>.</summary>
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
||||
{
|
||||
RemoveQueue.Enqueue(("equipment", equipmentNodeId));
|
||||
return RemoveReturns;
|
||||
}
|
||||
|
||||
/// <summary>Gets the queue of alarm condition write calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
|
||||
/// <summary>Gets the queue of folder creation calls.</summary>
|
||||
@@ -1828,12 +2252,18 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
|
||||
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();
|
||||
|
||||
/// <summary>Records a value write (no-op in this recording sink).</summary>
|
||||
/// <summary>Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad.</summary>
|
||||
public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new();
|
||||
/// <summary>Gets the list of recorded value writes.</summary>
|
||||
public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList();
|
||||
|
||||
/// <summary>Records a value write (NodeId + quality).</summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="quality">The OPC UA quality.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> ValueWriteQueue.Enqueue((nodeId, quality));
|
||||
/// <summary>Records an alarm condition write call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-07 T1 — table-driven tests over <see cref="AddressSpaceChangeClassifier.Classify"/>. The
|
||||
/// classifier is a pure function over an <see cref="AddressSpacePlan"/>: it names the delta class the
|
||||
/// applier routes each plan through (Empty / AttributeOnly / PureAdd / PureRemove / AddRemoveMix /
|
||||
/// Rebuild). First-match-wins per the plan's classification table, with the default-closed
|
||||
/// Rebuild safety valve catching every node-affecting change.
|
||||
/// </summary>
|
||||
public sealed class AddressSpaceChangeClassifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_plan_classifies_as_Empty()
|
||||
{
|
||||
AddressSpaceChangeClassifier.Classify(Plan()).ShouldBe(AddressSpaceChangeKind.Empty);
|
||||
}
|
||||
|
||||
// ----- single add class ⇒ PureAdd -----
|
||||
|
||||
[Fact]
|
||||
public void Added_equipment_only_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(addedEquipment: new[] { Eq("eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Added_alarm_only_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(addedAlarms: new[] { Alarm("alm-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Added_equipment_tag_only_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Added_virtual_tag_only_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(addedVtags: new[] { Vtag("vt-1", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
}
|
||||
|
||||
// ----- single remove class ⇒ PureRemove -----
|
||||
|
||||
[Fact]
|
||||
public void Removed_equipment_only_is_PureRemove()
|
||||
{
|
||||
var plan = Plan(removedEquipment: new[] { Eq("eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removed_alarm_only_is_PureRemove()
|
||||
{
|
||||
var plan = Plan(removedAlarms: new[] { Alarm("alm-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removed_equipment_tag_only_is_PureRemove()
|
||||
{
|
||||
var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removed_virtual_tag_only_is_PureRemove()
|
||||
{
|
||||
var plan = Plan(removedVtags: new[] { Vtag("vt-1", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
||||
}
|
||||
|
||||
// ----- add AND remove ⇒ AddRemoveMix -----
|
||||
|
||||
[Fact]
|
||||
public void Add_and_remove_is_AddRemoveMix()
|
||||
{
|
||||
var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") }, removedTags: new[] { Tag("tag-2", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_equipment_and_remove_vtag_is_AddRemoveMix()
|
||||
{
|
||||
var plan = Plan(addedEquipment: new[] { Eq("eq-2") }, removedVtags: new[] { Vtag("vt-1", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix);
|
||||
}
|
||||
|
||||
// ----- node-affecting change ⇒ Rebuild -----
|
||||
|
||||
[Fact]
|
||||
public void Changed_equipment_is_Rebuild()
|
||||
{
|
||||
var plan = Plan(changedEquipment: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentDelta(Eq("eq-1"), Eq("eq-1") with { DisplayName = "New" }),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_alarm_is_Rebuild()
|
||||
{
|
||||
var plan = Plan(changedAlarms: new[]
|
||||
{
|
||||
new AddressSpacePlan.AlarmDelta(Alarm("alm-1"), Alarm("alm-1") with { MessageTemplate = "changed" }),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_surgical_changed_tag_is_Rebuild()
|
||||
{
|
||||
// FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild.
|
||||
var plan = Plan(changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", fullName: "40001"),
|
||||
Tag("tag-1", "eq-1", fullName: "40002")),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Node_relevant_changed_vtag_is_Rebuild()
|
||||
{
|
||||
// Name change re-derives the NodeId ⇒ node-relevant ⇒ Rebuild.
|
||||
var plan = Plan(changedVtags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentVirtualTagDelta(
|
||||
Vtag("vt-1", "eq-1", name: "A"),
|
||||
Vtag("vt-1", "eq-1", name: "B")),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
// ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly -----
|
||||
|
||||
[Fact]
|
||||
public void Surgical_eligible_changed_tag_only_is_AttributeOnly()
|
||||
{
|
||||
// Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes).
|
||||
var plan = Plan(changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", writable: false),
|
||||
Tag("tag-1", "eq-1", writable: true)),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Node_irrelevant_changed_vtag_only_is_AttributeOnly()
|
||||
{
|
||||
// Only Expression differs ⇒ node-irrelevant ⇒ AttributeOnly.
|
||||
var plan = Plan(changedVtags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentVirtualTagDelta(
|
||||
Vtag("vt-1", "eq-1", expression: "a"),
|
||||
Vtag("vt-1", "eq-1", expression: "a * 2")),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Folder_renames_only_is_AttributeOnly()
|
||||
{
|
||||
var plan = Plan(renamedFolders: new[] { new AddressSpacePlan.FolderRename("area-1", "New Area") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_only_deltas_are_AttributeOnly_node_inert()
|
||||
{
|
||||
// Driver adds/removes/changes never touch the address space — excluded from hasAdds/hasRemoves.
|
||||
var plan = Plan(
|
||||
addedDrivers: new[] { new DriverInstancePlan("d-1", "Modbus", "{}") },
|
||||
removedDrivers: new[] { new DriverInstancePlan("d-2", "Modbus", "{}") },
|
||||
changedDrivers: new[]
|
||||
{
|
||||
new AddressSpacePlan.DriverDelta(
|
||||
new DriverInstancePlan("d-3", "Modbus", "{\"v\":1}"),
|
||||
new DriverInstancePlan("d-3", "Modbus", "{\"v\":2}")),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
||||
}
|
||||
|
||||
// ----- mixed-with-surgical: adds still win over coincident surgical changes -----
|
||||
|
||||
[Fact]
|
||||
public void Adds_with_coincident_surgical_change_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(
|
||||
addedTags: new[] { Tag("tag-new", "eq-1") },
|
||||
changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", writable: false),
|
||||
Tag("tag-1", "eq-1", writable: true)),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adds_with_coincident_non_surgical_change_is_Rebuild()
|
||||
{
|
||||
// Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical
|
||||
// change forces Rebuild even alongside pure adds.
|
||||
var plan = Plan(
|
||||
addedTags: new[] { Tag("tag-new", "eq-1") },
|
||||
changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", fullName: "40001"),
|
||||
Tag("tag-1", "eq-1", fullName: "40002")),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adds_with_coincident_folder_rename_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(
|
||||
addedTags: new[] { Tag("tag-new", "eq-1") },
|
||||
renamedFolders: new[] { new AddressSpacePlan.FolderRename("area-1", "New Area") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
}
|
||||
|
||||
// ----- construction helpers -----
|
||||
|
||||
private static EquipmentNode Eq(string id) => new(id, id, "line-1");
|
||||
|
||||
private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg");
|
||||
|
||||
private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) =>
|
||||
new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null);
|
||||
|
||||
private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") =>
|
||||
new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" });
|
||||
|
||||
private static AddressSpacePlan Plan(
|
||||
IReadOnlyList<EquipmentNode>? addedEquipment = null,
|
||||
IReadOnlyList<EquipmentNode>? removedEquipment = null,
|
||||
IReadOnlyList<AddressSpacePlan.EquipmentDelta>? changedEquipment = null,
|
||||
IReadOnlyList<DriverInstancePlan>? addedDrivers = null,
|
||||
IReadOnlyList<DriverInstancePlan>? removedDrivers = null,
|
||||
IReadOnlyList<AddressSpacePlan.DriverDelta>? changedDrivers = null,
|
||||
IReadOnlyList<ScriptedAlarmPlan>? addedAlarms = null,
|
||||
IReadOnlyList<ScriptedAlarmPlan>? removedAlarms = null,
|
||||
IReadOnlyList<AddressSpacePlan.AlarmDelta>? changedAlarms = null,
|
||||
IReadOnlyList<EquipmentTagPlan>? addedTags = null,
|
||||
IReadOnlyList<EquipmentTagPlan>? removedTags = null,
|
||||
IReadOnlyList<AddressSpacePlan.EquipmentTagDelta>? changedTags = null,
|
||||
IReadOnlyList<EquipmentVirtualTagPlan>? addedVtags = null,
|
||||
IReadOnlyList<EquipmentVirtualTagPlan>? removedVtags = null,
|
||||
IReadOnlyList<AddressSpacePlan.EquipmentVirtualTagDelta>? changedVtags = null,
|
||||
IReadOnlyList<AddressSpacePlan.FolderRename>? renamedFolders = null) =>
|
||||
new(
|
||||
addedEquipment ?? Array.Empty<EquipmentNode>(),
|
||||
removedEquipment ?? Array.Empty<EquipmentNode>(),
|
||||
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>())
|
||||
{
|
||||
AddedEquipmentTags = addedTags ?? Array.Empty<EquipmentTagPlan>(),
|
||||
RemovedEquipmentTags = removedTags ?? Array.Empty<EquipmentTagPlan>(),
|
||||
ChangedEquipmentTags = changedTags ?? Array.Empty<AddressSpacePlan.EquipmentTagDelta>(),
|
||||
AddedEquipmentVirtualTags = addedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
|
||||
RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
|
||||
ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty<AddressSpacePlan.EquipmentVirtualTagDelta>(),
|
||||
RenamedFolders = renamedFolders ?? Array.Empty<AddressSpacePlan.FolderRename>(),
|
||||
};
|
||||
}
|
||||
@@ -174,6 +174,40 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with
|
||||
/// arg-fidelity (kind + node id), returning the inner's own result; false when the inner is not surgical
|
||||
/// so the caller falls back to a full rebuild.</summary>
|
||||
[Fact]
|
||||
public void Remove_members_forward_to_a_surgical_inner_sink_with_arg_fidelity()
|
||||
{
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
var inner = new SurgicalRecordingSink { Result = true };
|
||||
deferred.SetSink(inner);
|
||||
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A").ShouldBeTrue();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1").ShouldBeTrue();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
||||
|
||||
inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") });
|
||||
}
|
||||
|
||||
/// <summary>The remove forwards return the inner's own result (false ⇒ id unknown ⇒ caller rebuilds),
|
||||
/// and return false when the inner is not surgical at all.</summary>
|
||||
[Fact]
|
||||
public void Remove_members_return_inner_result_and_false_when_not_surgical()
|
||||
{
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
|
||||
deferred.SetSink(null);
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
|
||||
/// inner sink only records the node id, so the exact state values don't matter here).</summary>
|
||||
private static AlarmConditionSnapshot Snapshot(bool active = false) =>
|
||||
@@ -240,6 +274,16 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
return Result;
|
||||
}
|
||||
|
||||
/// <summary>Gets the recorded surgical remove calls (kind + node id), in call order (R2-07 Phase 2).</summary>
|
||||
public List<(string Kind, string NodeId)> RemoveCalls { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveVariableNode(string variableNodeId) { RemoveCalls.Add(("var", variableNodeId)); return Result; }
|
||||
/// <inheritdoc />
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; }
|
||||
/// <inheritdoc />
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
/// <inheritdoc />
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-07 T4a — <see cref="OtOpcUaNodeManager.MaterialiseAlarmCondition"/> must be idempotent for a
|
||||
/// same-id + same-kind re-apply: it KEEPS the existing <c>AlarmConditionState</c> instance instead of
|
||||
/// dropping-and-recreating it. This is what preserves a client's MonitoredItems on an alarm-condition
|
||||
/// node across a PURE-ADD deploy (the MaterialiseScriptedAlarms pass re-runs over the whole composition
|
||||
/// every apply, re-touching every existing enabled condition). A genuine kind-swap (native↔scripted)
|
||||
/// still recreates so the flip takes effect.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-alarm-idempotent-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Re-materialising the SAME alarm id + kind returns the SAME instance (reference-equal) — the
|
||||
/// condition node is not torn down, so subscriptions on it survive.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Re_materialise_same_id_and_kind_keeps_the_same_instance()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
||||
var first = nm.TryGetAlarmCondition("alm-1");
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
// Same id + same kind ⇒ skip-if-present: the existing instance is kept.
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
||||
var second = nm.TryGetAlarmCondition("alm-1");
|
||||
|
||||
second.ShouldBeSameAs(first);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A kind-swap (scripted → native) on the same id still RECREATES the node so the native flag
|
||||
/// flips — the drop-and-recreate is preserved for the kind-swap case.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Kind_swap_recreates_and_flips_native_flag()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
||||
var scripted = nm.TryGetAlarmCondition("alm-1");
|
||||
nm.IsNativeAlarmNode("alm-1").ShouldBeFalse();
|
||||
|
||||
// Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set.
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true);
|
||||
var native = nm.TryGetAlarmCondition("alm-1");
|
||||
|
||||
native.ShouldNotBeSameAs(scripted);
|
||||
nm.IsNativeAlarmNode("alm-1").ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>After a full <see cref="OtOpcUaNodeManager.RebuildAddressSpace"/> cleared the maps, the next
|
||||
/// materialise creates a FRESH instance (the skip-if-present guard sees no entry) — so a re-severity
|
||||
/// arriving via the rebuild path is reflected.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Materialise_after_rebuild_creates_fresh_instance()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
||||
var before = nm.TryGetAlarmCondition("alm-1");
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
|
||||
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
||||
var after = nm.TryGetAlarmCondition("alm-1");
|
||||
|
||||
after.ShouldNotBeNull();
|
||||
after.ShouldNotBeSameAs(before);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AlarmIdempotentTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.AlarmIdempotentTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-07 Phase 2 (T8/T9/T10) — surgical IN-PLACE removal on the node manager: a single value variable
|
||||
/// (<see cref="OtOpcUaNodeManager.RemoveVariableNode"/>), a single Part 9 alarm condition
|
||||
/// (<see cref="OtOpcUaNodeManager.RemoveAlarmConditionNode"/>), and an equipment folder + its whole
|
||||
/// descendant subtree with notifier demotion (<see cref="OtOpcUaNodeManager.RemoveEquipmentSubtree"/>).
|
||||
/// Each removal detaches only the scoped node(s), cleans the matching maps, and raises a Part 3
|
||||
/// NodeDeleted model-change; an unknown id returns false (the caller falls back to a full rebuild).
|
||||
/// </summary>
|
||||
public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-surgical-remove-{Guid.NewGuid():N}");
|
||||
|
||||
// ---------- T8: RemoveVariableNode ----------
|
||||
|
||||
/// <summary>Ensure a variable then remove it: it disappears from the maps (TryGetVariable null,
|
||||
/// VariableCount decremented), its historized-tagname registration is dropped, and the call returns
|
||||
/// true.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveVariableNode_drops_variable_and_historian_registration()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A");
|
||||
nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false);
|
||||
var countBefore = nm.VariableCount;
|
||||
nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue();
|
||||
|
||||
nm.RemoveVariableNode("eq-1/A").ShouldBeTrue();
|
||||
|
||||
nm.TryGetVariable("eq-1/A").ShouldBeNull();
|
||||
nm.VariableCount.ShouldBe(countBefore - 1);
|
||||
nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeFalse();
|
||||
// The sibling variable is untouched.
|
||||
nm.TryGetVariable("eq-1/B").ShouldNotBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Removing an unknown variable id returns false (map drift ⇒ caller rebuilds).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveVariableNode_unknown_id_returns_false()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
nm.RemoveVariableNode("eq-1/nope").ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>The built removed-node event announces the deleted node with verb NodeDeleted.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Built_removed_event_announces_the_deleted_node_with_NodeDeleted_verb()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false);
|
||||
|
||||
var e = nm.BuildNodesRemovedModelChange("eq-1/A");
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.Changes.ShouldNotBeNull();
|
||||
var changes = e.Changes.Value;
|
||||
changes.Length.ShouldBe(1);
|
||||
changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeDeleted);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
// ---------- T9: RemoveAlarmConditionNode ----------
|
||||
|
||||
/// <summary>Materialise a native alarm condition then remove it: it disappears from the condition map and
|
||||
/// the native flag is cleared; unknown id returns false.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveAlarmConditionNode_drops_condition_and_native_flag()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true);
|
||||
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull();
|
||||
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue();
|
||||
|
||||
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeTrue();
|
||||
|
||||
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull();
|
||||
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse();
|
||||
|
||||
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeFalse(); // already gone ⇒ false
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A scripted condition removes cleanly too (no native flag was set).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveAlarmConditionNode_removes_scripted_condition()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false);
|
||||
|
||||
nm.RemoveAlarmConditionNode("alm-1").ShouldBeTrue();
|
||||
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
// ---------- T10: RemoveEquipmentSubtree ----------
|
||||
|
||||
/// <summary>Remove an equipment folder carrying a sub-folder, variables (one historized), and a condition:
|
||||
/// every descendant disappears from every map, the notifier registration is demoted, and a SIBLING
|
||||
/// equipment is fully intact.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveEquipmentSubtree_removes_all_descendants_and_leaves_siblings_intact()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
// Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition.
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag");
|
||||
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A");
|
||||
nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false);
|
||||
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true);
|
||||
|
||||
// Sibling equipment eq-2 that must survive untouched.
|
||||
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
||||
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false);
|
||||
|
||||
nm.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
||||
|
||||
// Every eq-1 descendant is gone from every map.
|
||||
nm.TryGetFolder("eq-1").ShouldBeNull();
|
||||
nm.TryGetFolder("eq-1/Diag").ShouldBeNull();
|
||||
nm.TryGetVariable("eq-1/A").ShouldBeNull();
|
||||
nm.TryGetVariable("eq-1/Diag/T").ShouldBeNull();
|
||||
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull();
|
||||
nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeFalse();
|
||||
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse();
|
||||
|
||||
// Sibling eq-2 is fully intact.
|
||||
nm.TryGetFolder("eq-2").ShouldNotBeNull();
|
||||
nm.TryGetVariable("eq-2/S").ShouldNotBeNull();
|
||||
|
||||
// Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the
|
||||
// eq-1 demotion) — proves no orphaned root-notifier ref broke the event path.
|
||||
Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Removing an unknown equipment id returns false (map drift ⇒ caller rebuilds).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveEquipmentSubtree_unknown_id_returns_false()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
nm.RemoveEquipmentSubtree("eq-nope").ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.SurgicalRemoveTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.SurgicalRemoveTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-10
@@ -25,7 +25,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
||||
/// </summary>
|
||||
public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>A rebuild whose sink throws increments otopcua.opcua.apply.failed (kind=rebuild).</summary>
|
||||
/// <summary>A rebuild whose sink throws increments otopcua.opcua.apply.failed (kind=rebuild). R2-07 — a
|
||||
/// pure-add no longer rebuilds, so the degraded-rebuild path is exercised via a rebuild-forcing change:
|
||||
/// a first PureAdd deploy establishes the applied composition, then a rename (ChangedEquipment ⇒ Rebuild)
|
||||
/// drives the throwing SafeRebuild.</summary>
|
||||
[Fact]
|
||||
public void Rebuild_when_sink_throws_increments_apply_failed_meter()
|
||||
{
|
||||
@@ -33,10 +36,17 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new ThrowOnRebuildSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedEquipmentDeployment(db, "eq-1");
|
||||
var dep1 = SeedEquipmentDeployment(db, ("eq-1", "eq-1"));
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
// First deploy: PureAdd — no rebuild, so the throwing sink is not hit yet.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
Thread.Sleep(200);
|
||||
recorder.Total.ShouldBe(0);
|
||||
|
||||
// Second deploy: eq-1 RENAMED ⇒ ChangedEquipment ⇒ Rebuild ⇒ the sink's RebuildAddressSpace throws.
|
||||
var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed"));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
@@ -53,7 +63,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new NoopSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedEquipmentDeployment(db, "eq-1");
|
||||
SeedEquipmentDeployment(db, ("eq-1", "eq-1"));
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
@@ -75,25 +85,26 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
if (last is not null) throw last;
|
||||
}
|
||||
|
||||
private static void SeedEquipmentDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params string[] equipmentIds)
|
||||
private static Guid SeedEquipmentDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params (string Id, string Name)[] equipment)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Equipment = equipmentIds.Select(id => new
|
||||
Equipment = equipment.Select(e => new
|
||||
{
|
||||
EquipmentId = id,
|
||||
MachineCode = id.ToUpperInvariant(),
|
||||
EquipmentId = e.Id,
|
||||
MachineCode = e.Id.ToUpperInvariant(),
|
||||
UnsLineId = "line-1",
|
||||
Name = id,
|
||||
Name = e.Name,
|
||||
}).ToArray(),
|
||||
DriverInstances = Array.Empty<object>(),
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = Guid.NewGuid(),
|
||||
DeploymentId = id,
|
||||
RevisionHash = new string('a', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
@@ -101,6 +112,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed).</summary>
|
||||
|
||||
+142
-15
@@ -18,7 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
||||
|
||||
public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>Tests that RebuildAddressSpace with dbFactory loads artifact, composes, and applies.</summary>
|
||||
/// <summary>R2-07 — RebuildAddressSpace with dbFactory loads the artifact, composes, and applies. Two
|
||||
/// brand-new equipment are a PureAdd: the Materialise passes create the equipment folders WITHOUT a full
|
||||
/// rebuild (existing subscriptions survive), and the added parents are announced via NodeAdded. (Was:
|
||||
/// pre-R2-07 this asserted one full rebuild.)</summary>
|
||||
[Fact]
|
||||
public void RebuildAddressSpace_with_dbFactory_loads_artifact_composes_and_applies()
|
||||
{
|
||||
@@ -37,10 +40,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
// Add path: Equipment + Driver + Alarm — but only Equipment/Alarm topology triggers
|
||||
// RebuildAddressSpace. With 2 new equipment we expect one Rebuild call.
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
// PureAdd: the equipment folders are materialised, no full rebuild, and the added parent
|
||||
// (the shared line-1) is announced exactly once.
|
||||
sink.Calls.ShouldContain("EF:eq-1");
|
||||
sink.Calls.ShouldContain("EF:eq-2");
|
||||
sink.Calls.ShouldContain("NA:line-1");
|
||||
}, duration: TimeSpan.FromSeconds(2));
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Tests that rebuild with no artifact is idempotent no-op.</summary>
|
||||
@@ -63,7 +69,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Tests that second rebuild with same artifact is empty plan no-op.</summary>
|
||||
/// <summary>Tests that a second rebuild with the same artifact is an empty-plan no-op. The first deploy
|
||||
/// (one new equipment) is a PureAdd — no full rebuild, but it DOES materialise the folder; the second
|
||||
/// deploy of the identical composition diffs to an empty plan and the actor short-circuits, adding no
|
||||
/// further sink calls.</summary>
|
||||
[Fact]
|
||||
public void Second_rebuild_with_same_artifact_is_empty_plan_no_op()
|
||||
{
|
||||
@@ -76,12 +85,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2));
|
||||
sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild
|
||||
var callsAfterFirst = sink.Calls.Count;
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
Thread.Sleep(200);
|
||||
// Same composition ⇒ plan IsEmpty ⇒ applier not called again.
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
// Same composition ⇒ plan IsEmpty ⇒ applier + materialise passes not run again.
|
||||
sink.Calls.Count.ShouldBe(callsAfterFirst);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Tests that rebuild without dbFactory falls back to raw sink rebuild.</summary>
|
||||
@@ -125,9 +137,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
|
||||
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
// PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice.
|
||||
// t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1".
|
||||
sinkA.Calls.ShouldContain("EV:eq-sa/F/S1");
|
||||
AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: TimeSpan.FromSeconds(2));
|
||||
sinkA.RebuildCalls.ShouldBe(0);
|
||||
// t-main (MAIN cluster) must NOT leak onto the SITE-A node.
|
||||
sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1");
|
||||
|
||||
@@ -145,8 +158,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
|
||||
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
sinkM.Calls.ShouldContain("EV:eq-main/F/M1");
|
||||
AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: TimeSpan.FromSeconds(2));
|
||||
sinkM.RebuildCalls.ShouldBe(0);
|
||||
sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1");
|
||||
}
|
||||
|
||||
@@ -212,6 +225,100 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T4b — a PureAdd deploy (here a single new equipment) does NOT rebuild, and the
|
||||
/// NodeAdded announcement is raised AFTER the Materialise passes have created the node (so the node
|
||||
/// exists when a re-browsing client arrives). Proven via the ordered <c>Calls</c> queue: the "NA:"
|
||||
/// announcement index is strictly after the "EF:" materialise index.</summary>
|
||||
[Fact]
|
||||
public void Pure_add_announces_node_after_materialising_and_never_rebuilds()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: TimeSpan.FromSeconds(2));
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
|
||||
var calls = sink.Calls.ToList();
|
||||
var materialiseIdx = calls.IndexOf("EF:eq-1");
|
||||
var announceIdx = calls.IndexOf("NA:line-1");
|
||||
materialiseIdx.ShouldBeGreaterThanOrEqualTo(0);
|
||||
announceIdx.ShouldBeGreaterThan(materialiseIdx); // announced AFTER the node was materialised
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T4b — when a deploy forces a full rebuild (here a renamed equipment ⇒ ChangedEquipment
|
||||
/// ⇒ Rebuild) even though it ALSO adds an equipment, the post-materialise announce hook is SKIPPED (the
|
||||
/// rebuild made subscriptions moot). Proven by capturing the NodeAdded count after the first pure-add
|
||||
/// deploy and asserting the rebuild-forcing second deploy adds none.</summary>
|
||||
[Fact]
|
||||
public void Rebuild_kind_deploy_skips_the_node_added_announce()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"));
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:"));
|
||||
|
||||
// Second deploy: eq-1 RENAMED (ChangedEquipment ⇒ Rebuild) AND a new eq-2 (an add). The rebuild
|
||||
// fires, so the announce hook is skipped despite eq-2 being added.
|
||||
var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2"));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
// No new NodeAdded announcement was raised for the rebuild-kind deploy.
|
||||
sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst);
|
||||
}
|
||||
|
||||
/// <summary>Seal a deployment carrying the given (equipmentId, name) rows under a shared line, returning
|
||||
/// the new DeploymentId so a test can target THAT artifact. Renaming an equipment across two such
|
||||
/// deployments produces a ChangedEquipment delta (Rebuild), while adding a row is a PureAdd.</summary>
|
||||
private static Guid SeedNamedEquipmentDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
params (string Id, string Name)[] equipment)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
|
||||
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
|
||||
Equipment = equipment.Select(e => new
|
||||
{
|
||||
EquipmentId = e.Id,
|
||||
DriverInstanceId = (string?)null,
|
||||
UnsLineId = "line-1",
|
||||
Name = e.Name,
|
||||
MachineCode = e.Id.ToUpperInvariant(),
|
||||
}).ToArray(),
|
||||
DriverInstances = Array.Empty<object>(),
|
||||
Namespaces = Array.Empty<object>(),
|
||||
Tags = Array.Empty<object>(),
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id,
|
||||
RevisionHash = new string('d', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static void SeedDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
string[] equipmentIds,
|
||||
@@ -265,9 +372,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
// First deploy: the area folder is materialised with the OLD name (one rebuild).
|
||||
// First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd
|
||||
// (area + line + equipment all added), so NO full rebuild; the folder is materialised directly.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: TimeSpan.FromSeconds(2));
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
|
||||
// Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and
|
||||
// drive a surgical in-place folder rename (NOT a rebuild, NOT a no-op).
|
||||
@@ -278,7 +387,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
{
|
||||
sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South"));
|
||||
}, duration: TimeSpan.FromSeconds(2));
|
||||
sink.RebuildCalls.ShouldBe(1); // still 1 — the rename did NOT force a second full rebuild
|
||||
sink.RebuildCalls.ShouldBe(0); // the rename did NOT force a full rebuild
|
||||
}
|
||||
|
||||
/// <summary>Seal a deployment whose area carries <paramref name="areaName"/>, with a line + one
|
||||
@@ -378,5 +487,23 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
FolderRenameQueue.Enqueue((folderNodeId, displayName));
|
||||
return true;
|
||||
}
|
||||
/// <summary>Records a surgical variable-node removal (always succeeds in this recording sink).</summary>
|
||||
public bool RemoveVariableNode(string variableNodeId)
|
||||
{
|
||||
Calls.Enqueue($"RV:{variableNodeId}");
|
||||
return true;
|
||||
}
|
||||
/// <summary>Records a surgical alarm-condition-node removal (always succeeds in this recording sink).</summary>
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
||||
{
|
||||
Calls.Enqueue($"RA:{alarmNodeId}");
|
||||
return true;
|
||||
}
|
||||
/// <summary>Records a surgical equipment-subtree removal (always succeeds in this recording sink).</summary>
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
||||
{
|
||||
Calls.Enqueue($"RE:{equipmentNodeId}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user