mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
- 实现基于 YAML 的静态游戏内容配置管理 - 集成 JSON Schema 结构描述与校验功能 - 提供一对象一文件的目录组织方式 - 支持运行时只读查询与类型安全访问 - 添加 Source Generator 自动生成配置类型和表包装 - 实现 VS Code 插件提供配置浏览与编辑功能 - 添加热重载支持开发期实时配置更新 - 提供官方启动帮助器简化初始化流程 - 支持跨表引用校验与关联表联动重载 - 实现强类型查询辅助方法提升开发效率
616 lines
28 KiB
C#
616 lines
28 KiB
C#
namespace GFramework.SourceGenerators.Tests.Config;
|
|
|
|
/// <summary>
|
|
/// 验证 schema 配置生成器的错误诊断行为。
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class SchemaConfigGeneratorTests
|
|
{
|
|
/// <summary>
|
|
/// 验证缺失必填 id 字段时会产生命名明确的诊断。
|
|
/// </summary>
|
|
[Test]
|
|
public void Run_Should_Report_Diagnostic_When_Id_Property_Is_Missing()
|
|
{
|
|
const string source = """
|
|
namespace TestApp
|
|
{
|
|
public sealed class Dummy
|
|
{
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"required": ["name"],
|
|
"properties": {
|
|
"name": { "type": "string" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaGeneratorTestDriver.Run(
|
|
source,
|
|
("monster.schema.json", schema));
|
|
|
|
var diagnostics = result.Results.Single().Diagnostics;
|
|
var diagnostic = diagnostics.Single();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_003"));
|
|
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
|
Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
|
|
/// </summary>
|
|
[Test]
|
|
public void Run_Should_Report_Diagnostic_When_Nested_Array_Type_Is_Not_Supported()
|
|
{
|
|
const string source = """
|
|
namespace TestApp
|
|
{
|
|
public sealed class Dummy
|
|
{
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"required": ["id"],
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"waves": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "array",
|
|
"items": { "type": "integer" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
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_004"));
|
|
Assert.That(diagnostic.GetMessage(), Does.Contain("waves"));
|
|
Assert.That(diagnostic.GetMessage(), Does.Contain("array<array>"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。
|
|
/// </summary>
|
|
/// <param name="schemaKey">会映射为非法 C# 标识符的 schema key。</param>
|
|
/// <param name="generatedIdentifier">当前命名规范化逻辑生成出的非法标识符。</param>
|
|
[TestCase("drop$item", "Drop$item")]
|
|
[TestCase("1-phase", "1Phase")]
|
|
public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier(
|
|
string schemaKey,
|
|
string generatedIdentifier)
|
|
{
|
|
const string source = """
|
|
namespace TestApp
|
|
{
|
|
public sealed class Dummy
|
|
{
|
|
}
|
|
}
|
|
""";
|
|
|
|
var schema = $$"""
|
|
{
|
|
"type": "object",
|
|
"required": ["id", "{{schemaKey}}"],
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"{{schemaKey}}": { "type": "string" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
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_006"));
|
|
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
|
Assert.That(diagnostic.GetMessage(), Does.Contain(schemaKey));
|
|
Assert.That(diagnostic.GetMessage(), Does.Contain(generatedIdentifier));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 schema 顶层允许通过元数据覆盖默认配置目录,并会统一路径分隔符。
|
|
/// </summary>
|
|
[Test]
|
|
public void Run_Should_Use_Custom_Config_Path_Metadata_For_Generated_Registration()
|
|
{
|
|
const string source = """
|
|
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace GFramework.Game.Abstractions.Config
|
|
{
|
|
public interface IConfigTable
|
|
{
|
|
Type KeyType { get; }
|
|
Type ValueType { get; }
|
|
int Count { get; }
|
|
}
|
|
|
|
public interface IConfigTable<TKey, TValue> : IConfigTable
|
|
where TKey : notnull
|
|
{
|
|
TValue Get(TKey key);
|
|
bool TryGet(TKey key, out TValue? value);
|
|
bool ContainsKey(TKey key);
|
|
IReadOnlyCollection<TValue> All();
|
|
}
|
|
|
|
public interface IConfigRegistry
|
|
{
|
|
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
|
|
where TKey : notnull;
|
|
|
|
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
|
where TKey : notnull;
|
|
}
|
|
}
|
|
|
|
namespace GFramework.Game.Config
|
|
{
|
|
public sealed class YamlConfigLoader
|
|
{
|
|
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
|
string tableName,
|
|
string relativePath,
|
|
string schemaRelativePath,
|
|
Func<TValue, TKey> keySelector,
|
|
IEqualityComparer<TKey>? comparer = null)
|
|
where TKey : notnull
|
|
{
|
|
return this;
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"x-gframework-config-path": "config\\monster",
|
|
"required": ["id", "name"],
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"name": { "type": "string" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
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["MonsterConfigBindings.g.cs"],
|
|
Does.Contain("public const string ConfigRelativePath = \"config/monster\";"));
|
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"], Does.Contain("Metadata.ConfigRelativePath,"));
|
|
Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
|
|
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
|
|
/// </summary>
|
|
[Test]
|
|
public void Run_Should_Report_Diagnostic_When_Custom_Config_Path_Metadata_Is_Invalid()
|
|
{
|
|
const string source = """
|
|
namespace TestApp
|
|
{
|
|
public sealed class Dummy
|
|
{
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"x-gframework-config-path": "../monster",
|
|
"required": ["id"],
|
|
"properties": {
|
|
"id": { "type": "integer" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
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_007"));
|
|
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
|
Assert.That(diagnostic.GetMessage(), Does.Contain("x-gframework-config-path"));
|
|
Assert.That(diagnostic.GetMessage(), Does.Contain("relative"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
|
|
/// </summary>
|
|
[Test]
|
|
public void Run_Should_Assign_Globally_Unique_Reference_Metadata_Member_Names()
|
|
{
|
|
const string source = """
|
|
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace GFramework.Game.Abstractions.Config
|
|
{
|
|
public interface IConfigTable
|
|
{
|
|
Type KeyType { get; }
|
|
Type ValueType { get; }
|
|
int Count { get; }
|
|
}
|
|
|
|
public interface IConfigTable<TKey, TValue> : IConfigTable
|
|
where TKey : notnull
|
|
{
|
|
TValue Get(TKey key);
|
|
bool TryGet(TKey key, out TValue? value);
|
|
bool ContainsKey(TKey key);
|
|
IReadOnlyCollection<TValue> All();
|
|
}
|
|
|
|
public interface IConfigRegistry
|
|
{
|
|
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
|
|
where TKey : notnull;
|
|
|
|
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
|
where TKey : notnull;
|
|
}
|
|
}
|
|
|
|
namespace GFramework.Game.Config
|
|
{
|
|
public sealed class YamlConfigLoader
|
|
{
|
|
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
|
string tableName,
|
|
string relativePath,
|
|
string schemaRelativePath,
|
|
Func<TValue, TKey> keySelector,
|
|
IEqualityComparer<TKey>? comparer = null)
|
|
where TKey : notnull
|
|
{
|
|
return this;
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"required": ["id"],
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"drop-items": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"x-gframework-ref-table": "item"
|
|
},
|
|
"drop_items": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"x-gframework-ref-table": "item"
|
|
},
|
|
"dropItems1": {
|
|
"type": "string",
|
|
"x-gframework-ref-table": "item"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
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.TryGetValue("MonsterConfigBindings.g.cs", out var bindingsSource), Is.True);
|
|
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems ="));
|
|
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 ="));
|
|
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 ="));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证生成器只为顶层非主键标量字段生成轻量查询辅助,
|
|
/// 避免把数组、对象和引用字段误生成为查询 API。
|
|
/// </summary>
|
|
[Test]
|
|
public void Run_Should_Generate_Query_Helpers_Only_For_Top_Level_Scalar_Properties()
|
|
{
|
|
const string source = """
|
|
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace GFramework.Game.Abstractions.Config
|
|
{
|
|
public interface IConfigTable
|
|
{
|
|
Type KeyType { get; }
|
|
Type ValueType { get; }
|
|
int Count { get; }
|
|
}
|
|
|
|
public interface IConfigTable<TKey, TValue> : IConfigTable
|
|
where TKey : notnull
|
|
{
|
|
TValue Get(TKey key);
|
|
bool TryGet(TKey key, out TValue? value);
|
|
bool ContainsKey(TKey key);
|
|
IReadOnlyCollection<TValue> All();
|
|
}
|
|
|
|
public interface IConfigRegistry
|
|
{
|
|
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
|
|
where TKey : notnull;
|
|
|
|
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
|
where TKey : notnull;
|
|
}
|
|
}
|
|
|
|
namespace GFramework.Game.Config
|
|
{
|
|
public sealed class YamlConfigLoader
|
|
{
|
|
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
|
string tableName,
|
|
string relativePath,
|
|
string schemaRelativePath,
|
|
Func<TValue, TKey> keySelector,
|
|
IEqualityComparer<TKey>? comparer = null)
|
|
where TKey : notnull
|
|
{
|
|
return this;
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"required": ["id", "name"],
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"name": { "type": "string" },
|
|
"hp": { "type": "integer" },
|
|
"dropItems": {
|
|
"type": "array",
|
|
"items": { "type": "string" }
|
|
},
|
|
"targetId": {
|
|
"type": "string",
|
|
"x-gframework-ref-table": "monster"
|
|
},
|
|
"reward": {
|
|
"type": "object",
|
|
"properties": {
|
|
"gold": { "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.TryGetValue("MonsterTable.g.cs", out var tableSource), Is.True);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(tableSource, Does.Contain("FindByName(string value)"));
|
|
Assert.That(tableSource, Does.Contain("TryFindFirstByName(string value, out MonsterConfig? result)"));
|
|
Assert.That(tableSource, Does.Contain("FindByHp(int? value)"));
|
|
Assert.That(tableSource, Does.Contain("TryFindFirstByHp(int? value, out MonsterConfig? result)"));
|
|
Assert.That(tableSource, Does.Not.Contain("FindById("));
|
|
Assert.That(tableSource, Does.Not.Contain("FindByDropItems("));
|
|
Assert.That(tableSource, Does.Not.Contain("FindByTargetId("));
|
|
Assert.That(tableSource, Does.Not.Contain("FindByReward("));
|
|
Assert.That(tableSource, Does.Not.Contain("TryFindFirstById("));
|
|
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByDropItems("));
|
|
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByTargetId("));
|
|
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByReward("));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证生成器会为当前消费者项目内的全部 schema 额外产出聚合注册入口,
|
|
/// 让 C# 启动代码可以一行注册所有生成表。
|
|
/// </summary>
|
|
[Test]
|
|
public void Run_Should_Generate_Project_Level_Registration_Catalog()
|
|
{
|
|
const string source = """
|
|
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace GFramework.Game.Abstractions.Config
|
|
{
|
|
public interface IConfigTable
|
|
{
|
|
Type KeyType { get; }
|
|
Type ValueType { get; }
|
|
int Count { get; }
|
|
}
|
|
|
|
public interface IConfigTable<TKey, TValue> : IConfigTable
|
|
where TKey : notnull
|
|
{
|
|
TValue Get(TKey key);
|
|
bool TryGet(TKey key, out TValue? value);
|
|
bool ContainsKey(TKey key);
|
|
IReadOnlyCollection<TValue> All();
|
|
}
|
|
|
|
public interface IConfigRegistry
|
|
{
|
|
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
|
|
where TKey : notnull;
|
|
|
|
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
|
where TKey : notnull;
|
|
}
|
|
}
|
|
|
|
namespace GFramework.Game.Config
|
|
{
|
|
public sealed class YamlConfigLoader
|
|
{
|
|
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
|
string tableName,
|
|
string relativePath,
|
|
string schemaRelativePath,
|
|
Func<TValue, TKey> keySelector,
|
|
IEqualityComparer<TKey>? comparer = null)
|
|
where TKey : notnull
|
|
{
|
|
return this;
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string monsterSchema = """
|
|
{
|
|
"type": "object",
|
|
"required": ["id"],
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"name": { "type": "string" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
const string itemSchema = """
|
|
{
|
|
"type": "object",
|
|
"required": ["id"],
|
|
"properties": {
|
|
"id": { "type": "string" },
|
|
"rarity": { "type": "string" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaGeneratorTestDriver.Run(
|
|
source,
|
|
("monster.schema.json", monsterSchema),
|
|
("item.schema.json", itemSchema));
|
|
|
|
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.TryGetValue("GeneratedConfigCatalog.g.cs", out var catalogSource), Is.True);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigCatalog"));
|
|
Assert.That(catalogSource, Does.Contain("public sealed class GeneratedConfigRegistrationOptions"));
|
|
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigRegistrationExtensions"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain(
|
|
"public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedConfigDomains { get; init; }"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain(
|
|
"public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedTableNames { get; init; }"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain(
|
|
"public global::System.Predicate<GeneratedConfigCatalog.TableMetadata>? TableFilter { get; init; }"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain(
|
|
"public global::System.Collections.Generic.IEqualityComparer<string>? ItemComparer { get; init; }"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain(
|
|
"public global::System.Collections.Generic.IEqualityComparer<int>? MonsterComparer { get; init; }"));
|
|
Assert.That(catalogSource, Does.Contain("return RegisterAllGeneratedConfigTables(loader, options: null);"));
|
|
Assert.That(catalogSource, Does.Contain("GeneratedConfigRegistrationOptions? options"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[0], options))"));
|
|
Assert.That(catalogSource, Does.Contain("loader.RegisterItemTable(options.ItemComparer);"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[1], options))"));
|
|
Assert.That(catalogSource, Does.Contain("loader.RegisterMonsterTable(options.MonsterComparer);"));
|
|
Assert.That(catalogSource, Does.Contain("ItemConfigBindings.Metadata.TableName"));
|
|
Assert.That(catalogSource, Does.Contain("MonsterConfigBindings.Metadata.TableName"));
|
|
Assert.That(catalogSource,
|
|
Does.Contain("public static bool TryGetByTableName(string tableName, out TableMetadata metadata)"));
|
|
Assert.That(catalogSource, Does.Contain("private static bool ShouldRegisterTable("));
|
|
Assert.That(catalogSource, Does.Contain("private static bool MatchesOptionalAllowList("));
|
|
});
|
|
}
|
|
}
|