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
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.
156 lines
6.8 KiB
C#
156 lines
6.8 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
public sealed class Phase7ApplierTests
|
|
{
|
|
[Fact]
|
|
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var outcome = applier.Apply(EmptyPlan);
|
|
|
|
outcome.RebuildCalled.ShouldBeFalse();
|
|
outcome.AddedNodes.ShouldBe(0);
|
|
outcome.RemovedNodes.ShouldBe(0);
|
|
outcome.ChangedNodes.ShouldBe(0);
|
|
sink.RebuildCalls.ShouldBe(0);
|
|
sink.AlarmWrites.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = WithEquipmentRemoval("eq-1", "eq-2");
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RemovedNodes.ShouldBe(2);
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
|
|
sink.AlarmWrites.All(a => a.Active == false && a.Acknowledged == false).ShouldBeTrue();
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Added_equipment_triggers_rebuild_without_alarm_writes()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = new Phase7Plan(
|
|
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
|
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
|
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
outcome.AddedNodes.ShouldBe(1);
|
|
sink.AlarmWrites.ShouldBeEmpty();
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = new Phase7Plan(
|
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: new[]
|
|
{
|
|
new Phase7Plan.DriverDelta(
|
|
new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
|
|
new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
|
|
},
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
|
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RebuildCalled.ShouldBeFalse();
|
|
sink.RebuildCalls.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sink_exception_in_WriteAlarmState_does_not_propagate_and_rebuild_still_fires()
|
|
{
|
|
var sink = new ThrowingSink(throwOnAlarmWrite: true);
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = WithEquipmentRemoval("eq-1");
|
|
|
|
var outcome = applier.Apply(plan); // should not throw
|
|
outcome.RemovedNodes.ShouldBe(1);
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
}
|
|
|
|
private static Phase7Plan EmptyPlan => new(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
|
|
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>());
|
|
|
|
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
|
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
|
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
|
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
|
|
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
|
public int RebuildCalls;
|
|
|
|
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
|
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
|
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
|
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
|
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
|
}
|
|
|
|
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
private readonly bool _throwOnAlarmWrite;
|
|
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
|
|
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
|
{
|
|
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
|
}
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
|
public void RebuildAddressSpace() { }
|
|
}
|
|
}
|