64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
156 lines
7.6 KiB
C#
156 lines
7.6 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// #85 — verifies <see cref="Phase7Applier.MaterialiseHierarchy"/> builds the UNS
|
|
/// Area/Line/Equipment folder tree through <see cref="IOpcUaAddressSpaceSink.EnsureFolder"/>.
|
|
/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
|
|
/// drives a real <see cref="OtOpcUaNodeManager"/> and inspects the resulting predefined-node
|
|
/// count to prove the folders land in the SDK address space.
|
|
/// </summary>
|
|
public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"otopcua-pki-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>Verifies that MaterialiseHierarchy creates areas, lines, and equipment with correct parent relationships.</summary>
|
|
[Fact]
|
|
public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
|
|
{
|
|
var sink = new RecordingFolderSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var composition = new Phase7CompositionResult(
|
|
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<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
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"));
|
|
}
|
|
|
|
/// <summary>Verifies that orphan equipment without a parent line appears under root.</summary>
|
|
[Fact]
|
|
public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
|
|
{
|
|
var sink = new RecordingFolderSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var composition = new Phase7CompositionResult(
|
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
applier.MaterialiseHierarchy(composition);
|
|
|
|
sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
|
|
}
|
|
|
|
/// <summary>Verifies that MaterialiseHierarchy creates folder nodes in a real SDK node manager.</summary>
|
|
[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<OpcUaApplicationHost>.Instance);
|
|
|
|
var sdkServer = new OtOpcUaSdkServer();
|
|
await host.StartAsync(sdkServer, Ct);
|
|
sdkServer.NodeManager.ShouldNotBeNull();
|
|
|
|
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
applier.MaterialiseHierarchy(new Phase7CompositionResult(
|
|
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<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
|
|
|
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 Phase7CompositionResult(
|
|
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<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
|
|
|
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
|
|
}
|
|
|
|
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>Disposes of resources allocated by this test class.</summary>
|
|
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();
|
|
/// <summary>Gets the list of EnsureFolder calls recorded by this sink.</summary>
|
|
public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
|
|
|
|
/// <summary>Records a value write (stub implementation for testing).</summary>
|
|
/// <param name="nodeId">The node ID of the variable.</param>
|
|
/// <param name="value">The value to write.</param>
|
|
/// <param name="quality">The OPC UA quality value.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
|
/// <summary>Records an alarm state write (stub implementation for testing).</summary>
|
|
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
|
/// <param name="active">Whether the alarm is active.</param>
|
|
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
|
/// <summary>Records a folder creation request.</summary>
|
|
/// <param name="folderNodeId">The node ID of the folder.</param>
|
|
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
|
/// <param name="displayName">The display name of the folder.</param>
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
|
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
|
|
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
|
public void RebuildAddressSpace() { }
|
|
}
|
|
}
|