mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-12 13:14:30 +08:00
Compare commits
14 Commits
1d6ff223d5
...
59dfb68add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59dfb68add | ||
|
|
b7a476456a | ||
|
|
263586b139 | ||
|
|
7d25f4cb98 | ||
|
|
bcde9f644e | ||
|
|
cde234ddea | ||
|
|
57a006caeb | ||
|
|
9ec83fa56a | ||
|
|
2486352341 | ||
|
|
b19877f970 | ||
|
|
01a815a518 | ||
|
|
35a1634697 | ||
|
|
5185247c35 | ||
|
|
d0b4946bba |
@ -118,6 +118,9 @@ public sealed class PriorityGenerator : MetadataAttributeClassGeneratorBase
|
||||
? $"<{string.Join(", ", symbol.TypeParameters.Select(tp => tp.Name))}>"
|
||||
: string.Empty;
|
||||
|
||||
sb.AppendLine("/// <summary>");
|
||||
sb.AppendLine("/// 为当前分部类型补充自动生成的优先级契约实现。");
|
||||
sb.AppendLine("/// </summary>");
|
||||
sb.AppendLine(
|
||||
$"partial class {symbol.Name}{typeParameters} : global::GFramework.Core.Abstractions.Bases.IPrioritized");
|
||||
sb.AppendLine("{");
|
||||
|
||||
@ -95,6 +95,9 @@ public sealed class EnumExtensionsGenerator : AttributeEnumGeneratorBase
|
||||
|
||||
sb.AppendLine("{");
|
||||
|
||||
sb.AppendLine(" /// <summary>");
|
||||
sb.AppendLine($" /// 为 <see cref=\"{fullEnumName}\" /> 提供自动生成的扩展方法。");
|
||||
sb.AppendLine(" /// </summary>");
|
||||
sb.AppendLine($" public static partial class {enumName}Extensions");
|
||||
sb.AppendLine(" {");
|
||||
|
||||
@ -176,7 +179,13 @@ public sealed class EnumExtensionsGenerator : AttributeEnumGeneratorBase
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine($" /// <summary>是否为 {memberName}</summary>");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
$" /// 判断给定值是否为 <see cref=\"{fullEnumName}.{memberName}\" />。");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"value\">要检查的枚举值。</param>");
|
||||
builder.AppendLine(
|
||||
$" /// <returns>当 <paramref name=\"value\" /> 等于 <see cref=\"{fullEnumName}.{memberName}\" /> 时返回 <see langword=\"true\" />;否则返回 <see langword=\"false\" />。</returns>");
|
||||
builder.AppendLine(
|
||||
$" public static bool Is{memberName}(this {fullEnumName} value) => value == {fullEnumName}.{memberName};");
|
||||
hasGeneratedMembers = true;
|
||||
@ -192,7 +201,14 @@ public sealed class EnumExtensionsGenerator : AttributeEnumGeneratorBase
|
||||
/// <param name="fullEnumName">枚举的完整类型名。</param>
|
||||
private static void AppendIsInMethod(StringBuilder builder, string fullEnumName)
|
||||
{
|
||||
builder.AppendLine(" /// <summary>判断是否属于指定集合</summary>");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// 判断给定值是否属于指定候选集合。");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"value\">要检查的枚举值。</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"values\">用于匹配的候选枚举值集合;当为 <see langword=\"null\" /> 时返回 <see langword=\"false\" />。</param>");
|
||||
builder.AppendLine(
|
||||
" /// <returns>当 <paramref name=\"value\" /> 命中任一候选值时返回 <see langword=\"true\" />;否则返回 <see langword=\"false\" />。</returns>");
|
||||
builder.AppendLine(
|
||||
$" public static bool IsIn(this {fullEnumName} value, params {fullEnumName}[] values)");
|
||||
builder.AppendLine(" {");
|
||||
|
||||
@ -71,13 +71,18 @@ public sealed class LoggerGenerator : TypeAttributeClassGeneratorBase
|
||||
.AppendLine($"namespace {ns};");
|
||||
|
||||
sb.AppendLine()
|
||||
.AppendLine("/// <summary>")
|
||||
.AppendLine("/// 为当前分部类型提供自动生成的日志字段。")
|
||||
.AppendLine("/// </summary>")
|
||||
.AppendLine($"partial {typeKind} {className}{generics.Parameters}");
|
||||
|
||||
foreach (var c in generics.Constraints)
|
||||
sb.AppendLine($" {c}");
|
||||
|
||||
sb.AppendLine("{")
|
||||
.AppendLine(" /// <summary>Auto-generated logger</summary>")
|
||||
.AppendLine(" /// <summary>")
|
||||
.AppendLine(" /// 自动生成的日志字段。")
|
||||
.AppendLine(" /// </summary>")
|
||||
.AppendLine(
|
||||
$" {access} {staticKeyword}readonly ILogger {fieldName} = " +
|
||||
$"LoggerFactoryResolver.Provider.CreateLogger(\"{logName}\");")
|
||||
|
||||
@ -96,6 +96,21 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
|
||||
|
||||
var interfaceName = iContextAware.ToDisplayString(
|
||||
SymbolDisplayFormat.FullyQualifiedFormat);
|
||||
sb.AppendLine("/// <summary>");
|
||||
sb.AppendLine("/// 为当前规则类型补充自动生成的架构上下文访问实现。");
|
||||
sb.AppendLine("/// </summary>");
|
||||
sb.AppendLine("/// <remarks>");
|
||||
sb.AppendLine(
|
||||
"/// 生成代码会在实例级缓存首次解析到的上下文,并在未显式配置提供者时回退到 <see cref=\"GFramework.Core.Architectures.GameContextProvider\" />。");
|
||||
sb.AppendLine(
|
||||
"/// 同一生成类型的所有实例共享一个静态上下文提供者;切换或重置提供者只会影响尚未缓存上下文的新实例或未初始化实例,");
|
||||
sb.AppendLine(
|
||||
"/// 已缓存的实例上下文需要通过 <see cref=\"GFramework.Core.Abstractions.Rule.IContextAware.SetContext(GFramework.Core.Abstractions.Architectures.IArchitectureContext)\" /> 显式覆盖。");
|
||||
sb.AppendLine(
|
||||
"/// 与手动继承 <see cref=\"global::GFramework.Core.Rule.ContextAwareBase\" /> 的路径相比,生成实现会使用 <c>_contextSync</c> 协调惰性初始化、provider 切换和显式上下文注入;");
|
||||
sb.AppendLine(
|
||||
"/// <see cref=\"global::GFramework.Core.Rule.ContextAwareBase\" /> 则保持无锁的实例级缓存语义,更适合已经由调用方线程模型保证串行访问的简单场景。");
|
||||
sb.AppendLine("/// </remarks>");
|
||||
sb.AppendLine($"partial class {symbol.Name} : {interfaceName}");
|
||||
sb.AppendLine("{");
|
||||
|
||||
@ -128,41 +143,82 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
|
||||
sb.AppendLine(" private global::GFramework.Core.Abstractions.Architectures.IArchitectureContext? _context;");
|
||||
sb.AppendLine(
|
||||
" private static global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider? _contextProvider;");
|
||||
sb.AppendLine(" private static readonly object _contextSync = new();");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" /// <summary>");
|
||||
sb.AppendLine(" /// 自动获取的架构上下文(懒加载,默认使用 GameContextProvider)");
|
||||
sb.AppendLine(" /// 获取当前实例绑定的架构上下文。");
|
||||
sb.AppendLine(" /// </summary>");
|
||||
sb.AppendLine(" /// <remarks>");
|
||||
sb.AppendLine(
|
||||
" /// 该属性会先返回通过 <c>IContextAware.SetContext(...)</c> 显式注入的实例上下文;若尚未设置,则在同一个同步域内惰性初始化共享提供者。");
|
||||
sb.AppendLine(
|
||||
" /// 当静态提供者尚未配置时,生成代码会回退到 <see cref=\"GFramework.Core.Architectures.GameContextProvider\" />。");
|
||||
sb.AppendLine(
|
||||
" /// 一旦某个实例成功缓存上下文,后续 <see cref=\"SetContextProvider(GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider)\" />");
|
||||
sb.AppendLine(
|
||||
" /// 或 <see cref=\"ResetContextProvider\" /> 不会自动清除此缓存;如需覆盖,请显式调用 <c>IContextAware.SetContext(...)</c>。");
|
||||
sb.AppendLine(
|
||||
" /// 当前实现还假设 <see cref=\"GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider.GetContext\" /> 可在持有 <c>_contextSync</c> 时安全执行;");
|
||||
sb.AppendLine(
|
||||
" /// 自定义 provider 不应在该调用链内重新进入当前类型的 provider 配置 API,且应避免引入与外部全局锁相互等待的锁顺序。");
|
||||
sb.AppendLine(" /// </remarks>");
|
||||
sb.AppendLine(" protected global::GFramework.Core.Abstractions.Architectures.IArchitectureContext Context");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" get");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" if (_context == null)");
|
||||
sb.AppendLine(" var context = _context;");
|
||||
sb.AppendLine(" if (context is not null)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" return context;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" // 在同一个同步域内协调懒加载与 provider 切换,避免读取到被并发重置的空提供者。");
|
||||
sb.AppendLine(
|
||||
" // provider 的 GetContext() 会在持有 _contextSync 时执行;自定义 provider 必须避免在该调用链内回调 SetContextProvider/ResetContextProvider 或形成反向锁顺序。");
|
||||
sb.AppendLine(" lock (_contextSync)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(
|
||||
" _contextProvider ??= new global::GFramework.Core.Architectures.GameContextProvider();");
|
||||
sb.AppendLine(" _context = _contextProvider.GetContext();");
|
||||
sb.AppendLine(" _context ??= _contextProvider.GetContext();");
|
||||
sb.AppendLine(" return _context;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" return _context;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" /// <summary>");
|
||||
sb.AppendLine(" /// 配置上下文提供者(用于测试或多架构场景)");
|
||||
sb.AppendLine(" /// 配置当前生成类型共享的上下文提供者。");
|
||||
sb.AppendLine(" /// </summary>");
|
||||
sb.AppendLine(" /// <param name=\"provider\">上下文提供者实例</param>");
|
||||
sb.AppendLine(" /// <param name=\"provider\">后续懒加载上下文时要使用的提供者实例。</param>");
|
||||
sb.AppendLine(" /// <remarks>");
|
||||
sb.AppendLine(" /// 该方法使用与 <see cref=\"Context\" /> 相同的同步锁,避免提供者切换与惰性初始化交错。");
|
||||
sb.AppendLine(
|
||||
" /// 已经缓存上下文的实例不会因为提供者切换而自动失效;该变更仅影响尚未初始化上下文的新实例或未缓存实例。");
|
||||
sb.AppendLine(" /// 如需覆盖已有实例的上下文,请显式调用 <c>IContextAware.SetContext(...)</c>。");
|
||||
sb.AppendLine(" /// </remarks>");
|
||||
sb.AppendLine(
|
||||
" public static void SetContextProvider(global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider provider)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" _contextProvider = provider;");
|
||||
sb.AppendLine(" lock (_contextSync)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" _contextProvider = provider;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" /// <summary>");
|
||||
sb.AppendLine(" /// 重置上下文提供者为默认值(用于测试清理)");
|
||||
sb.AppendLine(" /// 重置共享上下文提供者,使后续懒加载回退到默认提供者。");
|
||||
sb.AppendLine(" /// </summary>");
|
||||
sb.AppendLine(" /// <remarks>");
|
||||
sb.AppendLine(" /// 该方法主要用于测试清理或跨用例恢复默认行为。");
|
||||
sb.AppendLine(
|
||||
" /// 它不会清除已经缓存到实例字段中的上下文;只有后续尚未初始化上下文的实例会重新回退到 <see cref=\"GFramework.Core.Architectures.GameContextProvider\" />。");
|
||||
sb.AppendLine(" /// 如需覆盖已有实例的上下文,请显式调用 <c>IContextAware.SetContext(...)</c>。");
|
||||
sb.AppendLine(" /// </remarks>");
|
||||
sb.AppendLine(" public static void ResetContextProvider()");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" _contextProvider = null;");
|
||||
sb.AppendLine(" lock (_contextSync)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" _contextProvider = null;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
}
|
||||
@ -232,7 +288,11 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
|
||||
switch (method.Name)
|
||||
{
|
||||
case "SetContext":
|
||||
sb.AppendLine(" _context = context;");
|
||||
sb.AppendLine(" // 与 Context getter 共享同一同步协议,避免显式注入被并发懒加载覆盖。");
|
||||
sb.AppendLine(" lock (_contextSync)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" _context = context;");
|
||||
sb.AppendLine(" }");
|
||||
break;
|
||||
|
||||
case "GetContext":
|
||||
|
||||
@ -5,19 +5,33 @@ using GFramework.Core.Architectures;
|
||||
namespace GFramework.Core.Rule;
|
||||
|
||||
/// <summary>
|
||||
/// 上下文感知基类,实现了IContextAware接口,为需要感知架构上下文的类提供基础实现
|
||||
/// 上下文感知基类,实现了 <see cref="IContextAware" />,为需要感知架构上下文的类提供基础实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该基类面向手动继承场景,使用简单的实例字段缓存上下文,不提供额外同步保护。
|
||||
/// 与 <c>ContextAwareGenerator</c> 生成的实现不同,它不会维护静态共享的
|
||||
/// <see cref="IArchitectureContextProvider" />,也不会在 <see cref="IContextAware.SetContext" /> /
|
||||
/// <see cref="IContextAware.GetContext" /> 上加锁。
|
||||
/// 若调用方需要跨实例共享 provider、在惰性初始化期间协调 provider 切换,或希望生成代码自动补齐这些约束,应优先使用
|
||||
/// <c>[ContextAware]</c> 生成路径;若场景本身由框架主线程驱动,且只需要最小化的实例级上下文缓存,则该基类更直接。
|
||||
/// </remarks>
|
||||
public abstract class ContextAwareBase : IContextAware
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前实例的架构上下文
|
||||
/// 获取或设置当前实例缓存的架构上下文。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该属性不执行同步;调用方应保证对同一实例的访问遵循其自身线程模型。
|
||||
/// </remarks>
|
||||
protected IArchitectureContext? Context { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设置架构上下文的实现方法,由框架调用
|
||||
/// 设置架构上下文的实现方法,由框架调用。
|
||||
/// </summary>
|
||||
/// <param name="context">要设置的架构上下文实例</param>
|
||||
/// <param name="context">要设置的架构上下文实例。</param>
|
||||
/// <remarks>
|
||||
/// 该实现只做简单赋值,然后调用 <see cref="OnContextReady" />;不与 <see cref="IContextAware.GetContext" /> 共享锁。
|
||||
/// </remarks>
|
||||
void IContextAware.SetContext(IArchitectureContext context)
|
||||
{
|
||||
Context = context;
|
||||
@ -25,9 +39,13 @@ public abstract class ContextAwareBase : IContextAware
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取架构上下文
|
||||
/// 获取架构上下文。
|
||||
/// </summary>
|
||||
/// <returns>当前架构上下文对象</returns>
|
||||
/// <returns>当前架构上下文对象。</returns>
|
||||
/// <remarks>
|
||||
/// 当 <see cref="Context" /> 为空时,该实现会直接回退到 <see cref="GameContext.GetFirstArchitectureContext" />。
|
||||
/// 该回退过程不执行额外同步,也不支持替换 provider;如需这些能力,请改用生成的 ContextAware 实现。
|
||||
/// </remarks>
|
||||
IArchitectureContext IContextAware.GetContext()
|
||||
{
|
||||
Context ??= GameContext.GetFirstArchitectureContext();
|
||||
@ -35,9 +53,9 @@ public abstract class ContextAwareBase : IContextAware
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当上下文准备就绪时调用的虚方法,子类可以重写此方法来执行上下文相关的初始化逻辑
|
||||
/// 当上下文准备就绪时调用的虚方法,子类可以重写此方法来执行上下文相关的初始化逻辑。
|
||||
/// </summary>
|
||||
protected virtual void OnContextReady()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
; Shipped analyzer releases
|
||||
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
@ -0,0 +1,8 @@
|
||||
; Unshipped analyzer release
|
||||
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
|
||||
### New Rules
|
||||
|
||||
Rule ID | Category | Severity | Notes
|
||||
-------------|----------------------------------|----------|------------------------------
|
||||
GF_Cqrs_001 | GFramework.Cqrs.SourceGenerators | Error | CqrsHandlerRegistryGenerator
|
||||
@ -19,12 +19,23 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
private const string CqrsHandlerRegistryAttributeMetadataName =
|
||||
$"{CqrsRuntimeNamespace}.CqrsHandlerRegistryAttribute";
|
||||
|
||||
private const string CqrsReflectionFallbackAttributeMetadataName =
|
||||
$"{CqrsRuntimeNamespace}.CqrsReflectionFallbackAttribute";
|
||||
|
||||
private const string ILoggerMetadataName = $"{LoggingNamespace}.ILogger";
|
||||
private const string IServiceCollectionMetadataName = "Microsoft.Extensions.DependencyInjection.IServiceCollection";
|
||||
private const string GeneratedNamespace = "GFramework.Generated.Cqrs";
|
||||
private const string GeneratedTypeName = "__GFrameworkGeneratedCqrsHandlerRegistry";
|
||||
private const string HintName = "CqrsHandlerRegistry.g.cs";
|
||||
|
||||
private static readonly DiagnosticDescriptor MissingReflectionFallbackContractDiagnostic = new(
|
||||
"GF_Cqrs_001",
|
||||
"Cannot emit CQRS registry without reflection fallback contract",
|
||||
"Cannot generate CQRS handler registry because fallback metadata is required for handler(s): {0}, but runtime contract '{1}' is unavailable",
|
||||
"GFramework.Cqrs.SourceGenerators",
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
@ -53,8 +64,10 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
CqrsHandlerRegistryAttributeMetadataName) is not null &&
|
||||
compilation.GetTypeByMetadataName(ILoggerMetadataName) is not null &&
|
||||
compilation.GetTypeByMetadataName(IServiceCollectionMetadataName) is not null;
|
||||
var supportsReflectionFallbackAttribute =
|
||||
compilation.GetTypeByMetadataName(CqrsReflectionFallbackAttributeMetadataName) is not null;
|
||||
|
||||
return new GenerationEnvironment(generationEnabled);
|
||||
return new GenerationEnvironment(generationEnabled, supportsReflectionFallbackAttribute);
|
||||
}
|
||||
|
||||
private static bool IsHandlerCandidate(SyntaxNode node)
|
||||
@ -92,9 +105,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
ImmutableArray.CreateBuilder<ReflectedImplementationRegistrationSpec>(handlerInterfaces.Length);
|
||||
var preciseReflectedRegistrations =
|
||||
ImmutableArray.CreateBuilder<PreciseReflectedRegistrationSpec>(handlerInterfaces.Length);
|
||||
var runtimeDiscoveredHandlerInterfaceLogNames =
|
||||
ImmutableArray.CreateBuilder<string>(handlerInterfaces.Length);
|
||||
var requiresRuntimeInterfaceDiscovery = false;
|
||||
string? reflectionFallbackHandlerTypeMetadataName = null;
|
||||
foreach (var handlerInterface in handlerInterfaces)
|
||||
{
|
||||
var canReferenceHandlerInterface =
|
||||
@ -126,11 +137,12 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
continue;
|
||||
}
|
||||
|
||||
// 某些关闭 handler interface 仍包含只能在实现类型运行时语义里解析的类型形态。
|
||||
// 对这些边角场景保留“已知接口静态注册 + 剩余接口运行时补洞”的组合路径,
|
||||
// 避免单个未知接口把同实现上的其它已知注册全部拖回整实现反射发现。
|
||||
requiresRuntimeInterfaceDiscovery = true;
|
||||
runtimeDiscoveredHandlerInterfaceLogNames.Add(GetLogDisplayName(handlerInterface));
|
||||
// Concrete closed handler contracts should now always map to either direct registrations,
|
||||
// reflected implementation registrations, or precise runtime type references.
|
||||
// If a future Roslyn type shape still slips through this net, keep the generator conservative:
|
||||
// preserve the static registrations we do understand, and let the runtime recover the remaining
|
||||
// interfaces via the existing assembly-level targeted reflection fallback contract.
|
||||
reflectionFallbackHandlerTypeMetadataName ??= GetReflectionTypeMetadataName(type);
|
||||
}
|
||||
|
||||
return new HandlerCandidateAnalysis(
|
||||
@ -140,11 +152,44 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
reflectedImplementationRegistrations.ToImmutable(),
|
||||
preciseReflectedRegistrations.ToImmutable(),
|
||||
canReferenceImplementation ? null : GetReflectionTypeMetadataName(type),
|
||||
requiresRuntimeInterfaceDiscovery,
|
||||
runtimeDiscoveredHandlerInterfaceLogNames.ToImmutable());
|
||||
reflectionFallbackHandlerTypeMetadataName);
|
||||
}
|
||||
|
||||
private static void Execute(SourceProductionContext context, GenerationEnvironment generationEnvironment,
|
||||
/// <summary>
|
||||
/// 执行 CQRS handler registry 生成管线的最终发射阶段,负责将候选 handler 分析结果汇总为单个
|
||||
/// <c>CqrsHandlerRegistry.g.cs</c>,并在需要时附带程序集级 reflection fallback 元数据。
|
||||
/// </summary>
|
||||
/// <param name="context">用于报告诊断并发射生成源码的源生产上下文。</param>
|
||||
/// <param name="generationEnvironment">
|
||||
/// 当前编译轮次可用的 runtime 合同快照。
|
||||
/// 只有当 CQRS 注册器生成所需的基础契约齐备时,才允许继续生成;当存在
|
||||
/// <c>CqrsReflectionFallbackAttribute</c> 时,才允许输出依赖 fallback 元数据恢复的注册结果。
|
||||
/// </param>
|
||||
/// <param name="candidates">
|
||||
/// 来自语法和语义分析阶段的 handler 候选结果。
|
||||
/// 集合中可能包含 <see langword="null" /> 占位项,且同一实现类型可能因 partial 声明重复出现,后续会统一去重并聚合。
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 该方法负责发射两类生成结果:注册器类型本体,以及在静态类型信息不足时用于运行时补全注册的程序集级
|
||||
/// <c>CqrsReflectionFallbackAttribute</c> 元数据。生成这些结果的目标是把可静态确定的 handler 注册尽量前移到编译期,
|
||||
/// 从而减少运行时程序集扫描成本,同时保留对少数复杂类型形态的兼容回退路径。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 该阶段依赖两个语义前提:一是 runtime 已提供 CQRS 注册器生成所需的基础合同;二是只要存在任何 handler
|
||||
/// 需要通过 reflection fallback 恢复,就必须同时存在承载该元数据的
|
||||
/// <c>CqrsReflectionFallbackAttribute</c>。如果基础合同缺失,生成器会静默跳过本轮发射;如果候选集合去重后没有任何可注册
|
||||
/// handler,也会直接跳过 <c>AddSource</c>,避免输出空注册器。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 当 fallback handler 元数据非空但 runtime 缺少 <c>CqrsReflectionFallbackAttribute</c> 时,
|
||||
/// 该方法会报告 <c>GF_Cqrs_001</c> 并停止发射源码。这样可以避免生成一个表面可用、但会静默漏掉部分 handler 注册的半成品
|
||||
/// registry。只有在静态注册结果与 fallback 契约同时成立时,才允许调用 <c>AddSource</c>。
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void Execute(
|
||||
SourceProductionContext context,
|
||||
GenerationEnvironment generationEnvironment,
|
||||
ImmutableArray<HandlerCandidateAnalysis?> candidates)
|
||||
{
|
||||
if (!generationEnvironment.GenerationEnabled)
|
||||
@ -155,9 +200,65 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
if (registrations.Count == 0)
|
||||
return;
|
||||
|
||||
var fallbackHandlerTypeMetadataNames = registrations
|
||||
.Select(static registration => registration.ReflectionFallbackHandlerTypeMetadataName)
|
||||
.Where(static typeMetadataName => !string.IsNullOrWhiteSpace(typeMetadataName))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
|
||||
if (!CanEmitGeneratedRegistry(
|
||||
generationEnvironment.SupportsReflectionFallbackAttribute,
|
||||
fallbackHandlerTypeMetadataNames.Length))
|
||||
{
|
||||
ReportMissingReflectionFallbackContractDiagnostic(
|
||||
context,
|
||||
fallbackHandlerTypeMetadataNames);
|
||||
return;
|
||||
}
|
||||
|
||||
context.AddSource(
|
||||
HintName,
|
||||
GenerateSource(registrations));
|
||||
GenerateSource(generationEnvironment, registrations, fallbackHandlerTypeMetadataNames));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断当前轮次是否允许输出生成注册器。
|
||||
/// </summary>
|
||||
/// <param name="supportsReflectionFallbackAttribute">
|
||||
/// runtime 合同中是否存在 <c>CqrsReflectionFallbackAttribute</c>,以承载生成器无法静态精确表达的 handler 回退元数据。
|
||||
/// </param>
|
||||
/// <param name="fallbackHandlerTypeCount">
|
||||
/// 当前轮次需要依赖程序集级 reflection fallback 元数据恢复的 handler 数量。
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// 当没有 handler 依赖 fallback,或 runtime 已提供承载该元数据的特性契约时返回 <see langword="true" />;
|
||||
/// 否则返回 <see langword="false" />,调用方必须放弃生成以避免输出会静默漏注册的半成品注册器。
|
||||
/// </returns>
|
||||
private static bool CanEmitGeneratedRegistry(
|
||||
bool supportsReflectionFallbackAttribute,
|
||||
int fallbackHandlerTypeCount)
|
||||
{
|
||||
return fallbackHandlerTypeCount == 0 || supportsReflectionFallbackAttribute;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告当前轮次因缺少 fallback 元数据承载契约而无法安全生成注册器的诊断。
|
||||
/// </summary>
|
||||
/// <param name="context">源生产上下文。</param>
|
||||
/// <param name="fallbackHandlerTypeMetadataNames">需要通过程序集级 reflection fallback 元数据恢复的 handler 元数据名称。</param>
|
||||
private static void ReportMissingReflectionFallbackContractDiagnostic(
|
||||
SourceProductionContext context,
|
||||
IReadOnlyList<string> fallbackHandlerTypeMetadataNames)
|
||||
{
|
||||
var handlerList = string.Join(
|
||||
", ",
|
||||
fallbackHandlerTypeMetadataNames.OrderBy(static name => name, StringComparer.Ordinal));
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
MissingReflectionFallbackContractDiagnostic,
|
||||
Location.None,
|
||||
handlerList,
|
||||
CqrsReflectionFallbackAttributeMetadataName));
|
||||
}
|
||||
|
||||
private static List<ImplementationRegistrationSpec> CollectRegistrations(
|
||||
@ -186,8 +287,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
candidate.ReflectedImplementationRegistrations,
|
||||
candidate.PreciseReflectedRegistrations,
|
||||
candidate.ReflectionTypeMetadataName,
|
||||
candidate.RequiresRuntimeInterfaceDiscovery,
|
||||
candidate.RuntimeDiscoveredHandlerInterfaceLogNames));
|
||||
candidate.ReflectionFallbackHandlerTypeMetadataName));
|
||||
}
|
||||
|
||||
registrations.Sort(static (left, right) =>
|
||||
@ -492,15 +592,35 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
return GetTypeSortKey(type).Replace("global::", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成程序集级 CQRS handler 注册器源码。
|
||||
/// </summary>
|
||||
/// <param name="generationEnvironment">
|
||||
/// 当前轮次的生成环境,用于决定 runtime 是否提供 <c>CqrsReflectionFallbackAttribute</c> 契约,以及是否需要在输出中发射对应的程序集级元数据。
|
||||
/// </param>
|
||||
/// <param name="registrations">
|
||||
/// 已整理并排序的 handler 注册描述。方法会据此生成 <c>CqrsHandlerRegistry.g.cs</c>,其中包含直接注册、实现类型反射注册、精确运行时类型查找等分支。
|
||||
/// </param>
|
||||
/// <param name="fallbackHandlerTypeMetadataNames">
|
||||
/// 仍需依赖程序集级 reflection fallback 元数据恢复的 handler 元数据名称集合。
|
||||
/// 调用方必须先确保:若该集合非空,则 <paramref name="generationEnvironment" /> 已声明支持对应的 fallback attribute 契约;
|
||||
/// 否则应在进入本方法前报告诊断并放弃生成,而不是输出会静默漏注册的半成品注册器。
|
||||
/// </param>
|
||||
/// <returns>完整的注册器源代码文本。</returns>
|
||||
/// <remarks>
|
||||
/// 当 <paramref name="fallbackHandlerTypeMetadataNames" /> 为空时,输出只包含程序集级 <c>CqrsHandlerRegistryAttribute</c> 和注册器实现。
|
||||
/// 当其非空且 runtime 合同可用时,输出还会附带程序集级 <c>CqrsReflectionFallbackAttribute</c>,让运行时补齐生成阶段无法精确表达的剩余 handler。
|
||||
/// 该方法本身不报告诊断;“fallback 必需但 runtime 契约缺失”的错误由调用方在进入本方法前处理。
|
||||
/// </remarks>
|
||||
private static string GenerateSource(
|
||||
IReadOnlyList<ImplementationRegistrationSpec> registrations)
|
||||
GenerationEnvironment generationEnvironment,
|
||||
IReadOnlyList<ImplementationRegistrationSpec> registrations,
|
||||
IReadOnlyList<string> fallbackHandlerTypeMetadataNames)
|
||||
{
|
||||
var hasReflectedImplementationRegistrations = registrations.Any(static registration =>
|
||||
!registration.ReflectedImplementationRegistrations.IsDefaultOrEmpty);
|
||||
var hasPreciseReflectedRegistrations = registrations.Any(static registration =>
|
||||
!registration.PreciseReflectedRegistrations.IsDefaultOrEmpty);
|
||||
var hasRuntimeInterfaceDiscovery = registrations.Any(static registration =>
|
||||
registration.RequiresRuntimeInterfaceDiscovery);
|
||||
var hasExternalAssemblyTypeLookups = registrations.Any(static registration =>
|
||||
registration.PreciseReflectedRegistrations.Any(static preciseRegistration =>
|
||||
preciseRegistration.ServiceTypeArguments.Any(ContainsExternalAssemblyTypeLookup)));
|
||||
@ -508,6 +628,25 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
builder.AppendLine("// <auto-generated />");
|
||||
builder.AppendLine("#nullable enable");
|
||||
builder.AppendLine();
|
||||
if (generationEnvironment.SupportsReflectionFallbackAttribute && fallbackHandlerTypeMetadataNames.Count > 0)
|
||||
{
|
||||
builder.Append("[assembly: global::");
|
||||
builder.Append(CqrsRuntimeNamespace);
|
||||
builder.Append(".CqrsReflectionFallbackAttribute(");
|
||||
for (var index = 0; index < fallbackHandlerTypeMetadataNames.Count; index++)
|
||||
{
|
||||
if (index > 0)
|
||||
builder.Append(", ");
|
||||
|
||||
builder.Append('"');
|
||||
builder.Append(EscapeStringLiteral(fallbackHandlerTypeMetadataNames[index]));
|
||||
builder.Append('"');
|
||||
}
|
||||
|
||||
builder.AppendLine(")]");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.Append("[assembly: global::");
|
||||
builder.Append(CqrsRuntimeNamespace);
|
||||
builder.Append(".CqrsHandlerRegistryAttribute(typeof(global::");
|
||||
@ -555,8 +694,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
{
|
||||
var registration = registrations[registrationIndex];
|
||||
if (!registration.ReflectedImplementationRegistrations.IsDefaultOrEmpty ||
|
||||
!registration.PreciseReflectedRegistrations.IsDefaultOrEmpty ||
|
||||
registration.RequiresRuntimeInterfaceDiscovery)
|
||||
!registration.PreciseReflectedRegistrations.IsDefaultOrEmpty)
|
||||
{
|
||||
AppendOrderedImplementationRegistrations(builder, registration, registrationIndex);
|
||||
}
|
||||
@ -568,13 +706,10 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
|
||||
builder.AppendLine(" }");
|
||||
|
||||
if (hasRuntimeInterfaceDiscovery || hasExternalAssemblyTypeLookups)
|
||||
if (hasExternalAssemblyTypeLookups)
|
||||
{
|
||||
builder.AppendLine();
|
||||
AppendReflectionHelpers(
|
||||
builder,
|
||||
hasRuntimeInterfaceDiscovery,
|
||||
hasExternalAssemblyTypeLookups);
|
||||
AppendReflectionHelpers(builder, hasExternalAssemblyTypeLookups);
|
||||
}
|
||||
|
||||
builder.AppendLine("}");
|
||||
@ -647,7 +782,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
StringComparer.Ordinal.Compare(left.HandlerInterfaceLogName, right.HandlerInterfaceLogName));
|
||||
|
||||
var implementationVariableName = $"implementationType{registrationIndex}";
|
||||
var knownServiceTypesVariableName = $"knownServiceTypes{registrationIndex}";
|
||||
if (string.IsNullOrWhiteSpace(registration.ReflectionTypeMetadataName))
|
||||
{
|
||||
builder.Append(" var ");
|
||||
@ -670,35 +804,12 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" is not null)");
|
||||
builder.AppendLine(" {");
|
||||
|
||||
if (registration.RequiresRuntimeInterfaceDiscovery)
|
||||
{
|
||||
builder.Append(" var ");
|
||||
builder.Append(knownServiceTypesVariableName);
|
||||
builder.AppendLine(" = new global::System.Collections.Generic.HashSet<global::System.Type>();");
|
||||
foreach (var runtimeDiscoveredHandlerInterfaceLogName in registration
|
||||
.RuntimeDiscoveredHandlerInterfaceLogNames)
|
||||
{
|
||||
builder.Append(" // Remaining runtime interface discovery target: ");
|
||||
builder.Append(runtimeDiscoveredHandlerInterfaceLogName);
|
||||
builder.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var orderedRegistration in orderedRegistrations)
|
||||
{
|
||||
switch (orderedRegistration.Kind)
|
||||
{
|
||||
case OrderedRegistrationKind.Direct:
|
||||
var directRegistration = registration.DirectRegistrations[orderedRegistration.Index];
|
||||
if (registration.RequiresRuntimeInterfaceDiscovery)
|
||||
{
|
||||
builder.Append(" ");
|
||||
builder.Append(knownServiceTypesVariableName);
|
||||
builder.Append(".Add(typeof(");
|
||||
builder.Append(directRegistration.HandlerInterfaceDisplayName);
|
||||
builder.AppendLine("));");
|
||||
}
|
||||
|
||||
builder.AppendLine(
|
||||
" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient(");
|
||||
builder.AppendLine(" services,");
|
||||
@ -717,15 +828,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
case OrderedRegistrationKind.ReflectedImplementation:
|
||||
var reflectedRegistration =
|
||||
registration.ReflectedImplementationRegistrations[orderedRegistration.Index];
|
||||
if (registration.RequiresRuntimeInterfaceDiscovery)
|
||||
{
|
||||
builder.Append(" ");
|
||||
builder.Append(knownServiceTypesVariableName);
|
||||
builder.Append(".Add(typeof(");
|
||||
builder.Append(reflectedRegistration.HandlerInterfaceDisplayName);
|
||||
builder.AppendLine("));");
|
||||
}
|
||||
|
||||
builder.AppendLine(
|
||||
" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient(");
|
||||
builder.AppendLine(" services,");
|
||||
@ -752,8 +854,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
preciseRegistration.OpenHandlerTypeDisplayName,
|
||||
registration.ImplementationLogName,
|
||||
preciseRegistration.HandlerInterfaceLogName,
|
||||
knownServiceTypesVariableName,
|
||||
registration.RequiresRuntimeInterfaceDiscovery,
|
||||
3);
|
||||
break;
|
||||
default:
|
||||
@ -762,15 +862,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
}
|
||||
}
|
||||
|
||||
if (registration.RequiresRuntimeInterfaceDiscovery)
|
||||
{
|
||||
builder.Append(" RegisterRemainingReflectedHandlerInterfaces(services, logger, ");
|
||||
builder.Append(implementationVariableName);
|
||||
builder.Append(", ");
|
||||
builder.Append(knownServiceTypesVariableName);
|
||||
builder.AppendLine(");");
|
||||
}
|
||||
|
||||
builder.AppendLine(" }");
|
||||
}
|
||||
|
||||
@ -782,8 +873,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
string openHandlerTypeDisplayName,
|
||||
string implementationLogName,
|
||||
string handlerInterfaceLogName,
|
||||
string knownServiceTypesVariableName,
|
||||
bool trackKnownServiceTypes,
|
||||
int indentLevel)
|
||||
{
|
||||
var indent = new string(' ', indentLevel * 4);
|
||||
@ -848,15 +937,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
builder.Append(" ");
|
||||
builder.Append(implementationVariableName);
|
||||
builder.AppendLine(");");
|
||||
if (trackKnownServiceTypes)
|
||||
{
|
||||
builder.Append(indent);
|
||||
builder.Append(knownServiceTypesVariableName);
|
||||
builder.Append(".Add(");
|
||||
builder.Append(registrationVariablePrefix);
|
||||
builder.AppendLine(");");
|
||||
}
|
||||
|
||||
builder.Append(indent);
|
||||
builder.Append("logger.Debug(\"Registered CQRS handler ");
|
||||
builder.Append(EscapeStringLiteral(implementationLogName));
|
||||
@ -945,7 +1025,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
|
||||
private static void AppendReflectionHelpers(
|
||||
StringBuilder builder,
|
||||
bool includeRuntimeInterfaceDiscoveryHelpers,
|
||||
bool includeExternalAssemblyTypeLookupHelpers)
|
||||
{
|
||||
if (includeExternalAssemblyTypeLookupHelpers)
|
||||
@ -991,123 +1070,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine(" }");
|
||||
}
|
||||
|
||||
if (!includeRuntimeInterfaceDiscoveryHelpers)
|
||||
return;
|
||||
|
||||
if (includeExternalAssemblyTypeLookupHelpers)
|
||||
builder.AppendLine();
|
||||
|
||||
// Emit the runtime helper methods only when at least one handler still needs implementation-scoped
|
||||
// interface discovery after all direct / precise registrations have been emitted.
|
||||
builder.AppendLine(
|
||||
" private static void RegisterRemainingReflectedHandlerInterfaces(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::GFramework.Core.Abstractions.Logging.ILogger logger, global::System.Type implementationType, global::System.Collections.Generic.ISet<global::System.Type> knownServiceTypes)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" var handlerInterfaces = implementationType.GetInterfaces();");
|
||||
builder.AppendLine(" global::System.Array.Sort(handlerInterfaces, CompareTypes);");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" foreach (var handlerInterface in handlerInterfaces)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" if (!IsSupportedHandlerInterface(handlerInterface))");
|
||||
builder.AppendLine(" continue;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" if (knownServiceTypes.Contains(handlerInterface))");
|
||||
builder.AppendLine(" continue;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient(");
|
||||
builder.AppendLine(" services,");
|
||||
builder.AppendLine(" handlerInterface,");
|
||||
builder.AppendLine(" implementationType);");
|
||||
builder.AppendLine(
|
||||
" logger.Debug($\"Registered CQRS handler {GetRuntimeTypeDisplayName(implementationType)} as {GetRuntimeTypeDisplayName(handlerInterface)}.\");");
|
||||
builder.AppendLine(" knownServiceTypes.Add(handlerInterface);");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" private static int CompareTypes(global::System.Type left, global::System.Type right)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(
|
||||
" return global::System.StringComparer.Ordinal.Compare(GetRuntimeTypeDisplayName(left), GetRuntimeTypeDisplayName(right));");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" private static bool IsSupportedHandlerInterface(global::System.Type interfaceType)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" if (!interfaceType.IsGenericType)");
|
||||
builder.AppendLine(" return false;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" var definitionFullName = interfaceType.GetGenericTypeDefinition().FullName;");
|
||||
builder.AppendLine(
|
||||
$" return global::System.StringComparer.Ordinal.Equals(definitionFullName, \"{IRequestHandlerMetadataName}\")");
|
||||
builder.AppendLine(
|
||||
$" || global::System.StringComparer.Ordinal.Equals(definitionFullName, \"{INotificationHandlerMetadataName}\")");
|
||||
builder.AppendLine(
|
||||
$" || global::System.StringComparer.Ordinal.Equals(definitionFullName, \"{IStreamRequestHandlerMetadataName}\");");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" private static string GetRuntimeTypeDisplayName(global::System.Type type)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" if (type == typeof(string))");
|
||||
builder.AppendLine(" return \"string\";");
|
||||
builder.AppendLine(" if (type == typeof(int))");
|
||||
builder.AppendLine(" return \"int\";");
|
||||
builder.AppendLine(" if (type == typeof(long))");
|
||||
builder.AppendLine(" return \"long\";");
|
||||
builder.AppendLine(" if (type == typeof(short))");
|
||||
builder.AppendLine(" return \"short\";");
|
||||
builder.AppendLine(" if (type == typeof(byte))");
|
||||
builder.AppendLine(" return \"byte\";");
|
||||
builder.AppendLine(" if (type == typeof(bool))");
|
||||
builder.AppendLine(" return \"bool\";");
|
||||
builder.AppendLine(" if (type == typeof(object))");
|
||||
builder.AppendLine(" return \"object\";");
|
||||
builder.AppendLine(" if (type == typeof(void))");
|
||||
builder.AppendLine(" return \"void\";");
|
||||
builder.AppendLine(" if (type == typeof(uint))");
|
||||
builder.AppendLine(" return \"uint\";");
|
||||
builder.AppendLine(" if (type == typeof(ulong))");
|
||||
builder.AppendLine(" return \"ulong\";");
|
||||
builder.AppendLine(" if (type == typeof(ushort))");
|
||||
builder.AppendLine(" return \"ushort\";");
|
||||
builder.AppendLine(" if (type == typeof(sbyte))");
|
||||
builder.AppendLine(" return \"sbyte\";");
|
||||
builder.AppendLine(" if (type == typeof(float))");
|
||||
builder.AppendLine(" return \"float\";");
|
||||
builder.AppendLine(" if (type == typeof(double))");
|
||||
builder.AppendLine(" return \"double\";");
|
||||
builder.AppendLine(" if (type == typeof(decimal))");
|
||||
builder.AppendLine(" return \"decimal\";");
|
||||
builder.AppendLine(" if (type == typeof(char))");
|
||||
builder.AppendLine(" return \"char\";");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" if (type.IsArray)");
|
||||
builder.AppendLine(" return GetRuntimeTypeDisplayName(type.GetElementType()!) + \"[]\";");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" if (!type.IsGenericType)");
|
||||
builder.AppendLine(" return (type.FullName ?? type.Name).Replace('+', '.');");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" var genericTypeName = type.GetGenericTypeDefinition().FullName ?? type.Name;");
|
||||
builder.AppendLine(" var arityIndex = genericTypeName.IndexOf('`');");
|
||||
builder.AppendLine(" if (arityIndex >= 0)");
|
||||
builder.AppendLine(" genericTypeName = genericTypeName[..arityIndex];");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" genericTypeName = genericTypeName.Replace('+', '.');");
|
||||
builder.AppendLine(" var arguments = type.GetGenericArguments();");
|
||||
builder.AppendLine(" var builder = new global::System.Text.StringBuilder();");
|
||||
builder.AppendLine(" builder.Append(genericTypeName);");
|
||||
builder.AppendLine(" builder.Append('<');");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" for (var index = 0; index < arguments.Length; index++)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" if (index > 0)");
|
||||
builder.AppendLine(" builder.Append(\", \");");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" builder.Append(GetRuntimeTypeDisplayName(arguments[index]));");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" builder.Append('>');");
|
||||
builder.AppendLine(" return builder.ToString();");
|
||||
builder.AppendLine(" }");
|
||||
}
|
||||
|
||||
private static string EscapeStringLiteral(string value)
|
||||
@ -1218,8 +1180,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
ImmutableArray<ReflectedImplementationRegistrationSpec> ReflectedImplementationRegistrations,
|
||||
ImmutableArray<PreciseReflectedRegistrationSpec> PreciseReflectedRegistrations,
|
||||
string? ReflectionTypeMetadataName,
|
||||
bool RequiresRuntimeInterfaceDiscovery,
|
||||
ImmutableArray<string> RuntimeDiscoveredHandlerInterfaceLogNames);
|
||||
string? ReflectionFallbackHandlerTypeMetadataName);
|
||||
|
||||
private readonly struct HandlerCandidateAnalysis : IEquatable<HandlerCandidateAnalysis>
|
||||
{
|
||||
@ -1230,8 +1191,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
ImmutableArray<ReflectedImplementationRegistrationSpec> reflectedImplementationRegistrations,
|
||||
ImmutableArray<PreciseReflectedRegistrationSpec> preciseReflectedRegistrations,
|
||||
string? reflectionTypeMetadataName,
|
||||
bool requiresRuntimeInterfaceDiscovery,
|
||||
ImmutableArray<string> runtimeDiscoveredHandlerInterfaceLogNames)
|
||||
string? reflectionFallbackHandlerTypeMetadataName)
|
||||
{
|
||||
ImplementationTypeDisplayName = implementationTypeDisplayName;
|
||||
ImplementationLogName = implementationLogName;
|
||||
@ -1239,8 +1199,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
ReflectedImplementationRegistrations = reflectedImplementationRegistrations;
|
||||
PreciseReflectedRegistrations = preciseReflectedRegistrations;
|
||||
ReflectionTypeMetadataName = reflectionTypeMetadataName;
|
||||
RequiresRuntimeInterfaceDiscovery = requiresRuntimeInterfaceDiscovery;
|
||||
RuntimeDiscoveredHandlerInterfaceLogNames = runtimeDiscoveredHandlerInterfaceLogNames;
|
||||
ReflectionFallbackHandlerTypeMetadataName = reflectionFallbackHandlerTypeMetadataName;
|
||||
}
|
||||
|
||||
public string ImplementationTypeDisplayName { get; }
|
||||
@ -1255,9 +1214,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
|
||||
public string? ReflectionTypeMetadataName { get; }
|
||||
|
||||
public bool RequiresRuntimeInterfaceDiscovery { get; }
|
||||
|
||||
public ImmutableArray<string> RuntimeDiscoveredHandlerInterfaceLogNames { get; }
|
||||
public string? ReflectionFallbackHandlerTypeMetadataName { get; }
|
||||
|
||||
public bool Equals(HandlerCandidateAnalysis other)
|
||||
{
|
||||
@ -1266,12 +1223,13 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
!string.Equals(ImplementationLogName, other.ImplementationLogName, StringComparison.Ordinal) ||
|
||||
!string.Equals(ReflectionTypeMetadataName, other.ReflectionTypeMetadataName,
|
||||
StringComparison.Ordinal) ||
|
||||
RequiresRuntimeInterfaceDiscovery != other.RequiresRuntimeInterfaceDiscovery ||
|
||||
!string.Equals(
|
||||
ReflectionFallbackHandlerTypeMetadataName,
|
||||
other.ReflectionFallbackHandlerTypeMetadataName,
|
||||
StringComparison.Ordinal) ||
|
||||
Registrations.Length != other.Registrations.Length ||
|
||||
ReflectedImplementationRegistrations.Length != other.ReflectedImplementationRegistrations.Length ||
|
||||
PreciseReflectedRegistrations.Length != other.PreciseReflectedRegistrations.Length ||
|
||||
RuntimeDiscoveredHandlerInterfaceLogNames.Length !=
|
||||
other.RuntimeDiscoveredHandlerInterfaceLogNames.Length)
|
||||
PreciseReflectedRegistrations.Length != other.PreciseReflectedRegistrations.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@ -1295,17 +1253,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < RuntimeDiscoveredHandlerInterfaceLogNames.Length; index++)
|
||||
{
|
||||
if (!string.Equals(
|
||||
RuntimeDiscoveredHandlerInterfaceLogNames[index],
|
||||
other.RuntimeDiscoveredHandlerInterfaceLogNames[index],
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1324,7 +1271,10 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
(ReflectionTypeMetadataName is null
|
||||
? 0
|
||||
: StringComparer.Ordinal.GetHashCode(ReflectionTypeMetadataName));
|
||||
hashCode = (hashCode * 397) ^ RequiresRuntimeInterfaceDiscovery.GetHashCode();
|
||||
hashCode = (hashCode * 397) ^
|
||||
(ReflectionFallbackHandlerTypeMetadataName is null
|
||||
? 0
|
||||
: StringComparer.Ordinal.GetHashCode(ReflectionFallbackHandlerTypeMetadataName));
|
||||
foreach (var registration in Registrations)
|
||||
{
|
||||
hashCode = (hashCode * 397) ^ registration.GetHashCode();
|
||||
@ -1340,16 +1290,12 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
|
||||
hashCode = (hashCode * 397) ^ preciseReflectedRegistration.GetHashCode();
|
||||
}
|
||||
|
||||
foreach (var runtimeDiscoveredHandlerInterfaceLogName in RuntimeDiscoveredHandlerInterfaceLogNames)
|
||||
{
|
||||
hashCode = (hashCode * 397) ^
|
||||
StringComparer.Ordinal.GetHashCode(runtimeDiscoveredHandlerInterfaceLogName);
|
||||
}
|
||||
|
||||
return hashCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct GenerationEnvironment(bool GenerationEnabled);
|
||||
private readonly record struct GenerationEnvironment(
|
||||
bool GenerationEnabled,
|
||||
bool SupportsReflectionFallbackAttribute);
|
||||
}
|
||||
|
||||
@ -15,3 +15,4 @@
|
||||
GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
|
||||
@ -8,8 +8,8 @@ namespace GFramework.Game.SourceGenerators.Config;
|
||||
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。
|
||||
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
|
||||
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
|
||||
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>
|
||||
/// 与稳定字符串 <c>format</c> 子集写入生成代码文档,
|
||||
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>、
|
||||
/// <c>dependentSchemas</c> 与稳定字符串 <c>format</c> 子集写入生成代码文档,
|
||||
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
|
||||
/// </summary>
|
||||
[Generator]
|
||||
@ -151,6 +151,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return SchemaParseResult.FromDiagnostic(dependentRequiredDiagnostic!);
|
||||
}
|
||||
|
||||
if (!TryValidateDependentSchemasMetadataRecursively(
|
||||
file.Path,
|
||||
"<root>",
|
||||
root,
|
||||
out var dependentSchemasDiagnostic))
|
||||
{
|
||||
return SchemaParseResult.FromDiagnostic(dependentSchemasDiagnostic!);
|
||||
}
|
||||
|
||||
var entityName = ToPascalCase(GetSchemaBaseName(file.Path));
|
||||
var rootObject = ParseObjectSpec(
|
||||
file.Path,
|
||||
@ -672,7 +681,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
|
||||
/// <summary>
|
||||
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
|
||||
/// 该遍历覆盖对象属性、<c>not</c> 子 schema、数组 <c>items</c> 与 <c>contains</c>,
|
||||
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>not</c> 子 schema、
|
||||
/// 数组 <c>items</c> 与 <c>contains</c>,
|
||||
/// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
@ -726,6 +736,29 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(schemaType, "object", StringComparison.Ordinal) &&
|
||||
element.TryGetProperty("dependentSchemas", out var dependentSchemasElement) &&
|
||||
dependentSchemasElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var dependentSchema in dependentSchemasElement.EnumerateObject())
|
||||
{
|
||||
if (dependentSchema.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryTraverseSchemaRecursively(
|
||||
filePath,
|
||||
$"{displayPath}[dependentSchemas:{dependentSchema.Name}]",
|
||||
dependentSchema.Value,
|
||||
nodeValidator,
|
||||
out diagnostic))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("not", out var notElement) &&
|
||||
notElement.ValueKind == JsonValueKind.Object &&
|
||||
!TryTraverseSchemaRecursively(
|
||||
@ -772,7 +805,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
|
||||
/// <summary>
|
||||
/// 递归验证 schema 树中的对象级 <c>dependentRequired</c> 元数据。
|
||||
/// 该遍历会覆盖根节点、<c>not</c> 子 schema、数组元素与 <c>contains</c> 子 schema,
|
||||
/// 该遍历会覆盖根节点、<c>dependentSchemas</c> / <c>not</c> 子 schema、
|
||||
/// 数组元素与 <c>contains</c> 子 schema,
|
||||
/// 避免生成器在对象字段依赖规则上比运行时和工具侧更宽松。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
@ -924,6 +958,170 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当前 schema 节点是否以运行时支持的方式声明了 <c>dependentSchemas</c>。
|
||||
/// 只有 object 节点允许挂载该关键字;一旦关键字出现,就继续复用对象节点的形状校验,
|
||||
/// 保证发布到 XML 文档和运行时的约束解释范围保持一致。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
/// <param name="displayPath">逻辑字段路径。</param>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
/// <param name="schemaType">当前节点声明的 schema 类型。</param>
|
||||
/// <param name="diagnostic">失败时返回的诊断。</param>
|
||||
/// <returns>当前节点上的 dependentSchemas 声明是否有效。</returns>
|
||||
private static bool TryValidateDependentSchemasDeclaration(
|
||||
string filePath,
|
||||
string displayPath,
|
||||
JsonElement element,
|
||||
string? schemaType,
|
||||
out Diagnostic? diagnostic)
|
||||
{
|
||||
diagnostic = null;
|
||||
if (!element.TryGetProperty("dependentSchemas", out _))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(schemaType, "object", StringComparison.Ordinal))
|
||||
{
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
"Only object schemas can declare 'dependentSchemas'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryValidateDependentSchemasMetadata(filePath, displayPath, element, out diagnostic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归验证 schema 树中的对象级 <c>dependentSchemas</c> 元数据。
|
||||
/// 该遍历会覆盖根节点、<c>not</c>、数组元素、<c>contains</c> 与嵌套 <c>dependentSchemas</c>,
|
||||
/// 确保生成器对条件对象子 schema 的接受范围不会比运行时更宽松。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
/// <param name="displayPath">逻辑字段路径。</param>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
/// <param name="diagnostic">失败时返回的诊断。</param>
|
||||
/// <returns>当前节点树的 dependentSchemas 元数据是否有效。</returns>
|
||||
private static bool TryValidateDependentSchemasMetadataRecursively(
|
||||
string filePath,
|
||||
string displayPath,
|
||||
JsonElement element,
|
||||
out Diagnostic? diagnostic)
|
||||
{
|
||||
return TryTraverseSchemaRecursively(
|
||||
filePath,
|
||||
displayPath,
|
||||
element,
|
||||
static (currentFilePath, currentDisplayPath, currentElement, schemaType) =>
|
||||
{
|
||||
return TryValidateDependentSchemasDeclaration(
|
||||
currentFilePath,
|
||||
currentDisplayPath,
|
||||
currentElement,
|
||||
schemaType,
|
||||
out var currentDiagnostic)
|
||||
? (true, (Diagnostic?)null)
|
||||
: (false, currentDiagnostic);
|
||||
},
|
||||
out diagnostic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证单个对象 schema 节点上的 <c>dependentSchemas</c> 元数据。
|
||||
/// 生成器当前只接受“已声明 sibling 字段触发 object 子 schema”的形状,
|
||||
/// 避免 XML 文档描述出运行时无法识别的条件 schema。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
/// <param name="displayPath">逻辑字段路径。</param>
|
||||
/// <param name="element">当前对象 schema 节点。</param>
|
||||
/// <param name="diagnostic">失败时返回的诊断。</param>
|
||||
/// <returns>当前对象上的 dependentSchemas 元数据是否有效。</returns>
|
||||
private static bool TryValidateDependentSchemasMetadata(
|
||||
string filePath,
|
||||
string displayPath,
|
||||
JsonElement element,
|
||||
out Diagnostic? diagnostic)
|
||||
{
|
||||
diagnostic = null;
|
||||
if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dependentSchemasElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
"The 'dependentSchemas' value must be an object.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!element.TryGetProperty("properties", out var propertiesElement) ||
|
||||
propertiesElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
"Object schemas using 'dependentSchemas' must also declare an object-valued 'properties' map.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var declaredProperties = new HashSet<string>(
|
||||
propertiesElement
|
||||
.EnumerateObject()
|
||||
.Select(static property => property.Name),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var dependency in dependentSchemasElement.EnumerateObject())
|
||||
{
|
||||
if (!declaredProperties.Contains(dependency.Name))
|
||||
{
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
$"Trigger property '{dependency.Name}' is not declared in the same object schema.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dependency.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
$"Property '{dependency.Name}' must declare 'dependentSchemas' as an object-valued schema.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!dependency.Value.TryGetProperty("type", out var dependentSchemaTypeElement) ||
|
||||
dependentSchemaTypeElement.ValueKind != JsonValueKind.String ||
|
||||
!string.Equals(dependentSchemaTypeElement.GetString(), "object", StringComparison.Ordinal))
|
||||
{
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
$"Property '{dependency.Name}' must declare an object-typed 'dependentSchemas' schema.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断给定 format 名称是否属于当前共享支持子集。
|
||||
/// </summary>
|
||||
@ -3158,6 +3356,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
{
|
||||
parts.Add($"dependentRequired = {dependentRequiredDocumentation}");
|
||||
}
|
||||
|
||||
var dependentSchemasDocumentation = TryBuildDependentSchemasDocumentation(element);
|
||||
if (dependentSchemasDocumentation is not null)
|
||||
{
|
||||
parts.Add($"dependentSchemas = {dependentSchemasDocumentation}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(", ", parts) : null;
|
||||
@ -3204,6 +3408,41 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将对象 <c>dependentSchemas</c> 关系整理成 XML 文档可读字符串。
|
||||
/// </summary>
|
||||
/// <param name="element">对象 schema 节点。</param>
|
||||
/// <returns>格式化后的 dependentSchemas 说明。</returns>
|
||||
private static string? TryBuildDependentSchemasDocumentation(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement) ||
|
||||
dependentSchemasElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var dependency in dependentSchemasElement.EnumerateObject())
|
||||
{
|
||||
if (dependency.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var summary = TryBuildInlineSchemaSummary(dependency.Value, includeRequiredProperties: true);
|
||||
if (summary is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{dependency.Name} => {summary}");
|
||||
}
|
||||
|
||||
return parts.Count > 0
|
||||
? $"{{ {string.Join("; ", parts)} }}"
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将数组 <c>contains</c> 子 schema 整理成 XML 文档可读字符串。
|
||||
/// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。
|
||||
@ -3242,8 +3481,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
/// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains / not 与主属性文档逐渐漂移。
|
||||
/// </summary>
|
||||
/// <param name="schemaElement">内联子 schema。</param>
|
||||
/// <param name="includeRequiredProperties">
|
||||
/// 为对象摘要额外输出 <c>required</c> 信息时返回 <see langword="true" />。
|
||||
/// </param>
|
||||
/// <returns>格式化后的摘要字符串。</returns>
|
||||
private static string? TryBuildInlineSchemaSummary(JsonElement schemaElement)
|
||||
private static string? TryBuildInlineSchemaSummary(
|
||||
JsonElement schemaElement,
|
||||
bool includeRequiredProperties = false)
|
||||
{
|
||||
if (!schemaElement.TryGetProperty("type", out var typeElement) ||
|
||||
typeElement.ValueKind != JsonValueKind.String)
|
||||
@ -3258,6 +3502,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
}
|
||||
|
||||
var details = new List<string>();
|
||||
if (includeRequiredProperties &&
|
||||
schemaType == "object")
|
||||
{
|
||||
var requiredDocumentation = TryBuildRequiredPropertiesDocumentation(schemaElement);
|
||||
if (requiredDocumentation is not null)
|
||||
{
|
||||
details.Add(requiredDocumentation);
|
||||
}
|
||||
}
|
||||
|
||||
var enumDocumentation = TryBuildEnumDocumentation(schemaElement, schemaType!);
|
||||
if (enumDocumentation is not null)
|
||||
{
|
||||
@ -3281,6 +3535,30 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
: $"{schemaType} ({string.Join(", ", details)})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将对象 schema 的 <c>required</c> 字段整理成紧凑说明。
|
||||
/// </summary>
|
||||
/// <param name="element">对象 schema 节点。</param>
|
||||
/// <returns>格式化后的 required 说明。</returns>
|
||||
private static string? TryBuildRequiredPropertiesDocumentation(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("required", out var requiredElement) ||
|
||||
requiredElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var requiredProperties = requiredElement
|
||||
.EnumerateArray()
|
||||
.Where(static item => item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString()))
|
||||
.Select(static item => item.GetString()!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return requiredProperties.Length == 0
|
||||
? null
|
||||
: $"required = [{string.Join(", ", requiredProperties)}]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 const 值整理成 XML 文档可读字符串。
|
||||
/// </summary>
|
||||
|
||||
@ -118,4 +118,15 @@ public static class ConfigSchemaDiagnostics
|
||||
SourceGeneratorsConfigCategory,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// schema 对象节点的 dependentSchemas 元数据无效。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor InvalidDependentSchemasMetadata = new(
|
||||
"GF_ConfigSchema_011",
|
||||
"Config schema uses invalid dependentSchemas metadata",
|
||||
"Property '{1}' in schema file '{0}' uses invalid 'dependentSchemas' metadata: {2}",
|
||||
SourceGeneratorsConfigCategory,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
}
|
||||
|
||||
@ -0,0 +1,411 @@
|
||||
using System.IO;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
|
||||
namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 YAML 配置加载器对对象级 <c>dependentSchemas</c> 约束的运行时行为。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class YamlConfigLoaderDependentSchemasTests
|
||||
{
|
||||
private const string DefaultRewardPropertiesJson = """
|
||||
{
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" },
|
||||
"bonus": { "type": "integer" }
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultDependentSchemasJson = """
|
||||
{
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"required": ["itemCount"],
|
||||
"properties": {
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private string? _rootPath;
|
||||
|
||||
/// <summary>
|
||||
/// 为每个用例创建隔离的临时目录,避免不同 dependentSchemas 场景互相污染。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_rootPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理当前测试创建的目录,避免本地临时文件堆积。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_rootPath) &&
|
||||
Directory.Exists(_rootPath))
|
||||
{
|
||||
Directory.Delete(_rootPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证触发字段出现但条件 schema 未满足时,运行时会拒绝当前对象。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_DependentSchema_Is_Not_Satisfied()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
BuildMonsterConfigYaml(
|
||||
"""
|
||||
itemId: potion
|
||||
"""));
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultDependentSchemasJson));
|
||||
|
||||
var loader = CreateMonsterRewardLoader();
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation));
|
||||
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward"));
|
||||
Assert.That(exception.Message, Does.Contain("dependentSchemas"));
|
||||
Assert.That(exception.Message, Does.Contain("reward.itemId"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证触发字段缺席时,不会误触发 dependentSchemas 检查。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task LoadAsync_Should_Accept_When_DependentSchemas_Trigger_Is_Absent()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
BuildMonsterConfigYaml(
|
||||
"""
|
||||
bonus: 2
|
||||
"""));
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultDependentSchemasJson));
|
||||
|
||||
var loader = CreateMonsterRewardLoader();
|
||||
var registry = CreateRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetTable<int, MonsterDependentSchemasConfigStub>("monster");
|
||||
Assert.That(table.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证触发字段出现且条件 schema 满足时,可以保留对象上的额外同级字段并正常通过加载。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task LoadAsync_Should_Accept_When_DependentSchema_Is_Satisfied()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
BuildMonsterConfigYaml(
|
||||
"""
|
||||
itemId: potion
|
||||
itemCount: 3
|
||||
bonus: 1
|
||||
"""));
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultDependentSchemasJson));
|
||||
|
||||
var loader = CreateMonsterRewardLoader();
|
||||
var registry = CreateRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetTable<int, MonsterDependentSchemasConfigStub>("monster");
|
||||
var reward = table.Get(1).Reward;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(table.Count, Is.EqualTo(1));
|
||||
Assert.That(reward.ItemId, Is.EqualTo("potion"));
|
||||
Assert.That(reward.ItemCount, Is.EqualTo(3));
|
||||
Assert.That(reward.Bonus, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证非对象 dependentSchemas 声明会在 schema 解析阶段被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_DependentSchemas_Is_Not_An_Object()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
BuildMonsterConfigYaml(
|
||||
"""
|
||||
itemId: potion
|
||||
"""));
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
BuildMonsterSchema(
|
||||
"""
|
||||
{
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
""",
|
||||
"""
|
||||
["itemId"]
|
||||
"""));
|
||||
|
||||
var loader = CreateMonsterRewardLoader();
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
|
||||
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward"));
|
||||
Assert.That(exception.Message, Does.Contain("must declare 'dependentSchemas' as an object"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 dependentSchemas 的触发字段必须在同级 properties 中显式声明。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_DependentSchemas_Trigger_Is_Not_Declared()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
BuildMonsterConfigYaml(
|
||||
"""
|
||||
itemId: potion
|
||||
"""));
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
BuildMonsterSchema(
|
||||
"""
|
||||
{
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
""",
|
||||
"""
|
||||
{
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""));
|
||||
|
||||
var loader = CreateMonsterRewardLoader();
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
|
||||
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward"));
|
||||
Assert.That(exception.Message, Does.Contain("dependentSchemas' for undeclared property 'itemId'"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 dependentSchemas 只接受 object-typed 条件子 schema。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_DependentSchemas_Schema_Is_Not_Object_Typed()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
BuildMonsterConfigYaml(
|
||||
"""
|
||||
itemId: potion
|
||||
"""));
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
BuildMonsterSchema(
|
||||
"""
|
||||
{
|
||||
"itemId": { "type": "string" }
|
||||
}
|
||||
""",
|
||||
"""
|
||||
{
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"const": "potion"
|
||||
}
|
||||
}
|
||||
"""));
|
||||
|
||||
var loader = CreateMonsterRewardLoader();
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
|
||||
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[dependentSchemas:itemId]"));
|
||||
Assert.That(exception.Message, Does.Contain("object-typed 'dependentSchemas' schema"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在测试目录下写入配置文件,并自动创建缺失目录。
|
||||
/// </summary>
|
||||
/// <param name="relativePath">相对根目录的配置文件路径。</param>
|
||||
/// <param name="content">要写入的 YAML 或 schema 内容。</param>
|
||||
private void CreateConfigFile(string relativePath, string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(_rootPath);
|
||||
|
||||
var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
var directoryPath = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
}
|
||||
|
||||
File.WriteAllText(filePath, content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入测试 schema 文件,复用统一的测试文件创建逻辑。
|
||||
/// </summary>
|
||||
/// <param name="relativePath">schema 相对路径。</param>
|
||||
/// <param name="content">schema JSON 内容。</param>
|
||||
private void CreateSchemaFile(string relativePath, string content)
|
||||
{
|
||||
CreateConfigFile(relativePath, content);
|
||||
}
|
||||
|
||||
private static string BuildMonsterConfigYaml(string rewardYaml)
|
||||
{
|
||||
return $$"""
|
||||
id: 1
|
||||
reward:
|
||||
{{IndentLines(rewardYaml, 2)}}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildMonsterSchema(
|
||||
string rewardPropertiesJson,
|
||||
string dependentSchemasJson)
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {{rewardPropertiesJson}},
|
||||
"dependentSchemas": {{dependentSchemasJson}}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string IndentLines(string text, int indentLevel)
|
||||
{
|
||||
var indentation = new string(' ', indentLevel);
|
||||
var lines = text
|
||||
.Trim()
|
||||
.Split('\n', StringSplitOptions.None)
|
||||
.Select(static line => line.TrimEnd('\r'));
|
||||
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
lines.Select(line => $"{indentation}{line}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建用于对象 dependentSchemas 场景的加载器。
|
||||
/// </summary>
|
||||
/// <returns>已注册测试表与 schema 路径的加载器。</returns>
|
||||
private YamlConfigLoader CreateMonsterRewardLoader()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(_rootPath);
|
||||
|
||||
return new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterDependentSchemasConfigStub>(
|
||||
"monster",
|
||||
"monster",
|
||||
"schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建新的配置注册表,确保每个用例从干净状态开始。
|
||||
/// </summary>
|
||||
/// <returns>空的配置注册表。</returns>
|
||||
private static ConfigRegistry CreateRegistry()
|
||||
{
|
||||
return new ConfigRegistry();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于对象 dependentSchemas 回归测试的最小配置类型。
|
||||
/// </summary>
|
||||
private sealed class MonsterDependentSchemasConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置奖励对象。
|
||||
/// </summary>
|
||||
public DependentSchemasRewardConfigStub Reward { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示对象 dependentSchemas 回归测试中的奖励节点。
|
||||
/// </summary>
|
||||
private sealed class DependentSchemasRewardConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置掉落物 ID。
|
||||
/// </summary>
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置掉落物数量。
|
||||
/// </summary>
|
||||
public int ItemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置额外奖励值。
|
||||
/// </summary>
|
||||
public int Bonus { get; set; }
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ namespace GFramework.Game.Tests.Config;
|
||||
[TestFixture]
|
||||
public sealed class YamlConfigSchemaValidatorTests
|
||||
{
|
||||
private string _rootPath = null!;
|
||||
private string? _rootPath;
|
||||
|
||||
/// <summary>
|
||||
/// 为每个测试准备独立临时目录。
|
||||
@ -27,7 +27,8 @@ public sealed class YamlConfigSchemaValidatorTests
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_rootPath))
|
||||
if (!string.IsNullOrEmpty(_rootPath) &&
|
||||
Directory.Exists(_rootPath))
|
||||
{
|
||||
Directory.Delete(_rootPath, true);
|
||||
}
|
||||
@ -70,6 +71,61 @@ public sealed class YamlConfigSchemaValidatorTests
|
||||
Assert.That(schema.ReferencedTableNames, Is.EqualTo(new[] { "ally", "item", "weapon" }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证条件子 schema 复用同一条 ref-table 字段时,不会把同一引用重复写入结果。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ValidateAndCollectReferences_Should_Not_Duplicate_Reference_Usages_From_DependentSchemas()
|
||||
{
|
||||
var schemaPath = CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"x-gframework-ref-table": "item"
|
||||
}
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"x-gframework-ref-table": "item"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
var schema = YamlConfigSchemaValidator.Load("monster", schemaPath);
|
||||
|
||||
var references = YamlConfigSchemaValidator.ValidateAndCollectReferences(
|
||||
"monster",
|
||||
schema,
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
reward:
|
||||
itemId: potion
|
||||
""");
|
||||
|
||||
Assert.That(references, Has.Count.EqualTo(1));
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(references[0].DisplayPath, Is.EqualTo("reward.itemId"));
|
||||
Assert.That(references[0].ReferencedTableName, Is.EqualTo("item"));
|
||||
Assert.That(references[0].RawValue, Is.EqualTo("potion"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在临时目录中创建 schema 文件。
|
||||
/// </summary>
|
||||
@ -80,6 +136,8 @@ public sealed class YamlConfigSchemaValidatorTests
|
||||
string relativePath,
|
||||
string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(_rootPath);
|
||||
|
||||
var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
var directoryPath = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(directoryPath))
|
||||
|
||||
@ -10,9 +10,10 @@ namespace GFramework.Game.Config;
|
||||
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
|
||||
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
|
||||
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
|
||||
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>
|
||||
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>、
|
||||
/// <c>dependentSchemas</c>
|
||||
/// 与稳定字符串 <c>format</c> 子集,让数值步进、数组去重、数组匹配计数、
|
||||
/// 对象属性数量与对象内字段依赖规则在运行时与生成器 / 工具侧保持一致。
|
||||
/// 对象属性数量、对象内字段依赖以及条件对象子 schema 在运行时与生成器 / 工具侧保持一致。
|
||||
/// </summary>
|
||||
internal static class YamlConfigSchemaValidator
|
||||
{
|
||||
@ -607,7 +608,7 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 <c>contains</c> / <c>not</c> 这类内联子 schema 构建稳定的诊断路径。
|
||||
/// 为 <c>contains</c> / <c>not</c> / <c>dependentSchemas</c> 这类内联子 schema 构建稳定的诊断路径。
|
||||
/// </summary>
|
||||
/// <param name="propertyPath">当前节点路径。</param>
|
||||
/// <param name="suffix">内联子 schema 后缀。</param>
|
||||
@ -797,7 +798,14 @@ internal static class YamlConfigSchemaValidator
|
||||
displayPath: requiredPath);
|
||||
}
|
||||
|
||||
ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties, schemaNode);
|
||||
ValidateObjectConstraints(
|
||||
tableName,
|
||||
yamlPath,
|
||||
displayPath,
|
||||
mappingNode,
|
||||
seenProperties,
|
||||
schemaNode,
|
||||
references);
|
||||
|
||||
ValidateAllowedValues(tableName, yamlPath, displayPath, mappingNode, schemaNode);
|
||||
ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode);
|
||||
@ -805,19 +813,28 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验对象节点声明的属性数量约束。
|
||||
/// 校验对象节点声明的数量约束与条件对象约束。
|
||||
/// 该阶段除了检查 <c>minProperties</c> / <c>maxProperties</c>,还会复用同一份 sibling 集合处理
|
||||
/// <c>dependentRequired</c>,并在 <c>dependentSchemas</c> 命中时以 focused constraint block 语义
|
||||
/// 对整个 <paramref name="mappingNode" /> 做额外试匹配。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">对象字段路径;根对象时为空。</param>
|
||||
/// <param name="mappingNode">当前 YAML 对象节点;用于让条件子 schema 在完整对象视图上做匹配。</param>
|
||||
/// <param name="seenProperties">当前对象已出现的属性集合。</param>
|
||||
/// <param name="schemaNode">对象 schema 节点。</param>
|
||||
/// <param name="references">
|
||||
/// 可选的跨表引用收集器;当 <c>dependentSchemas</c> 命中且匹配成功时,只会回写该条件分支新增的引用。
|
||||
/// </param>
|
||||
private static void ValidateObjectConstraints(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlMappingNode mappingNode,
|
||||
HashSet<string> seenProperties,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
YamlConfigSchemaNode schemaNode,
|
||||
ICollection<YamlConfigReferenceUsage>? references)
|
||||
{
|
||||
var constraints = schemaNode.ObjectConstraints;
|
||||
if (constraints is null)
|
||||
@ -861,15 +878,47 @@ internal static class YamlConfigSchemaValidator
|
||||
$"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||
}
|
||||
|
||||
if (constraints.DependentRequired is null ||
|
||||
constraints.DependentRequired.Count == 0)
|
||||
if (constraints.DependentRequired is not null &&
|
||||
constraints.DependentRequired.Count > 0)
|
||||
{
|
||||
// Reuse the collected sibling-name set so the main validation path and
|
||||
// the contains/not matcher both interpret object dependencies identically.
|
||||
foreach (var dependency in constraints.DependentRequired)
|
||||
{
|
||||
if (!seenProperties.Contains(dependency.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var triggerPath = CombineDisplayPath(displayPath, dependency.Key);
|
||||
foreach (var dependentProperty in dependency.Value)
|
||||
{
|
||||
if (seenProperties.Contains(dependentProperty))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var requiredPath = CombineDisplayPath(displayPath, dependentProperty);
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.MissingRequiredProperty,
|
||||
tableName,
|
||||
$"Property '{requiredPath}' in config file '{yamlPath}' is required when sibling property '{triggerPath}' is present.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: requiredPath,
|
||||
detail:
|
||||
$"Dependent requirement: when '{triggerPath}' exists, '{requiredPath}' must also be declared.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (constraints.DependentSchemas is null ||
|
||||
constraints.DependentSchemas.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse the collected sibling-name set so the main validation path and
|
||||
// the contains/not matcher both interpret object dependencies identically.
|
||||
foreach (var dependency in constraints.DependentRequired)
|
||||
foreach (var dependency in constraints.DependentSchemas)
|
||||
{
|
||||
if (!seenProperties.Contains(dependency.Key))
|
||||
{
|
||||
@ -877,24 +926,33 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
|
||||
var triggerPath = CombineDisplayPath(displayPath, dependency.Key);
|
||||
foreach (var dependentProperty in dependency.Value)
|
||||
{
|
||||
if (seenProperties.Contains(dependentProperty))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var requiredPath = CombineDisplayPath(displayPath, dependentProperty);
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.MissingRequiredProperty,
|
||||
// dependentSchemas acts as an additional conditional constraint block on the
|
||||
// current object. Keep undeclared sibling fields outside the dependent sub-schema
|
||||
// from blocking the match so schema authors can express focused follow-up rules.
|
||||
// The trial matcher merges only new reference usages back into the outer collector,
|
||||
// so re-checking the same scalar via a conditional sub-schema does not duplicate
|
||||
// cross-table validation work later in the loader pipeline.
|
||||
if (TryMatchSchemaNode(
|
||||
tableName,
|
||||
$"Property '{requiredPath}' in config file '{yamlPath}' is required when sibling property '{triggerPath}' is present.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: requiredPath,
|
||||
detail:
|
||||
$"Dependent requirement: when '{triggerPath}' exists, '{requiredPath}' must also be declared.");
|
||||
yamlPath,
|
||||
displayPath,
|
||||
mappingNode,
|
||||
dependency.Value,
|
||||
references,
|
||||
allowUnknownObjectProperties: true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.ConstraintViolation,
|
||||
tableName,
|
||||
$"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: GetDiagnosticPath(displayPath),
|
||||
detail:
|
||||
$"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1562,6 +1620,7 @@ internal static class YamlConfigSchemaValidator
|
||||
element,
|
||||
"maxProperties");
|
||||
var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties);
|
||||
var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties);
|
||||
|
||||
if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value)
|
||||
{
|
||||
@ -1574,9 +1633,9 @@ internal static class YamlConfigSchemaValidator
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null
|
||||
return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null
|
||||
? null
|
||||
: new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired);
|
||||
: new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -1688,6 +1747,86 @@ internal static class YamlConfigSchemaValidator
|
||||
: dependentRequired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析对象节点声明的 <c>dependentSchemas</c> 条件 schema。
|
||||
/// 当前实现把它作为“当触发字段出现时,当前对象还必须额外满足一段内联 schema”来解释,
|
||||
/// 因此触发字段仍限制在当前对象已声明的属性内,而具体约束则继续复用现有递归节点解析逻辑。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">对象字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="properties">当前对象已声明的属性集合。</param>
|
||||
/// <returns>归一化后的触发字段到条件 schema 的映射;未声明时返回空。</returns>
|
||||
private static IReadOnlyDictionary<string, YamlConfigSchemaNode>? ParseDependentSchemasConstraints(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
JsonElement element,
|
||||
IReadOnlyDictionary<string, YamlConfigSchemaNode> properties)
|
||||
{
|
||||
if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dependentSchemasElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentSchemas' as an object.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
var dependentSchemas = new Dictionary<string, YamlConfigSchemaNode>(StringComparer.Ordinal);
|
||||
foreach (var dependency in dependentSchemasElement.EnumerateObject())
|
||||
{
|
||||
if (!properties.ContainsKey(dependency.Name))
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentSchemas' for undeclared property '{dependency.Name}'.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
if (dependency.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentSchemas' as an object-valued schema.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
var dependencySchemaPath = BuildNestedSchemaPath(propertyPath, $"dependentSchemas:{dependency.Name}");
|
||||
var dependencySchemaNode = ParseNode(
|
||||
tableName,
|
||||
schemaPath,
|
||||
dependencySchemaPath,
|
||||
dependency.Value);
|
||||
if (dependencySchemaNode.NodeType != YamlConfigSchemaPropertyType.Object)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed 'dependentSchemas' schema.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(dependencySchemaPath));
|
||||
}
|
||||
|
||||
dependentSchemas[dependency.Name] = dependencySchemaNode;
|
||||
}
|
||||
|
||||
return dependentSchemas.Count == 0
|
||||
? null
|
||||
: dependentSchemas;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取数值区间约束。
|
||||
/// </summary>
|
||||
@ -3007,10 +3146,7 @@ internal static class YamlConfigSchemaValidator
|
||||
if (references is not null &&
|
||||
matchedReferences is not null)
|
||||
{
|
||||
foreach (var referenceUsage in matchedReferences)
|
||||
{
|
||||
references.Add(referenceUsage);
|
||||
}
|
||||
AddUniqueReferenceUsages(references, matchedReferences);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -3022,6 +3158,50 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将试匹配分支采集到的引用回写到外层集合,并按结构化标识去重。
|
||||
/// </summary>
|
||||
/// <param name="references">外层引用集合。</param>
|
||||
/// <param name="matchedReferences">当前成功匹配分支采集到的引用。</param>
|
||||
private static void AddUniqueReferenceUsages(
|
||||
ICollection<YamlConfigReferenceUsage> references,
|
||||
IEnumerable<YamlConfigReferenceUsage> matchedReferences)
|
||||
{
|
||||
foreach (var referenceUsage in matchedReferences)
|
||||
{
|
||||
if (!ContainsReferenceUsage(references, referenceUsage))
|
||||
{
|
||||
references.Add(referenceUsage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断外层引用集合中是否已经存在同一条引用使用记录。
|
||||
/// </summary>
|
||||
/// <param name="references">要检查的引用集合。</param>
|
||||
/// <param name="candidate">当前待合并的引用记录。</param>
|
||||
/// <returns>当集合中已存在语义相同的记录时返回 <see langword="true" />。</returns>
|
||||
private static bool ContainsReferenceUsage(
|
||||
IEnumerable<YamlConfigReferenceUsage> references,
|
||||
YamlConfigReferenceUsage candidate)
|
||||
{
|
||||
foreach (var referenceUsage in references)
|
||||
{
|
||||
if (string.Equals(referenceUsage.YamlPath, candidate.YamlPath, StringComparison.Ordinal) &&
|
||||
string.Equals(referenceUsage.SchemaPath, candidate.SchemaPath, StringComparison.Ordinal) &&
|
||||
string.Equals(referenceUsage.PropertyPath, candidate.PropertyPath, StringComparison.Ordinal) &&
|
||||
string.Equals(referenceUsage.RawValue, candidate.RawValue, StringComparison.Ordinal) &&
|
||||
string.Equals(referenceUsage.ReferencedTableName, candidate.ReferencedTableName, StringComparison.Ordinal) &&
|
||||
referenceUsage.ValueType == candidate.ValueType)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验节点是否命中了 <c>not</c> 声明的禁用 schema。
|
||||
/// 与 contains 不同,not 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。
|
||||
@ -3435,6 +3615,15 @@ internal static class YamlConfigSchemaValidator
|
||||
{
|
||||
CollectReferencedTableNames(containsNode, referencedTableNames);
|
||||
}
|
||||
|
||||
var dependentSchemas = node.ObjectConstraints?.DependentSchemas;
|
||||
if (dependentSchemas is not null)
|
||||
{
|
||||
foreach (var dependentSchemaNode in dependentSchemas.Values)
|
||||
{
|
||||
CollectReferencedTableNames(dependentSchemaNode, referencedTableNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -4028,7 +4217,7 @@ internal sealed class YamlConfigAllowedValue
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个对象节点上声明的属性数量约束与字段依赖约束。
|
||||
/// 表示一个对象节点上声明的属性数量约束、字段依赖约束与条件子 schema。
|
||||
/// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。
|
||||
/// </summary>
|
||||
internal sealed class YamlConfigObjectConstraints
|
||||
@ -4039,14 +4228,17 @@ internal sealed class YamlConfigObjectConstraints
|
||||
/// <param name="minProperties">最小属性数量约束。</param>
|
||||
/// <param name="maxProperties">最大属性数量约束。</param>
|
||||
/// <param name="dependentRequired">对象内字段依赖约束。</param>
|
||||
/// <param name="dependentSchemas">对象内条件 schema 约束。</param>
|
||||
public YamlConfigObjectConstraints(
|
||||
int? minProperties,
|
||||
int? maxProperties,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>>? dependentRequired)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>>? dependentRequired,
|
||||
IReadOnlyDictionary<string, YamlConfigSchemaNode>? dependentSchemas)
|
||||
{
|
||||
MinProperties = minProperties;
|
||||
MaxProperties = maxProperties;
|
||||
DependentRequired = dependentRequired;
|
||||
DependentSchemas = dependentSchemas;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -4064,6 +4256,12 @@ internal sealed class YamlConfigObjectConstraints
|
||||
/// 键表示“触发字段”,值表示“触发字段出现后还必须存在的同级字段集合”。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>>? DependentRequired { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取对象内条件 schema 约束。
|
||||
/// 键表示“触发字段”,值表示“触发字段出现后当前对象还必须满足的额外 schema 子树”。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, YamlConfigSchemaNode>? DependentSchemas { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -50,12 +50,7 @@ public class PriorityGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<PriorityGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"bases",
|
||||
"snapshots",
|
||||
"PriorityGenerator",
|
||||
"BasicPriority"));
|
||||
GetSnapshotFolder("BasicPriority"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -98,12 +93,7 @@ public class PriorityGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<PriorityGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"bases",
|
||||
"snapshots",
|
||||
"PriorityGenerator",
|
||||
"NegativePriority"));
|
||||
GetSnapshotFolder("NegativePriority"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -156,12 +146,7 @@ public class PriorityGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<PriorityGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"bases",
|
||||
"snapshots",
|
||||
"PriorityGenerator",
|
||||
"PriorityGroup"));
|
||||
GetSnapshotFolder("PriorityGroup"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -204,11 +189,25 @@ public class PriorityGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<PriorityGenerator>.RunAsync(
|
||||
source,
|
||||
GetSnapshotFolder("GenericClass"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将运行时测试目录映射回仓库内已提交的 Priority 生成器快照目录。
|
||||
/// </summary>
|
||||
/// <param name="scenarioName">快照场景名称。</param>
|
||||
/// <returns>场景对应的绝对快照目录。</returns>
|
||||
private static string GetSnapshotFolder(string scenarioName)
|
||||
{
|
||||
return Path.GetFullPath(
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"bases",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"Bases",
|
||||
"snapshots",
|
||||
"PriorityGenerator",
|
||||
"GenericClass"));
|
||||
scenarioName));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
// <auto-generated/>
|
||||
#nullable enable
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型补充自动生成的优先级契约实现。
|
||||
/// </summary>
|
||||
partial class MySystem : global::GFramework.Core.Abstractions.Bases.IPrioritized
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取优先级值: 10
|
||||
/// </summary>
|
||||
public int Priority => 10;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
// <auto-generated/>
|
||||
#nullable enable
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型补充自动生成的优先级契约实现。
|
||||
/// </summary>
|
||||
partial class GenericSystem<T> : global::GFramework.Core.Abstractions.Bases.IPrioritized
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取优先级值: 20
|
||||
/// </summary>
|
||||
public int Priority => 20;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
// <auto-generated/>
|
||||
#nullable enable
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型补充自动生成的优先级契约实现。
|
||||
/// </summary>
|
||||
partial class CriticalSystem : global::GFramework.Core.Abstractions.Bases.IPrioritized
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取优先级值: -100
|
||||
/// </summary>
|
||||
public int Priority => -100;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
// <auto-generated/>
|
||||
#nullable enable
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型补充自动生成的优先级契约实现。
|
||||
/// </summary>
|
||||
partial class HighPrioritySystem : global::GFramework.Core.Abstractions.Bases.IPrioritized
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取优先级值: -50
|
||||
/// </summary>
|
||||
public int Priority => -50;
|
||||
}
|
||||
@ -473,6 +473,223 @@ public class SchemaConfigGeneratorTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象 <c>dependentSchemas</c> 会写入生成 XML 文档。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Write_DependentSchemas_Constraint_Into_Generated_Documentation()
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"required": ["itemCount"],
|
||||
"properties": {
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var generatedSources = result.Results
|
||||
.Single()
|
||||
.GeneratedSources
|
||||
.ToDictionary(
|
||||
static sourceResult => sourceResult.HintName,
|
||||
static sourceResult => sourceResult.SourceText.ToString(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
|
||||
Assert.That(
|
||||
generatedSources["MonsterConfig.g.cs"],
|
||||
Does.Contain("Constraints: dependentSchemas = { itemId => object (required = [itemCount]) }."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成器会拒绝非 object-typed 的 <c>dependentSchemas</c> 子 schema。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Report_Diagnostic_When_DependentSchemas_Schema_Is_Not_Object_Typed()
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"const": "potion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var diagnostic = result.Results.Single().Diagnostics.Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_011"));
|
||||
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("reward"));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("object-typed 'dependentSchemas' schema"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证只有 object 节点允许声明 <c>dependentSchemas</c>。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Report_Diagnostic_When_NonObject_Schema_Declares_DependentSchemas()
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "tag"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var diagnostic = result.Results.Single().Diagnostics.Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_011"));
|
||||
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("tag"));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("Only object schemas can declare 'dependentSchemas'."));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <c>dependentSchemas</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Report_Diagnostic_When_DependentSchemas_Schema_Uses_Format_On_Non_String_Node()
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bonus": {
|
||||
"type": "integer",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var diagnostic = result.Results.Single().Diagnostics.Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_009"));
|
||||
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("reward[dependentSchemas:itemId].bonus"));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'."));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
|
||||
/// </summary>
|
||||
|
||||
@ -10,36 +10,72 @@ public static class GeneratorSnapshotTest<TGenerator>
|
||||
where TGenerator : new()
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行源代码生成器的快照测试
|
||||
/// 运行指定源生成器的端到端快照测试。
|
||||
/// </summary>
|
||||
/// <param name="source">输入的源代码字符串</param>
|
||||
/// <param name="snapshotFolder">快照文件存储的文件夹路径</param>
|
||||
/// <param name="source">输入的源代码字符串。</param>
|
||||
/// <param name="snapshotFolder">用于存放已提交快照文件的根目录。</param>
|
||||
/// <param name="snapshotFileNameSelector">将生成文件名映射为快照文件名的规则;为空时使用原始生成文件名。</param>
|
||||
/// <returns>异步任务</returns>
|
||||
/// <returns>当所有生成输出都通过快照校验后完成的异步任务。</returns>
|
||||
/// <remarks>
|
||||
/// 该辅助器会手动构建 Roslyn 编译并执行生成器,然后依次验证生成器自身诊断、更新后编译诊断、生成输出数量和快照内容。
|
||||
/// 若生成器报告错误、生成后的编译出现错误、生成器没有任何输出,或首次运行缺少快照文件,测试都会失败。
|
||||
/// 首次缺少快照时,本方法会先将当前输出写入 <paramref name="snapshotFolder" />,再通过断言中断测试,提示调用方提交快照资产。
|
||||
/// <paramref name="snapshotFileNameSelector" /> 的返回值还必须保持在 <paramref name="snapshotFolder" /> 根目录之内,否则会抛出异常。
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">当快照文件名映射结果为空、为绝对路径,或逃逸出快照根目录时抛出。</exception>
|
||||
public static async Task RunAsync(
|
||||
string source,
|
||||
string snapshotFolder,
|
||||
Func<string, string>? snapshotFileNameSelector = null)
|
||||
{
|
||||
var test = new CSharpSourceGeneratorTest<TGenerator, DefaultVerifier>
|
||||
{
|
||||
TestState =
|
||||
{
|
||||
Sources = { source }
|
||||
},
|
||||
TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck,
|
||||
DisabledDiagnostics = { "GF_Common_Trace_001" }
|
||||
};
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(source);
|
||||
var compilation = CSharpCompilation.Create(
|
||||
$"{typeof(TGenerator).Name}SnapshotTests",
|
||||
[syntaxTree],
|
||||
MetadataReferenceTestBuilder.GetRuntimeMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
GeneratorDriver driver = CSharpGeneratorDriver.Create(
|
||||
generators: [CreateGenerator()],
|
||||
parseOptions: (CSharpParseOptions)syntaxTree.Options);
|
||||
driver = driver.RunGeneratorsAndUpdateCompilation(
|
||||
compilation,
|
||||
out var updatedCompilation,
|
||||
out var generatorDiagnostics);
|
||||
|
||||
await test.RunAsync();
|
||||
var generatorErrors = generatorDiagnostics
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
generatorErrors,
|
||||
Is.Empty,
|
||||
() =>
|
||||
$"执行生成器时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, generatorErrors.Select(static diagnostic => diagnostic.ToString()))}");
|
||||
|
||||
var generated = test.TestState.GeneratedSources;
|
||||
var compilationErrors = updatedCompilation.GetDiagnostics()
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
compilationErrors,
|
||||
Is.Empty,
|
||||
() =>
|
||||
$"编译生成的代码时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, compilationErrors.Select(static diagnostic => diagnostic.ToString()))}");
|
||||
|
||||
var runResult = driver.GetRunResult();
|
||||
var generated = runResult.Results
|
||||
.SelectMany(static result => result.GeneratedSources)
|
||||
.OrderBy(static source => source.HintName, StringComparer.Ordinal)
|
||||
.Select(static source => (filename: source.HintName, content: source.SourceText.ToString()))
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
generated,
|
||||
Is.Not.Empty,
|
||||
$"生成器 '{typeof(TGenerator).FullName}' 未产生任何输出。");
|
||||
|
||||
foreach (var (filename, content) in generated)
|
||||
{
|
||||
// 不同测试套件可能需要将生成文件映射到非 .cs 快照,以避免测试资产被当作可编译源码参与构建。
|
||||
var snapshotFileName = snapshotFileNameSelector?.Invoke(filename) ?? filename;
|
||||
var path = Path.Combine(
|
||||
var path = ResolveSnapshotPath(
|
||||
snapshotFolder,
|
||||
snapshotFileName);
|
||||
|
||||
@ -50,7 +86,7 @@ public static class GeneratorSnapshotTest<TGenerator>
|
||||
await File.WriteAllTextAsync(path, content.ToString());
|
||||
|
||||
Assert.Fail(
|
||||
$"Snapshot not found. Generated new snapshot at:\n{path}");
|
||||
$"未找到快照文件,已在以下路径生成新快照:\n{path}");
|
||||
}
|
||||
|
||||
var expected = await File.ReadAllTextAsync(path);
|
||||
@ -58,7 +94,7 @@ public static class GeneratorSnapshotTest<TGenerator>
|
||||
Assert.That(
|
||||
Normalize(expected),
|
||||
Is.EqualTo(Normalize(content.ToString())),
|
||||
$"Snapshot mismatch: {snapshotFileName}");
|
||||
$"快照不匹配:{snapshotFileName}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,4 +107,52 @@ public static class GeneratorSnapshotTest<TGenerator>
|
||||
{
|
||||
return text.Replace("\r\n", "\n").Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建可由 Roslyn 驱动直接执行的源生成器实例,并统一兼容经典与增量生成器。
|
||||
/// </summary>
|
||||
/// <returns>适配后的源生成器实例。</returns>
|
||||
/// <exception cref="InvalidOperationException">当测试类型既不是源生成器也不是增量生成器时抛出。</exception>
|
||||
private static ISourceGenerator CreateGenerator()
|
||||
{
|
||||
var generator = new TGenerator();
|
||||
return generator switch
|
||||
{
|
||||
ISourceGenerator sourceGenerator => sourceGenerator,
|
||||
IIncrementalGenerator incrementalGenerator => incrementalGenerator.AsSourceGenerator(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Generator type '{typeof(TGenerator).FullName}' must implement {nameof(ISourceGenerator)} or {nameof(IIncrementalGenerator)}.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析并验证快照路径,确保文件名映射不会逃逸出当前快照根目录。
|
||||
/// </summary>
|
||||
/// <param name="snapshotFolder">快照根目录。</param>
|
||||
/// <param name="snapshotFileName">映射后的快照文件名。</param>
|
||||
/// <returns>可安全访问的快照绝对路径。</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 当映射结果为空白、为绝对路径,或通过相对路径越界到快照目录之外时抛出。
|
||||
/// </exception>
|
||||
private static string ResolveSnapshotPath(string snapshotFolder, string snapshotFileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotFileName) || Path.IsPathRooted(snapshotFileName))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid snapshot file name: {snapshotFileName}");
|
||||
}
|
||||
|
||||
// 先规范化根目录再做包含关系判断,避免 `..` 或平台大小写差异导致的目录逃逸。
|
||||
var snapshotRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(snapshotFolder));
|
||||
var snapshotPath = Path.GetFullPath(Path.Combine(snapshotRoot, snapshotFileName));
|
||||
var comparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
if (!snapshotPath.StartsWith(snapshotRoot + Path.DirectorySeparatorChar, comparison))
|
||||
{
|
||||
throw new InvalidOperationException($"Snapshot path escapes root folder: {snapshotFileName}");
|
||||
}
|
||||
|
||||
return snapshotPath;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
using System.IO;
|
||||
using GFramework.Core.SourceGenerators.Enums;
|
||||
|
||||
namespace GFramework.SourceGenerators.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 验证快照测试辅助器对快照文件路径映射的安全约束。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GeneratorSnapshotTestSecurityTests
|
||||
{
|
||||
private const string EnumAttributeNamespace = "GFramework.Core.SourceGenerators.Abstractions.Enums";
|
||||
|
||||
/// <summary>
|
||||
/// 验证快照文件名映射返回绝对路径时,会在访问文件系统前被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RunAsync_SnapshotFileNameSelectorReturnsAbsolutePath_ThrowsInvalidOperationException()
|
||||
{
|
||||
var snapshotRoot = CreateSnapshotRoot();
|
||||
var source = BuildSource();
|
||||
|
||||
Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await GeneratorSnapshotTest<EnumExtensionsGenerator>.RunAsync(
|
||||
source,
|
||||
snapshotRoot,
|
||||
_ => Path.Combine(snapshotRoot, "Status.EnumExtensions.g.cs")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证快照文件名映射尝试通过父级目录片段逃逸根目录时,会在访问文件系统前被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RunAsync_SnapshotFileNameSelectorEscapesSnapshotRoot_ThrowsInvalidOperationException()
|
||||
{
|
||||
var snapshotRoot = CreateSnapshotRoot();
|
||||
var source = BuildSource();
|
||||
|
||||
Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await GeneratorSnapshotTest<EnumExtensionsGenerator>.RunAsync(
|
||||
source,
|
||||
snapshotRoot,
|
||||
_ => Path.Combine("..", "escaped", "Status.EnumExtensions.g.cs")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为安全测试创建隔离的快照根目录路径,避免不同用例共享状态。
|
||||
/// </summary>
|
||||
/// <returns>当前用例专属的快照根目录绝对路径。</returns>
|
||||
private static string CreateSnapshotRoot()
|
||||
{
|
||||
return Path.Combine(
|
||||
TestContext.CurrentContext.WorkDirectory,
|
||||
"temp-snapshots",
|
||||
TestContext.CurrentContext.Test.ID,
|
||||
Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造可稳定触发枚举扩展生成器输出的最小测试源码。
|
||||
/// </summary>
|
||||
/// <returns>包含测试属性与目标枚举的完整源码。</returns>
|
||||
private static string BuildSource()
|
||||
{
|
||||
return $$"""
|
||||
using System;
|
||||
|
||||
namespace {{EnumAttributeNamespace}}
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Enum)]
|
||||
public sealed class GenerateEnumExtensionsAttribute : Attribute
|
||||
{
|
||||
public bool GenerateIsMethods { get; set; } = true;
|
||||
public bool GenerateIsInMethod { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
namespace TestApp
|
||||
{
|
||||
using {{EnumAttributeNamespace}};
|
||||
|
||||
[GenerateEnumExtensions]
|
||||
public enum Status
|
||||
{
|
||||
Active,
|
||||
Inactive
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
@ -1001,6 +1001,12 @@ public class CqrsHandlerRegistryGeneratorTests
|
||||
contractsReference,
|
||||
dependencyReference);
|
||||
|
||||
Assert.That(
|
||||
generatedSource,
|
||||
Does.Not.Contain("RegisterRemainingReflectedHandlerInterfaces("));
|
||||
Assert.That(
|
||||
generatedSource,
|
||||
Does.Not.Contain("Remaining runtime interface discovery target:"));
|
||||
Assert.That(
|
||||
generatedSource,
|
||||
Is.EqualTo(ExternalAssemblyPreciseLookupExpected));
|
||||
@ -1162,6 +1168,224 @@ public class CqrsHandlerRegistryGeneratorTests
|
||||
("CqrsHandlerRegistry.g.cs", HiddenNestedHandlerSelfRegistrationExpected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当某轮生成仍然需要程序集级 reflection fallback 元数据,且 runtime 合同缺少承载该元数据的特性时,
|
||||
/// 生成器会给出明确诊断并停止输出注册器。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void
|
||||
Reports_Diagnostic_And_Skips_Registry_When_Fallback_Metadata_Is_Required_But_Runtime_Contract_Lacks_Fallback_Attribute()
|
||||
{
|
||||
const string source = """
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection
|
||||
{
|
||||
public interface IServiceCollection { }
|
||||
|
||||
public static class ServiceCollectionServiceExtensions
|
||||
{
|
||||
public static void AddTransient(IServiceCollection services, Type serviceType, Type implementationType) { }
|
||||
}
|
||||
}
|
||||
|
||||
namespace GFramework.Core.Abstractions.Logging
|
||||
{
|
||||
public interface ILogger
|
||||
{
|
||||
void Debug(string msg);
|
||||
}
|
||||
}
|
||||
|
||||
namespace GFramework.Cqrs.Abstractions.Cqrs
|
||||
{
|
||||
public interface IRequest<TResponse> { }
|
||||
public interface INotification { }
|
||||
public interface IStreamRequest<TResponse> { }
|
||||
|
||||
public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse> { }
|
||||
public interface INotificationHandler<in TNotification> where TNotification : INotification { }
|
||||
public interface IStreamRequestHandler<in TRequest, out TResponse> where TRequest : IStreamRequest<TResponse> { }
|
||||
}
|
||||
|
||||
namespace GFramework.Cqrs
|
||||
{
|
||||
public interface ICqrsHandlerRegistry
|
||||
{
|
||||
void Register(Microsoft.Extensions.DependencyInjection.IServiceCollection services, GFramework.Core.Abstractions.Logging.ILogger logger);
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
|
||||
public sealed class CqrsHandlerRegistryAttribute : Attribute
|
||||
{
|
||||
public CqrsHandlerRegistryAttribute(Type registryType) { }
|
||||
}
|
||||
}
|
||||
|
||||
namespace TestApp
|
||||
{
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
|
||||
public sealed class Container
|
||||
{
|
||||
private unsafe struct HiddenResponse
|
||||
{
|
||||
}
|
||||
|
||||
private unsafe sealed record HiddenRequest() : IRequest<HiddenResponse*>;
|
||||
|
||||
public unsafe sealed class HiddenHandler : IRequestHandler<HiddenRequest, HiddenResponse*>
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var execution = ExecuteGenerator(
|
||||
source,
|
||||
allowUnsafe: true);
|
||||
var generatedCompilationErrors = execution.GeneratedCompilationDiagnostics
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
var generatorErrors = execution.GeneratorDiagnostics
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
var missingContractDiagnostic =
|
||||
generatorErrors.SingleOrDefault(static diagnostic => diagnostic.Id == "GF_Cqrs_001");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(generatedCompilationErrors, Is.Empty);
|
||||
Assert.That(execution.GeneratedSources, Is.Empty);
|
||||
Assert.That(missingContractDiagnostic, Is.Not.Null);
|
||||
Assert.That(
|
||||
missingContractDiagnostic!.GetMessage(),
|
||||
Does.Contain("TestApp.Container+HiddenHandler"));
|
||||
Assert.That(
|
||||
missingContractDiagnostic.GetMessage(),
|
||||
Does.Contain("GFramework.Cqrs.CqrsReflectionFallbackAttribute"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 fallback metadata 仍然必需且 runtime 提供了承载契约时,
|
||||
/// 生成器会继续产出注册器并发射程序集级 <c>CqrsReflectionFallbackAttribute</c>。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void
|
||||
Emits_Assembly_Level_Fallback_Metadata_When_Fallback_Is_Required_And_Runtime_Contract_Is_Available()
|
||||
{
|
||||
const string source = """
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection
|
||||
{
|
||||
public interface IServiceCollection { }
|
||||
|
||||
public static class ServiceCollectionServiceExtensions
|
||||
{
|
||||
public static void AddTransient(IServiceCollection services, Type serviceType, Type implementationType) { }
|
||||
}
|
||||
}
|
||||
|
||||
namespace GFramework.Core.Abstractions.Logging
|
||||
{
|
||||
public interface ILogger
|
||||
{
|
||||
void Debug(string msg);
|
||||
}
|
||||
}
|
||||
|
||||
namespace GFramework.Cqrs.Abstractions.Cqrs
|
||||
{
|
||||
public interface IRequest<TResponse> { }
|
||||
public interface INotification { }
|
||||
public interface IStreamRequest<TResponse> { }
|
||||
|
||||
public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse> { }
|
||||
public interface INotificationHandler<in TNotification> where TNotification : INotification { }
|
||||
public interface IStreamRequestHandler<in TRequest, out TResponse> where TRequest : IStreamRequest<TResponse> { }
|
||||
}
|
||||
|
||||
namespace GFramework.Cqrs
|
||||
{
|
||||
public interface ICqrsHandlerRegistry
|
||||
{
|
||||
void Register(Microsoft.Extensions.DependencyInjection.IServiceCollection services, GFramework.Core.Abstractions.Logging.ILogger logger);
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
|
||||
public sealed class CqrsHandlerRegistryAttribute : Attribute
|
||||
{
|
||||
public CqrsHandlerRegistryAttribute(Type registryType) { }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Assembly)]
|
||||
public sealed class CqrsReflectionFallbackAttribute : Attribute
|
||||
{
|
||||
public CqrsReflectionFallbackAttribute(params string[] fallbackHandlerTypeNames) { }
|
||||
}
|
||||
}
|
||||
|
||||
namespace TestApp
|
||||
{
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
|
||||
public sealed class Container
|
||||
{
|
||||
private unsafe struct AlphaResponse
|
||||
{
|
||||
}
|
||||
|
||||
private unsafe struct BetaResponse
|
||||
{
|
||||
}
|
||||
|
||||
private unsafe sealed record AlphaRequest() : IRequest<AlphaResponse*>;
|
||||
|
||||
private unsafe sealed record BetaRequest() : IRequest<BetaResponse*>;
|
||||
|
||||
public unsafe sealed class BetaHandler : IRequestHandler<BetaRequest, BetaResponse*>
|
||||
{
|
||||
}
|
||||
|
||||
public unsafe sealed class AlphaHandler : IRequestHandler<AlphaRequest, AlphaResponse*>
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var execution = ExecuteGenerator(
|
||||
source,
|
||||
allowUnsafe: true);
|
||||
var generatedCompilationErrors = execution.GeneratedCompilationDiagnostics
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
var generatorErrors = execution.GeneratorDiagnostics
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(generatedCompilationErrors, Is.Empty);
|
||||
Assert.That(generatorErrors, Is.Empty);
|
||||
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
|
||||
Assert.That(execution.GeneratedSources[0].filename, Is.EqualTo("CqrsHandlerRegistry.g.cs"));
|
||||
Assert.That(
|
||||
execution.GeneratedSources[0].content,
|
||||
Does.Contain(
|
||||
"[assembly: global::GFramework.Cqrs.CqrsReflectionFallbackAttribute(\"TestApp.Container+AlphaHandler\", \"TestApp.Container+BetaHandler\")]"));
|
||||
Assert.That(
|
||||
execution.GeneratedSources[0].content,
|
||||
Does.Contain(
|
||||
"[assembly: global::GFramework.Cqrs.CqrsHandlerRegistryAttribute(typeof(global::GFramework.Generated.Cqrs.__GFrameworkGeneratedCqrsHandlerRegistry))]"));
|
||||
Assert.That(
|
||||
execution.GeneratedSources[0].content,
|
||||
Does.Contain("internal sealed class __GFrameworkGeneratedCqrsHandlerRegistry"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证日志字符串转义会覆盖换行、反斜杠和双引号,避免生成代码中的字符串字面量被意外截断。
|
||||
/// </summary>
|
||||
@ -1188,22 +1412,19 @@ public class CqrsHandlerRegistryGeneratorTests
|
||||
string source,
|
||||
params MetadataReference[] additionalReferences)
|
||||
{
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(source);
|
||||
var compilation = CSharpCompilation.Create(
|
||||
"TestProject",
|
||||
[syntaxTree],
|
||||
MetadataReferenceTestBuilder.GetRuntimeMetadataReferences().AddRange(additionalReferences),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
GeneratorDriver driver = CSharpGeneratorDriver.Create(
|
||||
generators: [new CqrsHandlerRegistryGenerator().AsSourceGenerator()],
|
||||
parseOptions: (CSharpParseOptions)syntaxTree.Options);
|
||||
driver = driver.RunGeneratorsAndUpdateCompilation(
|
||||
compilation,
|
||||
out var updatedCompilation,
|
||||
out _);
|
||||
|
||||
var compilationErrors = updatedCompilation.GetDiagnostics()
|
||||
var execution = ExecuteGenerator(
|
||||
source,
|
||||
allowUnsafe: false,
|
||||
additionalReferences: additionalReferences);
|
||||
var generatorErrors = execution.GeneratorDiagnostics
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
generatorErrors,
|
||||
Is.Empty,
|
||||
() =>
|
||||
$"执行生成器时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, generatorErrors.Select(static diagnostic => diagnostic.ToString()))}");
|
||||
var compilationErrors = execution.CompilationDiagnostics
|
||||
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
@ -1211,11 +1432,76 @@ public class CqrsHandlerRegistryGeneratorTests
|
||||
Is.Empty,
|
||||
() =>
|
||||
$"编译生成的代码时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, compilationErrors.Select(static diagnostic => diagnostic.ToString()))}");
|
||||
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
|
||||
|
||||
return execution.GeneratedSources[0].content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 运行 CQRS handler registry generator,并返回生成输出及相关诊断。
|
||||
/// </summary>
|
||||
/// <param name="source">输入源码。</param>
|
||||
/// <param name="allowUnsafe">
|
||||
/// 是否允许测试编译包含 <c>unsafe</c> 代码。
|
||||
/// 某些回归用例会故意构造带指针类型的非法 handler 合同,以覆盖 fallback 防御分支,此时需要启用该选项避免把缺少
|
||||
/// <c>unsafe</c> 编译上下文的错误与目标生成器行为混淆。
|
||||
/// </param>
|
||||
/// <param name="additionalReferences">附加元数据引用,用于构造跨程序集场景。</param>
|
||||
/// <returns>包含生成源、生成器诊断和更新后编译诊断的执行结果。</returns>
|
||||
private static GeneratorExecutionResult ExecuteGenerator(
|
||||
string source,
|
||||
bool allowUnsafe = false,
|
||||
params MetadataReference[] additionalReferences)
|
||||
{
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(source);
|
||||
var compilation = CSharpCompilation.Create(
|
||||
"TestProject",
|
||||
[syntaxTree],
|
||||
MetadataReferenceTestBuilder.GetRuntimeMetadataReferences().AddRange(additionalReferences),
|
||||
new CSharpCompilationOptions(
|
||||
OutputKind.DynamicallyLinkedLibrary,
|
||||
allowUnsafe: allowUnsafe));
|
||||
|
||||
GeneratorDriver driver = CSharpGeneratorDriver.Create(
|
||||
generators: [new CqrsHandlerRegistryGenerator().AsSourceGenerator()],
|
||||
parseOptions: (CSharpParseOptions)syntaxTree.Options);
|
||||
driver = driver.RunGeneratorsAndUpdateCompilation(
|
||||
compilation,
|
||||
out var updatedCompilation,
|
||||
out var generatorDiagnostics);
|
||||
|
||||
var runResult = driver.GetRunResult();
|
||||
Assert.That(runResult.Results, Has.Length.EqualTo(1));
|
||||
Assert.That(runResult.Results[0].GeneratedSources, Has.Length.EqualTo(1));
|
||||
|
||||
return runResult.Results[0].GeneratedSources[0].SourceText.ToString();
|
||||
var generatedSyntaxTrees = runResult.Results[0].GeneratedSources
|
||||
.Select(static sourceResult => sourceResult.SyntaxTree)
|
||||
.ToHashSet();
|
||||
var generatedSources = runResult.Results[0].GeneratedSources
|
||||
.Select(static sourceResult =>
|
||||
(filename: sourceResult.HintName, content: sourceResult.SourceText.ToString()))
|
||||
.ToArray();
|
||||
var compilationDiagnostics = updatedCompilation.GetDiagnostics().ToArray();
|
||||
var generatedCompilationDiagnostics = compilationDiagnostics
|
||||
.Where(diagnostic =>
|
||||
diagnostic.Location.SourceTree is not null &&
|
||||
generatedSyntaxTrees.Contains(diagnostic.Location.SourceTree))
|
||||
.ToArray();
|
||||
return new GeneratorExecutionResult(
|
||||
generatedSources,
|
||||
generatorDiagnostics.ToArray(),
|
||||
compilationDiagnostics,
|
||||
generatedCompilationDiagnostics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 封装 CQRS handler registry generator 的单次执行结果。
|
||||
/// </summary>
|
||||
/// <param name="GeneratedSources">本轮生成产生的源文件集合。</param>
|
||||
/// <param name="GeneratorDiagnostics">生成器自身报告的诊断集合。</param>
|
||||
/// <param name="CompilationDiagnostics">将生成结果并回编译后的完整编译诊断集合。</param>
|
||||
/// <param name="GeneratedCompilationDiagnostics">仅来自生成源文件的编译诊断集合。</param>
|
||||
private sealed record GeneratorExecutionResult(
|
||||
(string filename, string content)[] GeneratedSources,
|
||||
Diagnostic[] GeneratorDiagnostics,
|
||||
Diagnostic[] CompilationDiagnostics,
|
||||
Diagnostic[] GeneratedCompilationDiagnostics);
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ public class EnumExtensionsGeneratorSnapshotTests
|
||||
/// <summary>
|
||||
/// 验证默认配置会为普通枚举生成逐项判断方法与集合判断方法。
|
||||
/// </summary>
|
||||
/// <returns>异步任务。</returns>
|
||||
[Test]
|
||||
public async Task Snapshot_BasicEnum_IsMethods()
|
||||
{
|
||||
@ -34,9 +35,31 @@ public class EnumExtensionsGeneratorSnapshotTests
|
||||
GetSnapshotFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证未提供快照文件名映射时,会直接按生成文件名进行快照比对。
|
||||
/// </summary>
|
||||
/// <returns>异步任务。</returns>
|
||||
[Test]
|
||||
public async Task Snapshot_BasicEnum_IsMethods_DefaultSnapshotFileNameSelector()
|
||||
{
|
||||
var source = BuildSource(
|
||||
"""
|
||||
public enum Status
|
||||
{
|
||||
Active,
|
||||
Inactive
|
||||
}
|
||||
""");
|
||||
|
||||
await GeneratorSnapshotTest<EnumExtensionsGenerator>.RunAsync(
|
||||
source,
|
||||
GetSnapshotFolder("BasicEnum_IsMethods_DefaultSnapshotFileNameSelector"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证默认配置在较小枚举上仍会生成集合判断方法。
|
||||
/// </summary>
|
||||
/// <returns>异步任务。</returns>
|
||||
[Test]
|
||||
public async Task Snapshot_BasicEnum_IsInMethod()
|
||||
{
|
||||
@ -58,6 +81,7 @@ public class EnumExtensionsGeneratorSnapshotTests
|
||||
/// <summary>
|
||||
/// 验证带显式位标志值的枚举也会生成对应扩展方法。
|
||||
/// </summary>
|
||||
/// <returns>异步任务。</returns>
|
||||
[Test]
|
||||
public async Task Snapshot_EnumWithFlagValues()
|
||||
{
|
||||
@ -82,6 +106,7 @@ public class EnumExtensionsGeneratorSnapshotTests
|
||||
/// <summary>
|
||||
/// 验证关闭逐项判断开关后仅保留集合判断方法。
|
||||
/// </summary>
|
||||
/// <returns>异步任务。</returns>
|
||||
[Test]
|
||||
public async Task Snapshot_DisableIsMethods()
|
||||
{
|
||||
@ -104,6 +129,7 @@ public class EnumExtensionsGeneratorSnapshotTests
|
||||
/// <summary>
|
||||
/// 验证关闭集合判断开关后仅保留逐项判断方法。
|
||||
/// </summary>
|
||||
/// <returns>异步任务。</returns>
|
||||
[Test]
|
||||
public async Task Snapshot_DisableIsInMethod()
|
||||
{
|
||||
@ -126,6 +152,7 @@ public class EnumExtensionsGeneratorSnapshotTests
|
||||
/// <summary>
|
||||
/// 验证同时关闭两个生成开关时不会输出任何扩展方法。
|
||||
/// </summary>
|
||||
/// <returns>异步任务。</returns>
|
||||
[Test]
|
||||
public async Task Snapshot_DisableAllGeneratedMethods()
|
||||
{
|
||||
|
||||
@ -2,15 +2,31 @@
|
||||
using System;
|
||||
namespace TestApp
|
||||
{
|
||||
/// <summary>
|
||||
/// 为 <see cref="TestApp.Status" /> 提供自动生成的扩展方法。
|
||||
/// </summary>
|
||||
public static partial class StatusExtensions
|
||||
{
|
||||
/// <summary>是否为 Active</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Active" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Active" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsActive(this TestApp.Status value) => value == TestApp.Status.Active;
|
||||
|
||||
/// <summary>是否为 Inactive</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Inactive" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Inactive" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsInactive(this TestApp.Status value) => value == TestApp.Status.Inactive;
|
||||
|
||||
/// <summary>判断是否属于指定集合</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否属于指定候选集合。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <param name="values">用于匹配的候选枚举值集合;当为 <see langword="null" /> 时返回 <see langword="false" />。</param>
|
||||
/// <returns>当 <paramref name="value" /> 命中任一候选值时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsIn(this TestApp.Status value, params TestApp.Status[] values)
|
||||
{
|
||||
if (values == null) return false;
|
||||
|
||||
@ -2,18 +2,38 @@
|
||||
using System;
|
||||
namespace TestApp
|
||||
{
|
||||
/// <summary>
|
||||
/// 为 <see cref="TestApp.Status" /> 提供自动生成的扩展方法。
|
||||
/// </summary>
|
||||
public static partial class StatusExtensions
|
||||
{
|
||||
/// <summary>是否为 Active</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Active" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Active" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsActive(this TestApp.Status value) => value == TestApp.Status.Active;
|
||||
|
||||
/// <summary>是否为 Inactive</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Inactive" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Inactive" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsInactive(this TestApp.Status value) => value == TestApp.Status.Inactive;
|
||||
|
||||
/// <summary>是否为 Pending</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Pending" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Pending" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsPending(this TestApp.Status value) => value == TestApp.Status.Pending;
|
||||
|
||||
/// <summary>判断是否属于指定集合</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否属于指定候选集合。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <param name="values">用于匹配的候选枚举值集合;当为 <see langword="null" /> 时返回 <see langword="false" />。</param>
|
||||
/// <returns>当 <paramref name="value" /> 命中任一候选值时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsIn(this TestApp.Status value, params TestApp.Status[] values)
|
||||
{
|
||||
if (values == null) return false;
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
namespace TestApp
|
||||
{
|
||||
/// <summary>
|
||||
/// 为 <see cref="TestApp.Status" /> 提供自动生成的扩展方法。
|
||||
/// </summary>
|
||||
public static partial class StatusExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Active" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Active" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsActive(this TestApp.Status value) => value == TestApp.Status.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Inactive" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Inactive" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsInactive(this TestApp.Status value) => value == TestApp.Status.Inactive;
|
||||
|
||||
/// <summary>
|
||||
/// 判断给定值是否属于指定候选集合。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <param name="values">用于匹配的候选枚举值集合;当为 <see langword="null" /> 时返回 <see langword="false" />。</param>
|
||||
/// <returns>当 <paramref name="value" /> 命中任一候选值时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsIn(this TestApp.Status value, params TestApp.Status[] values)
|
||||
{
|
||||
if (values == null) return false;
|
||||
foreach (var v in values) if (value == v) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
using System;
|
||||
namespace TestApp
|
||||
{
|
||||
/// <summary>
|
||||
/// 为 <see cref="TestApp.Status" /> 提供自动生成的扩展方法。
|
||||
/// </summary>
|
||||
public static partial class StatusExtensions
|
||||
{
|
||||
}
|
||||
|
||||
@ -2,12 +2,23 @@
|
||||
using System;
|
||||
namespace TestApp
|
||||
{
|
||||
/// <summary>
|
||||
/// 为 <see cref="TestApp.Status" /> 提供自动生成的扩展方法。
|
||||
/// </summary>
|
||||
public static partial class StatusExtensions
|
||||
{
|
||||
/// <summary>是否为 Active</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Active" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Active" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsActive(this TestApp.Status value) => value == TestApp.Status.Active;
|
||||
|
||||
/// <summary>是否为 Inactive</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Status.Inactive" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Status.Inactive" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsInactive(this TestApp.Status value) => value == TestApp.Status.Inactive;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,17 @@
|
||||
using System;
|
||||
namespace TestApp
|
||||
{
|
||||
/// <summary>
|
||||
/// 为 <see cref="TestApp.Status" /> 提供自动生成的扩展方法。
|
||||
/// </summary>
|
||||
public static partial class StatusExtensions
|
||||
{
|
||||
/// <summary>判断是否属于指定集合</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否属于指定候选集合。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <param name="values">用于匹配的候选枚举值集合;当为 <see langword="null" /> 时返回 <see langword="false" />。</param>
|
||||
/// <returns>当 <paramref name="value" /> 命中任一候选值时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsIn(this TestApp.Status value, params TestApp.Status[] values)
|
||||
{
|
||||
if (values == null) return false;
|
||||
|
||||
@ -2,21 +2,45 @@
|
||||
using System;
|
||||
namespace TestApp
|
||||
{
|
||||
/// <summary>
|
||||
/// 为 <see cref="TestApp.Permissions" /> 提供自动生成的扩展方法。
|
||||
/// </summary>
|
||||
public static partial class PermissionsExtensions
|
||||
{
|
||||
/// <summary>是否为 None</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Permissions.None" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Permissions.None" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsNone(this TestApp.Permissions value) => value == TestApp.Permissions.None;
|
||||
|
||||
/// <summary>是否为 Read</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Permissions.Read" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Permissions.Read" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsRead(this TestApp.Permissions value) => value == TestApp.Permissions.Read;
|
||||
|
||||
/// <summary>是否为 Write</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Permissions.Write" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Permissions.Write" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsWrite(this TestApp.Permissions value) => value == TestApp.Permissions.Write;
|
||||
|
||||
/// <summary>是否为 Execute</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否为 <see cref="TestApp.Permissions.Execute" />。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <returns>当 <paramref name="value" /> 等于 <see cref="TestApp.Permissions.Execute" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsExecute(this TestApp.Permissions value) => value == TestApp.Permissions.Execute;
|
||||
|
||||
/// <summary>判断是否属于指定集合</summary>
|
||||
/// <summary>
|
||||
/// 判断给定值是否属于指定候选集合。
|
||||
/// </summary>
|
||||
/// <param name="value">要检查的枚举值。</param>
|
||||
/// <param name="values">用于匹配的候选枚举值集合;当为 <see langword="null" /> 时返回 <see langword="false" />。</param>
|
||||
/// <returns>当 <paramref name="value" /> 命中任一候选值时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
public static bool IsIn(this TestApp.Permissions value, params TestApp.Permissions[] values)
|
||||
{
|
||||
if (values == null) return false;
|
||||
|
||||
@ -96,12 +96,7 @@ public class LoggerGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<LoggerGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"logging",
|
||||
"snapshots",
|
||||
"LoggerGenerator",
|
||||
"DefaultConfiguration_Class"));
|
||||
GetSnapshotFolder("DefaultConfiguration_Class"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -193,12 +188,7 @@ public class LoggerGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<LoggerGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"logging",
|
||||
"snapshots",
|
||||
"LoggerGenerator",
|
||||
"CustomName_Class"));
|
||||
GetSnapshotFolder("CustomName_Class"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -290,12 +280,7 @@ public class LoggerGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<LoggerGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"logging",
|
||||
"snapshots",
|
||||
"LoggerGenerator",
|
||||
"CustomFieldName_Class"));
|
||||
GetSnapshotFolder("CustomFieldName_Class"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -387,12 +372,7 @@ public class LoggerGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<LoggerGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"logging",
|
||||
"snapshots",
|
||||
"LoggerGenerator",
|
||||
"InstanceField_Class"));
|
||||
GetSnapshotFolder("InstanceField_Class"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -484,12 +464,7 @@ public class LoggerGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<LoggerGenerator>.RunAsync(
|
||||
source,
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"logging",
|
||||
"snapshots",
|
||||
"LoggerGenerator",
|
||||
"PublicField_Class"));
|
||||
GetSnapshotFolder("PublicField_Class"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -581,11 +556,25 @@ public class LoggerGeneratorSnapshotTests
|
||||
|
||||
await GeneratorSnapshotTest<LoggerGenerator>.RunAsync(
|
||||
source,
|
||||
GetSnapshotFolder("GenericClass"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将运行时测试目录映射回仓库内已提交的日志生成器快照目录。
|
||||
/// </summary>
|
||||
/// <param name="scenarioName">快照场景名称。</param>
|
||||
/// <returns>场景对应的绝对快照目录。</returns>
|
||||
private static string GetSnapshotFolder(string scenarioName)
|
||||
{
|
||||
return Path.GetFullPath(
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"logging",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"Logging",
|
||||
"snapshots",
|
||||
"LoggerGenerator",
|
||||
"GenericClass"));
|
||||
scenarioName));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
// <auto-generated />
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型提供自动生成的日志字段。
|
||||
/// </summary>
|
||||
partial class MyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动生成的日志字段。
|
||||
/// </summary>
|
||||
private static readonly ILogger MyLogger = LoggerFactoryResolver.Provider.CreateLogger("MyService");
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
// <auto-generated />
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型提供自动生成的日志字段。
|
||||
/// </summary>
|
||||
partial class MyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动生成的日志字段。
|
||||
/// </summary>
|
||||
private static readonly ILogger _log = LoggerFactoryResolver.Provider.CreateLogger("MyService");
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
// <auto-generated />
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型提供自动生成的日志字段。
|
||||
/// </summary>
|
||||
partial class MyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动生成的日志字段。
|
||||
/// </summary>
|
||||
private static readonly ILogger _log = LoggerFactoryResolver.Provider.CreateLogger("MyService");
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
// <auto-generated />
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型提供自动生成的日志字段。
|
||||
/// </summary>
|
||||
partial class MyService<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动生成的日志字段。
|
||||
/// </summary>
|
||||
private static readonly ILogger _log = LoggerFactoryResolver.Provider.CreateLogger("MyService");
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
// <auto-generated />
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型提供自动生成的日志字段。
|
||||
/// </summary>
|
||||
partial class MyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动生成的日志字段。
|
||||
/// </summary>
|
||||
private readonly ILogger _log = LoggerFactoryResolver.Provider.CreateLogger("MyService");
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
// <auto-generated />
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前分部类型提供自动生成的日志字段。
|
||||
/// </summary>
|
||||
partial class MyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动生成的日志字段。
|
||||
/// </summary>
|
||||
public static readonly ILogger _log = LoggerFactoryResolver.Provider.CreateLogger("MyService");
|
||||
}
|
||||
@ -86,9 +86,22 @@ public class ContextAwareGeneratorSnapshotTests
|
||||
// 执行生成器快照测试,将生成的代码与预期快照进行比较
|
||||
await GeneratorSnapshotTest<ContextAwareGenerator>.RunAsync(
|
||||
source,
|
||||
GetSnapshotFolder());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将运行时测试目录映射回仓库内已提交的上下文感知生成器快照目录。
|
||||
/// </summary>
|
||||
/// <returns>快照目录的绝对路径。</returns>
|
||||
private static string GetSnapshotFolder()
|
||||
{
|
||||
return Path.GetFullPath(
|
||||
Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"rule",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"Rule",
|
||||
"snapshots",
|
||||
"ContextAwareGenerator"));
|
||||
}
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
// <auto-generated/>
|
||||
#nullable enable
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
/// <summary>
|
||||
/// 为当前规则类型补充自动生成的架构上下文访问实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 生成代码会在实例级缓存首次解析到的上下文,并在未显式配置提供者时回退到 <see cref="GFramework.Core.Architectures.GameContextProvider" />。
|
||||
/// 同一生成类型的所有实例共享一个静态上下文提供者;切换或重置提供者只会影响尚未缓存上下文的新实例或未初始化实例,
|
||||
/// 已缓存的实例上下文需要通过 <see cref="GFramework.Core.Abstractions.Rule.IContextAware.SetContext(GFramework.Core.Abstractions.Architectures.IArchitectureContext)" /> 显式覆盖。
|
||||
/// 与手动继承 <see cref="global::GFramework.Core.Rule.ContextAwareBase" /> 的路径相比,生成实现会使用 <c>_contextSync</c> 协调惰性初始化、provider 切换和显式上下文注入;
|
||||
/// <see cref="global::GFramework.Core.Rule.ContextAwareBase" /> 则保持无锁的实例级缓存语义,更适合已经由调用方线程模型保证串行访问的简单场景。
|
||||
/// </remarks>
|
||||
partial class MyRule : global::GFramework.Core.Abstractions.Rule.IContextAware
|
||||
{
|
||||
private global::GFramework.Core.Abstractions.Architectures.IArchitectureContext? _context;
|
||||
private static global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider? _contextProvider;
|
||||
private static readonly object _contextSync = new();
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前实例绑定的架构上下文。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该属性会先返回通过 <c>IContextAware.SetContext(...)</c> 显式注入的实例上下文;若尚未设置,则在同一个同步域内惰性初始化共享提供者。
|
||||
/// 当静态提供者尚未配置时,生成代码会回退到 <see cref="GFramework.Core.Architectures.GameContextProvider" />。
|
||||
/// 一旦某个实例成功缓存上下文,后续 <see cref="SetContextProvider(GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider)" />
|
||||
/// 或 <see cref="ResetContextProvider" /> 不会自动清除此缓存;如需覆盖,请显式调用 <c>IContextAware.SetContext(...)</c>。
|
||||
/// 当前实现还假设 <see cref="GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider.GetContext" /> 可在持有 <c>_contextSync</c> 时安全执行;
|
||||
/// 自定义 provider 不应在该调用链内重新进入当前类型的 provider 配置 API,且应避免引入与外部全局锁相互等待的锁顺序。
|
||||
/// </remarks>
|
||||
protected global::GFramework.Core.Abstractions.Architectures.IArchitectureContext Context
|
||||
{
|
||||
get
|
||||
{
|
||||
var context = _context;
|
||||
if (context is not null)
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
// 在同一个同步域内协调懒加载与 provider 切换,避免读取到被并发重置的空提供者。
|
||||
// provider 的 GetContext() 会在持有 _contextSync 时执行;自定义 provider 必须避免在该调用链内回调 SetContextProvider/ResetContextProvider 或形成反向锁顺序。
|
||||
lock (_contextSync)
|
||||
{
|
||||
_contextProvider ??= new global::GFramework.Core.Architectures.GameContextProvider();
|
||||
_context ??= _contextProvider.GetContext();
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置当前生成类型共享的上下文提供者。
|
||||
/// </summary>
|
||||
/// <param name="provider">后续懒加载上下文时要使用的提供者实例。</param>
|
||||
/// <remarks>
|
||||
/// 该方法使用与 <see cref="Context" /> 相同的同步锁,避免提供者切换与惰性初始化交错。
|
||||
/// 已经缓存上下文的实例不会因为提供者切换而自动失效;该变更仅影响尚未初始化上下文的新实例或未缓存实例。
|
||||
/// 如需覆盖已有实例的上下文,请显式调用 <c>IContextAware.SetContext(...)</c>。
|
||||
/// </remarks>
|
||||
public static void SetContextProvider(global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider provider)
|
||||
{
|
||||
lock (_contextSync)
|
||||
{
|
||||
_contextProvider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置共享上下文提供者,使后续懒加载回退到默认提供者。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该方法主要用于测试清理或跨用例恢复默认行为。
|
||||
/// 它不会清除已经缓存到实例字段中的上下文;只有后续尚未初始化上下文的实例会重新回退到 <see cref="GFramework.Core.Architectures.GameContextProvider" />。
|
||||
/// 如需覆盖已有实例的上下文,请显式调用 <c>IContextAware.SetContext(...)</c>。
|
||||
/// </remarks>
|
||||
public static void ResetContextProvider()
|
||||
{
|
||||
lock (_contextSync)
|
||||
{
|
||||
_contextProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
void global::GFramework.Core.Abstractions.Rule.IContextAware.SetContext(global::GFramework.Core.Abstractions.Architectures.IArchitectureContext context)
|
||||
{
|
||||
// 与 Context getter 共享同一同步协议,避免显式注入被并发懒加载覆盖。
|
||||
lock (_contextSync)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
}
|
||||
|
||||
global::GFramework.Core.Abstractions.Architectures.IArchitectureContext global::GFramework.Core.Abstractions.Rule.IContextAware.GetContext()
|
||||
{
|
||||
return Context;
|
||||
}
|
||||
|
||||
}
|
||||
@ -12,7 +12,7 @@
|
||||
- JSON Schema 作为结构描述
|
||||
- 一对象一文件的目录组织
|
||||
- 运行时只读查询
|
||||
- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`
|
||||
- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`
|
||||
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
|
||||
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
|
||||
|
||||
@ -725,6 +725,7 @@ var loader = new YamlConfigLoader("config-root")
|
||||
- 数组字段违反 `contains` / `minContains` / `maxContains`
|
||||
- 对象字段违反 `minProperties` / `maxProperties`
|
||||
- 对象字段违反 `dependentRequired`
|
||||
- 对象字段违反 `dependentSchemas`
|
||||
- 标量 / 对象 / 数组字段违反 `const`
|
||||
- 标量 / 对象 / 数组字段命中 `not`
|
||||
- 标量 / 对象 / 数组字段违反 `enum`
|
||||
@ -790,6 +791,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
|
||||
- `contains` / `minContains` / `maxContains`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”,其中仅声明 `contains` 时默认至少需要 1 个匹配元素
|
||||
- `minProperties` / `maxProperties`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束
|
||||
- `dependentRequired`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只表达“当对象内某个字段出现时,还必须同时声明哪些同级字段”,不会改变生成类型形状
|
||||
- `dependentSchemas`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受“已声明 sibling 字段触发 object 子 schema”的形状,不改变生成类型形状,并按 focused constraint block 语义允许条件子 schema 未声明的额外同级字段继续存在
|
||||
|
||||
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
||||
|
||||
@ -886,7 +888,7 @@ var hotReload = loader.EnableHotReload(
|
||||
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
|
||||
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
|
||||
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
||||
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||
|
||||
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||
|
||||
|
||||
@ -28,6 +28,11 @@ is_excluded() {
|
||||
Godot/script_templates|Godot/script_templates/*)
|
||||
return 0
|
||||
;;
|
||||
GFramework.SourceGenerators.Tests/*/snapshots|GFramework.SourceGenerators.Tests/*/snapshots/*)
|
||||
# Source-generator snapshots are committed test assets rather than hand-authored source layout.
|
||||
# Keep naming enforcement for the real test code, but skip generated snapshot trees.
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
|
||||
@ -1134,6 +1134,7 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key));
|
||||
}
|
||||
const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties);
|
||||
const dependentSchemas = parseDependentSchemasMetadata(value.dependentSchemas, displayPath, properties);
|
||||
|
||||
return applyEnumMetadata(applyConstMetadata({
|
||||
type: "object",
|
||||
@ -1143,6 +1144,7 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
minProperties: metadata.minProperties,
|
||||
maxProperties: metadata.maxProperties,
|
||||
dependentRequired,
|
||||
dependentSchemas,
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
defaultValue: metadata.defaultValue,
|
||||
@ -1322,6 +1324,56 @@ function parseDependentRequiredMetadata(rawDependentRequired, displayPath, prope
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one object-level `dependentSchemas` map and keep it aligned with the
|
||||
* runtime's "declared siblings trigger object-typed inline schemas" contract.
|
||||
*
|
||||
* @param {unknown} rawDependentSchemas Raw dependentSchemas node.
|
||||
* @param {string} displayPath Parent schema path.
|
||||
* @param {Record<string, SchemaNode>} properties Declared object properties.
|
||||
* @returns {Record<string, SchemaNode> | undefined} Normalized dependency schema map.
|
||||
*/
|
||||
function parseDependentSchemasMetadata(rawDependentSchemas, displayPath, properties) {
|
||||
if (rawDependentSchemas === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!rawDependentSchemas ||
|
||||
typeof rawDependentSchemas !== "object" ||
|
||||
Array.isArray(rawDependentSchemas)) {
|
||||
throw new Error(`Schema property '${displayPath}' must declare 'dependentSchemas' as an object.`);
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
for (const [triggerProperty, rawDependencySchema] of Object.entries(rawDependentSchemas)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(properties, triggerProperty)) {
|
||||
throw new Error(
|
||||
`Schema property '${displayPath}' declares 'dependentSchemas' for undeclared property '${triggerProperty}'.`);
|
||||
}
|
||||
|
||||
if (!rawDependencySchema ||
|
||||
typeof rawDependencySchema !== "object" ||
|
||||
Array.isArray(rawDependencySchema)) {
|
||||
throw new Error(
|
||||
`Schema property '${displayPath}' must declare 'dependentSchemas' for '${triggerProperty}' as an object-valued schema.`);
|
||||
}
|
||||
|
||||
const dependencySchema = parseSchemaNode(
|
||||
rawDependencySchema,
|
||||
`${displayPath}[dependentSchemas:${triggerProperty}]`);
|
||||
if (dependencySchema.type !== "object") {
|
||||
throw new Error(
|
||||
`Schema property '${displayPath}' must declare an object-typed 'dependentSchemas' schema for '${triggerProperty}'.`);
|
||||
}
|
||||
|
||||
normalized[triggerProperty] = dependencySchema;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one schema node against one YAML node.
|
||||
*
|
||||
@ -1689,6 +1741,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
|
||||
}
|
||||
}
|
||||
|
||||
if (schemaNode.dependentSchemas && typeof schemaNode.dependentSchemas === "object") {
|
||||
for (const [triggerProperty, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) {
|
||||
if (matchesSchemaNode(dependentSchema, yamlNode, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localizedMessage = localizeValidationMessage(
|
||||
ValidationMessageKeys.dependentSchemasViolation,
|
||||
localizer,
|
||||
{
|
||||
displayPath: displayPath || "<root>",
|
||||
triggerProperty: joinPropertyPath(displayPath, triggerProperty)
|
||||
});
|
||||
|
||||
if (reportedMessages.has(localizedMessage)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: localizedMessage
|
||||
});
|
||||
reportedMessages.add(localizedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof schemaNode.minProperties === "number" &&
|
||||
propertyCount < schemaNode.minProperties) {
|
||||
diagnostics.push({
|
||||
@ -1716,6 +1794,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
|
||||
validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate object-level `dependentSchemas` entries whose trigger property is
|
||||
* present on the current YAML object.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Schema node.
|
||||
* @param {YamlNode} yamlNode YAML node.
|
||||
* @returns {Array<[string, SchemaNode]>} Triggered dependent schema entries.
|
||||
*/
|
||||
function getTriggeredDependentSchemas(schemaNode, yamlNode) {
|
||||
if (!schemaNode.dependentSchemas ||
|
||||
typeof schemaNode.dependentSchemas !== "object" ||
|
||||
!yamlNode ||
|
||||
yamlNode.kind !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const triggeredSchemas = [];
|
||||
for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) {
|
||||
if (yamlNode.map.has(triggerProperty)) {
|
||||
triggeredSchemas.push([triggerProperty, dependentSchema]);
|
||||
}
|
||||
}
|
||||
|
||||
return triggeredSchemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether one YAML node satisfies one schema node without emitting user-facing diagnostics.
|
||||
* This is used by array `contains`, where object sub-schemas must behave like
|
||||
@ -1790,6 +1894,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) {
|
||||
if (!matchesSchemaNodeInternal(dependentSchema, yamlNode, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof schemaNode.minProperties === "number" &&
|
||||
propertyCount < schemaNode.minProperties) {
|
||||
return false;
|
||||
@ -2203,6 +2313,8 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `属性“${params.displayPath}”必须匹配固定值 ${params.value}。`;
|
||||
case ValidationMessageKeys.dependentRequiredViolation:
|
||||
return `属性“${params.triggerProperty}”存在时,必须同时声明属性“${params.displayPath}”。`;
|
||||
case ValidationMessageKeys.dependentSchemasViolation:
|
||||
return `对象“${params.displayPath}”在属性“${params.triggerProperty}”存在时,必须满足对应的 dependent schema。`;
|
||||
case ValidationMessageKeys.expectedArray:
|
||||
return `属性“${params.displayPath}”应为数组。`;
|
||||
case ValidationMessageKeys.expectedScalarShape:
|
||||
@ -2255,6 +2367,8 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `Property '${params.displayPath}' must match constant value ${params.value}.`;
|
||||
case ValidationMessageKeys.dependentRequiredViolation:
|
||||
return `Property '${params.displayPath}' is required when sibling property '${params.triggerProperty}' is present.`;
|
||||
case ValidationMessageKeys.dependentSchemasViolation:
|
||||
return `Object '${params.displayPath}' must satisfy the dependent schema triggered by sibling property '${params.triggerProperty}'.`;
|
||||
case ValidationMessageKeys.expectedArray:
|
||||
return `Property '${params.displayPath}' is expected to be an array.`;
|
||||
case ValidationMessageKeys.expectedScalarShape:
|
||||
@ -3004,6 +3118,7 @@ module.exports = {
|
||||
* minProperties?: number,
|
||||
* maxProperties?: number,
|
||||
* dependentRequired?: Record<string, string[]>,
|
||||
* dependentSchemas?: Record<string, SchemaNode>,
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* defaultValue?: string,
|
||||
|
||||
@ -1575,10 +1575,54 @@ function getScalarArrayValue(yamlNode) {
|
||||
.map((item) => unquoteScalar(item.value || ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one compact inline-schema summary for form hints.
|
||||
*
|
||||
* @param {{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} schema Parsed inline schema metadata.
|
||||
* @param {boolean} includeRequiredProperties Whether object `required` members should be surfaced.
|
||||
* @returns {string} Localized summary.
|
||||
*/
|
||||
function describeInlineSchemaForHint(schema, includeRequiredProperties = false) {
|
||||
const parts = [];
|
||||
if (schema.type) {
|
||||
parts.push(schema.type);
|
||||
}
|
||||
|
||||
if (includeRequiredProperties &&
|
||||
Array.isArray(schema.required) &&
|
||||
schema.required.length > 0) {
|
||||
parts.push(localizer.t("webview.hint.required", {
|
||||
properties: schema.required.join(", ")
|
||||
}));
|
||||
}
|
||||
|
||||
if (schema.constValue !== undefined) {
|
||||
parts.push(localizer.t("webview.hint.const", {
|
||||
value: schema.constDisplayValue ?? schema.constValue
|
||||
}));
|
||||
} else if (Array.isArray(schema.enumValues) && schema.enumValues.length > 0) {
|
||||
parts.push(localizer.t("webview.hint.allowed", {
|
||||
values: schema.enumValues.join(", ")
|
||||
}));
|
||||
} else if (schema.pattern) {
|
||||
parts.push(localizer.t("webview.hint.pattern", {
|
||||
value: schema.pattern
|
||||
}));
|
||||
}
|
||||
|
||||
if (schema.refTable) {
|
||||
parts.push(localizer.t("webview.hint.refTable", {
|
||||
refTable: schema.refTable
|
||||
}));
|
||||
}
|
||||
|
||||
return parts.join(", ") || localizer.t("webview.objectArray.item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render human-facing metadata hints for one schema field.
|
||||
*
|
||||
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, dependentRequired?: Record<string, string[]>, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata.
|
||||
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record<string, string[]>, dependentSchemas?: Record<string, {type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata.
|
||||
* @param {boolean} isArrayField Whether the field is an array.
|
||||
* @param {boolean} includeDescription Whether description text should be included in the hint output.
|
||||
* @returns {string} HTML fragment.
|
||||
@ -1668,6 +1712,17 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
|
||||
}
|
||||
}
|
||||
|
||||
if (propertySchema.type === "object" &&
|
||||
propertySchema.dependentSchemas &&
|
||||
typeof propertySchema.dependentSchemas === "object") {
|
||||
for (const [trigger, dependentSchema] of Object.entries(propertySchema.dependentSchemas)) {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.dependentSchemas", {
|
||||
trigger,
|
||||
schema: describeInlineSchemaForHint(dependentSchema, true)
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
if (isArrayField && typeof propertySchema.minItems === "number") {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems})));
|
||||
}
|
||||
|
||||
@ -121,6 +121,7 @@ const enMessages = {
|
||||
"webview.hint.minContains": "Min contains: {value}",
|
||||
"webview.hint.maxContains": "Max contains: {value}",
|
||||
"webview.hint.uniqueItems": "Items must be unique",
|
||||
"webview.hint.required": "Required: {properties}",
|
||||
"webview.hint.itemMinimum": "Item minimum: {value}",
|
||||
"webview.hint.itemConst": "Item const: {value}",
|
||||
"webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}",
|
||||
@ -134,6 +135,7 @@ const enMessages = {
|
||||
"webview.hint.minProperties": "Min properties: {value}",
|
||||
"webview.hint.maxProperties": "Max properties: {value}",
|
||||
"webview.hint.dependentRequired": "When {trigger} is set: require {dependencies}",
|
||||
"webview.hint.dependentSchemas": "When {trigger} is set: satisfy {schema}",
|
||||
"webview.hint.refTable": "Ref table: {refTable}",
|
||||
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
|
||||
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
|
||||
@ -141,6 +143,7 @@ const enMessages = {
|
||||
"webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.",
|
||||
[ValidationMessageKeys.constMismatch]: "Property '{displayPath}' must match constant value {value}.",
|
||||
[ValidationMessageKeys.dependentRequiredViolation]: "Property '{displayPath}' is required when sibling property '{triggerProperty}' is present.",
|
||||
[ValidationMessageKeys.dependentSchemasViolation]: "Object '{displayPath}' must satisfy the dependent schema triggered by sibling property '{triggerProperty}'.",
|
||||
[ValidationMessageKeys.exclusiveMaximumViolation]: "Property '{displayPath}' must be less than {value}.",
|
||||
[ValidationMessageKeys.exclusiveMinimumViolation]: "Property '{displayPath}' must be greater than {value}.",
|
||||
[ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.",
|
||||
@ -242,6 +245,7 @@ const zhCnMessages = {
|
||||
"webview.hint.minContains": "最少 contains 匹配数:{value}",
|
||||
"webview.hint.maxContains": "最多 contains 匹配数:{value}",
|
||||
"webview.hint.uniqueItems": "元素必须唯一",
|
||||
"webview.hint.required": "必填字段:{properties}",
|
||||
"webview.hint.itemMinimum": "元素最小值:{value}",
|
||||
"webview.hint.itemConst": "元素固定值:{value}",
|
||||
"webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}",
|
||||
@ -255,6 +259,7 @@ const zhCnMessages = {
|
||||
"webview.hint.minProperties": "最少属性数:{value}",
|
||||
"webview.hint.maxProperties": "最多属性数:{value}",
|
||||
"webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}",
|
||||
"webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}",
|
||||
"webview.hint.refTable": "引用表:{refTable}",
|
||||
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
|
||||
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
|
||||
@ -262,6 +267,7 @@ const zhCnMessages = {
|
||||
"webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。",
|
||||
[ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。",
|
||||
[ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。",
|
||||
[ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。",
|
||||
[ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。",
|
||||
[ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。",
|
||||
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const ValidationMessageKeys = Object.freeze({
|
||||
constMismatch: "validation.constMismatch",
|
||||
dependentSchemasViolation: "validation.dependentSchemasViolation",
|
||||
enumMismatch: "validation.enumMismatch",
|
||||
exclusiveMaximumViolation: "validation.exclusiveMaximumViolation",
|
||||
exclusiveMinimumViolation: "validation.exclusiveMinimumViolation",
|
||||
|
||||
@ -1691,6 +1691,107 @@ test("parseSchemaContent should reject dependentRequired targets outside the sam
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should capture dependentSchemas metadata", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"required": ["itemCount"],
|
||||
"properties": {
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.equal(schema.properties.reward.dependentSchemas.itemId.type, "object");
|
||||
assert.deepEqual(schema.properties.reward.dependentSchemas.itemId.required, ["itemCount"]);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject non-object dependentSchemas declarations", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" }
|
||||
},
|
||||
"dependentSchemas": ["itemId"]
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/must declare 'dependentSchemas' as an object/u
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject dependentSchemas triggers outside the same object schema", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemCount": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/dependentSchemas' for undeclared property 'itemCount'/u
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject non-object-typed dependentSchemas sub-schemas", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"const": "potion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/object-typed 'dependentSchemas' schema/u
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should capture not sub-schema metadata", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
@ -1768,6 +1869,79 @@ reward:
|
||||
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report dependentSchemas violations", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"required": ["itemCount"],
|
||||
"properties": {
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
reward:
|
||||
itemId: potion
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.equal(diagnostics[0].severity, "error");
|
||||
assert.match(diagnostics[0].message, /dependent schema/u);
|
||||
assert.match(diagnostics[0].message, /reward\.itemId/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should accept satisfied dependentSchemas", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemId": { "type": "string" },
|
||||
"itemCount": { "type": "integer" },
|
||||
"bonus": { "type": "integer" }
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"itemId": {
|
||||
"type": "object",
|
||||
"required": ["itemCount"],
|
||||
"properties": {
|
||||
"itemCount": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
reward:
|
||||
itemId: potion
|
||||
itemCount: 3
|
||||
bonus: 1
|
||||
`);
|
||||
|
||||
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject non-object not declarations", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
|
||||
@ -111,3 +111,38 @@ test("createLocalizer should expose dependentRequired validation keys", () => {
|
||||
}),
|
||||
"属性“reward.itemId”存在时,必须同时声明属性“reward.itemCount”。");
|
||||
});
|
||||
|
||||
test("createLocalizer should expose dependentSchemas validation keys", () => {
|
||||
const englishLocalizer = createLocalizer("en");
|
||||
const chineseLocalizer = createLocalizer("zh-cn");
|
||||
|
||||
assert.equal(
|
||||
englishLocalizer.t("webview.hint.required", {
|
||||
properties: "itemCount, bonusCount"
|
||||
}),
|
||||
"Required: itemCount, bonusCount");
|
||||
assert.equal(
|
||||
englishLocalizer.t("webview.hint.dependentSchemas", {
|
||||
trigger: "reward.itemId",
|
||||
schema: "object, Required: itemCount"
|
||||
}),
|
||||
"When reward.itemId is set: satisfy object, Required: itemCount");
|
||||
assert.equal(
|
||||
chineseLocalizer.t("webview.hint.dependentSchemas", {
|
||||
trigger: "reward.itemId",
|
||||
schema: "object, 必填字段:itemCount"
|
||||
}),
|
||||
"当 reward.itemId 出现时:还必须满足 object, 必填字段:itemCount");
|
||||
assert.equal(
|
||||
englishLocalizer.t(ValidationMessageKeys.dependentSchemasViolation, {
|
||||
displayPath: "reward",
|
||||
triggerProperty: "reward.itemId"
|
||||
}),
|
||||
"Object 'reward' must satisfy the dependent schema triggered by sibling property 'reward.itemId'.");
|
||||
assert.equal(
|
||||
chineseLocalizer.t(ValidationMessageKeys.dependentSchemasViolation, {
|
||||
displayPath: "reward",
|
||||
triggerProperty: "reward.itemId"
|
||||
}),
|
||||
"对象“reward”在属性“reward.itemId”存在时,必须满足对应的 dependent schema。");
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user