feat(scripting): 添加脚本类型元数据服务与按钮动作链功能

- 扩展 CreateBuildFlowButtonDefinitionRequest 添加 BeforeActionsJson 和 AfterActionsJson 字段
- 为自定义函数请求添加 Type、HttpMethod、HttpUrl、HttpHeaders、HttpBodyTemplate 字段
- 添加 ScriptTypeRegistry 服务用于反射扫描 BuildFlow.* 程序集中的 MediatR 类型
- 实现 GetScriptAssemblyListEndpoint 和 GetScriptTypeMetaEndpoint 两个新端点
- 添加 ScriptTypeMeta 相关 DTO 和 GetScriptTypeMetaQuery 查询处理器
- 为年份和年月字符串添加单元测试避免误判为 DateTime 类型
- 在应用启动时自动扫描并缓存脚本可用类型元数据
This commit is contained in:
xiangning 2026-07-07 09:07:05 +08:00
parent bec5bfd0dd
commit 505c46ec05
15 changed files with 618 additions and 2 deletions

View File

@ -0,0 +1,24 @@
using MediatR;
using Scripting.Application.Queries;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>
/// 获取脚本可访问的程序集白名单(调试沙箱用)。
/// GET /scripts/meta/assemblies。需 scripts.read 权限(由 T7 种子数据授权)。
/// </summary>
public class GetScriptAssemblyListEndpoint(IMediator mediator)
: EndpointWithoutRequest<List<string>>
{
public override void Configure()
{
Get("/scripts/meta/assemblies");
Permissions("scripts.read");
}
public override async Task HandleAsync(CancellationToken ct)
{
var result = await mediator.Send(new GetScriptAssemblyListQuery(), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,28 @@
using MediatR;
using Scripting.Application.DTOs;
using Scripting.Application.Queries;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>
/// 获取脚本可调用的 MediatR 类型元数据(Monaco 方法面板用)。
/// GET /scripts/meta/types?keyword=&amp;kind=Query&amp;pageIndex=1&amp;pageSize=50。
/// 需 scripts.read 权限(由 T7 种子数据授权)。
/// </summary>
public class GetScriptTypeMetaEndpoint(IMediator mediator)
: Endpoint<GetScriptTypeMetaRequest, ScriptTypeMetaPage>
{
public override void Configure()
{
Get("/scripts/meta/types");
Permissions("scripts.read");
}
public override async Task HandleAsync(GetScriptTypeMetaRequest req, CancellationToken ct)
{
var result = await mediator.Send(
new GetScriptTypeMetaQuery(req.Keyword, req.Kind, req.PageIndex, req.PageSize),
ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,17 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>脚本类型元数据查询请求。</summary>
public class GetScriptTypeMetaRequest
{
/// <summary>关键字(按类名/全名/命名空间模糊匹配,可选)。</summary>
public string? Keyword { get; set; }
/// <summary>类型分类过滤:"Query" 或 "Command"(可选)。</summary>
public string? Kind { get; set; }
/// <summary>页码(从 1 开始)。</summary>
public int PageIndex { get; set; } = 1;
/// <summary>每页条数。</summary>
public int PageSize { get; set; } = 50;
}

View File

@ -30,7 +30,9 @@ public class CreateBuildFlowButtonDefinitionEndpoint(IMediator mediator)
req.VisibleWhenJson,
req.DisabledWhenJson,
req.ConfirmJson,
req.BeforeActionsJson,
req.ActionsJson,
req.AfterActionsJson,
req.SortOrder),
ct);
await Send.ResponseAsync(result, 201, ct);

View File

@ -2,7 +2,7 @@ using Workflow.Domain.Enums;
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons;
/// <summary>创建 BuildFlow 按钮定义请求。</summary>
/// <summary>创建 BuildFlow 按钮定义请求。含三阶段动作链(before/main/after)。</summary>
public sealed record CreateBuildFlowButtonDefinitionRequest(
Guid? ApplicationId,
Guid? PageDefinitionId,
@ -16,5 +16,7 @@ public sealed record CreateBuildFlowButtonDefinitionRequest(
string? VisibleWhenJson,
string? DisabledWhenJson,
string? ConfirmJson,
string? BeforeActionsJson,
string ActionsJson,
string? AfterActionsJson,
int SortOrder);

View File

@ -21,7 +21,12 @@ public class CreateCustomFunctionEndpoint(IMediator mediator)
req.Name,
req.DisplayName,
req.Description,
req.Type,
req.Code,
req.HttpMethod,
req.HttpUrl,
req.HttpHeaders,
req.HttpBodyTemplate,
req.InputSchema,
req.OutputSchema,
req.TimeoutMs,

View File

@ -1,12 +1,19 @@
using Workflow.Domain.Enums;
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions;
/// <summary>创建自定义函数请求。</summary>
/// <summary>创建自定义函数请求。Type=Script 使用 Code;Type=Http 使用 HttpMethod/Url/Headers/BodyTemplate。</summary>
public sealed record CreateCustomFunctionRequest(
Guid? FormDefinitionId,
string Name,
string DisplayName,
string? Description,
CustomFunctionType Type,
string Code,
string? HttpMethod,
string? HttpUrl,
string? HttpHeaders,
string? HttpBodyTemplate,
string? InputSchema,
string? OutputSchema,
int TimeoutMs);

View File

@ -0,0 +1,15 @@
namespace Scripting.Application.DTOs;
/// <summary>一个可被脚本调用的 MediatR request 类型元数据。供 Monaco 方法面板展示。</summary>
public sealed record ScriptTypeMeta(
string FullName, // e.g. "Platform.Application.Users.Queries.GetUserQuery"
string Name, // e.g. "GetUserQuery"
string Namespace,
string Assembly,
string Kind, // "Query" 或 "Command"
string? ResponseType, // 返回类型友好名(如 "UserDto");null 表示返回 Unit
IReadOnlyList<ScriptPropertyMeta> Properties,
string? Description); // XML doc summary(v1 恒为 null,预留)
/// <summary>脚本类型属性元数据:属性名 + 友好类型名。</summary>
public sealed record ScriptPropertyMeta(string Name, string Type);

View File

@ -0,0 +1,8 @@
namespace Scripting.Application.DTOs;
/// <summary>脚本类型元数据分页结果。</summary>
public sealed record ScriptTypeMetaPage(
List<ScriptTypeMeta> Items,
int Total,
int PageIndex,
int PageSize);

View File

@ -0,0 +1,24 @@
using MediatR;
using Scripting.Application.Services;
namespace Scripting.Application.Queries;
/// <summary>
/// 查询脚本可访问的程序集白名单(用于调试沙箱:脚本里 using 的类型必须来自这些程序集)。
/// 返回已扫描到的所有 BuildFlow.*(非 Tests/Infrastructure)程序集名的去重列表。
/// </summary>
public record GetScriptAssemblyListQuery : IRequest<List<string>>;
public class GetScriptAssemblyListQueryHandler(ScriptTypeRegistry registry)
: IRequestHandler<GetScriptAssemblyListQuery, List<string>>
{
public Task<List<string>> Handle(GetScriptAssemblyListQuery req, CancellationToken ct)
{
var assemblies = registry.GetAll()
.Select(m => m.Assembly)
.Distinct()
.OrderBy(a => a)
.ToList();
return Task.FromResult(assemblies);
}
}

View File

@ -0,0 +1,24 @@
using MediatR;
using Scripting.Application.DTOs;
using Scripting.Application.Services;
namespace Scripting.Application.Queries;
/// <summary>查询可用脚本类型元数据(Monaco 方法面板用)。支持关键字、Kind 过滤与分页。</summary>
public record GetScriptTypeMetaQuery(
string? Keyword,
string? Kind,
int PageIndex = 1,
int PageSize = 50) : IRequest<ScriptTypeMetaPage>;
/// <summary>查询处理器:委托 <see cref="ScriptTypeRegistry"/> 做过滤/分页。</summary>
public class GetScriptTypeMetaQueryHandler(ScriptTypeRegistry registry)
: IRequestHandler<GetScriptTypeMetaQuery, ScriptTypeMetaPage>
{
public Task<ScriptTypeMetaPage> Handle(GetScriptTypeMetaQuery req, CancellationToken ct)
{
var items = registry.Search(req.Keyword, req.Kind, req.PageIndex, req.PageSize).ToList();
var total = registry.Total(req.Keyword, req.Kind);
return Task.FromResult(new ScriptTypeMetaPage(items, total, req.PageIndex, req.PageSize));
}
}

View File

@ -1,7 +1,9 @@
using BuildFlow.Platform.Application;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Scripting.Application.Services;
using Scripting.Infrastructure;
using Volo.Abp;
using Volo.Abp.Modularity;
namespace Scripting.Application;
@ -19,5 +21,16 @@ public class ScriptingApplicationModule : AbpModule
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddScoped<IScriptExecutionService, ScriptExecutionService>();
// 脚本类型注册表:内存缓存,单例。启动时扫描一次(见 OnApplicationInitialization)。
services.TryAddSingleton<ScriptTypeRegistry>();
}
/// <summary>
/// 应用启动后触发一次反射扫描,填充 <see cref="ScriptTypeRegistry"/> 缓存。
/// 此时所有业务模块的程序集已加载,扫描可覆盖完整的 BuildFlow.* IBaseRequest 类型集合。
/// </summary>
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
context.ServiceProvider.GetRequiredService<ScriptTypeRegistry>().Scan();
}
}

View File

@ -0,0 +1,243 @@
using System.Collections.Concurrent;
using System.Reflection;
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Services;
/// <summary>
/// 脚本类型注册表:启动时反射扫描所有 BuildFlow.* 程序集中实现 <see cref="IBaseRequest"/> 的类型,
/// 缓存其元数据供 Monaco 方法面板使用。扫描结果缓存到内存,支持手动刷新。
/// 单例,应用启动时由模块 <c>OnApplicationInitialization</c> 触发一次扫描。
/// </summary>
public class ScriptTypeRegistry
{
private readonly ConcurrentDictionary<string, ScriptTypeMeta> _types = new();
/// <summary>执行(或刷新)扫描。幂等:重复调用会重建字典。</summary>
public void Scan()
{
var found = new List<ScriptTypeMeta>();
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var name = asm.GetName().Name ?? string.Empty;
if (!name.StartsWith("BuildFlow", StringComparison.Ordinal)) continue;
// 排除测试与基础设施程序集:脚本沙箱内无法引用这些类型,列出它们只会误导用户
if (name.EndsWith(".Tests", StringComparison.Ordinal)) continue;
if (name.EndsWith(".Test", StringComparison.Ordinal)) continue;
if (name.EndsWith(".Infrastructure", StringComparison.Ordinal)) continue;
Type[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException) { continue; } // 跳过无法加载的程序集
foreach (var t in types)
{
if (!t.IsClass) continue;
if (t.IsAbstract) continue;
if (t.IsGenericTypeDefinition) continue;
if (!typeof(IBaseRequest).IsAssignableFrom(t)) continue;
found.Add(BuildMeta(t));
}
}
// 原子替换:清空后重新填充。Scan 非并发触发(仅启动 + 手动刷新),此处无需细粒度锁。
_types.Clear();
foreach (var m in found) _types[m.FullName] = m;
}
private static ScriptTypeMeta BuildMeta(Type type)
{
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite)
.Select(p => new ScriptPropertyMeta(p.Name, FormatTypeName(p.PropertyType)))
.ToList();
var kind = InferKind(type);
return new ScriptTypeMeta(
type.FullName ?? type.Name,
type.Name,
type.Namespace ?? string.Empty,
type.Assembly.GetName().Name ?? string.Empty,
kind,
GetResponseType(type), // IRequest<T> 的 T;null 表示返回 Unit
props,
GetXmlDocSummary(type));
}
/// <summary>
/// 推断类型是 Query 还是 Command。
/// 启发式:类名以 Get/List/Search/Count/Query 开头或以 Query 结尾 → Query;否则 Command。
/// 不要求精确——前端面板按 Kind 分组,误判只影响分组归类。
/// </summary>
private static string InferKind(Type type)
{
var n = type.Name;
if (n.StartsWith("Get", StringComparison.Ordinal)
|| n.StartsWith("List", StringComparison.Ordinal)
|| n.StartsWith("Search", StringComparison.Ordinal)
|| n.StartsWith("Count", StringComparison.Ordinal)
|| n.StartsWith("Query", StringComparison.Ordinal)
|| n.EndsWith("Query", StringComparison.Ordinal))
{
return "Query";
}
return "Command";
}
/// <summary>
/// 提取 MediatR 响应类型(IRequest&lt;T&gt; 的 T)。非泛型 IRequest 返回 null(语义为 Unit)。
/// </summary>
private static string? GetResponseType(Type type)
{
foreach (var iface in type.GetInterfaces())
{
if (iface.IsGenericType
&& iface.GetGenericTypeDefinition() == typeof(IRequest<>))
{
return FormatTypeName(iface.GetGenericArguments()[0]);
}
}
return null; // IRequest(非泛型)返回 Unit,统一记为 null
}
/// <summary>
/// 将一个 <see cref="Type"/> 格式化为脚本友好的可读字符串:
/// 基元类型用 C# 关键字(int/string/bool…);泛型用 List&lt;T&gt;/Dictionary&lt;K,V&gt;;
/// 数组用 T[];可空用 T?;其余用命名空间限定名。
/// </summary>
internal static string FormatTypeName(Type? t)
{
if (t is null) return "?";
// Nullable<T> → "T?"
var nullableUnderlying = Nullable.GetUnderlyingType(t);
if (nullableUnderlying is not null)
{
return FormatTypeName(nullableUnderlying) + "?";
}
// by-ref / pointer 不应出现在脚本签名里,退化为元素名
if (t.IsByRef) return FormatTypeName(t.GetElementType());
if (t.IsPointer) return FormatTypeName(t.GetElementType());
// 数组(含多维)→ "T[]"(多维退化为单维展示,避免 T[?,?] 这类对脚本无意义的记号)
if (t.IsArray)
{
var elem = t.GetElementType();
return FormatTypeName(elem) + "[]";
}
// 基元/已知类型:用 C# 关键字或常用短名
if (TryPrimitiveAlias(t, out var alias))
{
return alias;
}
// 泛型实例:List<T>、Dictionary<K,V>、IReadOnlyList<T> 等
if (t.IsGenericType)
{
var def = t.GetGenericTypeDefinition();
var args = t.GetGenericArguments();
var argsFormatted = string.Join(", ", args.Select(FormatTypeName));
// 值元组 (T1,T2,...)
if (def.FullName?.StartsWith("System.ValueTuple`", StringComparison.Ordinal) == true)
{
return "(" + argsFormatted + ")";
}
// Nullable<T> 理论上已被上面拦截,防御性处理
if (def == typeof(Nullable<>))
{
return argsFormatted + "?";
}
// 取泛型定义的短名,去掉 `N 后缀
var name = def.Name;
var tick = name.IndexOf('`');
if (tick >= 0) name = name[..tick];
return $"{name}<{argsFormatted}>";
}
// 普通类型:优先用命名空间限定名,回退到短名
return !string.IsNullOrEmpty(t.FullName) ? t.FullName : t.Name;
}
/// <summary>常见基元/系统类型到 C# 友好名的映射。命中返回 true。</summary>
private static bool TryPrimitiveAlias(Type t, out string alias)
{
// 命中频率最高的内联快表
switch (Type.GetTypeCode(t))
{
case TypeCode.Boolean: alias = "bool"; return true;
case TypeCode.Byte: alias = "byte"; return true;
case TypeCode.SByte: alias = "sbyte"; return true;
case TypeCode.Int16: alias = "short"; return true;
case TypeCode.UInt16: alias = "ushort"; return true;
case TypeCode.Int32: alias = "int"; return true;
case TypeCode.UInt32: alias = "uint"; return true;
case TypeCode.Int64: alias = "long"; return true;
case TypeCode.UInt64: alias = "ulong"; return true;
case TypeCode.Single: alias = "float"; return true;
case TypeCode.Double: alias = "double"; return true;
case TypeCode.Decimal: alias = "decimal"; return true;
case TypeCode.Char: alias = "char"; return true;
case TypeCode.String: alias = "string"; return true;
case TypeCode.Object:
default:
break;
}
switch (t.FullName)
{
case "System.Object": alias = "object"; return true;
case "System.IntPtr": alias = "IntPtr"; return true;
case "System.UIntPtr": alias = "UIntPtr"; return true;
case "System.DateTime": alias = "DateTime"; return true;
case "System.DateTimeOffset": alias = "DateTimeOffset"; return true;
case "System.TimeSpan": alias = "TimeSpan"; return true;
case "System.Guid": alias = "Guid"; return true;
case "System.DateOnly": alias = "DateOnly"; return true;
case "System.TimeOnly": alias = "TimeOnly"; return true;
default:
alias = string.Empty;
return false;
}
}
/// <summary>XML 文档摘要(v1 不加载 doc XML,恒返回 null,预留扩展点)。</summary>
private static string? GetXmlDocSummary(Type type) => null;
/// <summary>返回所有已扫描的类型元数据(无序快照)。</summary>
public IReadOnlyCollection<ScriptTypeMeta> GetAll() => _types.Values.ToArray();
/// <summary>带过滤 + 分页的搜索。匹配关键字(名/全名/命名空间)与 Kind。</summary>
public IEnumerable<ScriptTypeMeta> Search(string? keyword, string? kind, int pageIndex, int pageSize)
{
var q = ApplyFilter(_types.Values, keyword, kind);
return q.OrderBy(m => m.Namespace).ThenBy(m => m.Name)
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize);
}
/// <summary>当前过滤条件下的总数(用于分页)。</summary>
public int Total(string? keyword, string? kind)
=> ApplyFilter(_types.Values, keyword, kind).Count();
private static IEnumerable<ScriptTypeMeta> ApplyFilter(
IEnumerable<ScriptTypeMeta> source, string? keyword, string? kind)
{
var q = source;
if (!string.IsNullOrWhiteSpace(kind))
q = q.Where(m => m.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(keyword))
{
var kw = keyword.Trim();
q = q.Where(m => m.Name.Contains(kw, StringComparison.OrdinalIgnoreCase)
|| m.FullName.Contains(kw, StringComparison.OrdinalIgnoreCase)
|| m.Namespace.Contains(kw, StringComparison.OrdinalIgnoreCase));
}
return q;
}
}

View File

@ -97,6 +97,28 @@ public class ScriptExecutionServiceParseVariablesTests
vars["ts"].Should().BeOfType<DateTime>();
}
/// <summary>
/// 年份串 "2026" 不应误判为 DateTime(会误伤客户/订单 ID 等数字样字符串)。
/// </summary>
[Fact]
public void Year_Only_String_Remains_String()
{
var vars = Parse("{\"year\": \"2026\"}");
vars["year"].Should().BeOfType<string>().Which.Should().Be("2026");
}
/// <summary>
/// 年月串 "2026-07" 不应误判为 DateTime(缺少日期分隔,非完整 ISO-8601)。
/// </summary>
[Fact]
public void Year_Month_String_Remains_String()
{
var vars = Parse("{\"period\": \"2026-07\"}");
vars["period"].Should().BeOfType<string>().Which.Should().Be("2026-07");
}
[Fact]
public void Object_Maps_To_JsonElement()
{

View File

@ -0,0 +1,182 @@
using System.Collections.Generic;
using FluentAssertions;
using Scripting.Application.DTOs;
using Scripting.Application.Services;
using Xunit;
namespace BuildFlow.Scripting.Tests;
/// <summary>
/// <see cref="ScriptTypeRegistry"/> 单元测试:反射扫描、关键字/Kind 过滤、分页与类型格式化。
/// 扫描依赖测试运行时加载的 BuildFlow.* 程序集;本测试项目通过 Scripting.Application
/// 传递引用了 Platform.Application,其 Query 类型应被扫描到。
/// </summary>
public class ScriptTypeRegistryTests
{
private readonly ScriptTypeRegistry _registry = new();
public ScriptTypeRegistryTests()
{
// 测试构造时扫描一次,保证后续断言基于已填充状态
_registry.Scan();
}
/// <summary>扫描应至少发现一个 BuildFlow.* IBaseRequest 实现(来自 Platform.Application)。</summary>
[Fact]
public void Scan_Populates_Known_MediatR_Types()
{
var all = _registry.GetAll();
all.Should().NotBeEmpty("Platform.Application 含大量 IRequest 实现,扫描后应非空");
// 抽样:应能找到带 Query/Command 后缀的类型(启发式分类的输入)
all.Should().Contain(m => m.Kind == "Query" || m.Kind == "Command");
}
/// <summary>关键字搜索应按 名/全名/命名空间 模糊过滤。</summary>
[Fact]
public void Search_By_Keyword_Filters()
{
var all = _registry.GetAll();
all.Should().NotBeEmpty();
// 任取一个类型的名字片段作为关键字,搜索结果必须全部命中该关键字
var keyword = PickProbe(all);
var results = _registry.Search(keyword, kind: null, pageIndex: 1, pageSize: 100).ToList();
results.Should().NotBeEmpty($"关键字 '{keyword}' 来源于已扫描类型");
foreach (var m in results)
{
var hit = m.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|| m.FullName.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|| m.Namespace.Contains(keyword, StringComparison.OrdinalIgnoreCase);
hit.Should().BeTrue($"结果 '{m.FullName}' 应匹配关键字 '{keyword}'");
}
}
/// <summary>Kind=Query 过滤后,结果应全部是 Query;Total 计数应与分页前一致。</summary>
[Fact]
public void Search_By_Kind_Query_Filters()
{
if (_registry.GetAll().Count == 0)
{
// 没有类型可过滤时跳过(不应失败)
return;
}
var queryTotal = _registry.Total(keyword: null, kind: "Query");
var queryPage = _registry.Search(keyword: null, kind: "Query", pageIndex: 1, pageSize: 1000).ToList();
queryPage.Count.Should().Be(queryTotal, "Total 与 Search(不限页大小)计数应一致");
queryPage.Should().AllSatisfy(m => m.Kind.Should().Be("Query"));
queryTotal.Should().BeGreaterThan(0, "Platform.Application 至少含一个 Query");
}
/// <summary>分页:第二页应正确跳过第一页条数。</summary>
[Fact]
public void Search_Paginates()
{
var total = _registry.Total(keyword: null, kind: null);
if (total < 2) return;
var firstPageFirst = _registry.Search(null, null, pageIndex: 1, pageSize: 1).First();
var secondPageFirst = _registry.Search(null, null, pageIndex: 2, pageSize: 1).First();
firstPageFirst.FullName.Should().NotBe(secondPageFirst.FullName, "翻页后首页首项应不同于第二页首项");
}
/// <summary>ResponseType 字段:IRequest&lt;T&gt; 实现应能解析出 T 的友好名。</summary>
[Fact]
public void BuildMeta_Extracts_Response_Type()
{
var all = _registry.GetAll();
all.Should().Contain(m => m.ResponseType is not null,
"至少一个类型实现了 IRequest<T>,应解析出非空 ResponseType");
}
/// <summary>重复扫描是幂等的:再次扫描后总数不变。</summary>
[Fact]
public void Scan_Is_Idempotent()
{
var before = _registry.GetAll().Count;
_registry.Scan();
var after = _registry.GetAll().Count;
after.Should().Be(before, "重复扫描不应改变类型集合");
}
// -------- FormatTypeName(internal static,经 InternalsVisibleTo 暴露) --------
[Theory]
[InlineData(typeof(int), "int")]
[InlineData(typeof(string), "string")]
[InlineData(typeof(bool), "bool")]
[InlineData(typeof(long), "long")]
[InlineData(typeof(decimal), "decimal")]
[InlineData(typeof(double), "double")]
[InlineData(typeof(Guid), "Guid")]
[InlineData(typeof(DateTime), "DateTime")]
[InlineData(typeof(object), "object")]
public void FormatTypeName_Primitives_Use_CSharp_Alias(Type type, string expected)
{
ScriptTypeRegistry.FormatTypeName(type).Should().Be(expected);
}
[Fact]
public void FormatTypeName_Null_Returns_Question_Mark()
{
ScriptTypeRegistry.FormatTypeName(null).Should().Be("?");
}
[Fact]
public void FormatTypeName_Nullable_Primitive_Appends_Question_Mark()
{
ScriptTypeRegistry.FormatTypeName(typeof(int?)).Should().Be("int?");
ScriptTypeRegistry.FormatTypeName(typeof(Guid?)).Should().Be("Guid?");
ScriptTypeRegistry.FormatTypeName(typeof(DateTime?)).Should().Be("DateTime?");
}
[Fact]
public void FormatTypeName_Generic_Collection_Formats_With_Angle_Brackets()
{
ScriptTypeRegistry.FormatTypeName(typeof(List<int>)).Should().Be("List<int>");
ScriptTypeRegistry.FormatTypeName(typeof(IEnumerable<string>)).Should().Be("IEnumerable<string>");
ScriptTypeRegistry.FormatTypeName(typeof(IReadOnlyList<Guid>)).Should().Be("IReadOnlyList<Guid>");
}
[Fact]
public void FormatTypeName_Dictionary_Formats_Two_Type_Arguments()
{
ScriptTypeRegistry.FormatTypeName(typeof(Dictionary<string, int>))
.Should().Be("Dictionary<string, int>");
}
[Fact]
public void FormatTypeName_Nested_Generic_Formats_Recursively()
{
ScriptTypeRegistry.FormatTypeName(typeof(List<List<string>>))
.Should().Be("List<List<string>>");
}
[Fact]
public void FormatTypeName_Array_Appends_Brackets()
{
ScriptTypeRegistry.FormatTypeName(typeof(int[])).Should().Be("int[]");
ScriptTypeRegistry.FormatTypeName(typeof(string[])).Should().Be("string[]");
ScriptTypeRegistry.FormatTypeName(typeof(List<int>[])).Should().Be("List<int>[]");
}
[Fact]
public void FormatTypeName_Complex_Type_Uses_FullName()
{
ScriptTypeRegistry.FormatTypeName(typeof(System.Text.Json.JsonElement))
.Should().Be("System.Text.Json.JsonElement");
}
// -------- 辅助 --------
/// <summary>从已扫描集合中挑一个适合做关键字探测的样本(取名字的子串,避免取到单字符)。</summary>
private static string PickProbe(IReadOnlyCollection<ScriptTypeMeta> all)
{
// 选一个名字至少 4 字符的类型,截取前 4 字符作为关键字,保证可复现命中
var pick = all.First(m => m.Name.Length >= 4);
return pick.Name[..4];
}
}