build-flow.api/tests/BuildFlow.Scripting.Tests/ScriptTypeRegistryTests.cs
xiangning 505c46ec05 feat(scripting): 添加脚本类型元数据服务与按钮动作链功能
- 扩展 CreateBuildFlowButtonDefinitionRequest 添加 BeforeActionsJson 和 AfterActionsJson 字段
- 为自定义函数请求添加 Type、HttpMethod、HttpUrl、HttpHeaders、HttpBodyTemplate 字段
- 添加 ScriptTypeRegistry 服务用于反射扫描 BuildFlow.* 程序集中的 MediatR 类型
- 实现 GetScriptAssemblyListEndpoint 和 GetScriptTypeMetaEndpoint 两个新端点
- 添加 ScriptTypeMeta 相关 DTO 和 GetScriptTypeMetaQuery 查询处理器
- 为年份和年月字符串添加单元测试避免误判为 DateTime 类型
- 在应用启动时自动扫描并缓存脚本可用类型元数据
2026-07-07 09:07:05 +08:00

183 lines
7.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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];
}
}