using System.Buffers.Binary;
using Google.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// lmxopcua#780 design §13 — frame codec and the CLR ↔ Variant mapping.
public sealed class ProtocolTests
{
public static IEnumerable Values()
{
object?[] values =
[
null, true, false, (sbyte)-5, (byte)250, (short)-3, (ushort)7, -42, 42u, long.MinValue, ulong.MaxValue,
1.5f, double.NaN, 3.25, "text", string.Empty, new DateTime(2026, 11, 9, 1, 2, 3, DateTimeKind.Utc),
new[] { 1, 2, 3 }, new short[] { -1, 1 }, new[] { "a", "b" }, new[] { true, false }, Array.Empty(),
new[] { new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, (short)0, 0, 0L, 0.0,
];
return values.Select(v => new[] { v });
}
/// The exact boxed CLR type survives (design §4.4): (short)ctx.GetTag(x).Value must keep working.
[Theory]
[MemberData(nameof(Values))]
public void Every_type_tag_round_trips_with_its_exact_clr_type(object? value)
{
VariantCodec.TryEncode(value, out var variant, out var unsupported).ShouldBeTrue();
unsupported.ShouldBeNull();
// Through the wire, not just the object: a payload the serializer drops must still decode.
var wire = Variant.Parser.ParseFrom(variant.ToByteArray());
var decoded = VariantCodec.Decode(wire);
if (value is null)
{
decoded.ShouldBeNull();
return;
}
decoded.ShouldNotBeNull();
decoded.GetType().ShouldBe(value.GetType());
if (value is Array expected)
((Array)decoded).Cast().ShouldBe(expected.Cast());
else if (value is double d && double.IsNaN(d))
double.IsNaN((double)decoded).ShouldBeTrue();
else
decoded.ShouldBe(value);
}
[Fact]
public void Unsupported_types_are_refused_not_converted()
{
foreach (var value in new object[] { new List { 1 }, 1.5m, Guid.NewGuid(), new { A = 1 }, new int[1, 1], new object[] { 1 } })
{
VariantCodec.TryEncode(value, out _, out var type).ShouldBeFalse(value.GetType().Name);
type.ShouldNotBeNullOrEmpty();
}
}
[Fact]
public void Local_datetime_crosses_as_utc()
{
var local = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Local);
VariantCodec.TryEncode(local, out var variant, out _).ShouldBeTrue();
var decoded = (DateTime)VariantCodec.Decode(variant)!;
decoded.Kind.ShouldBe(DateTimeKind.Utc);
decoded.ShouldBe(local.ToUniversalTime());
}
[Fact]
public void A_payload_that_contradicts_its_tag_is_a_protocol_fault()
{
Should.Throw(() => VariantCodec.Decode(new Variant { Type = Variant.Types.TypeTag.Int16, S = "x" }));
Should.Throw(() => VariantCodec.Decode(new Variant { Type = (Variant.Types.TypeTag)99 }));
Should.Throw(() => VariantCodec.Decode(new Variant { Type = Variant.Types.TypeTag.Int16, I = 1 << 20 }));
}
private static Envelope Sample() => new()
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Seq = 7,
Heartbeat = new Heartbeat { Inflight = 3, HeapBytes = 1234 },
};
[Fact]
public async Task A_frame_round_trips()
{
using var stream = new MemoryStream();
await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 1024, TestContext.Current.CancellationToken);
stream.Position = 0;
var read = await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken);
read.ShouldBe(Sample());
(await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken)).ShouldBeNull("a clean end of stream at a frame boundary");
}
[Fact]
public async Task Oversize_frames_are_refused_on_write_and_on_read()
{
using var stream = new MemoryStream();
await Should.ThrowAsync(async () =>
await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 4, TestContext.Current.CancellationToken));
stream.Length.ShouldBe(0, "nothing is written for a refused frame");
await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 1024, TestContext.Current.CancellationToken);
stream.Position = 0;
await Should.ThrowAsync(async () =>
await ScriptWorkerFrameCodec.ReadAsync(stream, 4, TestContext.Current.CancellationToken));
}
public static TheoryData BadFrames()
{
var good = Sample().ToByteArray();
var header = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(header, good.Length);
var wrongVersion = new Envelope { ProtocolVersion = 99, Heartbeat = new Heartbeat() }.ToByteArray();
var noBody = new Envelope { ProtocolVersion = 1, Seq = 1 }.ToByteArray();
return new TheoryData
{
{ "zero length", new byte[] { 0, 0, 0, 0 } },
{ "negative length", new byte[] { 0xFF, 0xFF, 0xFF, 0xFF } },
{ "truncated header", new byte[] { 5, 0 } },
{ "truncated body", [.. header, .. good[..^1]] },
{ "garbage body", [5, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] },
{ "wrong protocol version", Frame(wrongVersion) },
{ "no body", Frame(noBody) },
};
}
private static byte[] Frame(byte[] body)
{
var header = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(header, body.Length);
return [.. header, .. body];
}
[Theory]
[MemberData(nameof(BadFrames))]
public async Task Malformed_frames_are_protocol_faults(string why, byte[] bytes)
{
using var stream = new MemoryStream(bytes);
await Should.ThrowAsync(async () =>
await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken), why);
}
/// Random bytes never escape as anything but a protocol fault (or a clean end of stream).
[Fact]
public async Task Fuzzed_input_only_ever_yields_a_protocol_fault()
{
var random = new Random(780);
for (var i = 0; i < 3000; i++)
{
var bytes = new byte[random.Next(0, 64)];
random.NextBytes(bytes);
if (bytes.Length >= 4 && random.Next(2) == 0)
BinaryPrimitives.WriteInt32LittleEndian(bytes, random.Next(1, bytes.Length));
using var stream = new MemoryStream(bytes);
try
{
while (await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken) is { } envelope)
{
// A frame that parses must still decode its variants without an unexpected exception type.
if (envelope.BodyCase == Envelope.BodyOneofCase.Results)
foreach (var r in envelope.Results.Items)
try { VariantCodec.Decode(r.Value); } catch (Exception ex) when (ex is ScriptWorkerProtocolException or OverflowException) { }
}
}
catch (ScriptWorkerProtocolException)
{
}
}
}
[Fact]
public void Backoff_doubles_from_250ms_to_a_30s_cap_with_20_percent_jitter()
{
WorkerShard.NextBackoff(1, 0.5).ShouldBe(TimeSpan.FromMilliseconds(250));
WorkerShard.NextBackoff(2, 0.5).ShouldBe(TimeSpan.FromMilliseconds(500));
WorkerShard.NextBackoff(3, 0.5).ShouldBe(TimeSpan.FromSeconds(1));
WorkerShard.NextBackoff(20, 0.5).ShouldBe(TimeSpan.FromSeconds(30));
WorkerShard.NextBackoff(1, 0).ShouldBe(TimeSpan.FromMilliseconds(200));
WorkerShard.NextBackoff(1, 0.9999999).TotalMilliseconds.ShouldBe(300, tolerance: 0.01);
WorkerShard.NextBackoff(40, 1).ShouldBe(TimeSpan.FromSeconds(36));
}
[Fact]
public void Shard_placement_is_stable_and_in_range()
{
// FNV-1a, not string.GetHashCode (randomised per process): a script stays on its shard across restarts.
OutOfProcessScriptRunner.ShardIndex("1:vt-1", 2).ShouldBe(OutOfProcessScriptRunner.ShardIndex("1:vt-1", 2));
var spread = Enumerable.Range(0, 400).Select(i => OutOfProcessScriptRunner.ShardIndex($"1:vt-{i}", 4)).ToArray();
spread.ShouldAllBe(i => i >= 0 && i < 4);
spread.Distinct().Count().ShouldBe(4);
OutOfProcessScriptRunner.ShardIndex("abc", 1000).ShouldBe((int)(0x1A47E90Bu % 1000));
}
}