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.VisibleWhenJson,
req.DisabledWhenJson, req.DisabledWhenJson,
req.ConfirmJson, req.ConfirmJson,
req.BeforeActionsJson,
req.ActionsJson, req.ActionsJson,
req.AfterActionsJson,
req.SortOrder), req.SortOrder),
ct); ct);
await Send.ResponseAsync(result, 201, ct); await Send.ResponseAsync(result, 201, ct);

View File

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

View File

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

View File

@ -1,12 +1,19 @@
using Workflow.Domain.Enums;
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions; namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions;
/// <summary>创建自定义函数请求。</summary> /// <summary>创建自定义函数请求。Type=Script 使用 Code;Type=Http 使用 HttpMethod/Url/Headers/BodyTemplate。</summary>
public sealed record CreateCustomFunctionRequest( public sealed record CreateCustomFunctionRequest(
Guid? FormDefinitionId, Guid? FormDefinitionId,
string Name, string Name,
string DisplayName, string DisplayName,
string? Description, string? Description,
CustomFunctionType Type,
string Code, string Code,
string? HttpMethod,
string? HttpUrl,
string? HttpHeaders,
string? HttpBodyTemplate,
string? InputSchema, string? InputSchema,
string? OutputSchema, string? OutputSchema,
int TimeoutMs); 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 BuildFlow.Platform.Application;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Scripting.Application.Services; using Scripting.Application.Services;
using Scripting.Infrastructure; using Scripting.Infrastructure;
using Volo.Abp;
using Volo.Abp.Modularity; using Volo.Abp.Modularity;
namespace Scripting.Application; namespace Scripting.Application;
@ -19,5 +21,16 @@ public class ScriptingApplicationModule : AbpModule
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly)); services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddScoped<IScriptExecutionService, ScriptExecutionService>(); 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>(); 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] [Fact]
public void Object_Maps_To_JsonElement() 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];
}
}