using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using MessagePack; using Serilog.Core; 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.Historian.Wonderware.Tests.Ipc { /// /// Pins the sidecar's poison-event classifier and the per-event status mapping in /// . A structurally-malformed alarm event is marked /// Permanent (status 2) and excluded from the writer batch so the store-and-forward sink /// dead-letters it immediately rather than looping to the retry cap; well-formed events /// map to Ack (0) / Retry (1) from the writer's per-event bool result. /// [Trait("Category", "Unit")] public sealed class HistorianEventClassifierTests { /// Verifies a blank source name is classified structurally malformed. [Fact] public void IsStructurallyMalformed_BlankSourceName_IsTrue() { var e = WellFormed(); e.SourceName = " "; HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue(); } /// Verifies a blank alarm type is classified structurally malformed. [Fact] public void IsStructurallyMalformed_BlankAlarmType_IsTrue() { var e = WellFormed(); e.AlarmType = ""; HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue(); } /// Verifies a non-positive event timestamp is classified structurally malformed. /// The event timestamp in ticks to test. [Theory] [InlineData(0L)] [InlineData(-1L)] public void IsStructurallyMalformed_NonPositiveTimestamp_IsTrue(long ticks) { var e = WellFormed(); e.EventTimeUtcTicks = ticks; HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue(); } /// Verifies a well-formed event is not classified structurally malformed. [Fact] public void IsStructurallyMalformed_WellFormedEvent_IsFalse() { HistorianFrameHandler.IsStructurallyMalformed(WellFormed()).ShouldBeFalse(); } /// /// A mixed batch — one poison event then one well-formed event the writer acks — must /// yield PerEventStatus = [2, 0]: the poison event is Permanent and excluded from the /// writer batch, and only the well-formed event reaches the writer. /// [Fact] public async Task Handler_MixedBatch_MarksPoisonPermanent_AndOnlyWritesWellFormed() { var poison = WellFormed(); poison.EventId = "poison"; poison.SourceName = ""; // structurally malformed var good = WellFormed(); good.EventId = "good"; var fakeWriter = new RecordingAlarmEventWriter(_ => true); var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter); var req = new WriteAlarmEventsRequest { Events = new[] { poison, good }, CorrelationId = "c1" }; var reply = await RoundTripAsync(handler, req); reply.Success.ShouldBeTrue(); reply.PerEventStatus.ShouldBe(new byte[] { 2, 0 }); reply.PerEventOk.ShouldBe(new[] { false, true }); // The writer only ever saw the well-formed event. fakeWriter.Received.Count.ShouldBe(1); fakeWriter.Received[0].EventId.ShouldBe("good"); } /// /// A well-formed event the writer reports as not-persisted maps to Retry (status 1), /// not Permanent — only structurally-malformed events are Permanent. /// [Fact] public async Task Handler_WriterReportsNotPersisted_MapsToRetry() { var good = WellFormed(); good.EventId = "good"; var fakeWriter = new RecordingAlarmEventWriter(_ => false); var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter); var req = new WriteAlarmEventsRequest { Events = new[] { good }, CorrelationId = "c2" }; var reply = await RoundTripAsync(handler, req); reply.Success.ShouldBeTrue(); reply.PerEventStatus.ShouldBe(new byte[] { 1 }); reply.PerEventOk.ShouldBe(new[] { false }); } /// /// An all-poison batch must short-circuit the writer entirely (no WriteAsync call) /// and mark every slot Permanent. /// [Fact] public async Task Handler_AllPoison_SkipsWriter_AllPermanent() { var p1 = WellFormed(); p1.SourceName = ""; var p2 = WellFormed(); p2.AlarmType = ""; var fakeWriter = new RecordingAlarmEventWriter(_ => true); var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter); var req = new WriteAlarmEventsRequest { Events = new[] { p1, p2 }, CorrelationId = "c3" }; var reply = await RoundTripAsync(handler, req); reply.Success.ShouldBeTrue(); reply.PerEventStatus.ShouldBe(new byte[] { 2, 2 }); fakeWriter.Received.Count.ShouldBe(0); } private static AlarmHistorianEventDto WellFormed() => new() { EventId = "ev", SourceName = "Tank.HiHi", ConditionId = "HiHi", AlarmType = "LimitAlarm:Activated", Message = "msg", Severity = 700, EventTimeUtcTicks = new DateTime(2026, 6, 18, 12, 0, 0, DateTimeKind.Utc).Ticks, AckComment = null, }; /// /// Drives a WriteAlarmEvents request through the real frame handler over an in-memory /// duplex stream pair and deserializes the reply the handler writes back. /// private static async Task RoundTripAsync( HistorianFrameHandler handler, WriteAlarmEventsRequest req) { var capture = new MemoryStream(); using var writer = new FrameWriter(capture, leaveOpen: true); var body = MessagePackSerializer.Serialize(req); await handler.HandleAsync(MessageKind.WriteAlarmEventsRequest, body, writer, CancellationToken.None); capture.Position = 0; using var reader = new FrameReader(capture, leaveOpen: true); var frame = await reader.ReadFrameAsync(CancellationToken.None); frame.ShouldNotBeNull(); frame!.Value.Kind.ShouldBe(MessageKind.WriteAlarmEventsReply); return MessagePackSerializer.Deserialize(frame.Value.Body); } /// An that records the batch it received and returns a fixed verdict. private sealed class RecordingAlarmEventWriter : IAlarmEventWriter { private readonly Func _verdict; /// Initializes a new instance with the given per-event verdict. /// Maps each received event to its persisted/not-persisted result. public RecordingAlarmEventWriter(Func verdict) => _verdict = verdict; /// The events the writer was handed, in order. public List Received { get; } = new(); /// public Task WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken) { Received.AddRange(events); return Task.FromResult(events.Select(_verdict).ToArray()); } } /// /// A read data source the WriteAlarmEvents path never touches — present only to /// satisfy the ctor's non-null requirement. /// private sealed class StubHistorian : IHistorianDataSource { /// public Task> ReadRawAsync( string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default) => throw new NotSupportedException(); /// public Task> ReadAggregateAsync( string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default) => throw new NotSupportedException(); /// public Task> ReadAtTimeAsync( string tagName, DateTime[] timestamps, CancellationToken ct = default) => throw new NotSupportedException(); /// public Task> ReadEventsAsync( string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default) => throw new NotSupportedException(); /// public HistorianHealthSnapshot GetHealthSnapshot() => throw new NotSupportedException(); /// public void Dispose() { } } } }