feat(scripting): ScriptGlobals + ScriptExecutionService + 沙箱 + 编译缓存

- ScriptGlobals 注入 ISender/ICurrentUserContext/CancellationToken/ScriptLogger
- ScriptLogger 收集日志写入 ScriptExecutionLog.LogLines
- 变量类型升级:JsonElement→原生类型(long/double/decimal/bool/string/DateTime/Guid)
- 程序集白名单(含 BuildFlow.* Application/Domain,排除 Infrastructure)
- 代码黑名单扫描(防 System.Reflection/IO.File/Net.Http 等)
- 编译缓存(按 code SHA256 缓存委托,上限 500)
- 调用深度计数器(AsyncLocal,超 5 抛异常,防递归)
- 草稿可测(TestExecuteAsync 跳过 Published 校验)

Task T3 of scripting module refactor
This commit is contained in:
xiangning 2026-07-07 08:42:49 +08:00
parent a978e7f868
commit 624579cf78
10 changed files with 629 additions and 2 deletions

View File

@ -41,5 +41,6 @@
<Project Path="tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj" />
<Project Path="tests/BuildFlow.IM.Integration.Tests/BuildFlow.IM.Integration.Tests.csproj" />
<Project Path="tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj" />
<Project Path="tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj" />
</Folder>
</Solution>

View File

@ -0,0 +1,11 @@
using Scripting.Domain.Enums;
namespace Scripting.Application.DTOs;
/// <summary>脚本执行结果 DTO。</summary>
public sealed record ScriptExecutionResult(
ScriptExecutionStatus Status,
string? ResultJson,
string? ErrorMessage,
long DurationMs,
string? LogLines = null);

View File

@ -0,0 +1,45 @@
using BuildFlow.Shared.Domain.Common;
using MediatR;
namespace Scripting.Application.Host;
/// <summary>
/// 脚本宿主对象:向 C# 脚本暴露输入变量、MediatR 发送器、当前用户上下文、超时令牌与受控日志。
/// 脚本通过此对象调用系统任意 Command/Query(MediatR 全开放),权限继承触发者身份。
/// </summary>
public sealed class ScriptGlobals
{
public ScriptGlobals(
IReadOnlyDictionary<string, object?> variables,
string inputJson,
ISender mediator,
ICurrentUserContext currentUser,
CancellationToken cancellationToken,
ScriptLogger logger)
{
Variables = variables;
InputJson = inputJson;
Mediator = mediator;
CurrentUser = currentUser;
CancellationToken = cancellationToken;
Logger = logger;
}
/// <summary>输入变量(已从 JSON 转为 .NET 原生类型:long/double/decimal/bool/string/DateTime/Guid)。</summary>
public IReadOnlyDictionary<string, object?> Variables { get; }
/// <summary>原始输入 JSON 字符串,兜底使用。</summary>
public string InputJson { get; }
/// <summary>MediatR 发送器。脚本里 await Mediator.Send(query, CancellationToken) 调用任意系统方法。</summary>
public ISender Mediator { get; }
/// <summary>当前触发用户上下文(继承触发者身份与权限)。</summary>
public ICurrentUserContext CurrentUser { get; }
/// <summary>超时令牌。必须传给 Mediator.Send 的第二参数,否则脚本超时时 handler 不会停。</summary>
public CancellationToken CancellationToken { get; }
/// <summary>受控日志器。Logger.Info/Debug 输出会写入 ScriptExecutionLog.LogLines。</summary>
public ScriptLogger Logger { get; }
}

View File

@ -0,0 +1,27 @@
namespace Scripting.Application.Host;
/// <summary>受控日志器:脚本里 Logger.Info/Debug/Warn 的输出会被收集,写入 ScriptExecutionLog.LogLines。</summary>
public sealed class ScriptLogger
{
private readonly List<string> _lines = new();
private readonly object _lock = new();
public IReadOnlyList<string> Lines
{
get { lock (_lock) { return _lines.ToArray(); } }
}
public void Info(string message) => Append("INFO", message);
public void Debug(string message) => Append("DEBUG", message);
public void Warn(string message) => Append("WARN", message);
public void Error(string message) => Append("ERROR", message);
private void Append(string level, string message)
{
var line = $"[{level}] {message}";
lock (_lock) { _lines.Add(line); }
}
/// <summary>收集到的日志合并为多行文本,供持久化到 ScriptExecutionLog.LogLines。</summary>
public string ToText() => string.Join("\n", Lines);
}

View File

@ -10,9 +10,16 @@
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Scripting" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure\Scripting.Infrastructure.csproj" />
<ProjectReference Include="..\..\platform\Application\Platform.Application.csproj" />
</ItemGroup>
<!-- 暴露 internal 成员给测试项目(ScriptExecutionService.ParseVariables 纯函数测试) -->
<ItemGroup>
<InternalsVisibleTo Include="BuildFlow.Scripting.Tests" />
</ItemGroup>
</Project>

View File

@ -1,13 +1,13 @@
using BuildFlow.Platform.Application;
using Microsoft.Extensions.DependencyInjection;
using Scripting.Application.Services;
using Scripting.Infrastructure;
using Volo.Abp.Modularity;
namespace Scripting.Application;
/// <summary>
/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)。
/// Roslyn 脚本引擎等服务注册留待 T3。
/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)与脚本执行服务。
/// </summary>
[DependsOn(typeof(ScriptingInfrastructureModule), typeof(PlatformApplicationModule))]
public class ScriptingApplicationModule : AbpModule
@ -18,5 +18,6 @@ public class ScriptingApplicationModule : AbpModule
var assembly = typeof(ScriptingApplicationModule).Assembly;
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddScoped<IScriptExecutionService, ScriptExecutionService>();
}
}

View File

@ -0,0 +1,16 @@
using Scripting.Application.DTOs;
namespace Scripting.Application.Services;
/// <summary>脚本执行服务:编译+运行 C# 脚本,注入 MediatR/CurrentUser/CancellationToken,并写执行日志。</summary>
public interface IScriptExecutionService
{
/// <summary>按 Id 执行已发布脚本(按钮动作链用)。</summary>
Task<ScriptExecutionResult> ExecuteByIdAsync(Guid scriptId, string inputJson, CancellationToken ct);
/// <summary>按 Name 执行已发布脚本(HTTP /scripts/{name}/invoke 用)。</summary>
Task<ScriptExecutionResult> ExecuteByNameAsync(string scriptName, string inputJson, CancellationToken ct);
/// <summary>测试运行:不校验 Published 状态,允许草稿直接测,仍写日志(FunctionName 标 [TEST])。</summary>
Task<ScriptExecutionResult> TestExecuteAsync(Guid scriptId, string inputJson, CancellationToken ct);
}

View File

@ -0,0 +1,339 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Domain.Exceptions;
using MediatR;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.EntityFrameworkCore;
using Scripting.Application.DTOs;
using Scripting.Application.Host;
using Scripting.Domain.Entities;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Services;
/// <summary>
/// 脚本执行服务:编译+运行受控 C# 脚本,注入 MediatR/CurrentUser/CancellationToken/Logger,并写执行日志。
/// 通过 MediatR 全开放系统方法调用(权限继承触发者),含沙箱(程序集白名单 + API 黑名单)、
/// 编译缓存(按 code SHA256 缓存委托)、调用深度限制(防递归)、超时控制。
/// </summary>
public partial class ScriptExecutionService(
ScriptingDbContext db,
ISender mediator,
ICurrentUserContext currentUser) : IScriptExecutionService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>脚本调用深度计数器(AsyncLocal 跨 await 流动,防脚本递归调用自身)。</summary>
private static readonly AsyncLocal<int> _recursionDepth = new();
private const int MaxRecursionDepth = 5;
/// <summary>编译缓存:按 code SHA256 缓存可复用委托,避免每次执行重复编译。</summary>
private static readonly ConcurrentDictionary<string, ScriptRunner<object>> _compiledCache = new();
private const int MaxCacheSize = 500;
private const int DefaultTimeoutMs = 5000;
private const int MaxTimeoutMs = 30000;
/// <summary>脚本程序集白名单(§5.1):仅允许安全程序集,排除 Infrastructure(防直接访问 DbContext)。</summary>
private static readonly MetadataReference[] AllowedAssemblies = BuildAllowedAssemblies();
/// <summary>禁用 API 正则黑名单(§5.3,软约束 best-effort):拦截反射、文件、网络、进程等危险调用。</summary>
[GeneratedRegex(
@"System\.Reflection|System\.IO\.(File|Directory|FileInfo|FileStream)|System\.Net\.(Http|Sockets|WebClient)|System\.Diagnostics\.Process|AppDomain|Assembly\.(Load|Get)|typeof\s*\(",
RegexOptions.Compiled | RegexOptions.CultureInvariant)]
private static partial Regex ForbiddenApiRegex { get; }
// ── 公共入口 ──────────────────────────────────────────────────────────
/// <inheritdoc />
public Task<ScriptExecutionResult> ExecuteByIdAsync(Guid scriptId, string inputJson, CancellationToken ct)
=> ExecuteAsync(scriptId, null, inputJson, requirePublished: true, isTest: false, ct);
/// <inheritdoc />
public Task<ScriptExecutionResult> ExecuteByNameAsync(string scriptName, string inputJson, CancellationToken ct)
=> ExecuteAsync(null, scriptName, inputJson, requirePublished: true, isTest: false, ct);
/// <inheritdoc />
public Task<ScriptExecutionResult> TestExecuteAsync(Guid scriptId, string inputJson, CancellationToken ct)
=> ExecuteAsync(scriptId, null, inputJson, requirePublished: false, isTest: true, ct);
// ── 核心执行 ──────────────────────────────────────────────────────────
private async Task<ScriptExecutionResult> ExecuteAsync(
Guid? scriptId, string? scriptName, string inputJson,
bool requirePublished, bool isTest, CancellationToken ct)
{
// E2 调用深度检查:防脚本通过 MediatR 触发自身形成无限递归
if (_recursionDepth.Value >= MaxRecursionDepth)
{
throw new BusinessException($"脚本调用深度超限(最大 {MaxRecursionDepth})");
}
// 加载脚本定义
ScriptFunction function;
if (scriptId is { } id)
{
function = await db.Scripts.FindAsync([id], ct)
?? throw new BusinessException($"脚本函数 {id} 不存在");
}
else
{
var name = scriptName ?? throw new BusinessException("脚本名称不能为空");
function = await db.Scripts.FirstOrDefaultAsync(f => f.Name == name, ct)
?? throw new BusinessException($"脚本函数 {name} 不存在");
}
// Published 校验(TestExecute 跳过 — 草稿可测)
if (requirePublished)
{
if (function.Status == ScriptStatus.Disabled)
{
throw new BusinessException($"脚本函数 {function.DisplayName} 已禁用");
}
if (function.Status != ScriptStatus.Published)
{
throw new BusinessException($"脚本函数 {function.DisplayName} 尚未发布");
}
}
// E7 禁用 API 扫描(编译前静态检查,best-effort 软约束)
var forbiddenMatch = ForbiddenApiRegex.Match(function.Code);
if (forbiddenMatch.Success)
{
throw new BusinessException($"脚本包含禁用 API: {forbiddenMatch.Value}");
}
// 深度计数 +1(finally 归位)
_recursionDepth.Value++;
var stopwatch = Stopwatch.StartNew();
var logger = new ScriptLogger();
try
{
// 超时令牌:默认 5s,上限 30s
var timeoutMs = function.TimeoutMs <= 0 ? DefaultTimeoutMs : Math.Min(function.TimeoutMs, MaxTimeoutMs);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));
var variables = ParseVariables(inputJson);
var globals = new ScriptGlobals(
variables, inputJson, mediator, currentUser, timeoutCts.Token, logger);
// 编译缓存命中则复用委托,否则编译并入缓存
var runner = GetOrCreateRunner(function.Code);
var result = await runner(globals, timeoutCts.Token);
stopwatch.Stop();
var resultJson = JsonSerializer.Serialize(result, JsonOptions);
var logText = logger.ToText();
await AddLogAsync(function, isTest, ScriptExecutionStatus.Success,
inputJson, resultJson, null, stopwatch.ElapsedMilliseconds, logText, ct);
return new ScriptExecutionResult(
ScriptExecutionStatus.Success, resultJson, null,
stopwatch.ElapsedMilliseconds, string.IsNullOrEmpty(logText) ? null : logText);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
stopwatch.Stop();
const string message = "脚本执行超时";
await AddLogAsync(function, isTest, ScriptExecutionStatus.Timeout,
inputJson, null, message, stopwatch.ElapsedMilliseconds, logger.ToText(), CancellationToken.None);
throw new BusinessException(message);
}
catch (BusinessException)
{
// MediatR handler 抛出的业务异常(权限拒绝等)直接冒泡,不包裹
stopwatch.Stop();
await AddLogAsync(function, isTest, ScriptExecutionStatus.Failed,
inputJson, null, "脚本调用业务异常", stopwatch.ElapsedMilliseconds, logger.ToText(), ct);
throw;
}
catch (Exception ex) when (ex is CompilationErrorException or InvalidOperationException or JsonException)
{
stopwatch.Stop();
await AddLogAsync(function, isTest, ScriptExecutionStatus.Failed,
inputJson, null, ex.Message, stopwatch.ElapsedMilliseconds, logger.ToText(), ct);
throw new BusinessException($"脚本执行失败: {ex.Message}");
}
finally
{
_recursionDepth.Value--;
}
}
// ── 编译缓存 ──────────────────────────────────────────────────────────
/// <summary>按 code SHA256 获取或创建可复用委托。超 500 条时淘汰一半(v1 简单策略)。</summary>
private ScriptRunner<object> GetOrCreateRunner(string code)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(code));
var key = Convert.ToHexString(hash);
// 简单容量控制:超上限时淘汰一半
if (_compiledCache.Count > MaxCacheSize)
{
foreach (var k in _compiledCache.Keys.Take(MaxCacheSize / 2).ToList())
{
_compiledCache.TryRemove(k, out _);
}
}
return _compiledCache.GetOrAdd(key, _ =>
CSharpScript.Create<object>(code, BuildScriptOptions(), typeof(ScriptGlobals))
.CreateDelegate());
}
/// <summary>脚本编译选项(§5.1 程序集白名单 + §5.2 using 白名单)。</summary>
private static ScriptOptions BuildScriptOptions()
{
return ScriptOptions.Default
.WithImports(AllowedImports)
.WithReferences(AllowedAssemblies);
}
private static readonly string[] AllowedImports =
[
"System",
"System.Linq",
"System.Text.Json",
"System.Collections.Generic",
"System.Threading",
"System.Threading.Tasks",
"MediatR",
];
// ── 沙箱程序集白名单 ─────────────────────────────────────────────────
private static MetadataReference[] BuildAllowedAssemblies()
{
// 基线:CLR 核心 + LINQ + JSON + MediatR + Shared kernel
var baseline = new[]
{
typeof(object).Assembly, // System.Private.CoreLib
typeof(Enumerable).Assembly, // System.Linq
typeof(JsonElement).Assembly, // System.Text.Json
typeof(ISender).Assembly, // MediatR
typeof(ICurrentUserContext).Assembly, // BuildFlow.Shared
};
// 加载所有 BuildFlow.* 的 Application/Domain 程序集(让脚本能 new Query/Command)
// 排除 Infrastructure(防直接 DbContext 访问)与 Tests 程序集
var moduleAssemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name is { } name
&& name.StartsWith("BuildFlow")
&& !name.EndsWith(".Infrastructure")
&& !name.Contains(".Tests"));
return baseline.Concat(moduleAssemblies).Distinct()
.Select(a => MetadataReference.CreateFromFile(a.Location))
.ToArray();
}
// ── 变量解析(JsonElement → 原生 .NET 类型) ─────────────────────────
/// <summary>
/// 将输入 JSON 解析为原生 .NET 类型字典,让脚本可直接写 (decimal)Variables["x"] 而非操作 JsonElement。
/// 算法:Number→long/decimal/double,Bool→bool,String→DateTime/Guid/string,Object/Array→保留 JsonElement。
/// </summary>
internal static IReadOnlyDictionary<string, object?> ParseVariables(string inputJson)
{
if (string.IsNullOrWhiteSpace(inputJson))
{
return new Dictionary<string, object?>(StringComparer.Ordinal);
}
var values = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(inputJson)
?? new Dictionary<string, JsonElement>();
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var kvp in values)
{
result[kvp.Key] = ConvertElement(kvp.Value);
}
return result;
}
private static object? ConvertElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => ConvertNumber(element),
JsonValueKind.String => ConvertString(element),
// Object/Array 保留为 JsonElement,脚本如需可进一步处理
_ => element,
};
}
private static object ConvertNumber(JsonElement element)
{
// 整数优先用 long(避免精度损失);decimal 次选(金额友好);最后 double
if (element.TryGetInt64(out var longVal))
{
return longVal;
}
// 含小数或超出 long 范围:优先 decimal
if (element.TryGetDecimal(out var decVal))
{
return decVal;
}
return element.GetDouble();
}
private static object ConvertString(JsonElement element)
{
var str = element.GetString() ?? string.Empty;
// ISO 8601 日期优先
if (DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
{
return dt;
}
// Guid
if (Guid.TryParse(str, out var guid))
{
return guid;
}
return str;
}
// ── 日志持久化 ────────────────────────────────────────────────────────
private async Task AddLogAsync(
ScriptFunction function, bool isTest,
ScriptExecutionStatus status, string inputJson,
string? resultJson, string? errorMessage,
long durationMs, string? logLines, CancellationToken ct)
{
db.ScriptExecutionLogs.Add(new ScriptExecutionLog
{
Id = Guid.NewGuid(),
FunctionId = function.Id,
FunctionName = isTest ? $"[TEST] {function.Name}" : function.Name,
Status = status,
InputJson = string.IsNullOrWhiteSpace(inputJson) ? "{}" : inputJson,
ResultJson = resultJson,
ErrorMessage = errorMessage,
DurationMs = durationMs,
LogLines = logLines,
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\modules\scripting\Application\Scripting.Application.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,161 @@
using System.Text.Json;
using FluentAssertions;
using Scripting.Application.Services;
using Xunit;
namespace BuildFlow.Scripting.Tests;
/// <summary>
/// ScriptExecutionService.ParseVariables 类型映射逻辑测试(纯函数,无需 DI/数据库)。
/// 验证 JSON 输入被正确转为原生 .NET 类型,让脚本能直接 (decimal)Variables["x"] 而非操作 JsonElement。
/// </summary>
public class ScriptExecutionServiceParseVariablesTests
{
// ParseVariables 是 internal static,通过 InternalsVisibleTo 暴露给本测试程序集
private static IReadOnlyDictionary<string, object?> Parse(string json)
=> ScriptExecutionService.ParseVariables(json);
[Fact]
public void Empty_Or_Whitespace_Input_Returns_Empty_Dictionary()
{
Parse("").Should().BeEmpty();
Parse(" ").Should().BeEmpty();
}
[Fact]
public void Integer_Number_Maps_To_Long()
{
var vars = Parse("{\"count\": 42, \"big\": 9007199254740993}");
vars["count"].Should().BeOfType<long>().Which.Should().Be(42L);
// 9007199254740993 超出 double 精度但 long 可表示,应保持 long
vars["big"].Should().BeOfType<long>().Which.Should().Be(9007199254740993L);
}
[Fact]
public void Decimal_Number_Maps_To_Decimal_For_Money_Friendliness()
{
var vars = Parse("{\"price\": 19.99}");
vars["price"].Should().BeOfType<decimal>().Which.Should().Be(19.99m);
}
[Fact]
public void Bool_Maps_To_Boolean()
{
var vars = Parse("{\"active\": true, \"deleted\": false}");
vars["active"].Should().BeOfType<bool>().Which.Should().BeTrue();
vars["deleted"].Should().BeOfType<bool>().Which.Should().BeFalse();
}
[Fact]
public void Null_Maps_To_Null()
{
var vars = Parse("{\"name\": null}");
vars["name"].Should().BeNull();
}
[Fact]
public void Plain_String_Maps_To_String()
{
var vars = Parse("{\"title\": \"hello world\"}");
vars["title"].Should().BeOfType<string>().Which.Should().Be("hello world");
}
[Fact]
public void Iso8601_String_Maps_To_DateTime()
{
var vars = Parse("{\"createdAt\": \"2026-07-06T10:30:00Z\"}");
vars["createdAt"].Should().BeOfType<DateTime>();
var dt = (DateTime)vars["createdAt"]!;
dt.Year.Should().Be(2026);
dt.Month.Should().Be(7);
dt.Day.Should().Be(6);
dt.Hour.Should().Be(10);
dt.Minute.Should().Be(30);
}
[Fact]
public void Guid_String_Maps_To_Guid()
{
var guidStr = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
var vars = Parse("{\"id\": \"" + guidStr + "\"}");
vars["id"].Should().BeOfType<Guid>();
((Guid)vars["id"]!).Should().Be(Guid.Parse(guidStr));
}
/// <summary>ISO 8601 日期优先于 Guid(日期串不会误判为 Guid)。</summary>
[Fact]
public void Date_Parsed_Before_Guid_Check()
{
var vars = Parse("{\"ts\": \"2026-01-15\"}");
vars["ts"].Should().BeOfType<DateTime>();
}
[Fact]
public void Object_Maps_To_JsonElement()
{
var vars = Parse("{\"nested\": {\"a\": 1, \"b\": 2}}");
vars["nested"].Should().BeOfType<JsonElement>();
var el = (JsonElement)vars["nested"]!;
el.ValueKind.Should().Be(JsonValueKind.Object);
}
[Fact]
public void Array_Maps_To_JsonElement()
{
var vars = Parse("{\"items\": [1, 2, 3]}");
vars["items"].Should().BeOfType<JsonElement>();
var el = (JsonElement)vars["items"]!;
el.ValueKind.Should().Be(JsonValueKind.Array);
el.GetArrayLength().Should().Be(3);
}
[Fact]
public void Mixed_Types_All_Resolve_Correctly()
{
var json = "{\"count\": 100, \"price\": 49.95, \"active\": true, \"name\": \"widget\", "
+ "\"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"date\": \"2026-07-06T00:00:00\", "
+ "\"meta\": {\"key\": \"val\"}, \"nullable\": null}";
var vars = Parse(json);
vars["count"].Should().BeOfType<long>();
vars["price"].Should().BeOfType<decimal>();
vars["active"].Should().BeOfType<bool>();
vars["name"].Should().BeOfType<string>();
vars["id"].Should().BeOfType<Guid>();
vars["date"].Should().BeOfType<DateTime>();
vars["meta"].Should().BeOfType<JsonElement>();
vars["nullable"].Should().BeNull();
}
/// <summary>脚本端典型用法:原生类型可直接强转,无需 GetInt64() 等中间步骤。</summary>
[Fact]
public void Script_Idiom_Direct_Cast_Works()
{
var vars = Parse("{\"qty\": 5, \"rate\": 12.5}");
// 模拟脚本里写 (long)Variables["qty"] * (decimal)Variables["rate"]
var result = (long)vars["qty"]! * (decimal)vars["rate"]!;
result.Should().Be(62.5m);
}
/// <summary>Ordinal 键比较器:键名大小写敏感。</summary>
[Fact]
public void Keys_Are_Case_Sensitive_Ordinal()
{
var vars = Parse("{\"Name\": \"a\", \"name\": \"b\"}");
vars.Should().HaveCount(2);
vars["Name"].Should().Be("a");
vars["name"].Should().Be("b");
}
}