using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests { /// /// PR C.1 — pins the trinary outcome → IPC bool[] mapping that the sidecar uses /// on the WriteAlarmEvents reply. Per-event outcomes: /// Ack → true, RetryPlease → false, PermanentFail → false. /// The sender's B.4 widens the IPC bool back into the trinary outcome at the /// IPC boundary using structured diagnostics; the wire intentionally collapses /// to "ok / not-ok". /// [Trait("Category", "Unit")] public sealed class AahClientManagedAlarmEventWriterTests { [Fact] public async Task Empty_batch_returns_empty_array_without_invoking_backend() { var backend = new RecordingBackend(_ => throw new InvalidOperationException("must not invoke for empty input")); var writer = new AahClientManagedAlarmEventWriter(backend); var result = await writer.WriteAsync(Array.Empty(), CancellationToken.None); result.ShouldBeEmpty(); backend.Calls.ShouldBe(0); } [Fact] public async Task Single_ack_outcome_maps_to_true() { var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray()); var writer = new AahClientManagedAlarmEventWriter(backend); var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None); result.ShouldBe(new[] { true }); } [Fact] public async Task Mixed_batch_preserves_per_slot_ordering() { // Ack / Retry / Permanent / Ack — the sender uses positional matching against // its queue, so every slot must hit the exact bool corresponding to its input. var backend = new RecordingBackend(_ => new[] { AlarmHistorianWriteOutcome.Ack, AlarmHistorianWriteOutcome.RetryPlease, AlarmHistorianWriteOutcome.PermanentFail, AlarmHistorianWriteOutcome.Ack, }); var writer = new AahClientManagedAlarmEventWriter(backend); var result = await writer.WriteAsync( new[] { Event("E1"), Event("E2"), Event("E3"), Event("E4") }, CancellationToken.None); result.ShouldBe(new[] { true, false, false, true }); } [Fact] public async Task Backend_exception_marks_whole_batch_RetryPlease() { var backend = new RecordingBackend(_ => throw new InvalidOperationException("cluster unreachable")); var writer = new AahClientManagedAlarmEventWriter(backend); var result = await writer.WriteAsync( new[] { Event("E1"), Event("E2"), Event("E3") }, CancellationToken.None); // Whole batch must end up as "not ok" (RetryPlease at the trinary layer) — // dropping a transiently-failed batch corrupts the sender's queue. result.ShouldBe(new[] { false, false, false }); } [Fact] public async Task Cancellation_propagates_from_backend() { var backend = new RecordingBackend(_ => throw new OperationCanceledException()); var writer = new AahClientManagedAlarmEventWriter(backend); var ex = await Should.ThrowAsync(() => writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None)); ex.ShouldNotBeNull(); } [Fact] public async Task Backend_returning_wrong_count_degrades_to_RetryPlease() { // Backend returns more outcomes than inputs — defensive degrade rather than // letting a backend bug desync the sender's queue accounting. var backend = new RecordingBackend(_ => new[] { AlarmHistorianWriteOutcome.Ack, AlarmHistorianWriteOutcome.Ack, }); var writer = new AahClientManagedAlarmEventWriter(backend); var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None); result.ShouldBe(new[] { false }); } [Theory] // hresult 0 + clean → Ack [InlineData(0, false, false, AlarmHistorianWriteOutcome.Ack)] // hresult 0 but malformed → PermanentFail (malformed wins) [InlineData(0, false, true, AlarmHistorianWriteOutcome.PermanentFail)] // non-zero hresult + comm error → RetryPlease [InlineData(unchecked((int)0x80131500), true, false, AlarmHistorianWriteOutcome.RetryPlease)] // non-zero hresult, no comm flag, no malformed → conservative RetryPlease [InlineData(unchecked((int)0x80131500), false, false, AlarmHistorianWriteOutcome.RetryPlease)] // any malformed input → PermanentFail regardless of hresult [InlineData(unchecked((int)0x80131500), true, true, AlarmHistorianWriteOutcome.PermanentFail)] public void MapOutcome_table(int hresult, bool isCommunicationError, bool isMalformedInput, AlarmHistorianWriteOutcome expected) { AahClientManagedAlarmEventWriter .MapOutcome(hresult, isCommunicationError, isMalformedInput) .ShouldBe(expected); } private static AlarmHistorianEventDto Event(string id) => new AlarmHistorianEventDto { EventId = id, SourceName = "Tank01", ConditionId = "Tank01.Level.HiHi", AlarmType = "AnalogLimitAlarm.HiHi", Message = "Tank 01 high-high level", Severity = 750, EventTimeUtcTicks = DateTime.UtcNow.Ticks, AckComment = null, }; private sealed class RecordingBackend : IAlarmHistorianWriteBackend { private readonly Func _produce; public int Calls { get; private set; } public RecordingBackend(Func produce) { _produce = produce; } public Task WriteBatchAsync( AlarmHistorianEventDto[] events, CancellationToken cancellationToken) { Calls++; return Task.FromResult(_produce(events)); } } } }