Wire Galaxy security_classification to OPC UA AccessLevel (ReadOnly for SecuredWrite/VerifiedWrite/ViewOnly). Use deployed package chain for attribute queries to exclude undeployed attributes. Group primitive attributes under their parent variable node (merged Variable+Object). Add is_historized and is_alarm detection via HistoryExtension/AlarmExtension primitives. Implement OPC UA HistoryRead backed by Wonderware Historian Runtime database. Implement AlarmConditionState nodes driven by InAlarm with condition refresh support. Add historyread and alarms CLI commands for testing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
384 lines
14 KiB
C#
384 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
|
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
|
|
|
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
|
|
{
|
|
/// <summary>
|
|
/// Integration tests verifying dynamic address space changes via a real OPC UA client.
|
|
/// Tests browse, subscribe, add/remove nodes at runtime, and subscription quality changes.
|
|
/// </summary>
|
|
public class AddressSpaceRebuildTests
|
|
{
|
|
/// <summary>
|
|
/// Confirms that the initial browsed hierarchy matches the seeded Galaxy model.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Browse_ReturnsInitialHierarchy()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakes();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
using var client = new OpcUaTestClient();
|
|
await client.ConnectAsync(fixture.EndpointUrl);
|
|
|
|
// Browse from ZB root
|
|
var zbNode = client.MakeNodeId("ZB");
|
|
var children = await client.BrowseAsync(zbNode);
|
|
|
|
children.ShouldContain(c => c.Name == "DEV");
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that adding a Galaxy object and rebuilding exposes the new node to OPC UA clients.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Browse_AfterAddingObject_NewNodeAppears()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakes();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
using var client = new OpcUaTestClient();
|
|
await client.ConnectAsync(fixture.EndpointUrl);
|
|
|
|
// Verify initial state — browse TestMachine_001
|
|
var machineNode = client.MakeNodeId("TestMachine_001");
|
|
var initialChildren = await client.BrowseAsync(machineNode);
|
|
initialChildren.ShouldNotContain(c => c.Name == "NewReceiver");
|
|
|
|
// Add a new object to the hierarchy
|
|
fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo
|
|
{
|
|
GobjectId = 100, TagName = "NewReceiver_001",
|
|
ContainedName = "NewReceiver", BrowseName = "NewReceiver",
|
|
ParentGobjectId = 3, IsArea = false // parent = TestMachine_001
|
|
});
|
|
fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo
|
|
{
|
|
GobjectId = 100, TagName = "NewReceiver_001",
|
|
AttributeName = "NewAttr", FullTagReference = "NewReceiver_001.NewAttr",
|
|
MxDataType = 5, IsArray = false
|
|
});
|
|
|
|
// Trigger rebuild
|
|
fixture.Service.TriggerRebuild();
|
|
await Task.Delay(500); // allow rebuild to complete
|
|
|
|
// Browse again — new node should appear
|
|
var updatedChildren = await client.BrowseAsync(machineNode);
|
|
updatedChildren.ShouldContain(c => c.Name == "NewReceiver");
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that removing a Galaxy object and rebuilding removes the node from the OPC UA hierarchy.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Browse_AfterRemovingObject_NodeDisappears()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakes();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
using var client = new OpcUaTestClient();
|
|
await client.ConnectAsync(fixture.EndpointUrl);
|
|
|
|
// Verify MESReceiver exists initially
|
|
var machineNode = client.MakeNodeId("TestMachine_001");
|
|
var initialChildren = await client.BrowseAsync(machineNode);
|
|
initialChildren.ShouldContain(c => c.Name == "MESReceiver");
|
|
|
|
// Remove MESReceiver and its attributes from hierarchy
|
|
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
|
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
|
|
|
// Trigger rebuild
|
|
fixture.Service.TriggerRebuild();
|
|
await Task.Delay(500);
|
|
|
|
// Browse again — MESReceiver should be gone
|
|
var updatedChildren = await client.BrowseAsync(machineNode);
|
|
updatedChildren.ShouldNotContain(c => c.Name == "MESReceiver");
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that subscriptions on deleted nodes receive a bad-quality notification after rebuild.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Subscribe_RemovedNode_PublishesBadQuality()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakes();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
using var client = new OpcUaTestClient();
|
|
await client.ConnectAsync(fixture.EndpointUrl);
|
|
|
|
// Subscribe to an attribute that will be removed
|
|
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInBatchID");
|
|
var (sub, item) = await client.SubscribeAsync(nodeId, intervalMs: 100);
|
|
|
|
// Collect notifications
|
|
var notifications = new List<MonitoredItemNotification>();
|
|
item.Notification += (_, e) =>
|
|
{
|
|
if (e.NotificationValue is MonitoredItemNotification n)
|
|
notifications.Add(n);
|
|
};
|
|
|
|
await Task.Delay(500); // let initial subscription settle
|
|
|
|
// Remove MESReceiver and its attributes
|
|
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
|
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
|
|
|
// Trigger rebuild — nodes get deleted
|
|
fixture.Service.TriggerRebuild();
|
|
|
|
// Wait for publish cycle to deliver Bad status
|
|
await Task.Delay(2000);
|
|
|
|
// The subscription should have received a Bad quality notification
|
|
// after the node was deleted during rebuild
|
|
notifications.ShouldContain(n => StatusCode.IsBad(n.Value.StatusCode));
|
|
|
|
await sub.DeleteAsync(true);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that subscriptions on surviving nodes continue to work after a partial rebuild.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Subscribe_SurvivingNode_StillWorksAfterRebuild()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakes();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
using var client = new OpcUaTestClient();
|
|
await client.ConnectAsync(fixture.EndpointUrl);
|
|
|
|
// Subscribe to an attribute that will survive the rebuild
|
|
var nodeId = client.MakeNodeId("TestMachine_001.MachineID");
|
|
var (sub, item) = await client.SubscribeAsync(nodeId, intervalMs: 100);
|
|
|
|
await Task.Delay(500);
|
|
|
|
// Remove only MESReceiver (MachineID on TestMachine_001 survives)
|
|
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
|
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
|
|
|
fixture.Service.TriggerRebuild();
|
|
await Task.Delay(1000);
|
|
|
|
// The surviving node should still be browsable
|
|
var machineNode = client.MakeNodeId("TestMachine_001");
|
|
var children = await client.BrowseAsync(machineNode);
|
|
children.ShouldContain(c => c.Name == "MachineID");
|
|
|
|
await sub.DeleteAsync(true);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that adding a Galaxy attribute and rebuilding exposes a new OPC UA variable.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Browse_AddAttribute_NewVariableAppears()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakes();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
using var client = new OpcUaTestClient();
|
|
await client.ConnectAsync(fixture.EndpointUrl);
|
|
|
|
var machineNode = client.MakeNodeId("TestMachine_001");
|
|
var initialChildren = await client.BrowseAsync(machineNode);
|
|
initialChildren.ShouldNotContain(c => c.Name == "NewSensor");
|
|
|
|
// Add a new attribute
|
|
fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo
|
|
{
|
|
GobjectId = 3, TagName = "TestMachine_001",
|
|
AttributeName = "NewSensor", FullTagReference = "TestMachine_001.NewSensor",
|
|
MxDataType = 4, IsArray = false // Double
|
|
});
|
|
|
|
fixture.Service.TriggerRebuild();
|
|
await Task.Delay(500);
|
|
|
|
var updatedChildren = await client.BrowseAsync(machineNode);
|
|
updatedChildren.ShouldContain(c => c.Name == "NewSensor");
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that removing a Galaxy attribute and rebuilding removes the OPC UA variable.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Browse_RemoveAttribute_VariableDisappears()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakes();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
using var client = new OpcUaTestClient();
|
|
await client.ConnectAsync(fixture.EndpointUrl);
|
|
|
|
var machineNode = client.MakeNodeId("TestMachine_001");
|
|
var initialChildren = await client.BrowseAsync(machineNode);
|
|
initialChildren.ShouldContain(c => c.Name == "MachineCode");
|
|
|
|
// Remove MachineCode attribute
|
|
fixture.GalaxyRepository!.Attributes.RemoveAll(
|
|
a => a.TagName == "TestMachine_001" && a.AttributeName == "MachineCode");
|
|
|
|
fixture.Service.TriggerRebuild();
|
|
await Task.Delay(500);
|
|
|
|
var updatedChildren = await client.BrowseAsync(machineNode);
|
|
updatedChildren.ShouldNotContain(c => c.Name == "MachineCode");
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that rebuilds preserve subscription bookkeeping for nodes that survive the metadata refresh.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Rebuild_PreservesSubscriptionBookkeeping_ForSurvivingNodes()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
var nodeManager = fixture.Service.NodeManagerInstance!;
|
|
var mxClient = fixture.MxAccessClient!;
|
|
|
|
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
|
|
|
fixture.Service.TriggerRebuild();
|
|
await Task.Delay(200);
|
|
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
|
|
|
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that transferred monitored items recreate MXAccess subscriptions when the service has no local subscription state.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TransferSubscriptions_RestoresMxAccessSubscriptionState_WhenLocalStateIsMissing()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
var nodeManager = fixture.Service.NodeManagerInstance!;
|
|
var mxClient = fixture.MxAccessClient!;
|
|
|
|
nodeManager.RestoreTransferredSubscriptions(new[]
|
|
{
|
|
"TestMachine_001.MachineID",
|
|
"TestMachine_001.MachineID"
|
|
});
|
|
|
|
await Task.Delay(100);
|
|
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
|
|
|
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
|
|
|
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that transferring monitored items does not double-count subscriptions already tracked in memory.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TransferSubscriptions_DoesNotDoubleCount_WhenSubscriptionAlreadyTracked()
|
|
{
|
|
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
|
await fixture.InitializeAsync();
|
|
try
|
|
{
|
|
var nodeManager = fixture.Service.NodeManagerInstance!;
|
|
var mxClient = fixture.MxAccessClient!;
|
|
|
|
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
|
|
|
nodeManager.RestoreTransferredSubscriptions(new[]
|
|
{
|
|
"TestMachine_001.MachineID"
|
|
});
|
|
|
|
await Task.Delay(100);
|
|
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
|
|
|
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
|
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.DisposeAsync();
|
|
}
|
|
}
|
|
}
|
|
}
|