From bba589a8538f072099710666c68bee041ea9f6e6 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:19:49 +0800 Subject: [PATCH] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CI/CD 工作流配置文件,集成代码质量检查、安全扫描和构建测试 - 详细介绍配置系统架构,包括 YAML 源文件、JSON Schema 结构描述和运行时只读查询 - 提供完整的目录结构推荐和 Schema/JSON 示例配置 - 包含项目接入模板,涵盖 csproj 配置、启动帮助器和运行时读取模板 - 说明运行时校验行为,支持必填字段、类型匹配、数值范围等校验规则 - 介绍开发期热重载功能,支持配置文件变更自动刷新 - 详述生成器接入约定,包括配置类型、表包装和注册辅助生成 - 提供 VS Code 工具使用指南,支持配置浏览、表单编辑和批量操作 - 说明当前系统限制和未来发展规划,明确适用场景 --- .github/workflows/ci.yml | 18 ++ .../Config/GameConfigBootstrapTests.cs | 87 +++++++ .../Config/YamlConfigLoaderTests.cs | 155 ++++++++++++- GFramework.Game/Config/GameConfigBootstrap.cs | 216 +++++++++++++++--- .../Config/YamlConfigSchemaValidator.cs | 8 +- docs/zh-CN/game/config-system.md | 2 +- .../src/configValidation.js | 21 +- .../test/configValidation.test.js | 54 +++++ 8 files changed, 511 insertions(+), 50 deletions(-) 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/GameConfigBootstrapTests.cs b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs index a01d5ef3..56b00149 100644 --- a/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs +++ b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; @@ -126,6 +127,92 @@ public class GameConfigBootstrapTests Assert.That(exception!.ParamName, Is.EqualTo("ConfigureLoader")); } + /// + /// 验证初始化链路进行中时,第二个调用者不会再次进入并发初始化流程。 + /// + [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); + }); + } + /// /// 创建一个使用生成聚合注册入口的官方启动帮助器。 /// diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 8254afe1..059d61aa 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -413,10 +413,10 @@ public class YamlConfigLoaderTests } /// - /// 验证开区间数值边界约束会在运行时被统一拒绝。 + /// 验证数值命中开区间下界时会按 schema 在运行时被拒绝。 /// [Test] - public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum_Or_Exclusive_Maximum() + public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum() { CreateConfigFile( "monster/slime.yaml", @@ -461,6 +461,55 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数值命中开区间上界时会按 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)); + }); + } + /// /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// @@ -560,10 +609,56 @@ public class YamlConfigLoaderTests } /// - /// 验证数组元素数量约束会在运行时被统一拒绝。 + /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。 /// [Test] - public void LoadAsync_Should_Throw_When_Array_Violates_MinItems_Or_MaxItems() + 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", @@ -615,6 +710,58 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数组元素数量命中下界时会在运行时被统一拒绝。 + /// + [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 校验后,未知字段不会再被静默忽略。 /// diff --git a/GFramework.Game/Config/GameConfigBootstrap.cs b/GFramework.Game/Config/GameConfigBootstrap.cs index 5ab37571..242002be 100644 --- a/GFramework.Game/Config/GameConfigBootstrap.cs +++ b/GFramework.Game/Config/GameConfigBootstrap.cs @@ -8,15 +8,25 @@ namespace GFramework.Game.Config; /// 该类型负责把配置注册表、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; /// /// 使用指定选项创建配置启动帮助器。 @@ -35,14 +45,14 @@ public sealed class GameConfigBootstrap : IDisposable { throw new ArgumentException( RootPathCannotBeNullOrWhiteSpaceMessage, - nameof(options.RootPath)); + nameof(options)); } if (options.ConfigureLoader == null) { throw new ArgumentException( ConfigureLoaderCannotBeNullMessage, - nameof(options.ConfigureLoader)); + nameof(options)); } _options = options; @@ -63,13 +73,34 @@ public sealed class GameConfigBootstrap : IDisposable /// /// 获取一个值,指示启动帮助器是否已经成功完成初次加载。 + /// 该状态只会在完整初始化链路(包括可选热重载启动)成功后才对外可见, + /// 避免并发调用观察到半初始化生命周期。 /// - public bool IsInitialized => _loader != null; + public bool IsInitialized + { + get + { + lock (_stateGate) + { + return _loader != null; + } + } + } /// /// 获取一个值,指示开发期热重载是否已启用。 + /// 只有当监听句柄已经成功创建并提交到当前生命周期后,该属性才会返回 。 /// - public bool IsHotReloadEnabled => _hotReload != null; + public bool IsHotReloadEnabled + { + get + { + lock (_stateGate) + { + return _hotReload != null; + } + } + } /// /// 获取当前生效的 YAML 配置加载器。 @@ -81,10 +112,13 @@ public sealed class GameConfigBootstrap : IDisposable { get { - ThrowIfDisposed(); + lock (_stateGate) + { + ThrowIfDisposedCore(); - return _loader ?? throw new InvalidOperationException( - "The config bootstrap has not been initialized yet."); + return _loader ?? throw new InvalidOperationException( + "The config bootstrap has not been initialized yet."); + } } } @@ -99,24 +133,59 @@ public sealed class GameConfigBootstrap : IDisposable /// 当配置加载失败时抛出。 public async Task InitializeAsync(CancellationToken cancellationToken = default) { - ThrowIfDisposed(); - - if (_loader != null) + lock (_stateGate) { - throw new InvalidOperationException( - "The config bootstrap can only be initialized once per instance."); + ThrowIfDisposedCore(); + + if (_isInitializing || _loader != null) + { + throw new InvalidOperationException( + "The config bootstrap can only be initialized once per instance."); + } + + _isInitializing = true; } - var loader = new YamlConfigLoader(RootPath); - _options.ConfigureLoader!(loader); - await loader.LoadAsync(Registry, cancellationToken); + IUnRegister? hotReload = null; - // 仅在初次加载完全成功后才公开加载器实例,避免上层观察到半初始化状态。 - _loader = loader; - - if (_options.EnableHotReload) + try { - StartHotReload(_options.HotReloadOptions); + 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; } } @@ -135,17 +204,70 @@ public sealed class GameConfigBootstrap : IDisposable /// public void StartHotReload(YamlConfigHotReloadOptions? options = null) { - ThrowIfDisposed(); - - var loader = _loader ?? throw new InvalidOperationException( - "Hot reload can only be started after the initial config load succeeds."); - - if (_hotReload != null) + YamlConfigLoader loader; + lock (_stateGate) { - throw new InvalidOperationException("Hot reload is already enabled."); + 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; } - _hotReload = loader.EnableHotReload(Registry, options); + 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; + } } /// @@ -154,8 +276,19 @@ public sealed class GameConfigBootstrap : IDisposable /// public void StopHotReload() { - var hotReload = _hotReload; - _hotReload = null; + IUnRegister? hotReload; + lock (_stateGate) + { + if (_isStartingHotReload && _hotReload == null) + { + _stopHotReloadAfterStart = true; + return; + } + + hotReload = _hotReload; + _hotReload = null; + } + hotReload?.UnRegister(); } @@ -164,16 +297,29 @@ public sealed class GameConfigBootstrap : IDisposable /// public void Dispose() { - if (_disposed) + IUnRegister? hotReload; + lock (_stateGate) { - return; + if (_disposed) + { + return; + } + + _disposed = true; + + if (_isStartingHotReload && _hotReload == null) + { + _stopHotReloadAfterStart = true; + } + + hotReload = _hotReload; + _hotReload = null; } - _disposed = true; - StopHotReload(); + hotReload?.UnRegister(); } - private void ThrowIfDisposed() + private void ThrowIfDisposedCore() { if (_disposed) { diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 98f670b3..f435c10a 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -10,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 文件。 /// @@ -828,7 +832,7 @@ internal static class YamlConfigSchemaValidator ? null : new Regex( pattern, - RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)); + SupportedPatternRegexOptions)); } /// @@ -1005,7 +1009,7 @@ internal static class YamlConfigSchemaValidator var pattern = patternElement.GetString() ?? string.Empty; try { - _ = new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture); + _ = new Regex(pattern, SupportedPatternRegexOptions); } catch (ArgumentException exception) { diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index f28b4f13..982ab32c 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -607,7 +607,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 -- `pattern`:供运行时校验、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 2d563723..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[], @@ -384,9 +385,11 @@ function normalizeSchemaNonNegativeInteger(value) { * 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) { +function normalizeSchemaPattern(value, displayPath) { if (typeof value !== "string") { return undefined; } @@ -394,8 +397,8 @@ function normalizeSchemaPattern(value) { try { void new RegExp(value); return value; - } catch { - return undefined; + } catch (error) { + throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); } } @@ -435,17 +438,19 @@ function formatSchemaDefaultValue(value) { * * @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) { +function matchesSchemaPattern(scalarValue, pattern, displayPath) { if (typeof pattern !== "string") { return true; } try { return new RegExp(pattern).test(scalarValue); - } catch { - return true; + } catch (error) { + throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); } } @@ -502,7 +507,7 @@ function parseSchemaNode(rawNode, displayPath) { exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), - pattern: normalizeSchemaPattern(value.pattern), + pattern: normalizeSchemaPattern(value.pattern, displayPath), minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), refTable: typeof value["x-gframework-ref-table"] === "string" @@ -749,7 +754,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } if (supportsPatternConstraints && - !matchesSchemaPattern(scalarValue, schemaNode.pattern)) { + !matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, { diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index e35c16de..0c3ebaab 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -271,6 +271,43 @@ tags: 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(` { @@ -341,6 +378,23 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite 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(` {