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.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.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.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); } [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); } [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 { public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new(); public int RebuildCalls; public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.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 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 RebuildAddressSpace() { } } }