Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
Joseph Doherty 607dc51dec
Some checks failed
v2-ci / build (push) Failing after 42s
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 (push) Has been skipped
feat(opcua): #85 UNS Area/Line/Equipment folder hierarchy in SDK
Phase7Composer now carries UnsAreaProjection + UnsLineProjection lists so
the applier can materialise the full UNS topology in the OPC UA address
space. New IOpcUaAddressSpaceSink.EnsureFolder(folderNodeId, parentNodeId,
displayName) seam (no-op default, recorded in tests, forwarded by
DeferredAddressSpaceSink, implemented by SdkAddressSpaceSink). The SDK-
side OtOpcUaNodeManager gains an EnsureFolder API that creates
FolderState nodes with proper parent linkage; RebuildAddressSpace now
clears folders too so re-applies don't accumulate stale topology.

Phase7Applier.MaterialiseHierarchy walks composition.UnsAreas →
composition.UnsLines → composition.EquipmentNodes, calling EnsureFolder
with the correct parent at each level. Idempotent — calling twice with
the same composition is a no-op. OpcUaPublishActor.HandleRebuild invokes
it after Phase7Applier.Apply so OPC UA clients browsing the server now
see Area/Line/Equipment as proper folders rather than flat tag ids.

DeploymentArtifact.ParseComposition reads UnsAreas + UnsLines from the
JSON snapshot the ControlPlane emits, populating the new fields when
present.

Phase7Composer.Compose now accepts UnsAreas + UnsLines; a 3-arg overload
preserves the old signature for legacy callers + existing tests. The
Phase7CompositionResult convenience ctor likewise keeps the planner
tests working without UNS data.

3 new hierarchy tests (pure unit + boot-verify against a real
OtOpcUaSdkServer); OpcUaServer suite is 48/48 green (was 45, +3),
Runtime 74/74 unchanged.

Closes #85.
2026-05-26 10:48:56 -04:00

80 lines
2.8 KiB
C#

using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class DeferredAddressSpaceSinkTests
{
[Fact]
public void Default_inner_is_null_sink_so_calls_before_SetSink_are_safe()
{
var deferred = new DeferredAddressSpaceSink();
// No throw, no observable side effect.
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
deferred.WriteAlarmState("a", true, false, DateTime.UtcNow);
deferred.RebuildAddressSpace();
}
[Fact]
public void Calls_after_SetSink_are_forwarded_to_the_inner()
{
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow);
deferred.WriteAlarmState("a-1", true, false, DateTime.UtcNow);
deferred.RebuildAddressSpace();
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB" });
}
[Fact]
public void SetSink_to_null_reverts_to_null_sink()
{
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
inner.Calls.Count.ShouldBe(1);
deferred.SetSink(null);
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow); // dropped
inner.Calls.Count.ShouldBe(1);
}
[Fact]
public void SetSink_can_swap_between_implementations()
{
var deferred = new DeferredAddressSpaceSink();
var first = new RecordingSink();
var second = new RecordingSink();
deferred.SetSink(first);
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
deferred.SetSink(second);
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
first.Calls.Single().ShouldBe("WV:a");
second.Calls.Single().ShouldBe("WV:b");
}
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
public ConcurrentQueue<string> CallQueue { get; } = new();
public List<string> Calls => CallQueue.ToList();
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WV:{nodeId}");
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
}
}