Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -220,15 +220,17 @@ private void CreateMonsterFiles()
},
"name": {
"type": "string",
"description": "Monster display name."
"description": "Monster display name.",
"x-gframework-index": true
},
"hp": {
"type": "integer",
"description": "Monster base health."
},
"faction": {
"type": "string",
"description": "Used by the integration test to validate generated non-unique queries."
"description": "Used by the integration test to validate generated non-unique queries.",
"x-gframework-index": true
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions GFramework.Game.Tests/schemas/monster.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@
},
"name": {
"type": "string",
"description": "Monster display name."
"description": "Monster display name.",
"x-gframework-index": true
},
"hp": {
"type": "integer",
"description": "Monster base health."
},
"faction": {
"type": "string",
"description": "Used by integration tests to validate generated non-unique queries."
"description": "Used by integration tests to validate generated non-unique queries.",
"x-gframework-index": true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public YamlConfigLoader RegisterTable<TKey, TValue>(
"type": "string",
"title": "Monster Name",
"description": "Localized monster display name.",
"x-gframework-index": true,
"minLength": 3,
"maxLength": 16,
"pattern": "^[A-Z][a-z]+$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,94 @@ public YamlConfigLoader RegisterTable<TKey, TValue>(
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
}

/// <summary>
/// 验证生成的索引构建逻辑会跳过运行时空 key,避免 Lazy 索引因格式错误数据永久失效。
/// </summary>
[Test]
public void Run_Should_Skip_Runtime_Null_Keys_When_Generating_Indexed_Lookups()
{
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",
"x-gframework-index": true
}
}
}
""";

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["MonsterTable.g.cs"], Does.Contain("if (key is null)"));
Assert.That(generatedSources["MonsterTable.g.cs"],
Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance."));
}

/// <summary>
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
/// </summary>
Expand Down Expand Up @@ -267,6 +355,187 @@ public sealed class Dummy
});
}

/// <summary>
/// 验证查询索引元数据必须是布尔值,避免 schema 作者误以为字符串或数字也会被解释为开关。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Lookup_Index_Metadata_Is_Not_Boolean()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";

const string schema = """
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": {
"type": "string",
"x-gframework-index": "yes"
}
}
}
""";

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_008"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("x-gframework-index"));
Assert.That(diagnostic.GetMessage(), Does.Contain("boolean"));
});
}

/// <summary>
/// 验证查询索引元数据不能绑定到不满足约束的字段上,避免为嵌套字段生成误导性 API。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Lookup_Index_Metadata_Target_Is_Not_Eligible()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";

const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"required": ["rarity"],
"properties": {
"rarity": {
"type": "string",
"x-gframework-index": true
}
}
}
}
}
""";

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_008"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("reward.rarity"));
Assert.That(diagnostic.GetMessage(), Does.Contain("top-level required non-key scalar"));
});
}

/// <summary>
/// 验证根对象直接字段即使 schema key 本身包含点号,也不会被错误识别为嵌套字段。
/// </summary>
[Test]
public void Run_Should_Allow_Lookup_Index_For_Direct_Root_Property_With_Dotted_Schema_Key()
{
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", "display.name"],
"properties": {
"id": { "type": "integer" },
"display.name": {
"type": "string",
"x-gframework-index": true
}
}
}
""";

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["MonsterTable.g.cs"], Does.Contain("FindByDisplayName(string value)"));
Assert.That(generatedSources["MonsterTable.g.cs"], Does.Contain("_displayNameIndex"));
}

/// <summary>
/// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
/// </summary>
Expand Down Expand Up @@ -429,7 +698,10 @@ public YamlConfigLoader RegisterTable<TKey, TValue>(
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"name": {
"type": "string",
"x-gframework-index": true
},
"hp": { "type": "integer" },
"dropItems": {
"type": "array",
Expand Down Expand Up @@ -468,8 +740,15 @@ public YamlConfigLoader RegisterTable<TKey, TValue>(
{
Assert.That(tableSource, Does.Contain("FindByName(string value)"));
Assert.That(tableSource, Does.Contain("TryFindFirstByName(string value, out MonsterConfig? result)"));
Assert.That(tableSource, Does.Contain("_nameIndex"));
Assert.That(tableSource, Does.Contain("BuildNameIndex"));
Assert.That(tableSource, Does.Contain("if (value is null)"));
Assert.That(tableSource, Does.Contain("_nameIndex.Value.TryGetValue(value, out var matches)"));
Assert.That(tableSource, Does.Contain("materialized.Add(pair.Key, pair.Value.AsReadOnly());"));
Assert.That(tableSource, Does.Not.Contain("pair.Value.ToArray()"));
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("_hpIndex"));
Assert.That(tableSource, Does.Not.Contain("FindById("));
Assert.That(tableSource, Does.Not.Contain("FindByDropItems("));
Assert.That(tableSource, Does.Not.Contain("FindByTargetId("));
Expand Down
Loading
Loading