diff --git a/docs/requirements/Component-InboundAPI.md b/docs/requirements/Component-InboundAPI.md index fad57e0..3ecaa92 100644 --- a/docs/requirements/Component-InboundAPI.md +++ b/docs/requirements/Component-InboundAPI.md @@ -156,6 +156,10 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a - **Input parameters** are available as defined in the method definition. - **Return value** construction matching the defined return structure. +#### Parameter Access +- `Parameters["key"]` — Raw dictionary access. +- `Parameters.Get("key")` — Typed access (same API as site runtime scripts). See Site Runtime component for full type support. + #### Database Access - `Database.Connection("connectionName")` — Obtain a raw MS SQL client connection for querying the configuration or machine data databases directly from central. diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index a802a23..f3d72bb 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -258,6 +258,14 @@ Available to all Script Execution Actors and Alarm Execution Actors: - `Database.Connection("connectionName")` — Obtain a raw MS SQL client connection (ADO.NET) for synchronous read/write. - `Database.CachedWrite("connectionName", "sql", parameters)` — Submit a write operation for store-and-forward delivery. +### Parameter Access +- `Parameters["key"]` — Raw dictionary access (returns `object?`, requires manual casting). +- `Parameters.Get("key")` — Typed access with descriptive error messages. Throws `ScriptParameterException` if parameter is missing, null, or cannot be converted to `T`. +- `Parameters.Get("key")` — Nullable typed access. Returns `null` if parameter is missing, null, or cannot be converted. +- `Parameters.Get("key")` — Array access. Converts a list parameter to a typed array. Throws on first unconvertible element. +- `Parameters.Get>("key")` — List access. Converts a list parameter to a typed `List`. +- Supported types: `bool`, `int`, `long`, `float`, `double`, `string`, `DateTime`. + ### Recursion Limit - Every script call (`Instance.CallScript` and `Scripts.CallShared`) increments a call depth counter. - If the counter exceeds the maximum recursion depth (default: 10), the call fails with an error. diff --git a/src/ScadaLink.Commons/Types/ScriptParameters.cs b/src/ScadaLink.Commons/Types/ScriptParameters.cs new file mode 100644 index 0000000..741a0b9 --- /dev/null +++ b/src/ScadaLink.Commons/Types/ScriptParameters.cs @@ -0,0 +1,242 @@ +using System.Collections; +using System.Globalization; +using System.Text.Json; + +namespace ScadaLink.Commons.Types; + +/// +/// Typed wrapper around script parameters. Implements IReadOnlyDictionary for backward +/// compatibility (Parameters["key"]) and adds Get<T>() for typed access with +/// clear error messages. +/// +public class ScriptParameters : IReadOnlyDictionary +{ + private readonly IReadOnlyDictionary _inner; + + public ScriptParameters(IReadOnlyDictionary parameters) + { + _inner = parameters ?? throw new ArgumentNullException(nameof(parameters)); + } + + public ScriptParameters() : this(new Dictionary()) { } + + /// + /// Gets a parameter value with typed conversion. + /// + /// Get<int>("key") — throws if missing, null, or unconvertible. + /// Get<int?>("key") — returns null if missing, null, or unconvertible. + /// Get<int[]>("key") — converts list to typed array; throws on first bad element. + /// Get<List<int>>("key") — converts list to typed List; throws on first bad element. + /// + /// + public T Get(string key) + { + var targetType = typeof(T); + + // Array types: int[], string[], etc. + if (targetType.IsArray && targetType != typeof(string)) + { + var elementType = targetType.GetElementType()!; + return (T)(object)ConvertToArray(key, elementType); + } + + // List types + if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(List<>)) + { + var elementType = targetType.GetGenericArguments()[0]; + return (T)ConvertToList(key, elementType); + } + + // Nullable types: int?, bool?, etc. + var underlyingType = Nullable.GetUnderlyingType(targetType); + if (underlyingType != null) + { + return GetNullable(key, underlyingType); + } + + // Non-nullable scalar types + return GetRequired(key, targetType); + } + + private T GetRequired(string key, Type targetType) + { + if (!_inner.TryGetValue(key, out var value)) + throw new ScriptParameterException($"Parameter '{key}' not found"); + + if (value is null) + throw new ScriptParameterException($"Parameter '{key}' value is null"); + + return (T)ConvertScalar(value, targetType, key); + } + + private T GetNullable(string key, Type underlyingType) + { + if (!_inner.TryGetValue(key, out var value) || value is null) + return default!; // null for Nullable + + try + { + var converted = ConvertScalar(value, underlyingType, key); + return (T)converted; + } + catch (ScriptParameterException) + { + return default!; // null on conversion failure for nullable + } + } + + private Array ConvertToArray(string key, Type elementType) + { + var list = GetSourceList(key); + var array = Array.CreateInstance(elementType, list.Count); + for (var i = 0; i < list.Count; i++) + { + var item = list[i]; + if (item is null) + throw new ScriptParameterException( + $"Parameter '{key}' element at index {i} is null"); + + try + { + array.SetValue(ConvertScalar(item, elementType, key), i); + } + catch (ScriptParameterException) + { + throw new ScriptParameterException( + $"Parameter '{key}' element at index {i} with value '{item}' could not be parsed as {elementType.Name}"); + } + } + return array; + } + + private object ConvertToList(string key, Type elementType) + { + var list = GetSourceList(key); + var listType = typeof(List<>).MakeGenericType(elementType); + var result = (IList)Activator.CreateInstance(listType, list.Count)!; + for (var i = 0; i < list.Count; i++) + { + var item = list[i]; + if (item is null) + throw new ScriptParameterException( + $"Parameter '{key}' element at index {i} is null"); + + try + { + result.Add(ConvertScalar(item, elementType, key)); + } + catch (ScriptParameterException) + { + throw new ScriptParameterException( + $"Parameter '{key}' element at index {i} with value '{item}' could not be parsed as {elementType.Name}"); + } + } + return result; + } + + private IList GetSourceList(string key) + { + if (!_inner.TryGetValue(key, out var value)) + throw new ScriptParameterException($"Parameter '{key}' not found"); + + if (value is null) + throw new ScriptParameterException($"Parameter '{key}' value is null"); + + if (value is IList list) + return list; + + throw new ScriptParameterException($"Parameter '{key}' is not a list or array"); + } + + private static object ConvertScalar(object value, Type targetType, string key) + { + // Direct type match + if (targetType.IsInstanceOfType(value)) + return value; + + // Unwrap JsonElement (from JSON deserialization of List / Dictionary) + if (value is JsonElement je) + return ConvertJsonElement(je, targetType, key); + + // string target — use ToString + if (targetType == typeof(string)) + return value.ToString() ?? string.Empty; + + // DateTime from string + if (targetType == typeof(DateTime) && value is string dateStr) + { + if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out var dt)) + return dt; + + throw new ScriptParameterException( + $"Parameter '{key}' with value '{value}' could not be parsed as DateTime"); + } + + // Numeric and other IConvertible conversions + try + { + return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + throw new ScriptParameterException( + $"Parameter '{key}' with value '{value}' could not be parsed as {targetType.Name}"); + } + } + + private static object ConvertJsonElement(JsonElement element, Type targetType, string key) + { + try + { + if (targetType == typeof(bool) && element.ValueKind is JsonValueKind.True or JsonValueKind.False) + return element.GetBoolean(); + if (targetType == typeof(int) && element.ValueKind == JsonValueKind.Number) + return element.GetInt32(); + if (targetType == typeof(long) && element.ValueKind == JsonValueKind.Number) + return element.GetInt64(); + if (targetType == typeof(float) && element.ValueKind == JsonValueKind.Number) + return element.GetSingle(); + if (targetType == typeof(double) && element.ValueKind == JsonValueKind.Number) + return element.GetDouble(); + if (targetType == typeof(string) && element.ValueKind == JsonValueKind.String) + return element.GetString()!; + if (targetType == typeof(string)) + return element.ToString(); + if (targetType == typeof(DateTime) && element.ValueKind == JsonValueKind.String) + { + if (DateTime.TryParse(element.GetString(), CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out var dt)) + return dt; + } + } + catch (Exception ex) when (ex is FormatException or OverflowException or InvalidOperationException) + { + // Fall through to error + } + + throw new ScriptParameterException( + $"Parameter '{key}' with value '{element}' could not be parsed as {targetType.Name}"); + } + + // IReadOnlyDictionary implementation + public object? this[string key] => _inner[key]; + public IEnumerable Keys => _inner.Keys; + public IEnumerable Values => _inner.Values; + public int Count => _inner.Count; + public bool ContainsKey(string key) => _inner.ContainsKey(key); + public bool TryGetValue(string key, out object? value) => _inner.TryGetValue(key, out value); + public IEnumerator> GetEnumerator() => _inner.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +/// +/// Exception thrown by when a parameter +/// cannot be retrieved or converted to the requested type. +/// +public class ScriptParameterException : Exception +{ + public ScriptParameterException(string message) : base(message) { } + public ScriptParameterException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs index cc607d7..99d923b 100644 --- a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs +++ b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.InboundApi; +using ScadaLink.Commons.Types; namespace ScadaLink.InboundAPI; @@ -58,6 +59,7 @@ public class InboundScriptExecutor typeof(Enumerable).Assembly, typeof(Dictionary<,>).Assembly, typeof(RouteHelper).Assembly, + typeof(ScriptParameters).Assembly, typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly) .WithImports( "System", @@ -152,7 +154,7 @@ public class InboundScriptExecutor /// public class InboundScriptContext { - public IReadOnlyDictionary Parameters { get; } + public ScriptParameters Parameters { get; } public RouteHelper Route { get; } public CancellationToken CancellationToken { get; } @@ -161,7 +163,7 @@ public class InboundScriptContext RouteHelper route, CancellationToken cancellationToken = default) { - Parameters = parameters; + Parameters = new ScriptParameters(parameters); Route = route; CancellationToken = cancellationToken; } diff --git a/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs index 1598cbd..de88818 100644 --- a/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs @@ -1,6 +1,7 @@ using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Types; using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime.Actors; @@ -64,7 +65,7 @@ public class AlarmExecutionActor : ReceiveActor var globals = new ScriptGlobals { Instance = context, - Parameters = new Dictionary(), + Parameters = new ScriptParameters(), CancellationToken = cts.Token }; diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index 8f1787f..c5e4c99 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.Commons.Types; using ScadaLink.HealthMonitoring; using ScadaLink.SiteRuntime.Scripts; @@ -100,7 +101,7 @@ public class ScriptExecutionActor : ReceiveActor var globals = new ScriptGlobals { Instance = context, - Parameters = parameters ?? new Dictionary(), + Parameters = new ScriptParameters(parameters ?? new Dictionary()), CancellationToken = cts.Token }; diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs index 16cb0e3..a920dcd 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Types; namespace ScadaLink.SiteRuntime.Scripts; @@ -177,8 +178,7 @@ public class ScriptCompilationResult public class ScriptGlobals { public ScriptRuntimeContext Instance { get; set; } = null!; - public IReadOnlyDictionary Parameters { get; set; } = - new Dictionary(); + public ScriptParameters Parameters { get; set; } = new ScriptParameters(); public CancellationToken CancellationToken { get; set; } /// diff --git a/src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs b/src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs index 3e502f2..5b1bc57 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Types; namespace ScadaLink.SiteRuntime.Scripts; @@ -82,7 +83,7 @@ public class SharedScriptLibrary var globals = new ScriptGlobals { Instance = context, - Parameters = parameters ?? new Dictionary(), + Parameters = new ScriptParameters(parameters ?? new Dictionary()), CancellationToken = cancellationToken }; diff --git a/tests/ScadaLink.Commons.Tests/Types/ScriptParametersTests.cs b/tests/ScadaLink.Commons.Tests/Types/ScriptParametersTests.cs new file mode 100644 index 0000000..ac7f60a --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Types/ScriptParametersTests.cs @@ -0,0 +1,403 @@ +using System.Text.Json; +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Tests.Types; + +public class ScriptParametersTests +{ + // ── Non-nullable scalar Get ────────────────────────────────── + + [Fact] + public void Get_Int_ExactTypeMatch() + { + var p = new ScriptParameters(new Dictionary { ["x"] = 42 }); + Assert.Equal(42, p.Get("x")); + } + + [Fact] + public void Get_Int_FromLong() + { + var p = new ScriptParameters(new Dictionary { ["x"] = 42L }); + Assert.Equal(42, p.Get("x")); + } + + [Fact] + public void Get_Int_FromString() + { + var p = new ScriptParameters(new Dictionary { ["x"] = "42" }); + Assert.Equal(42, p.Get("x")); + } + + [Fact] + public void Get_Int_MissingKey_Throws() + { + var p = new ScriptParameters(); + var ex = Assert.Throws(() => p.Get("x")); + Assert.Contains("Parameter 'x' not found", ex.Message); + } + + [Fact] + public void Get_Int_NullValue_Throws() + { + var p = new ScriptParameters(new Dictionary { ["x"] = null }); + var ex = Assert.Throws(() => p.Get("x")); + Assert.Contains("Parameter 'x' value is null", ex.Message); + } + + [Fact] + public void Get_Int_Unparsable_Throws() + { + var p = new ScriptParameters(new Dictionary { ["x"] = "abc" }); + var ex = Assert.Throws(() => p.Get("x")); + Assert.Contains("could not be parsed as Int32", ex.Message); + } + + [Fact] + public void Get_String_DirectReturn() + { + var p = new ScriptParameters(new Dictionary { ["s"] = "hello" }); + Assert.Equal("hello", p.Get("s")); + } + + [Fact] + public void Get_String_NullValue_Throws() + { + var p = new ScriptParameters(new Dictionary { ["s"] = null }); + var ex = Assert.Throws(() => p.Get("s")); + Assert.Contains("value is null", ex.Message); + } + + [Fact] + public void Get_String_FromInt() + { + var p = new ScriptParameters(new Dictionary { ["s"] = 42 }); + Assert.Equal("42", p.Get("s")); + } + + [Fact] + public void Get_Bool_True() + { + var p = new ScriptParameters(new Dictionary { ["b"] = true }); + Assert.True(p.Get("b")); + } + + [Fact] + public void Get_Double_FromLong() + { + var p = new ScriptParameters(new Dictionary { ["d"] = 42L }); + Assert.Equal(42.0, p.Get("d")); + } + + [Fact] + public void Get_Double_FromFloat() + { + var p = new ScriptParameters(new Dictionary { ["d"] = 3.14f }); + Assert.Equal(3.14, p.Get("d"), 2); + } + + [Fact] + public void Get_Long_FromInt() + { + var p = new ScriptParameters(new Dictionary { ["l"] = 42 }); + Assert.Equal(42L, p.Get("l")); + } + + [Fact] + public void Get_Float_FromDouble() + { + var p = new ScriptParameters(new Dictionary { ["f"] = 3.14 }); + Assert.Equal(3.14f, p.Get("f"), 2); + } + + [Fact] + public void Get_DateTime_FromString() + { + var p = new ScriptParameters(new Dictionary { ["dt"] = "2026-03-22T10:30:00Z" }); + var result = p.Get("dt"); + Assert.Equal(new DateTime(2026, 3, 22, 10, 30, 0, DateTimeKind.Utc), result); + } + + [Fact] + public void Get_DateTime_ExactType() + { + var dt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var p = new ScriptParameters(new Dictionary { ["dt"] = dt }); + Assert.Equal(dt, p.Get("dt")); + } + + // ── Nullable scalar Get ───────────────────────────────────── + + [Fact] + public void Get_NullableInt_MissingKey_ReturnsNull() + { + var p = new ScriptParameters(); + Assert.Null(p.Get("x")); + } + + [Fact] + public void Get_NullableInt_NullValue_ReturnsNull() + { + var p = new ScriptParameters(new Dictionary { ["x"] = null }); + Assert.Null(p.Get("x")); + } + + [Fact] + public void Get_NullableInt_ValidValue_ReturnsValue() + { + var p = new ScriptParameters(new Dictionary { ["x"] = 42L }); + Assert.Equal(42, p.Get("x")); + } + + [Fact] + public void Get_NullableInt_Unparsable_ReturnsNull() + { + var p = new ScriptParameters(new Dictionary { ["x"] = "abc" }); + Assert.Null(p.Get("x")); + } + + [Fact] + public void Get_NullableDouble_ValidValue() + { + var p = new ScriptParameters(new Dictionary { ["d"] = 3.14 }); + Assert.Equal(3.14, p.Get("d")); + } + + [Fact] + public void Get_NullableBool_ValidValue() + { + var p = new ScriptParameters(new Dictionary { ["b"] = true }); + Assert.True(p.Get("b")); + } + + // ── Array Get ────────────────────────────────────────────── + + [Fact] + public void Get_IntArray_FromListOfLongs() + { + var list = new List { 1L, 2L, 3L }; + var p = new ScriptParameters(new Dictionary { ["arr"] = list }); + Assert.Equal(new[] { 1, 2, 3 }, p.Get("arr")); + } + + [Fact] + public void Get_IntArray_EmptyList() + { + var p = new ScriptParameters(new Dictionary { ["arr"] = new List() }); + Assert.Empty(p.Get("arr")); + } + + [Fact] + public void Get_IntArray_NonList_Throws() + { + var p = new ScriptParameters(new Dictionary { ["arr"] = "not a list" }); + var ex = Assert.Throws(() => p.Get("arr")); + Assert.Contains("is not a list or array", ex.Message); + } + + [Fact] + public void Get_IntArray_UnparsableElement_ThrowsWithIndex() + { + var list = new List { 1L, 2L, "bad" }; + var p = new ScriptParameters(new Dictionary { ["arr"] = list }); + var ex = Assert.Throws(() => p.Get("arr")); + Assert.Contains("element at index 2", ex.Message); + Assert.Contains("'bad'", ex.Message); + Assert.Contains("Int32", ex.Message); + } + + [Fact] + public void Get_IntArray_NullElement_Throws() + { + var list = new List { 1L, null, 3L }; + var p = new ScriptParameters(new Dictionary { ["arr"] = list }); + var ex = Assert.Throws(() => p.Get("arr")); + Assert.Contains("element at index 1 is null", ex.Message); + } + + [Fact] + public void Get_StringArray_FromListOfStrings() + { + var list = new List { "a", "b", "c" }; + var p = new ScriptParameters(new Dictionary { ["arr"] = list }); + Assert.Equal(new[] { "a", "b", "c" }, p.Get("arr")); + } + + [Fact] + public void Get_DoubleArray_FromListOfNumbers() + { + var list = new List { 1.1, 2.2, 3.3 }; + var p = new ScriptParameters(new Dictionary { ["arr"] = list }); + Assert.Equal(new[] { 1.1, 2.2, 3.3 }, p.Get("arr")); + } + + [Fact] + public void Get_IntArray_MissingKey_Throws() + { + var p = new ScriptParameters(); + var ex = Assert.Throws(() => p.Get("arr")); + Assert.Contains("not found", ex.Message); + } + + // ── List Get> ──────────────────────────────────────── + + [Fact] + public void Get_ListInt_FromListOfLongs() + { + var list = new List { 10L, 20L, 30L }; + var p = new ScriptParameters(new Dictionary { ["items"] = list }); + var result = p.Get>("items"); + Assert.Equal(new List { 10, 20, 30 }, result); + } + + [Fact] + public void Get_ListString_FromListOfStrings() + { + var list = new List { "x", "y" }; + var p = new ScriptParameters(new Dictionary { ["items"] = list }); + Assert.Equal(new List { "x", "y" }, p.Get>("items")); + } + + [Fact] + public void Get_ListInt_UnparsableElement_ThrowsWithIndex() + { + var list = new List { 1L, "oops" }; + var p = new ScriptParameters(new Dictionary { ["items"] = list }); + var ex = Assert.Throws(() => p.Get>("items")); + Assert.Contains("element at index 1", ex.Message); + } + + [Fact] + public void Get_ListDouble_FromMixedNumbers() + { + var list = new List { 1L, 2.5, 3 }; + var p = new ScriptParameters(new Dictionary { ["items"] = list }); + var result = p.Get>("items"); + Assert.Equal(3, result.Count); + Assert.Equal(1.0, result[0]); + Assert.Equal(2.5, result[1]); + Assert.Equal(3.0, result[2]); + } + + // ── Backward compatibility ────────────────────────────────────── + + [Fact] + public void Indexer_Works() + { + var p = new ScriptParameters(new Dictionary { ["key"] = "val" }); + Assert.Equal("val", p["key"]); + } + + [Fact] + public void ContainsKey_Works() + { + var p = new ScriptParameters(new Dictionary { ["key"] = 1 }); + Assert.True(p.ContainsKey("key")); + Assert.False(p.ContainsKey("missing")); + } + + [Fact] + public void TryGetValue_Works() + { + var p = new ScriptParameters(new Dictionary { ["key"] = 42 }); + Assert.True(p.TryGetValue("key", out var val)); + Assert.Equal(42, val); + } + + [Fact] + public void Count_Works() + { + var p = new ScriptParameters(new Dictionary { ["a"] = 1, ["b"] = 2 }); + Assert.Equal(2, p.Count); + } + + [Fact] + public void Enumeration_Works() + { + var dict = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var p = new ScriptParameters(dict); + var keys = p.Select(kv => kv.Key).OrderBy(k => k).ToList(); + Assert.Equal(new[] { "a", "b" }, keys); + } + + [Fact] + public void EmptyConstructor_ProducesEmptyDictionary() + { + var p = new ScriptParameters(); + Assert.Empty(p); + Assert.False(p.ContainsKey("anything")); + } + + // ── Edge cases ────────────────────────────────────────────────── + + [Fact] + public void Get_Double_FromInt() + { + var p = new ScriptParameters(new Dictionary { ["d"] = 42 }); + Assert.Equal(42.0, p.Get("d")); + } + + // ── JsonElement values (from JSON deserialization) ───────────── + + [Fact] + public void Get_Int_FromJsonElement() + { + using var doc = JsonDocument.Parse("{\"x\": 42}"); + var element = doc.RootElement.GetProperty("x").Clone(); + var p = new ScriptParameters(new Dictionary { ["x"] = element }); + Assert.Equal(42, p.Get("x")); + } + + [Fact] + public void Get_IntArray_FromJsonElementList() + { + // Simulates what JsonSerializer.Deserialize> produces + using var doc = JsonDocument.Parse("[10, 20, 30]"); + var list = JsonSerializer.Deserialize>(doc.RootElement.GetRawText())!; + var p = new ScriptParameters(new Dictionary { ["items"] = list }); + Assert.Equal(new[] { 10, 20, 30 }, p.Get("items")); + } + + [Fact] + public void Get_ListInt_FromJsonElementList() + { + using var doc = JsonDocument.Parse("[1, 2, 3]"); + var list = JsonSerializer.Deserialize>(doc.RootElement.GetRawText())!; + var p = new ScriptParameters(new Dictionary { ["items"] = list }); + Assert.Equal(new List { 1, 2, 3 }, p.Get>("items")); + } + + [Fact] + public void Get_String_FromJsonElement() + { + using var doc = JsonDocument.Parse("{\"s\": \"hello\"}"); + var element = doc.RootElement.GetProperty("s").Clone(); + var p = new ScriptParameters(new Dictionary { ["s"] = element }); + Assert.Equal("hello", p.Get("s")); + } + + [Fact] + public void Get_Double_FromJsonElement() + { + using var doc = JsonDocument.Parse("{\"d\": 3.14}"); + var element = doc.RootElement.GetProperty("d").Clone(); + var p = new ScriptParameters(new Dictionary { ["d"] = element }); + Assert.Equal(3.14, p.Get("d")); + } + + [Fact] + public void Get_Bool_FromJsonElement() + { + using var doc = JsonDocument.Parse("{\"b\": true}"); + var element = doc.RootElement.GetProperty("b").Clone(); + var p = new ScriptParameters(new Dictionary { ["b"] = element }); + Assert.True(p.Get("b")); + } + + [Fact] + public void Get_Int_OverflowFromLong_Throws() + { + var p = new ScriptParameters(new Dictionary { ["x"] = long.MaxValue }); + var ex = Assert.Throws(() => p.Get("x")); + Assert.Contains("could not be parsed as Int32", ex.Message); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/SandboxTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/SandboxTests.cs index 7dfc2c2..6e9a4e1 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/SandboxTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/SandboxTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Types; using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime.Tests.Scripts; @@ -236,7 +237,7 @@ public class SandboxTests var globals = new ScriptGlobals { Instance = null!, - Parameters = new Dictionary(), + Parameters = new ScriptParameters(), CancellationToken = cts.Token }; @@ -266,7 +267,7 @@ public class SandboxTests var globals = new ScriptGlobals { Instance = null!, - Parameters = new Dictionary(), + Parameters = new ScriptParameters(), CancellationToken = cts.Token };