using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
///
/// The shared batched alarm write: one statement per chunk instead of one round trip per event, with the per-event
/// outcomes the store-and-forward drain needs kept intact (a poison event is isolated, an outage retries everything,
/// a cancellation never dead-letters).
///
public sealed class AlarmBatchWriteTests
{
private sealed class PoisonException : Exception;
private sealed class OutageException : Exception;
private static HistorianWriteOutcome Classify(Exception ex) =>
ex is PoisonException ? HistorianWriteOutcome.PermanentFail : HistorianWriteOutcome.RetryPlease;
private static HistorianWriteOutcome?[] Undecided(int n) => new HistorianWriteOutcome?[n];
[Fact]
public async Task A_clean_batch_is_one_statement_per_chunk_and_all_Ack()
{
var statements = new List();
var r = await AlarmBatchWrite.RunAsync(Undecided(25), 10,
(ix, _) => { statements.Add(ix.ToArray()); return Task.CompletedTask; }, Classify, null, CancellationToken.None);
r.ShouldAllBe(o => o == HistorianWriteOutcome.Ack);
statements.Select(s => s.Length).ShouldBe([10, 10, 5]);
statements.SelectMany(s => s).ShouldBe(Enumerable.Range(0, 25));
}
[Fact]
public async Task A_poison_event_is_isolated_and_its_siblings_still_Ack()
{
var statements = 0;
var r = await AlarmBatchWrite.RunAsync(Undecided(6), 10, (ix, _) =>
{
statements++;
if (ix.Contains(3)) throw new PoisonException();
return Task.CompletedTask;
}, Classify, null, CancellationToken.None);
r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack,
HistorianWriteOutcome.PermanentFail, HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack]);
statements.ShouldBe(1 + 6, "the failed chunk, then one statement per event");
}
[Fact]
public async Task An_outage_retries_the_whole_chunk_without_re_sending_event_by_event()
{
var statements = 0;
var r = await AlarmBatchWrite.RunAsync(Undecided(6), 10,
(_, _) => { statements++; throw new OutageException(); }, Classify, null, CancellationToken.None);
r.ShouldAllBe(o => o == HistorianWriteOutcome.RetryPlease);
statements.ShouldBe(1);
}
[Fact]
public async Task Pre_decided_slots_are_kept_and_never_sent()
{
var sent = new List();
var outcomes = Undecided(4);
outcomes[1] = HistorianWriteOutcome.PermanentFail; // e.g. a pre-1970 time on QuestDB
var r = await AlarmBatchWrite.RunAsync(outcomes, 10,
(ix, _) => { sent.AddRange(ix); return Task.CompletedTask; }, Classify, null, CancellationToken.None);
r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.PermanentFail, HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack]);
sent.ShouldBe([0, 2, 3]);
}
[Fact]
public async Task A_cancellation_mid_batch_retries_everything_undecided_and_dead_letters_nothing()
{
using var cts = new CancellationTokenSource();
var r = await AlarmBatchWrite.RunAsync(Undecided(5), 2, (ix, ct) =>
{
if (ix.Contains(2)) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
return Task.CompletedTask;
}, Classify, null, cts.Token);
r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack,
HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.RetryPlease]);
}
[Fact]
public async Task A_cancellation_during_poison_isolation_retries_the_rest()
{
using var cts = new CancellationTokenSource();
var r = await AlarmBatchWrite.RunAsync(Undecided(4), 10, (ix, ct) =>
{
if (ix.Count > 1) throw new PoisonException();
if (ix[0] == 1) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
return Task.CompletedTask;
}, Classify, null, cts.Token);
r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.RetryPlease,
HistorianWriteOutcome.RetryPlease]);
}
}