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
{
/// Verifies that an empty plan does not call the sink or trigger a rebuild.
[Fact]
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger.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();
}
/// Verifies that removed equipment writes inactive alarm state and triggers rebuild.
[Fact]
public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger.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);
}
/// Verifies that added equipment triggers rebuild without writing alarm state.
[Fact]
public void Added_equipment_triggers_rebuild_without_alarm_writes()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger.Instance);
var plan = new Phase7Plan(
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
RemovedEquipment: Array.Empty(),
ChangedEquipment: Array.Empty(),
AddedDrivers: Array.Empty(),
RemovedDrivers: Array.Empty(),
ChangedDrivers: Array.Empty(),
AddedAlarms: Array.Empty(),
RemovedAlarms: Array.Empty(),
ChangedAlarms: Array.Empty());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.AddedNodes.ShouldBe(1);
sink.AlarmWrites.ShouldBeEmpty();
sink.RebuildCalls.ShouldBe(1);
}
/// Verifies that driver-only changes do not trigger address space rebuild.
[Fact]
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger.Instance);
var plan = new Phase7Plan(
AddedEquipment: Array.Empty(),
RemovedEquipment: Array.Empty(),
ChangedEquipment: Array.Empty(),
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
RemovedDrivers: Array.Empty(),
ChangedDrivers: new[]
{
new Phase7Plan.DriverDelta(
new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
},
AddedAlarms: Array.Empty(),
RemovedAlarms: Array.Empty(),
ChangedAlarms: Array.Empty());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
}
/// Verifies that sink exceptions in WriteAlarmState do not propagate and rebuild still fires.
[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.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(), Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(), Array.Empty());
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
AddedEquipment: Array.Empty(),
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
ChangedEquipment: Array.Empty(),
AddedDrivers: Array.Empty(),
RemovedDrivers: Array.Empty(),
ChangedDrivers: Array.Empty(),
AddedAlarms: Array.Empty(),
RemovedAlarms: Array.Empty(),
ChangedAlarms: Array.Empty());
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// Gets the queue of alarm state write calls.
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
/// Gets the queue of folder creation calls.
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// Gets the queue of variable creation calls.
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
/// Gets the number of rebuild calls made on this sink.
public int RebuildCalls;
/// Gets the list of recorded alarm writes.
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
/// Gets the list of recorded folder creation calls.
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// Gets the list of recorded variable creation calls.
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
/// Records a value write (no-op in this recording sink).
/// The node ID.
/// The value to write.
/// The OPC UA quality.
/// The source timestamp in UTC.
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// Records an alarm state write call.
/// The alarm node ID.
/// Whether the alarm is active.
/// Whether the alarm is acknowledged.
/// The source timestamp in UTC.
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
/// Records a folder creation call.
/// The folder node ID.
/// The parent folder node ID, if any.
/// The display name for the folder.
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// Records a variable creation call.
/// The variable node ID.
/// The parent folder node ID, if any.
/// The display name for the variable.
/// The OPC UA built-in type name.
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
/// Records a rebuild address space call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
{
private readonly bool _throwOnAlarmWrite;
/// Initializes a new instance of the ThrowingSink class.
/// Whether to throw on alarm state writes.
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
/// Records a value write (no-op in this sink).
/// The node ID.
/// The value to write.
/// The OPC UA quality.
/// The source timestamp in UTC.
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// Throws an exception if configured to do so.
/// The alarm node ID.
/// Whether the alarm is active.
/// Whether the alarm is acknowledged.
/// The source timestamp in UTC.
/// Thrown when configured to throw on alarm write.
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
/// No-op folder creation call.
/// The folder node ID.
/// The parent folder node ID, if any.
/// The display name for the folder.
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// No-op variable creation call.
/// The variable node ID.
/// The parent folder node ID, if any.
/// The display name for the variable.
/// The OPC UA built-in type name.
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// No-op rebuild address space call.
public void RebuildAddressSpace() { }
}
}