diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3fb6b9..70a6b34c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,24 @@ jobs: # 恢复.NET本地工具 - name: Restore .NET tools run: dotnet tool restore + + - name: Setup Node.js 20 + uses: actions/setup-node@v5 + with: + node-version: 20 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.15 + + - name: Install config tool dependencies + working-directory: tools/gframework-config-tool + run: bun install + + - name: Run config tool tests + working-directory: tools/gframework-config-tool + run: bun run test # 构建项目,使用Release配置且跳过恢复步骤 - name: Build diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs index 510ef01b..d8631e5d 100644 --- a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using GFramework.Core.Architectures; +using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; using NUnit.Framework; @@ -10,14 +11,14 @@ using NUnit.Framework; namespace GFramework.Game.Tests.Config; /// -/// 验证在 初始化流程中可以通过聚合注册入口加载生成配置表,并通过表访问器读取数据。 +/// 验证在 初始化流程中可以通过官方配置启动帮助器加载生成配置表,并通过表访问器读取数据。 /// [TestFixture] public class ArchitectureConfigIntegrationTests { /// - /// 架构初始化期间,通过 的聚合注册入口注册生成表, - /// 并将 作为 utility 暴露给架构上下文读取。 + /// 架构初始化期间,通过 收敛生成表注册、加载与注册表暴露, + /// 并将 作为 utility 暴露给架构上下文读取。 /// [Test] public async Task ConfigLoaderCanRunDuringArchitectureInitialization() @@ -43,7 +44,7 @@ public class ArchitectureConfigIntegrationTests Assert.That(retrieved, Is.Not.Null); Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime")); Assert.That(architecture.Registry.TryGetItemTable(out _), Is.False); - Assert.That(architecture.Context.GetUtility(), Is.SameAs(architecture.Registry)); + Assert.That(architecture.Context.GetUtility(), Is.SameAs(architecture.Registry)); }); } finally @@ -131,30 +132,43 @@ public class ArchitectureConfigIntegrationTests private sealed class ConsumerArchitecture : Architecture { - private readonly string _configRoot; + private readonly GameConfigBootstrap _bootstrap; - public ConfigRegistry Registry { get; } + public IConfigRegistry Registry => _bootstrap.Registry; public MonsterTable MonsterTable { get; private set; } = null!; public ConsumerArchitecture(string configRoot) { - _configRoot = configRoot ?? throw new ArgumentNullException(nameof(configRoot)); - Registry = new ConfigRegistry(); + if (configRoot == null) + { + throw new ArgumentNullException(nameof(configRoot)); + } + + _bootstrap = new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = configRoot, + ConfigureLoader = static loader => + loader.RegisterAllGeneratedConfigTables( + new GeneratedConfigRegistrationOptions + { + IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } + }) + }); } protected override void OnInitialize() { RegisterUtility(Registry); - - var loader = new YamlConfigLoader(_configRoot) - .RegisterAllGeneratedConfigTables( - new GeneratedConfigRegistrationOptions - { - IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } - }); - loader.LoadAsync(Registry).GetAwaiter().GetResult(); + _bootstrap.InitializeAsync().GetAwaiter().GetResult(); MonsterTable = Registry.GetMonsterTable(); } + + public override async ValueTask DestroyAsync() + { + _bootstrap.Dispose(); + await base.DestroyAsync(); + } } } diff --git a/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs new file mode 100644 index 00000000..c6f28484 --- /dev/null +++ b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs @@ -0,0 +1,322 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; +using GFramework.Game.Config.Generated; +using NUnit.Framework; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证官方配置启动帮助器能够收敛注册、加载与热重载生命周期。 +/// +[TestFixture] +public class GameConfigBootstrapTests +{ + /// + /// 为每个测试准备独立的临时配置目录。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.GameConfigBootstrapTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理测试期间创建的临时目录。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + private string _rootPath = null!; + + /// + /// 验证启动帮助器能够加载生成表,并复用调用方显式提供的注册表实例。 + /// + [Test] + public async Task InitializeAsync_Should_Load_Generated_Config_Tables_Into_Registry() + { + CreateMonsterFiles(); + + var registry = new ConfigRegistry(); + using var bootstrap = CreateBootstrap(registry); + + await bootstrap.InitializeAsync(); + + var monsterTable = registry.GetMonsterTable(); + + Assert.Multiple(() => + { + Assert.That(bootstrap.Registry, Is.SameAs(registry)); + Assert.That(bootstrap.Loader.RegistrationCount, Is.EqualTo(1)); + Assert.That(bootstrap.IsInitialized, Is.True); + Assert.That(bootstrap.IsHotReloadEnabled, Is.False); + Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(monsterTable.Get(2).Hp, Is.EqualTo(30)); + }); + } + + /// + /// 验证启动帮助器可以在初始化后显式启用热重载,并将刷新结果写回共享注册表。 + /// + [Test] + public async Task StartHotReload_Should_Update_Registered_Table_When_Config_File_Changes() + { + CreateMonsterFiles(); + + using var bootstrap = CreateBootstrap(); + await bootstrap.InitializeAsync(); + + var reloadTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + bootstrap.StartHotReload( + new YamlConfigHotReloadOptions + { + OnTableReloaded = tableName => reloadTaskSource.TrySetResult(tableName), + DebounceDelay = TimeSpan.FromMilliseconds(150) + }); + + try + { + CreateFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 25 + faction: dungeon + """); + + var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5)); + var monsterTable = bootstrap.Registry.GetMonsterTable(); + + Assert.Multiple(() => + { + Assert.That(tableName, Is.EqualTo(MonsterConfigBindings.TableName)); + Assert.That(bootstrap.IsHotReloadEnabled, Is.True); + Assert.That(monsterTable.Get(1).Hp, Is.EqualTo(25)); + }); + } + finally + { + bootstrap.StopHotReload(); + } + } + + /// + /// 验证缺少加载器配置回调时会在构造阶段被拒绝,避免启动帮助器静默创建空加载流程。 + /// + [Test] + public void Constructor_Should_Throw_When_ConfigureLoader_Is_Missing() + { + var exception = Assert.Throws(() => + _ = new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = _rootPath + })); + + Assert.That(exception!.ParamName, Is.EqualTo("options")); + } + + /// + /// 验证初始化链路进行中时,第二个调用者不会再次进入并发初始化流程。 + /// + [Test] + public void InitializeAsync_Should_Reject_Concurrent_Caller_While_Initialization_Is_In_Progress() + { + CreateMonsterFiles(); + + using ManualResetEventSlim initializeEntered = new(false); + using ManualResetEventSlim continueInitialization = new(false); + using var bootstrap = new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = _rootPath, + ConfigureLoader = loader => + { + initializeEntered.Set(); + Assert.That( + continueInitialization.Wait(TimeSpan.FromSeconds(5)), + Is.True, + "The first initialization attempt did not resume within the expected timeout."); + loader.RegisterAllGeneratedConfigTables( + new GeneratedConfigRegistrationOptions + { + IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } + }); + } + }); + + var firstInitializeTask = Task.Run(() => bootstrap.InitializeAsync()); + + Assert.That( + initializeEntered.Wait(TimeSpan.FromSeconds(5)), + Is.True, + "The first initialization attempt did not reach the guarded lifecycle section."); + + var secondCallerException = Assert.ThrowsAsync(async () => await bootstrap.InitializeAsync()); + + continueInitialization.Set(); + + Assert.DoesNotThrowAsync(async () => await firstInitializeTask); + + Assert.Multiple(() => + { + Assert.That(secondCallerException, Is.Not.Null); + Assert.That(secondCallerException!.Message, Does.Contain("only be initialized once")); + Assert.That(bootstrap.IsInitialized, Is.True); + }); + } + + /// + /// 验证在可选热重载启动失败时,不会提前公开加载器与初始化成功状态。 + /// + [Test] + public void InitializeAsync_Should_Not_Publish_State_When_HotReload_Enable_Fails() + { + CreateMonsterFiles(); + + using var bootstrap = new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = _rootPath, + EnableHotReload = true, + HotReloadOptions = new YamlConfigHotReloadOptions + { + DebounceDelay = TimeSpan.FromMilliseconds(-1) + }, + ConfigureLoader = static loader => + loader.RegisterAllGeneratedConfigTables( + new GeneratedConfigRegistrationOptions + { + IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } + }) + }); + + var exception = Assert.ThrowsAsync(async () => await bootstrap.InitializeAsync()); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(bootstrap.IsInitialized, Is.False); + Assert.That(bootstrap.IsHotReloadEnabled, Is.False); + Assert.Throws(() => _ = bootstrap.Loader); + }); + } + + /// + /// 创建一个使用生成聚合注册入口的官方启动帮助器。 + /// + /// 可选的外部注册表;为空时使用默认注册表。 + /// 已配置但尚未初始化的启动帮助器。 + private GameConfigBootstrap CreateBootstrap(IConfigRegistry? registry = null) + { + return new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = _rootPath, + Registry = registry, + ConfigureLoader = static loader => + loader.RegisterAllGeneratedConfigTables( + new GeneratedConfigRegistrationOptions + { + IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } + }) + }); + } + + /// + /// 在临时消费者根目录中创建测试文件。 + /// + /// 相对根目录的文件路径。 + /// 要写入的文件内容。 + private void CreateFile(string relativePath, string content) + { + var path = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal)); + } + + /// + /// 在临时目录中创建 monster schema 与 YAML 测试数据。 + /// + private void CreateMonsterFiles() + { + CreateFile( + "schemas/monster.schema.json", + """ + { + "title": "Monster Config", + "description": "Defines one monster entry for the bootstrap tests.", + "type": "object", + "required": ["id", "name", "hp", "faction"], + "properties": { + "id": { + "type": "integer", + "description": "Monster identifier." + }, + "name": { + "type": "string", + "description": "Monster display name." + }, + "hp": { + "type": "integer", + "description": "Monster base health." + }, + "faction": { + "type": "string", + "description": "Used by the bootstrap tests to validate generated queries." + } + } + } + """); + CreateFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + faction: dungeon + """); + CreateFile( + "monster/goblin.yaml", + """ + id: 2 + name: Goblin + hp: 30 + faction: dungeon + """); + } + + /// + /// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。 + /// + /// 任务结果类型。 + /// 要等待的任务。 + /// 超时时间。 + /// 任务结果。 + private static async Task WaitForTaskWithinAsync(Task task, TimeSpan timeout) + { + var completedTask = await Task.WhenAny(task, Task.Delay(timeout)); + if (!ReferenceEquals(completedTask, task)) + { + Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification."); + } + + return await task; + } +} diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 86f01098..059d61aa 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -412,6 +412,104 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数值命中开区间下界时会按 schema 在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { + "type": "integer", + "exclusiveMinimum": 10, + "exclusiveMaximum": 100 + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(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("hp")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("10")); + Assert.That(exception.Message, Does.Contain("greater than 10")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数值命中开区间上界时会按 schema 在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Maximum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 100 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { + "type": "integer", + "exclusiveMinimum": 10, + "exclusiveMaximum": 100 + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(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("hp")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("100")); + Assert.That(exception.Message, Does.Contain("less than 100")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// @@ -461,6 +559,209 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证字符串正则模式约束会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_String_Does_Not_Match_Pattern() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "pattern": "^[A-Z][a-z]+$" + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(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("name")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("slime")); + Assert.That(exception.Message, Does.Contain("regular expression")); + Assert.That(exception.Message, Does.Contain("^[A-Z][a-z]+$")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Backreference_Pattern_When_Value_Matches() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: aa + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "pattern": "^(a)\\1$" + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Name, Is.EqualTo("aa")); + }); + } + + /// + /// 验证数组元素数量命中上界时会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_MaxItems() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 1 + - 2 + - 3 + - 4 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(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("dropRates")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("4")); + Assert.That(exception.Message, Does.Contain("at most 3 items")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组元素数量命中下界时会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_MinItems() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: [] + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(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("dropRates")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("0")); + Assert.That(exception.Message, Does.Contain("at least 1 items")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// @@ -1594,4 +1895,4 @@ public class YamlConfigLoaderTests /// 配置主键。 /// 配置名称。 private sealed record ExistingConfigStub(int Id, string Name); -} \ No newline at end of file +} diff --git a/GFramework.Game/Config/GameConfigBootstrap.cs b/GFramework.Game/Config/GameConfigBootstrap.cs new file mode 100644 index 00000000..242002be --- /dev/null +++ b/GFramework.Game/Config/GameConfigBootstrap.cs @@ -0,0 +1,329 @@ +using GFramework.Core.Abstractions.Events; +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 提供官方的 C# 配置启动帮助器。 +/// 该类型负责把配置注册表、YAML 加载器与开发期热重载句柄收敛到一个长生命周期对象中, +/// 让消费者项目可以通过一个稳定入口完成配置启动,而不是在多个脚本里重复拼装运行时细节。 +/// +/// +/// 生命周期转换会串行化执行,因此并发调用只会观察到已经提交完成的加载器与热重载句柄。 +/// 如果初始化或热重载启动在中途失败,当前实例会保留失败前的稳定状态,而不会公开半初始化对象。 +/// +public sealed class GameConfigBootstrap : IDisposable +{ + private const string ConfigureLoaderCannotBeNullMessage = "ConfigureLoader must be provided."; + private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace."; + + // All lifecycle transitions share one gate so initialization, hot-reload startup, + // stop, and disposal never publish half-finished state to concurrent callers. + private readonly object _stateGate = new(); + private readonly GameConfigBootstrapOptions _options; + private IUnRegister? _hotReload; + private YamlConfigLoader? _loader; + private bool _disposed; + private bool _isInitializing; + private bool _isStartingHotReload; + private bool _stopHotReloadAfterStart; + + /// + /// 使用指定选项创建配置启动帮助器。 + /// + /// 配置启动约定。 + /// 为空时抛出。 + /// + /// 当 为空, + /// 或 未提供时抛出。 + /// + public GameConfigBootstrap(GameConfigBootstrapOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (string.IsNullOrWhiteSpace(options.RootPath)) + { + throw new ArgumentException( + RootPathCannotBeNullOrWhiteSpaceMessage, + nameof(options)); + } + + if (options.ConfigureLoader == null) + { + throw new ArgumentException( + ConfigureLoaderCannotBeNullMessage, + nameof(options)); + } + + _options = options; + RootPath = options.RootPath; + Registry = options.Registry ?? new ConfigRegistry(); + } + + /// + /// 获取配置根目录。 + /// + public string RootPath { get; } + + /// + /// 获取当前配置生命周期共享的注册表。 + /// 默认情况下该实例由启动帮助器创建;如调用方传入自定义注册表,则返回同一个对象。 + /// + public IConfigRegistry Registry { get; } + + /// + /// 获取一个值,指示启动帮助器是否已经成功完成初次加载。 + /// 该状态只会在完整初始化链路(包括可选热重载启动)成功后才对外可见, + /// 避免并发调用观察到半初始化生命周期。 + /// + public bool IsInitialized + { + get + { + lock (_stateGate) + { + return _loader != null; + } + } + } + + /// + /// 获取一个值,指示开发期热重载是否已启用。 + /// 只有当监听句柄已经成功创建并提交到当前生命周期后,该属性才会返回 。 + /// + public bool IsHotReloadEnabled + { + get + { + lock (_stateGate) + { + return _hotReload != null; + } + } + } + + /// + /// 获取当前生效的 YAML 配置加载器。 + /// 只有在 成功返回后该属性才可访问。 + /// + /// 当当前实例已释放时抛出。 + /// 当启动帮助器尚未初始化成功时抛出。 + public YamlConfigLoader Loader + { + get + { + lock (_stateGate) + { + ThrowIfDisposedCore(); + + return _loader ?? throw new InvalidOperationException( + "The config bootstrap has not been initialized yet."); + } + } + } + + /// + /// 执行初次配置加载,并在需要时启动开发期热重载。 + /// 该方法只能成功调用一次,避免同一个生命周期对象在运行中被重新拼装为另一套加载约定。 + /// + /// 取消令牌。 + /// 表示异步初始化流程的任务。 + /// 当当前实例已释放时抛出。 + /// 当当前实例已经初始化成功时抛出。 + /// 当配置加载失败时抛出。 + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + lock (_stateGate) + { + ThrowIfDisposedCore(); + + if (_isInitializing || _loader != null) + { + throw new InvalidOperationException( + "The config bootstrap can only be initialized once per instance."); + } + + _isInitializing = true; + } + + IUnRegister? hotReload = null; + + try + { + var loader = new YamlConfigLoader(RootPath); + _options.ConfigureLoader!(loader); + await loader.LoadAsync(Registry, cancellationToken); + + if (_options.EnableHotReload) + { + hotReload = loader.EnableHotReload(Registry, _options.HotReloadOptions); + } + + lock (_stateGate) + { + try + { + ThrowIfDisposedCore(); + + // 仅在初次加载与可选热重载都完整成功后才提交结果, + // 避免 IsInitialized / Loader 暴露半初始化生命周期。 + _loader = loader; + _hotReload = hotReload; + hotReload = null; + } + finally + { + _isInitializing = false; + } + } + } + catch + { + lock (_stateGate) + { + _isInitializing = false; + } + + hotReload?.UnRegister(); + throw; + } + } + + /// + /// 启用开发期热重载。 + /// 该入口让调用方可以先完成一次确定性的初始加载,再按环境决定是否追加文件监听。 + /// + /// 热重载选项;为空时使用 的默认行为。 + /// 当当前实例已释放时抛出。 + /// + /// 当初始加载尚未完成,或热重载已经处于启用状态时抛出。 + /// + /// + /// 当 小于 + /// 时抛出。 + /// + public void StartHotReload(YamlConfigHotReloadOptions? options = null) + { + YamlConfigLoader loader; + lock (_stateGate) + { + ThrowIfDisposedCore(); + + loader = _loader ?? throw new InvalidOperationException( + "Hot reload can only be started after the initial config load succeeds."); + + if (_isStartingHotReload || _hotReload != null) + { + throw new InvalidOperationException("Hot reload is already enabled."); + } + + _isStartingHotReload = true; + _stopHotReloadAfterStart = false; + } + + IUnRegister? hotReload = null; + try + { + hotReload = loader.EnableHotReload(Registry, options); + + var shouldStop = false; + lock (_stateGate) + { + try + { + ThrowIfDisposedCore(); + + // Stop/Dispose may arrive while the watcher is being created. In that + // case, release the new handle immediately instead of publishing it. + if (_stopHotReloadAfterStart) + { + shouldStop = true; + _stopHotReloadAfterStart = false; + } + else + { + _hotReload = hotReload; + hotReload = null; + } + } + finally + { + _isStartingHotReload = false; + } + } + + if (shouldStop) + { + hotReload?.UnRegister(); + } + } + catch + { + lock (_stateGate) + { + _isStartingHotReload = false; + _stopHotReloadAfterStart = false; + } + + hotReload?.UnRegister(); + throw; + } + } + + /// + /// 停止开发期热重载并释放监听资源。 + /// 该方法是幂等的,允许启动层在销毁阶段无条件调用。 + /// + public void StopHotReload() + { + IUnRegister? hotReload; + lock (_stateGate) + { + if (_isStartingHotReload && _hotReload == null) + { + _stopHotReloadAfterStart = true; + return; + } + + hotReload = _hotReload; + _hotReload = null; + } + + hotReload?.UnRegister(); + } + + /// + /// 停止热重载并释放当前帮助器持有的资源。 + /// + public void Dispose() + { + IUnRegister? hotReload; + lock (_stateGate) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_isStartingHotReload && _hotReload == null) + { + _stopHotReloadAfterStart = true; + } + + hotReload = _hotReload; + _hotReload = null; + } + + hotReload?.UnRegister(); + } + + private void ThrowIfDisposedCore() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(GameConfigBootstrap)); + } + } +} diff --git a/GFramework.Game/Config/GameConfigBootstrapOptions.cs b/GFramework.Game/Config/GameConfigBootstrapOptions.cs new file mode 100644 index 00000000..bb517c14 --- /dev/null +++ b/GFramework.Game/Config/GameConfigBootstrapOptions.cs @@ -0,0 +1,41 @@ +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 描述官方配置启动帮助器的初始化约定。 +/// 该选项对象把配置根目录、表注册回调和热重载策略收敛到一个稳定入口, +/// 让消费项目不必在多个启动脚本里重复拼装加载器细节。 +/// +public sealed class GameConfigBootstrapOptions +{ + /// + /// 获取或设置配置根目录。 + /// 该路径会直接传给 作为 YAML 与 schema 的共同根目录。 + /// + public string RootPath { get; init; } = string.Empty; + + /// + /// 获取或设置用于配置 的回调。 + /// 调用方通常应在这里调用生成器产出的 RegisterAllGeneratedConfigTables(), + /// 或显式注册当前场景所需的手写表定义。 + /// + public Action? ConfigureLoader { get; init; } + + /// + /// 获取或设置要复用的配置注册表。 + /// 为空时启动帮助器会创建默认的 实例。 + /// + public IConfigRegistry? Registry { get; init; } + + /// + /// 获取或设置是否在初次加载成功后立即启用开发期热重载。 + /// + public bool EnableHotReload { get; init; } + + /// + /// 获取或设置初始化阶段启用热重载时使用的选项。 + /// 当 时,该值会被忽略。 + /// + public YamlConfigHotReloadOptions? HotReloadOptions { get; init; } +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index a53fccab..f435c10a 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using GFramework.Game.Abstractions.Config; namespace GFramework.Game.Config; @@ -9,6 +10,10 @@ namespace GFramework.Game.Config; /// internal static class YamlConfigSchemaValidator { + // The runtime intentionally uses the same culture-invariant regex semantics as the + // JS tooling so grouping and backreferences behave consistently across environments. + private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant; + /// /// 从磁盘加载并解析一个 JSON Schema 文件。 /// @@ -297,6 +302,7 @@ internal static class YamlConfigSchemaValidator referenceTableName: null, allowedValues: null, constraints: null, + arrayConstraints: null, schemaPath); } @@ -365,6 +371,7 @@ internal static class YamlConfigSchemaValidator referenceTableName: null, allowedValues: null, constraints: null, + arrayConstraints: ParseArrayConstraints(tableName, schemaPath, propertyPath, element), schemaPath); } @@ -395,6 +402,7 @@ internal static class YamlConfigSchemaValidator referenceTableName, ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), + arrayConstraints: null, schemaPath); } @@ -580,6 +588,11 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(displayPath)); } + if (schemaNode.ArrayConstraints is not null) + { + ValidateArrayConstraints(tableName, yamlPath, displayPath, sequenceNode.Children.Count, schemaNode); + } + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { ValidateNode( @@ -739,8 +752,10 @@ internal static class YamlConfigSchemaValidator } /// - /// 解析标量字段支持的范围与长度约束。 - /// 当前共享子集只支持 `integer/number` 上的 `minimum/maximum` 和 `string` 上的 `minLength/maxLength`。 + /// 解析标量字段支持的范围、长度与模式约束。 + /// 当前共享子集支持: + /// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`, + /// 以及 `string` 上的 `minLength/maxLength/pattern`。 /// /// 所属配置表名称。 /// Schema 文件路径。 @@ -757,8 +772,13 @@ internal static class YamlConfigSchemaValidator { var minimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minimum"); var maximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maximum"); + var exclusiveMinimum = + TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMinimum"); + var exclusiveMaximum = + TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMaximum"); var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength"); var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength"); + var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType); if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value) { @@ -770,6 +790,15 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } + ValidateNumericConstraintRange( + tableName, + schemaPath, + propertyPath, + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum); + if (minLength.HasValue && maxLength.HasValue && minLength.Value > maxLength.Value) { throw ConfigLoadExceptionFactory.Create( @@ -780,12 +809,62 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - if (!minimum.HasValue && !maximum.HasValue && !minLength.HasValue && !maxLength.HasValue) + if (!minimum.HasValue && + !maximum.HasValue && + !exclusiveMinimum.HasValue && + !exclusiveMaximum.HasValue && + !minLength.HasValue && + !maxLength.HasValue && + pattern is null) { return null; } - return new YamlConfigScalarConstraints(minimum, maximum, minLength, maxLength); + return new YamlConfigScalarConstraints( + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + minLength, + maxLength, + pattern, + pattern is null + ? null + : new Regex( + pattern, + SupportedPatternRegexOptions)); + } + + /// + /// 解析数组节点支持的元素数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 数组字段路径。 + /// Schema 节点。 + /// 数组约束模型;未声明时返回空。 + private static YamlConfigArrayConstraints? ParseArrayConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems"); + var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems"); + + if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minItems' greater than 'maxItems'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return !minItems.HasValue && !maxItems.HasValue + ? null + : new YamlConfigArrayConstraints(minItems, maxItems); } /// @@ -886,6 +965,180 @@ internal static class YamlConfigSchemaValidator return constraintValue; } + /// + /// 读取字符串正则约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 字段类型。 + /// 正则模式;未声明时返回空。 + private static string? TryParsePatternConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType) + { + if (!element.TryGetProperty("pattern", out var patternElement)) + { + return null; + } + + if (nodeType != YamlConfigSchemaPropertyType.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'pattern', but only 'string' scalar types support regular-expression constraints.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (patternElement.ValueKind != JsonValueKind.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'pattern' as a string.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var pattern = patternElement.GetString() ?? string.Empty; + try + { + _ = new Regex(pattern, SupportedPatternRegexOptions); + } + catch (ArgumentException exception) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares an invalid 'pattern' regular expression.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath), + rawValue: pattern, + innerException: exception); + } + + return pattern; + } + + /// + /// 读取数组元素数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 关键字名称。 + /// 数组元素数量约束;未声明时返回空。 + private static int? TryParseArrayLengthConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + string keywordName) + { + if (!element.TryGetProperty(keywordName, out var constraintElement)) + { + return null; + } + + if (constraintElement.ValueKind != JsonValueKind.Number || + !constraintElement.TryGetInt32(out var constraintValue) || + constraintValue < 0) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return constraintValue; + } + + /// + /// 校验数值上下界组合不会形成空区间。 + /// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// 闭区间最小值。 + /// 闭区间最大值。 + /// 开区间最小值。 + /// 开区间最大值。 + private static void ValidateNumericConstraintRange( + string tableName, + string schemaPath, + string propertyPath, + double? minimum, + double? maximum, + double? exclusiveMinimum, + double? exclusiveMaximum) + { + var hasLowerBound = false; + var lowerBound = double.MinValue; + var isLowerBoundExclusive = false; + + if (minimum.HasValue) + { + hasLowerBound = true; + lowerBound = minimum.Value; + } + + if (exclusiveMinimum.HasValue && + (!hasLowerBound || + exclusiveMinimum.Value > lowerBound || + (exclusiveMinimum.Value.Equals(lowerBound) && !isLowerBoundExclusive))) + { + hasLowerBound = true; + lowerBound = exclusiveMinimum.Value; + isLowerBoundExclusive = true; + } + + var hasUpperBound = false; + var upperBound = double.MaxValue; + var isUpperBoundExclusive = false; + + if (maximum.HasValue) + { + hasUpperBound = true; + upperBound = maximum.Value; + } + + if (exclusiveMaximum.HasValue && + (!hasUpperBound || + exclusiveMaximum.Value < upperBound || + (exclusiveMaximum.Value.Equals(upperBound) && !isUpperBoundExclusive))) + { + hasUpperBound = true; + upperBound = exclusiveMaximum.Value; + isUpperBoundExclusive = true; + } + + if (!hasLowerBound || !hasUpperBound) + { + return; + } + + if (lowerBound > upperBound || + (lowerBound.Equals(upperBound) && (isLowerBoundExclusive || isUpperBoundExclusive))) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares numeric constraints that do not leave any valid value range.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + } + /// /// 校验标量值是否满足范围与长度约束。 /// @@ -943,6 +1196,20 @@ internal static class YamlConfigSchemaValidator $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}."); } + if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be greater than {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: + $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value) { throw ConfigLoadExceptionFactory.Create( @@ -957,6 +1224,20 @@ internal static class YamlConfigSchemaValidator $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}."); } + if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be less than {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: + $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + return; case YamlConfigSchemaPropertyType.String: @@ -988,6 +1269,20 @@ internal static class YamlConfigSchemaValidator detail: $"Maximum length: {constraints.MaxLength.Value}."); } + if (constraints.PatternRegex is not null && + !constraints.PatternRegex.IsMatch(rawValue)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must match regular expression '{constraints.Pattern}', but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Expected pattern: {constraints.Pattern}."); + } + return; default: @@ -1002,6 +1297,54 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验数组值是否满足元素数量约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 当前数组元素数量。 + /// 数组 schema 节点。 + private static void ValidateArrayConstraints( + string tableName, + string yamlPath, + string displayPath, + int itemCount, + YamlConfigSchemaNode schemaNode) + { + var constraints = schemaNode.ArrayConstraints; + if (constraints is null) + { + return; + } + + if (constraints.MinItems.HasValue && itemCount < constraints.MinItems.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must contain at least {constraints.MinItems.Value} items, but the current YAML sequence contains {itemCount}.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: itemCount.ToString(CultureInfo.InvariantCulture), + detail: $"Minimum item count: {constraints.MinItems.Value}."); + } + + if (constraints.MaxItems.HasValue && itemCount > constraints.MaxItems.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must contain at most {constraints.MaxItems.Value} items, but the current YAML sequence contains {itemCount}.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: itemCount.ToString(CultureInfo.InvariantCulture), + detail: $"Maximum item count: {constraints.MaxItems.Value}."); + } + } + /// /// 解析跨表引用目标表名称。 /// @@ -1323,6 +1666,7 @@ internal sealed class YamlConfigSchemaNode /// 目标引用表名称。 /// 标量允许值集合。 /// 标量范围与长度约束。 + /// 数组元素数量约束。 /// 用于错误信息的 schema 文件路径提示。 public YamlConfigSchemaNode( YamlConfigSchemaPropertyType nodeType, @@ -1332,6 +1676,7 @@ internal sealed class YamlConfigSchemaNode string? referenceTableName, IReadOnlyCollection? allowedValues, YamlConfigScalarConstraints? constraints, + YamlConfigArrayConstraints? arrayConstraints, string schemaPathHint) { NodeType = nodeType; @@ -1341,6 +1686,7 @@ internal sealed class YamlConfigSchemaNode ReferenceTableName = referenceTableName; AllowedValues = allowedValues; Constraints = constraints; + ArrayConstraints = arrayConstraints; SchemaPathHint = schemaPathHint; } @@ -1379,6 +1725,11 @@ internal sealed class YamlConfigSchemaNode /// public YamlConfigScalarConstraints? Constraints { get; } + /// + /// 获取数组元素数量约束;未声明时返回空。 + /// + public YamlConfigArrayConstraints? ArrayConstraints { get; } + /// /// 获取用于诊断显示的 schema 路径提示。 /// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。 @@ -1401,6 +1752,7 @@ internal sealed class YamlConfigSchemaNode referenceTableName, AllowedValues, Constraints, + ArrayConstraints, SchemaPathHint); } } @@ -1416,18 +1768,30 @@ internal sealed class YamlConfigScalarConstraints /// /// 最小值约束。 /// 最大值约束。 + /// 开区间最小值约束。 + /// 开区间最大值约束。 /// 最小长度约束。 /// 最大长度约束。 + /// 正则模式约束。 + /// 已编译的正则表达式。 public YamlConfigScalarConstraints( double? minimum, double? maximum, + double? exclusiveMinimum, + double? exclusiveMaximum, int? minLength, - int? maxLength) + int? maxLength, + string? pattern, + Regex? patternRegex) { Minimum = minimum; Maximum = maximum; + ExclusiveMinimum = exclusiveMinimum; + ExclusiveMaximum = exclusiveMaximum; MinLength = minLength; MaxLength = maxLength; + Pattern = pattern; + PatternRegex = patternRegex; } /// @@ -1440,6 +1804,16 @@ internal sealed class YamlConfigScalarConstraints /// public double? Maximum { get; } + /// + /// 获取开区间最小值约束。 + /// + public double? ExclusiveMinimum { get; } + + /// + /// 获取开区间最大值约束。 + /// + public double? ExclusiveMaximum { get; } + /// /// 获取最小长度约束。 /// @@ -1449,6 +1823,44 @@ internal sealed class YamlConfigScalarConstraints /// 获取最大长度约束。 /// public int? MaxLength { get; } + + /// + /// 获取正则模式约束原文。 + /// + public string? Pattern { get; } + + /// + /// 获取已编译的正则表达式。 + /// + public Regex? PatternRegex { get; } +} + +/// +/// 表示一个数组节点上声明的元素数量约束。 +/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。 +/// +internal sealed class YamlConfigArrayConstraints +{ + /// + /// 初始化数组约束模型。 + /// + /// 最小元素数量约束。 + /// 最大元素数量约束。 + public YamlConfigArrayConstraints(int? minItems, int? maxItems) + { + MinItems = minItems; + MaxItems = maxItems; + } + + /// + /// 获取最小元素数量约束。 + /// + public int? MinItems { get; } + + /// + /// 获取最大元素数量约束。 + /// + public int? MaxItems { get; } } /// @@ -1558,4 +1970,4 @@ internal enum YamlConfigSchemaPropertyType /// 数组类型。 /// Array -} \ No newline at end of file +} diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 9c375432..8dd98ed5 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -81,6 +81,7 @@ public class SchemaConfigGeneratorSnapshotTests "description": "Localized monster display name.", "minLength": 3, "maxLength": 16, + "pattern": "^[A-Z][a-z]+$", "default": "Slime", "enum": ["Slime", "Goblin"] }, @@ -88,11 +89,15 @@ public class SchemaConfigGeneratorSnapshotTests "type": "integer", "minimum": 1, "maximum": 999, + "exclusiveMinimum": 0, + "exclusiveMaximum": 1000, "default": 10 }, "dropItems": { "description": "Referenced drop ids.", "type": "array", + "minItems": 1, + "maxItems": 3, "items": { "type": "string", "minLength": 3, diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index 482d016a..2306fa00 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -24,7 +24,7 @@ public sealed partial class MonsterConfig /// Schema property path: 'name'. /// Display title: 'Monster Name'. /// Allowed values: Slime, Goblin. - /// Constraints: minLength = 3, maxLength = 16. + /// Constraints: minLength = 3, maxLength = 16, pattern = '^[A-Z][a-z]+$'. /// Generated default initializer: = "Slime"; /// public string Name { get; set; } = "Slime"; @@ -34,7 +34,7 @@ public sealed partial class MonsterConfig /// /// /// Schema property path: 'hp'. - /// Constraints: minimum = 1, maximum = 999. + /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000. /// Generated default initializer: = 10; /// public int? Hp { get; set; } = 10; @@ -45,6 +45,7 @@ public sealed partial class MonsterConfig /// /// Schema property path: 'dropItems'. /// Allowed values: potion, slime_gel. + /// Constraints: minItems = 1, maxItems = 3. /// References config table: 'item'. /// Item constraints: minLength = 3, maxLength = 12. /// Generated default initializer: = new string[] { "potion" }; diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 38eec75f..cd4e1392 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -477,7 +477,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? $" = global::System.Array.Empty<{itemClrType}>();", TryBuildEnumDocumentation(itemsElement, itemType), - null, + TryBuildConstraintDocumentation(property.Value, "array"), refTableName, null, new SchemaTypeSpec( @@ -527,7 +527,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", $" = global::System.Array.Empty<{objectSpec.ClassName}>();", null, - null, + TryBuildConstraintDocumentation(property.Value, "array"), null, null, new SchemaTypeSpec( @@ -1876,7 +1876,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围与长度约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -1891,12 +1891,24 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"minimum = {minimum.ToString(CultureInfo.InvariantCulture)}"); } + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "exclusiveMinimum", out var exclusiveMinimum)) + { + parts.Add($"exclusiveMinimum = {exclusiveMinimum.ToString(CultureInfo.InvariantCulture)}"); + } + if ((schemaType == "integer" || schemaType == "number") && TryGetFiniteNumber(element, "maximum", out var maximum)) { parts.Add($"maximum = {maximum.ToString(CultureInfo.InvariantCulture)}"); } + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "exclusiveMaximum", out var exclusiveMaximum)) + { + parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}"); + } + if (schemaType == "string" && TryGetNonNegativeInt32(element, "minLength", out var minLength)) { @@ -1909,6 +1921,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"maxLength = {maxLength.ToString(CultureInfo.InvariantCulture)}"); } + if (schemaType == "string" && + element.TryGetProperty("pattern", out var patternElement) && + patternElement.ValueKind == JsonValueKind.String) + { + parts.Add($"pattern = '{patternElement.GetString() ?? string.Empty}'"); + } + + if (schemaType == "array" && + TryGetNonNegativeInt32(element, "minItems", out var minItems)) + { + parts.Add($"minItems = {minItems.ToString(CultureInfo.InvariantCulture)}"); + } + + if (schemaType == "array" && + TryGetNonNegativeInt32(element, "maxItems", out var maxItems)) + { + parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}"); + } + return parts.Count > 0 ? string.Join(", ", parts) : null; } diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index d2f7a7f1..e52ae0c5 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`minLength`、`maxLength` +- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -92,7 +92,7 @@ dropItems: GameProject/ ├─ GameProject.csproj ├─ Config/ -│ ├─ GameConfigBootstrap.cs +│ ├─ GameConfigHost.cs │ └─ GameConfigRuntime.cs ├─ config/ │ ├─ monster/ @@ -142,7 +142,7 @@ GameProject/ 这段配置的作用: -- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry` 和只读表实现 +- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap` 和只读表实现 - 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目 - `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles` @@ -160,12 +160,51 @@ GameProject/ ``` -### 启动引导模板 +### 官方启动帮助器 -推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中: +`GFramework.Game` 现在内置 `GameConfigBootstrap` 与 `GameConfigBootstrapOptions`,用于把 `ConfigRegistry`、`YamlConfigLoader`、`LoadAsync` 和热重载句柄收敛到一个正式的 C# 入口中。 + +推荐直接组合这个帮助器,而不是继续在消费者项目里复制文档模板: + +```csharp +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; +using GFramework.Game.Config.Generated; + +var bootstrap = new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = configRootPath, + ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables(), + EnableHotReload = true, + HotReloadOptions = new YamlConfigHotReloadOptions + { + OnTableReloaded = tableName => Console.WriteLine($"Reloaded config table: {tableName}"), + OnTableReloadFailed = static (_, exception) => + { + var diagnostic = (exception as ConfigLoadException)?.Diagnostic; + Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}"); + } + } + }); + +await bootstrap.InitializeAsync(); + +var registry = bootstrap.Registry; +var monsterTable = registry.GetMonsterTable(); +var slime = monsterTable.Get(1); + +bootstrap.Dispose(); +``` + +如果你希望把它继续包装进自己的进程级入口,也建议只包一层生命周期壳,而不是重新拼装底层加载器。为了避免和后面的“运行时读取模板”冲突,推荐明确拆成两类文件: + +- `GameConfigHost.cs` 负责生命周期管理、初始化和热重载 +- `GameConfigRuntime.cs` 负责把已初始化的 `IConfigRegistry` 封装成业务层读取入口 + +如果你采用这套双层模板,建议把上面的生命周期壳文件命名为 `GameConfigHost.cs`,并把类型名同步改成 `GameConfigHost`: ```csharp -using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; @@ -173,64 +212,71 @@ using GFramework.Game.Config.Generated; namespace GameProject.Config; /// -/// 负责初始化游戏内容配置运行时入口。 +/// 封装当前游戏进程的配置启动生命周期。 /// -public sealed class GameConfigBootstrap : IDisposable +public sealed class GameConfigHost : IDisposable { - private readonly ConfigRegistry _registry = new(); - private IUnRegister? _hotReload; + private readonly GameConfigBootstrap _bootstrap; /// - /// 获取当前游戏进程共享的配置注册表。 - /// - public IConfigRegistry Registry => _registry; - - /// - /// 从指定配置根目录加载所有已注册配置表。 + /// 使用指定配置根目录创建运行时入口。 /// /// 配置根目录。 - /// 是否启用开发期热重载。 - public async Task InitializeAsync(string configRootPath, bool enableHotReload = false) + public GameConfigHost(string configRootPath) { - var loader = new YamlConfigLoader(configRootPath) - .RegisterAllGeneratedConfigTables(); - - await loader.LoadAsync(_registry); - - if (enableHotReload) - { - _hotReload = loader.EnableHotReload( - _registry, - onTableReloaded: tableName => Console.WriteLine($"Reloaded config table: {tableName}"), - onTableReloadFailed: static (_, exception) => - { - var diagnostic = (exception as ConfigLoadException)?.Diagnostic; - Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}"); - }); - } + _bootstrap = new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = configRootPath, + ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables() + }); } /// - /// 停止开发期热重载并释放相关资源。 + /// 获取共享配置注册表。 + /// + public IConfigRegistry Registry => _bootstrap.Registry; + + /// + /// 执行初次配置加载。 + /// + public Task InitializeAsync() + { + return _bootstrap.InitializeAsync(); + } + + /// + /// 创建业务层使用的只读配置入口。 + /// + /// 封装强类型表访问的读取入口。 + public GameConfigRuntime CreateRuntime() + { + return new GameConfigRuntime(_bootstrap.Registry); + } + + /// + /// 释放底层热重载句柄等资源。 /// public void Dispose() { - _hotReload?.UnRegister(); + _bootstrap.Dispose(); } } ``` -这段模板刻意遵循几个约定: +这个官方帮助器刻意遵循几个约定: -- 优先使用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口 -- 由一个长生命周期对象持有 `ConfigRegistry` -- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏 +- 优先通过 `ConfigureLoader` 调用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口 +- 由 `GameConfigBootstrap` 持有 `ConfigRegistry`、`YamlConfigLoader` 和热重载句柄 +- `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层 +- 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)` ### 运行时读取模板 推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口: ```csharp +using GFramework.Game.Abstractions.Config; using GFramework.Game.Config.Generated; namespace GameProject.Config; @@ -272,6 +318,16 @@ public sealed class GameConfigRuntime } ``` +它通常与上面的 `GameConfigHost` 配合使用: + +```csharp +var configHost = new GameConfigHost("config-root"); +await configHost.InitializeAsync(); + +var runtime = configHost.CreateRuntime(); +var slime = runtime.GetMonster(1); +``` + 这样做的收益: - 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string @@ -309,30 +365,38 @@ if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster)) ### Architecture 推荐接入模板 -如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `ConfigRegistry` 注册为 utility: +如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `GameConfigBootstrap.Registry` 注册为 utility: ```csharp using GFramework.Core.Architectures; +using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; public sealed class GameArchitecture : Architecture { - private readonly string _configRootPath; + private readonly GameConfigBootstrap _configBootstrap; public GameArchitecture(string configRootPath) { - _configRootPath = configRootPath ?? throw new ArgumentNullException(nameof(configRootPath)); + _configBootstrap = new GameConfigBootstrap( + new GameConfigBootstrapOptions + { + RootPath = configRootPath, + ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables() + }); } protected override void OnInitialize() { - var registry = RegisterUtility(new ConfigRegistry()); + RegisterUtility(_configBootstrap.Registry); + _configBootstrap.InitializeAsync().GetAwaiter().GetResult(); + } - var loader = new YamlConfigLoader(_configRootPath) - .RegisterAllGeneratedConfigTables(); - - loader.LoadAsync(registry).GetAwaiter().GetResult(); + public override async ValueTask DestroyAsync() + { + _configBootstrap.Dispose(); + await base.DestroyAsync(); } } ``` @@ -340,35 +404,40 @@ public sealed class GameArchitecture : Architecture 初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口: ```csharp -var registry = Context.GetUtility(); +var registry = Context.GetUtility(); var monsterTable = registry.GetMonsterTable(); var slime = monsterTable.Get(1); ``` 推荐遵循以下顺序: -- 先注册 `ConfigRegistry` -- 再构造并配置 `YamlConfigLoader` -- 在 `OnInitialize()` 内完成首次 `LoadAsync` +- 先构造 `GameConfigBootstrap` +- 在 `OnInitialize()` 里注册 `bootstrap.Registry` +- 再调用 `bootstrap.InitializeAsync()` 完成首次加载 +- 架构销毁时释放 `GameConfigBootstrap` - 初始化完成后只通过注册表和生成表包装访问配置 -当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。 +当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + GameConfigBootstrap + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。 ### 热重载模板 -如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本: +如果你希望把开发期热重载显式收敛为一个可选能力,推荐直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外: ```csharp -var hotReload = loader.EnableHotReload( - registry, - onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"), - onTableReloadFailed: (tableName, exception) => +await bootstrap.InitializeAsync(); + +bootstrap.StartHotReload( + new YamlConfigHotReloadOptions { - var diagnostic = (exception as ConfigLoadException)?.Diagnostic; - Console.WriteLine($"Reload failed: {tableName}"); - Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}"); - Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}"); - Console.WriteLine($"Display path: {diagnostic?.DisplayPath}"); + OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"), + OnTableReloadFailed = (tableName, exception) => + { + var diagnostic = (exception as ConfigLoadException)?.Diagnostic; + Console.WriteLine($"Reload failed: {tableName}"); + Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}"); + Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}"); + Console.WriteLine($"Display path: {diagnostic?.DisplayPath}"); + } }); ``` @@ -378,22 +447,7 @@ var hotReload = loader.EnableHotReload( - 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示 - 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串 -如果你后续还需要为热重载增加更多开关,推荐优先使用选项对象入口,而不是继续叠加位置参数: - -```csharp -var hotReload = loader.EnableHotReload( - registry, - new YamlConfigHotReloadOptions - { - OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"), - OnTableReloadFailed = (tableName, exception) => - { - var diagnostic = (exception as ConfigLoadException)?.Diagnostic; - Console.WriteLine($"{tableName}: {diagnostic?.FailureKind}"); - }, - DebounceDelay = TimeSpan.FromMilliseconds(150) - }); -``` +如果你确实需要直接控制底层加载器,`YamlConfigLoader.EnableHotReload(...)` 仍然保留;但在一般启动路径下,优先让 `GameConfigBootstrap` 持有并停止监听句柄。 ## 运行时接入 @@ -524,7 +578,10 @@ var loader = new YamlConfigLoader("config-root") - 嵌套对象字段类型不匹配 - 对象数组元素结构不匹配 - 数值字段违反 `minimum` / `maximum` +- 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum` - 字符串字段违反 `minLength` / `maxLength` +- 字符串字段违反 `pattern` +- 数组字段违反 `minItems` / `maxItems` - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -573,7 +630,10 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `default`:供生成类型属性初始值和工具提示复用 - `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 +- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 +- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错 +- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 31467f78..e80a559e 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -12,6 +12,7 @@ const {ValidationMessageKeys} = require("./localizationKeys"); * runtime validator and source generator so tooling diagnostics stay aligned. * * @param {string} content Raw schema JSON text. + * @throws {Error} Thrown when the schema declares one unsupported or invalid pattern string. * @returns {{ * type: "object", * required: string[], @@ -379,6 +380,28 @@ function normalizeSchemaNonNegativeInteger(value) { return Number.isInteger(value) && value >= 0 ? value : undefined; } +/** + * Normalize one schema pattern string when the regular expression can be + * compiled by the local tooling runtime. + * + * @param {unknown} value Raw schema value. + * @param {string} displayPath Logical property path used in diagnostics. + * @throws {Error} Thrown when the pattern string cannot be compiled. + * @returns {string | undefined} Normalized pattern string. + */ +function normalizeSchemaPattern(value, displayPath) { + if (typeof value !== "string") { + return undefined; + } + + try { + void new RegExp(value); + return value; + } catch (error) { + throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); + } +} + /** * Convert a schema default value into a compact string that can be shown in UI * metadata hints. @@ -410,6 +433,27 @@ function formatSchemaDefaultValue(value) { return undefined; } +/** + * Test one scalar value against one schema pattern string. + * + * @param {string} scalarValue Scalar value from YAML. + * @param {string | undefined} pattern Schema pattern string. + * @param {string} displayPath Logical property path used in diagnostics. + * @throws {Error} Thrown when the pattern string cannot be compiled. + * @returns {boolean} True when the value matches or no pattern is declared. + */ +function matchesSchemaPattern(scalarValue, pattern, displayPath) { + if (typeof pattern !== "string") { + return true; + } + + try { + return new RegExp(pattern).test(scalarValue); + } catch (error) { + throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); + } +} + /** * Format a scalar value for YAML output. * @@ -458,9 +502,14 @@ function parseSchemaNode(rawNode, displayPath) { description: typeof value.description === "string" ? value.description : undefined, defaultValue: formatSchemaDefaultValue(value.default), minimum: normalizeSchemaNumber(value.minimum), + exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum), maximum: normalizeSchemaNumber(value.maximum), + exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), + pattern: normalizeSchemaPattern(value.pattern, displayPath), + minItems: normalizeSchemaNonNegativeInteger(value.minItems), + maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), refTable: typeof value["x-gframework-ref-table"] === "string" ? value["x-gframework-ref-table"] : undefined @@ -494,6 +543,8 @@ function parseSchemaNode(rawNode, displayPath) { title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, + minItems: metadata.minItems, + maxItems: metadata.maxItems, refTable: metadata.refTable, items: itemNode }; @@ -508,15 +559,24 @@ function parseSchemaNode(rawNode, displayPath) { minimum: type === "integer" || type === "number" ? metadata.minimum : undefined, + exclusiveMinimum: type === "integer" || type === "number" + ? metadata.exclusiveMinimum + : undefined, maximum: type === "integer" || type === "number" ? metadata.maximum : undefined, + exclusiveMaximum: type === "integer" || type === "number" + ? metadata.exclusiveMaximum + : undefined, minLength: type === "string" ? metadata.minLength : undefined, maxLength: type === "string" ? metadata.maxLength : undefined, + pattern: type === "string" + ? metadata.pattern + : undefined, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }; @@ -548,6 +608,28 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) return; } + if (typeof schemaNode.minItems === "number" && + yamlNode.items.length < schemaNode.minItems) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.minItemsViolation, localizer, { + displayPath, + value: String(schemaNode.minItems) + }) + }); + } + + if (typeof schemaNode.maxItems === "number" && + yamlNode.items.length > schemaNode.maxItems) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.maxItemsViolation, localizer, { + displayPath, + value: String(schemaNode.maxItems) + }) + }); + } + for (let index = 0; index < yamlNode.items.length; index += 1) { validateNode( schemaNode.items, @@ -597,6 +679,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) const scalarValue = unquoteScalar(yamlNode.value); const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number"; const supportsLengthConstraints = schemaNode.type === "string"; + const supportsPatternConstraints = schemaNode.type === "string"; if (supportsNumericConstraints && typeof schemaNode.minimum === "number" && @@ -610,6 +693,18 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + if (supportsNumericConstraints && + typeof schemaNode.exclusiveMinimum === "number" && + Number(scalarValue) <= schemaNode.exclusiveMinimum) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.exclusiveMinimumViolation, localizer, { + displayPath, + value: String(schemaNode.exclusiveMinimum) + }) + }); + } + if (supportsNumericConstraints && typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) { @@ -622,6 +717,18 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + if (supportsNumericConstraints && + typeof schemaNode.exclusiveMaximum === "number" && + Number(scalarValue) >= schemaNode.exclusiveMaximum) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.exclusiveMaximumViolation, localizer, { + displayPath, + value: String(schemaNode.exclusiveMaximum) + }) + }); + } + if (supportsLengthConstraints && typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { @@ -645,6 +752,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }) }); } + + if (supportsPatternConstraints && + !matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, { + displayPath, + value: schemaNode.pattern + }) + }); + } } /** @@ -729,14 +847,24 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; case ValidationMessageKeys.enumMismatch: return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; + case ValidationMessageKeys.exclusiveMaximumViolation: + return `属性“${params.displayPath}”必须小于 ${params.value}。`; + case ValidationMessageKeys.exclusiveMinimumViolation: + return `属性“${params.displayPath}”必须大于 ${params.value}。`; case ValidationMessageKeys.maximumViolation: return `属性“${params.displayPath}”必须小于或等于 ${params.value}。`; + case ValidationMessageKeys.maxItemsViolation: + return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`; case ValidationMessageKeys.maxLengthViolation: return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; case ValidationMessageKeys.minimumViolation: return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; + case ValidationMessageKeys.minItemsViolation: + return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; case ValidationMessageKeys.minLengthViolation: return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; + case ValidationMessageKeys.patternViolation: + return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -757,14 +885,24 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; case ValidationMessageKeys.enumMismatch: return `Property '${params.displayPath}' must be one of: ${params.values}.`; + case ValidationMessageKeys.exclusiveMaximumViolation: + return `Property '${params.displayPath}' must be less than ${params.value}.`; + case ValidationMessageKeys.exclusiveMinimumViolation: + return `Property '${params.displayPath}' must be greater than ${params.value}.`; case ValidationMessageKeys.maximumViolation: return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`; + case ValidationMessageKeys.maxItemsViolation: + return `Property '${params.displayPath}' must contain at most ${params.value} items.`; case ValidationMessageKeys.maxLengthViolation: return `Property '${params.displayPath}' must be at most ${params.value} characters long.`; case ValidationMessageKeys.minimumViolation: return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; + case ValidationMessageKeys.minItemsViolation: + return `Property '${params.displayPath}' must contain at least ${params.value} items.`; case ValidationMessageKeys.minLengthViolation: return `Property '${params.displayPath}' must be at least ${params.value} characters long.`; + case ValidationMessageKeys.patternViolation: + return `Property '${params.displayPath}' must match pattern '${params.value}'.`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -1418,6 +1556,8 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * minItems?: number, + * maxItems?: number, * refTable?: string, * items: SchemaNode * } | { @@ -1426,6 +1566,13 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * minimum?: number, + * exclusiveMinimum?: number, + * maximum?: number, + * exclusiveMaximum?: number, + * minLength?: number, + * maxLength?: number, + * pattern?: string, * enumValues?: string[], * refTable?: string * }} SchemaNode diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 5268bea3..870f01e7 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) { /** * Render human-facing metadata hints for one schema field. * - * @param {{description?: string, defaultValue?: string, minimum?: number, maximum?: number, minLength?: number, maxLength?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, maximum?: number, minLength?: number, maxLength?: number}, refTable?: string}} propertySchema Property schema metadata. + * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @returns {string} HTML fragment. */ @@ -1602,10 +1602,18 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.minimum", {value: propertySchema.minimum}))); } + if (!isArrayField && typeof propertySchema.exclusiveMinimum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMinimum", {value: propertySchema.exclusiveMinimum}))); + } + if (!isArrayField && typeof propertySchema.maximum === "number") { hints.push(escapeHtml(localizer.t("webview.hint.maximum", {value: propertySchema.maximum}))); } + if (!isArrayField && typeof propertySchema.exclusiveMaximum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum}))); + } + if (!isArrayField && typeof propertySchema.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength}))); } @@ -1614,14 +1622,34 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.maxLength", {value: propertySchema.maxLength}))); } + if (!isArrayField && propertySchema.pattern) { + hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern}))); + } + + if (isArrayField && typeof propertySchema.minItems === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems}))); + } + + if (isArrayField && typeof propertySchema.maxItems === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems}))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum}))); } + if (isArrayField && propertySchema.items && typeof propertySchema.items.exclusiveMinimum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMinimum", {value: propertySchema.items.exclusiveMinimum}))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.maximum === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMaximum", {value: propertySchema.items.maximum}))); } + if (isArrayField && propertySchema.items && typeof propertySchema.items.exclusiveMaximum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum}))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength}))); } @@ -1630,6 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.itemMaxLength", {value: propertySchema.items.maxLength}))); } + if (isArrayField && propertySchema.items && propertySchema.items.pattern) { + hints.push(escapeHtml(localizer.t("webview.hint.itemPattern", {value: propertySchema.items.pattern}))); + } + if (propertySchema.refTable) { hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 6c25487b..b73c3848 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -106,22 +106,35 @@ const enMessages = { "webview.hint.default": "Default: {value}", "webview.hint.allowed": "Allowed: {values}", "webview.hint.minimum": "Minimum: {value}", + "webview.hint.exclusiveMinimum": "Exclusive minimum: {value}", "webview.hint.maximum": "Maximum: {value}", + "webview.hint.exclusiveMaximum": "Exclusive maximum: {value}", "webview.hint.minLength": "Min length: {value}", "webview.hint.maxLength": "Max length: {value}", + "webview.hint.pattern": "Pattern: {value}", + "webview.hint.minItems": "Min items: {value}", + "webview.hint.maxItems": "Max items: {value}", "webview.hint.itemMinimum": "Item minimum: {value}", + "webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}", "webview.hint.itemMaximum": "Item maximum: {value}", + "webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}", "webview.hint.itemMinLength": "Item min length: {value}", "webview.hint.itemMaxLength": "Item max length: {value}", + "webview.hint.itemPattern": "Item pattern: {value}", "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.", "webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.", "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.", + [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}.", + [ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.", [ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", + [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", + [ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.", [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", [ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.", @@ -192,22 +205,35 @@ const zhCnMessages = { "webview.hint.default": "默认值:{value}", "webview.hint.allowed": "允许值:{values}", "webview.hint.minimum": "最小值:{value}", + "webview.hint.exclusiveMinimum": "开区间最小值:{value}", "webview.hint.maximum": "最大值:{value}", + "webview.hint.exclusiveMaximum": "开区间最大值:{value}", "webview.hint.minLength": "最小长度:{value}", "webview.hint.maxLength": "最大长度:{value}", + "webview.hint.pattern": "正则模式:{value}", + "webview.hint.minItems": "最少元素数:{value}", + "webview.hint.maxItems": "最多元素数:{value}", "webview.hint.itemMinimum": "元素最小值:{value}", + "webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}", "webview.hint.itemMaximum": "元素最大值:{value}", + "webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}", "webview.hint.itemMinLength": "元素最小长度:{value}", "webview.hint.itemMaxLength": "元素最大长度:{value}", + "webview.hint.itemPattern": "元素正则模式:{value}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。", "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", + [ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。", + [ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。", [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", + [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。", [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", + [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", + [ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。", [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", [ValidationMessageKeys.expectedObject]: "{subject}", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 785f51ee..2eb991ff 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -1,14 +1,19 @@ const ValidationMessageKeys = Object.freeze({ enumMismatch: "validation.enumMismatch", + exclusiveMaximumViolation: "validation.exclusiveMaximumViolation", + exclusiveMinimumViolation: "validation.exclusiveMinimumViolation", expectedArray: "validation.expectedArray", expectedObject: "validation.expectedObject", expectedScalarShape: "validation.expectedScalarShape", expectedScalarValue: "validation.expectedScalarValue", maximumViolation: "validation.maximumViolation", + maxItemsViolation: "validation.maxItemsViolation", maxLengthViolation: "validation.maxLengthViolation", minimumViolation: "validation.minimumViolation", + minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", missingRequired: "validation.missingRequired", + patternViolation: "validation.patternViolation", unknownProperty: "validation.unknownProperty" }); diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index a88becb6..0c3ebaab 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -231,6 +231,83 @@ tags: assert.match(diagnostics[2].message, /tags\[1\]|shield/u); }); +test("validateParsedConfig should report exclusive bounds, pattern, and array item-count mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Z][a-z]+$" + }, + "hp": { + "type": "integer", + "exclusiveMinimum": 10, + "exclusiveMaximum": 20 + }, + "tags": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "string" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +name: slime +hp: 10 +tags: + - onlyOne +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 3); + assert.match(diagnostics[0].message, /pattern|正则模式/u); + assert.match(diagnostics[1].message, /greater than 10|大于 10/u); + assert.match(diagnostics[2].message, /at least 2 items|至少需要包含 2 个元素/u); +}); + +test("validateParsedConfig should report exclusive maximum and maxItems violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "hp": { + "type": "integer", + "exclusiveMinimum": 10, + "exclusiveMaximum": 20 + }, + "tags": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "string" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +hp: 20 +tags: + - a + - b + - c + - d +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /less than 20|小于 20/u); + assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u); +}); + test("parseSchemaContent should capture scalar range and length metadata", () => { const schema = parseSchemaContent(` { @@ -266,6 +343,58 @@ test("parseSchemaContent should capture scalar range and length metadata", () => assert.equal(schema.properties.tags.items.maxLength, 6); }); +test("parseSchemaContent should capture exclusive bounds, pattern, and array item-count metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Z][a-z]+$" + }, + "hp": { + "type": "integer", + "exclusiveMinimum": 1, + "exclusiveMaximum": 99 + }, + "tags": { + "type": "array", + "minItems": 2, + "maxItems": 4, + "items": { + "type": "string", + "pattern": "^[a-z]+$" + } + } + } + } + `); + + assert.equal(schema.properties.name.pattern, "^[A-Z][a-z]+$"); + assert.equal(schema.properties.hp.exclusiveMinimum, 1); + assert.equal(schema.properties.hp.exclusiveMaximum, 99); + assert.equal(schema.properties.tags.minItems, 2); + assert.equal(schema.properties.tags.maxItems, 4); + assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$"); +}); + +test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "[" + } + } + } + `), + /invalid 'pattern' regular expression/u + ); +}); + test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => { const schema = parseSchemaContent(` {