using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
///
/// lmxopcua#780 design ยง13 "Parity": the same script, through (today's guard)
/// and through the worker, gives the same status, reason, value and exact CLR value type.
///
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class ParityTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
public static TheoryData Cases() => new()
{
{ "arithmetic", "return 1 + 2;" },
{ "string", "return \"a\" + ctx.GetTag(\"x\").Value;" },
{ "throw", "throw new System.InvalidOperationException(\"boom\");" },
{ "compile error", "return undefinedThing;" },
{ "sandbox violation", "return System.IO.File.Exists(\"x\");" },
{ "missing input status", "return ctx.GetTag(\"missing\").StatusCode;" },
{ "input status", "return ctx.GetTag(\"x\").StatusCode;" },
{ "source timestamp", "return ctx.GetTag(\"x\").SourceTimestampUtc;" },
{ "clock", "return ctx.Now > new System.DateTime(2000, 1, 1);" },
{ "null", "return null;" },
{ "array", "return new double[] { 1.5, 2.5 };" },
{ "cast of wrong type", "return (short)ctx.GetTag(\"x\").Value;" },
};
private static readonly IReadOnlyDictionary Inputs =
new Dictionary(StringComparer.Ordinal)
{
["x"] = new DataValueSnapshot(5, 0x40000000u, new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), DateTime.UtcNow),
};
[Theory]
[MemberData(nameof(Cases))]
public async Task In_and_out_of_process_agree(string name, string source)
{
using var inProcess = new InProcessScriptRunner();
await using var outOfProcess = Scripts.Runner(Scripts.Options());
var invocation = Scripts.Vt(name, source, inputs: Inputs);
var expected = await inProcess.RunAsync(invocation, Ct);
var actual = await outOfProcess.RunAsync(invocation, Ct);
actual.Status.ShouldBe(expected.Status, $"{name}: {actual.Reason} vs {expected.Reason}");
actual.Reason.ShouldBe(expected.Reason);
Same(actual.Value, expected.Value, name);
}
public static TheoryData Typed() => new()
{
{ "bool", true }, { "sbyte", (sbyte)-2 }, { "byte", (byte)2 }, { "short", (short)-3 }, { "ushort", (ushort)3 },
{ "int", -4 }, { "uint", 4u }, { "long", -5L }, { "ulong", 5UL }, { "float", 1.25f }, { "double", 2.5 },
{ "string", "s" }, { "System.DateTime", new DateTime(2026, 11, 9, 0, 0, 0, DateTimeKind.Utc) },
};
///
/// Unboxing parity (pins design assumption A-7): (T)ctx.GetTag("x").Value works out of process for every
/// tag type, which needs the exact boxed CLR type to survive the wire.
///
[Theory]
[MemberData(nameof(Typed))]
public async Task Unboxing_to_the_exact_type_matches(string typeName, object value)
{
using var inProcess = new InProcessScriptRunner();
await using var outOfProcess = Scripts.Runner(Scripts.Options());
var invocation = Scripts.Vt("unbox-" + typeName, $"var v = ({typeName})ctx.GetTag(\"x\").Value; return v;",
inputs: Scripts.Input("x", value));
var expected = await inProcess.RunAsync(invocation, Ct);
var actual = await outOfProcess.RunAsync(invocation, Ct);
expected.Status.ShouldBe(ScriptRunStatus.Ok, expected.Reason);
actual.Status.ShouldBe(ScriptRunStatus.Ok, actual.Reason);
Same(actual.Value, expected.Value, typeName);
}
private static void Same(object? actual, object? expected, string because)
{
if (expected is null)
{
actual.ShouldBeNull(because);
return;
}
actual.ShouldNotBeNull(because);
actual.GetType().ShouldBe(expected.GetType(), because);
if (expected is Array array) ((Array)actual).Cast