using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// #85 — verifies builds the UNS
/// Area/Line/Equipment folder tree through .
/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
/// drives a real and inspects the resulting predefined-node
/// count to prove the folders land in the SDK address space.
///
public sealed class AddressSpaceApplierHierarchyTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
/// Verifies that MaterialiseHierarchy creates areas, lines, and equipment with correct parent relationships.
[Fact]
public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
{
var sink = new RecordingFolderSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty(),
ScriptedAlarmPlans: Array.Empty());
applier.MaterialiseHierarchy(composition);
var calls = sink.Calls;
calls.Count.ShouldBe(3);
calls[0].ShouldBe(("area-1", null, "Plant North"));
calls[1].ShouldBe(("line-1", "area-1", "Cell A"));
calls[2].ShouldBe(("eq-1", "line-1", "Pump-1"));
}
/// Verifies that orphan equipment without a parent line appears under root.
[Fact]
public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
{
var sink = new RecordingFolderSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
UnsAreas: Array.Empty(),
UnsLines: Array.Empty(),
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
DriverInstancePlans: Array.Empty(),
ScriptedAlarmPlans: Array.Empty());
applier.MaterialiseHierarchy(composition);
sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
}
/// Verifies that MaterialiseHierarchy creates folder nodes in a real SDK node manager.
[Fact]
public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Hierarchy",
ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
applier.MaterialiseHierarchy(new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty(),
ScriptedAlarmPlans: Array.Empty()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
// Idempotent: re-applying with the same composition doesn't create duplicates.
applier.MaterialiseHierarchy(new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty(),
ScriptedAlarmPlans: Array.Empty()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
}
/// Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
/// applying the same composition twice yields a single Variable node (no duplicates). This is the
/// restart-safety guarantee — HandleRebuild runs on both the apply path and the
/// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
/// restart re-runs this pass and must not double-materialise.
[Fact]
public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.EquipmentTags",
ApplicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var composition = new AddressSpaceComposition(
UnsAreas: Array.Empty(),
UnsLines: Array.Empty(),
EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") },
DriverInstancePlans: Array.Empty(),
ScriptedAlarmPlans: Array.Empty())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
// Equipment folder first (the variable's parent), then the tag pass — applied twice.
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseEquipmentTags(composition);
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
}
///
/// Full structure-materialisation pipeline against a real SDK node manager: real Config
/// entities (Area / Line / Equipment + an Equipment-namespace Tag) →
/// → MaterialiseHierarchy + MaterialiseEquipmentTags → . Proves
/// an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal
/// Variable in a live OPC UA address space (structure-only; live values are a later milestone).
/// Also covers the compose-side EquipmentTags extraction. The cluster-level deploy +
/// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked
/// as a follow-up.
///
[Fact]
public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.EquipmentE2e",
ApplicationUri = $"urn:OtOpcUa.EquipmentE2e:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
// One area / line / equipment + a Modbus FK driver in an Equipment-kind namespace, with a
// single equipment-bound Tag (the signal). Equipment.Name is the UNS browse segment.
var ns = new Namespace { NamespaceId = "ns-eq", ClusterId = "c1", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq" };
var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", NamespaceId = "ns-eq", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
var equipment = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-modbus", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
var tag = new Tag { TagId = "tag-speed", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\"}" };
var composition = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
Array.Empty(), new[] { tag }, new[] { ns });
// Compose-side EquipmentTags extraction.
var planned = composition.EquipmentTags.ShouldHaveSingleItem();
planned.EquipmentId.ShouldBe("eq-1");
planned.FullName.ShouldBe("40001");
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
sdkServer.NodeManager!.FolderCount.ShouldBe(3); // filling area + line-1 + station-1 equipment
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
}
/// OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's
/// DisplayName IN PLACE against a real SDK node manager (no rebuild, no node count change). Proves the
/// full chain: planner emits a RenamedFolders delta → applier drives the surgical
/// SdkAddressSpaceSink.UpdateFolderDisplayName → OtOpcUaNodeManager mutates the live FolderState +
/// ClearChangeMasks. Before the fix, EnsureFolder early-returned on the existing folder and the
/// DisplayName stayed stale until a full RebuildAddressSpace.
[Fact]
public async Task Area_and_line_rename_updates_existing_folder_display_name_in_place_against_real_SDK()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.FolderRename",
ApplicationUri = $"urn:OtOpcUa.FolderRename:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
var nm = sdkServer.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
// Materialise the initial hierarchy (area + line + equipment) with the OLD display names.
var initial = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty(),
ScriptedAlarmPlans: Array.Empty());
applier.MaterialiseHierarchy(initial);
nm.FolderCount.ShouldBe(3);
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant North");
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell A");
// Now a rename-only deploy: same ids, new display names on BOTH the area and the line.
var renamed = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty(),
ScriptedAlarmPlans: Array.Empty());
var plan = AddressSpacePlanner.Compute(initial, renamed);
plan.IsEmpty.ShouldBeFalse(); // rename is no longer a silent no-op
plan.RenamedFolders.Count.ShouldBe(2);
var outcome = applier.Apply(plan);
// In-place: no rebuild, no node-count change — only the DisplayNames were swapped.
outcome.RebuildCalled.ShouldBeFalse();
nm.FolderCount.ShouldBe(3);
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant South");
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell B");
// The unrelated equipment folder is untouched.
nm.TryGetFolder("eq-1")!.DisplayName.Text.ShouldBe("Pump-1");
}
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;
}
/// Disposes of resources allocated by this test class.
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort */ }
}
}
private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink
{
private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new();
/// Gets the list of EnsureFolder calls recorded by this sink.
public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
/// Records a value write (stub implementation for testing).
/// The node ID of the variable.
/// The value to write.
/// The OPC UA quality value.
/// The source timestamp in UTC.
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// Records an alarm condition write (stub implementation for testing).
/// The node ID of the alarm condition.
/// The full condition state snapshot.
/// The source timestamp in UTC.
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
/// Materialises an alarm condition (stub implementation for testing).
/// The alarm node ID (== ScriptedAlarmId).
/// The equipment folder node ID.
/// The condition display name.
/// The domain alarm type.
/// The domain severity.
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// Records a folder creation request.
/// The node ID of the folder.
/// The node ID of the parent folder, or null for root.
/// The display name of the folder.
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
/// Ensures a variable exists (stub implementation for testing).
/// The node ID of the variable.
/// The node ID of the parent folder, or null for root.
/// The display name of the variable.
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// Rebuilds the address space (stub implementation for testing).
public void RebuildAddressSpace() { }
}
}