using System.Collections.Concurrent;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
///
/// lmxopcua#780 batch submit (design §4.5) in the Calculation pump: with an out-of-process runner the pump hands
/// everything it dequeued to ONE and publishes in dequeue order. A tag queued
/// twice ends the batch, so every queued evaluation still runs, in order, against the snapshot that triggered it —
/// the published sequence is exactly the one-at-a-time pump's.
///
public sealed class CalculationDriverBatchPumpTests
{
private const uint StatusGood = 0u;
///
/// Answers src/a × n for tag calc/tn. The first batch is held until , so the
/// test can queue more triggers behind it.
///
private sealed class GatedRunner : IScriptRunner
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _batches;
public ConcurrentQueue Batches { get; } = new();
public int SingleRuns;
public void Release() => _gate.SetResult();
public Task RunAsync(ScriptInvocation invocation, CancellationToken ct = default)
{
Interlocked.Increment(ref SingleRuns);
return Task.FromResult(Answer(invocation));
}
public IReadOnlyList> RunBatch(IReadOnlyList invocations, CancellationToken ct = default)
{
Batches.Enqueue(invocations.Select(i => i.Key).ToArray());
var held = Interlocked.Increment(ref _batches) == 1;
return invocations.Select(i => held ? Later(i) : Task.FromResult(Answer(i))).ToArray();
}
private async Task Later(ScriptInvocation invocation)
{
await _gate.Task.ConfigureAwait(false);
return Answer(invocation);
}
private static ScriptRunOutcome Answer(ScriptInvocation invocation)
=> ScriptRunOutcome.Ok((double)invocation.Inputs["src/a"].Value! * int.Parse(invocation.Key["calc/t".Length..]));
public void Reset(ScriptContextKind kind) { }
public IReadOnlyCollection QuarantinedKeys(ScriptContextKind kind) => [];
}
private static RawTagEntry CalcTag(string rawPath, int factor)
{
var source = $"return (double)ctx.GetTag(\"src/a\").Value * {factor};";
var json = $"{{\"scriptId\":\"s{factor}\",\"changeTriggered\":true,\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
}
private static async Task Settle(CalculationDriver driver)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < deadline && !driver.IsEvaluationIdle) await Task.Delay(10);
driver.IsEvaluationIdle.ShouldBeTrue();
}
[Fact]
public async Task The_pump_submits_what_it_dequeued_as_one_batch_and_publishes_in_dequeue_order()
{
var runner = new GatedRunner();
var driver = new CalculationDriver(
new CalculationDriverOptions { RawTags = [CalcTag("calc/t1", 1), CalcTag("calc/t2", 2), CalcTag("calc/t3", 3)], ScriptRunner = runner },
"calc-drv", new ScriptRootLogger(new LoggerConfiguration().CreateLogger()));
var published = new ConcurrentQueue<(string Tag, object? Value, uint Status)>();
driver.OnDataChange += (_, e) => published.Enqueue((e.FullReference, e.Snapshot.Value, e.Snapshot.StatusCode));
var sizes = new ConcurrentQueue();
driver.BatchSubmittedForTest = sizes.Enqueue;
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", 1.0, StatusGood, DateTime.UtcNow);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline && runner.Batches.IsEmpty) await Task.Delay(5, TestContext.Current.CancellationToken);
// Two more triggers queue behind the held batch: six evaluations, each tag twice.
driver.OnDependencyValue("src/a", 2.0, StatusGood, DateTime.UtcNow);
driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
runner.Release();
await Settle(driver);
runner.SingleRuns.ShouldBe(0);
runner.Batches.ToArray().ShouldBe(
[["calc/t1", "calc/t2", "calc/t3"], ["calc/t1", "calc/t2", "calc/t3"], ["calc/t1", "calc/t2", "calc/t3"]],
"a tag queued twice ends the batch; every queued evaluation still runs");
sizes.ToArray().ShouldBe([3, 3, 3]);
published.ToArray().ShouldBe(
[
("calc/t1", 1.0, StatusGood), ("calc/t2", 2.0, StatusGood), ("calc/t3", 3.0, StatusGood),
("calc/t1", 2.0, StatusGood), ("calc/t2", 4.0, StatusGood), ("calc/t3", 6.0, StatusGood),
("calc/t1", 3.0, StatusGood), ("calc/t2", 6.0, StatusGood), ("calc/t3", 9.0, StatusGood),
], "dequeue order, each against the snapshot that triggered it");
}
}