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] [InlineData(100)] [InlineData(1000)] public async Task Large_batch_all_ack_returns_all_true(int batchSize) { // Spec: "1 / 100 / 1000 events through a fake aahClientManaged writer; // assert per-row outcome list parallel to input order." var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray()); var writer = new AahClientManagedAlarmEventWriter(backend); var batch = Enumerable.Range(0, batchSize) .Select(i => Event($"E{i}")) .ToArray(); var result = await writer.WriteAsync(batch, CancellationToken.None); result.Length.ShouldBe(batchSize); result.ShouldAllBe(ok => ok); backend.Calls.ShouldBe(1); } [Theory] [InlineData(100)] [InlineData(1000)] public async Task Large_batch_alternating_outcomes_are_positionally_correct(int batchSize) { // Verifies that per-row outcome ordering is preserved for large batches; // a backend that returns the outcomes in a different allocation order would // fail this test if the writer incorrectly indexing outcomes. var backend = new RecordingBackend(events => events.Select((_, i) => i % 2 == 0 ? AlarmHistorianWriteOutcome.Ack : AlarmHistorianWriteOutcome.RetryPlease).ToArray()); var writer = new AahClientManagedAlarmEventWriter(backend); var batch = Enumerable.Range(0, batchSize).Select(i => Event($"E{i}")).ToArray(); var result = await writer.WriteAsync(batch, CancellationToken.None); result.Length.ShouldBe(batchSize); for (var i = 0; i < result.Length; i++) { var expected = i % 2 == 0; result[i].ShouldBe(expected, $"slot {i}: expected {expected}"); } } [Fact] public async Task Backend_retry_then_succeed_simulates_cluster_failover() { // Spec: "Cluster failover: primary node returns BadCommunicationError; // picker rotates to secondary; assert eventual success." // // The real cluster-failover path is internal to SdkAlarmHistorianWriteBackend // (which is rig-gated) and is exercised at the HistorianClusterEndpointPicker // level in HistorianClusterEndpointPickerTests. Here we test the // AahClientManagedAlarmEventWriter's handling of a backend that returns // RetryPlease on the first call (primary-node failure) and Ack on the // second call (secondary-node success), confirming the IPC layer correctly // propagates the trinary outcome across two separate drain ticks. var callCount = 0; var backend = new RecordingBackend(events => { callCount++; if (callCount == 1) { // First call: simulate communication error (isCommunicationError=true) // which produces RetryPlease — equivalent to primary node failing. return events.Select(_ => AlarmHistorianWriteOutcome.RetryPlease).ToArray(); } // Second call (after cluster picker has rotated to secondary): Ack. return events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray(); }); var writer = new AahClientManagedAlarmEventWriter(backend); var batch = new[] { Event("E1"), Event("E2") }; // First drain tick: primary "fails" → all RetryPlease (false at IPC layer). var firstResult = await writer.WriteAsync(batch, CancellationToken.None); firstResult.ShouldBe(new[] { false, false }); // Second drain tick: secondary succeeds → all Ack (true at IPC layer). var secondResult = await writer.WriteAsync(batch, CancellationToken.None); secondResult.ShouldBe(new[] { true, true }); backend.Calls.ShouldBe(2); } [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)); } } } }