Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
T
Joseph Doherty da4634d67e
v2-ci / build (push) Failing after 44s
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
fix(tests,cli): implement IOpcUaAddressSpaceSink.EnsureVariable in test fakes; fix CLI CS1587
Resolves the 12 reported build errors (7 CS0535 sink fakes + 5 CLI CS1587).
Runtime.Tests green (74). NOTE: OpcUaServer.Tests still has pre-existing CS7036
errors from the in-progress Galaxy-tag workstream (Phase7Plan/Phase7CompositionResult
new required params) — separate, test-only, not addressed here.
2026-05-29 10:19:32 -04:00

216 lines
12 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
{
/// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
[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();
}
/// <summary>Verifies that removed equipment writes inactive alarm state and triggers rebuild.</summary>
[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);
}
/// <summary>Verifies that added equipment triggers rebuild without writing alarm state.</summary>
[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);
}
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
[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);
}
/// <summary>Verifies that sink exceptions in WriteAlarmState do not propagate and rebuild still fires.</summary>
[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
{
/// <summary>Gets the queue of alarm state write calls.</summary>
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
/// <summary>Gets the queue of folder creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// <summary>Gets the queue of variable creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
public int RebuildCalls;
/// <summary>Gets the list of recorded alarm writes.</summary>
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
/// <summary>Gets the list of recorded folder creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// <summary>Gets the list of recorded variable creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
/// <summary>Records a value write (no-op in this recording sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</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 call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
/// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records a variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
{
private readonly bool _throwOnAlarmWrite;
/// <summary>Initializes a new instance of the ThrowingSink class.</summary>
/// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
/// <summary>Records a value write (no-op in this sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
/// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>No-op variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
}
}