diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs index 0689f0dd..503d6b41 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs @@ -361,6 +361,73 @@ public sealed class WonderwareHistorianClientTests outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); } + /// + /// When PerEventStatus is present but its length does not equal the batch size, + /// the client must ignore it and fall back to the legacy PerEventOk path to + /// avoid mis-indexing into the status array. Here a 2-event batch receives + /// PerEventStatus=[1] (length 1) but PerEventOk=[true, false]; the + /// outcomes must reflect the PerEventOk values ([Ack, RetryPlease]), not the status + /// byte (which would have produced [RetryPlease] had it been used). + /// + [Fact] + public async Task WriteBatchAsync_PerEventStatusLengthMismatch_FallsBackToPerEventOk() + { + await using var server = new FakeSidecarServer(Secret) + { + OnWriteAlarmEvents = _ => new WriteAlarmEventsReply + { + Success = true, + PerEventStatus = [1], // length 1 ≠ batch count 2 → must be ignored + PerEventOk = [true, false], // legacy fallback: true→Ack, false→RetryPlease + }, + }; + await server.StartAsync(); + + await using var client = TcpClientFor(server); + var batch = new[] + { + new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), + new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "u", null, DateTime.UtcNow), + }; + + var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); + + outcomes.Count.ShouldBe(2); + outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); // PerEventOk[0] = true + outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); // PerEventOk[1] = false + } + + /// + /// Status byte 1 (the only value that is neither 0 nor 2) must map to + /// via the default arm of the + /// PerEventStatus switch. A single-event batch with PerEventStatus=[1] + /// (length matches batch) must yield [RetryPlease]. + /// + [Fact] + public async Task WriteBatchAsync_PerEventStatusRetry_MapsToRetryPlease() + { + await using var server = new FakeSidecarServer(Secret) + { + OnWriteAlarmEvents = _ => new WriteAlarmEventsReply + { + Success = true, + PerEventStatus = [1], // status 1 → RetryPlease + }, + }; + await server.StartAsync(); + + await using var client = TcpClientFor(server); + var batch = new[] + { + new AlarmHistorianEvent("ev-retry", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), + }; + + var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); + + outcomes.Count.ShouldBe(1); + outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease); + } + /// /// Rolling-deploy back-compat: an older sidecar that sends an empty PerEventStatus but a /// populated PerEventOk must still classify via the legacy bool path (false→RetryPlease).