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().ShouldBe(array.Cast(), because); else actual.ShouldBe(expected, because); } }