feat(config): 添加配置验证功能模块

- 实现配置架构解析器,支持JSON架构到递归树的转换
- 添加YAML解析器,支持根映射、缩进嵌套对象和数组结构
- 实现配置验证诊断功能,提供架构和YAML解析验证
- 添加表单更新应用功能,支持将表单更改安全写回YAML
- 实现批编辑器字段提取,支持可编辑标量类型的识别
- 添加配置注释提取功能,将注释映射到逻辑字段路径
- 实现示例配置YAML生成功能,包含架构描述作为注释
- 添加精确十进制算术运算,用于multipleOf约束检查
- 实现标量类型兼容性验证,包括整数、数字、布尔值模式匹配
- 添加常量值元数据处理,支持工具比较对齐运行时行为
This commit is contained in:
GeWuYou 2026-04-10 14:33:44 +08:00
parent 0320404514
commit e28a1e4ecd
7 changed files with 253 additions and 22 deletions

View File

@ -1403,6 +1403,53 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证空对象 <c>const</c> 约束会被视为合法 schema并与空 YAML 映射正确匹配。
/// </summary>
[Test]
public async Task LoadAsync_Should_Accept_Empty_Object_Schema_Const()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
reward: {}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "reward"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"reward": {
"type": "object",
"properties": {},
"const": {}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterNestedConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterNestedConfigStub>("monster");
Assert.Multiple(() =>
{
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
});
}
/// <summary>
/// 验证对象字段不满足 <c>minProperties</c> 时会在运行时被拒绝。
/// </summary>

View File

@ -2923,7 +2923,7 @@ internal sealed class YamlConfigConstantValue
/// <param name="displayValue">用于诊断输出的原始常量文本。</param>
public YamlConfigConstantValue(string comparableValue, string displayValue)
{
ArgumentException.ThrowIfNullOrWhiteSpace(comparableValue);
ArgumentNullException.ThrowIfNull(comparableValue);
ArgumentException.ThrowIfNullOrWhiteSpace(displayValue);
ComparableValue = comparableValue;

View File

@ -46,6 +46,51 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证空字符串 <c>const</c> 不会在生成 XML 文档时被当成“缺失约束”跳过。
/// </summary>
[Test]
public void Run_Should_Preserve_Empty_String_Const_In_Generated_Documentation()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": {
"type": "string",
"const": ""
}
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var generatedSources = result.Results
.Single()
.GeneratedSources
.ToDictionary(
static sourceResult => sourceResult.HintName,
static sourceResult => sourceResult.SourceText.ToString(),
StringComparer.Ordinal);
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: const = \"\"."));
}
/// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary>

View File

@ -2452,7 +2452,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var parts = new List<string>();
var constDocumentation = TryBuildConstDocumentation(element, schemaType);
if (!string.IsNullOrWhiteSpace(constDocumentation))
if (constDocumentation is not null)
{
parts.Add($"const = {constDocumentation}");
}
@ -2562,7 +2562,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
constElement.GetDouble().ToString(CultureInfo.InvariantCulture),
"boolean" when constElement.ValueKind == JsonValueKind.True => "true",
"boolean" when constElement.ValueKind == JsonValueKind.False => "false",
"string" when constElement.ValueKind == JsonValueKind.String => constElement.GetString(),
// Preserve the exact JSON literal so empty strings and other string-shaped constants
// remain unambiguous in generated XML documentation.
"string" when constElement.ValueKind == JsonValueKind.String => constElement.GetRawText(),
"array" when constElement.ValueKind == JsonValueKind.Array => constElement.GetRawText(),
"object" when constElement.ValueKind == JsonValueKind.Object => constElement.GetRawText(),
_ => null

View File

@ -10,6 +10,22 @@ const IntegerScalarPattern = /^[+-]?\d+$/u;
const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
const BooleanScalarPattern = /^(true|false)$/iu;
/**
* Compare two strings using the same UTF-16 code-unit ordering as C#'s
* string.CompareOrdinal so tooling stays aligned with the runtime.
*
* @param {string} left Left operand.
* @param {string} right Right operand.
* @returns {number} Negative when left < right, positive when left > right, zero when equal.
*/
function compareStringsOrdinal(left, right) {
if (left === right) {
return 0;
}
return left < right ? -1 : 1;
}
/**
* Parse the repository's minimal config-schema subset into a recursive tree.
* The parser intentionally mirrors the same high-level contract used by the
@ -89,7 +105,7 @@ function getEditableSchemaFields(schemaInfo) {
}
}
return editableFields.sort((left, right) => left.key.localeCompare(right.key));
return editableFields.sort((left, right) => compareStringsOrdinal(left.key, right.key));
}
/**
@ -462,18 +478,52 @@ function formatSchemaDefaultValue(value) {
}
/**
* Convert a schema const value into a compact string that can be shown in UI
* metadata hints without losing exactness for arrays and objects.
* Convert a schema const value into the raw scalar text used by sample YAML
* generation and scalar editors.
*
* @param {SchemaNode} schemaNode Parsed schema node.
* @param {unknown} value Raw schema const value.
* @returns {string | undefined} Display string for the const value.
* @returns {string | undefined} Raw scalar text, or a JSON literal fallback.
*/
function formatSchemaConstValue(value) {
function formatSchemaConstEditableValue(schemaNode, value) {
if (value === undefined) {
return undefined;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
if (schemaNode.type === "string" && typeof value === "string") {
return value;
}
if ((schemaNode.type === "integer" || schemaNode.type === "number") &&
typeof value === "number" &&
Number.isFinite(value)) {
return String(value);
}
if (schemaNode.type === "boolean" && typeof value === "boolean") {
return String(value);
}
return formatSchemaConstDisplayValue(value);
}
/**
* Convert a schema const value into an exact JSON-style literal for diagnostics
* and metadata hints.
*
* @param {unknown} value Raw schema const value.
* @returns {string | undefined} Display string for the const value.
*/
function formatSchemaConstDisplayValue(value) {
if (value === undefined) {
return undefined;
}
if (typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
@ -499,7 +549,8 @@ function applyConstMetadata(schemaNode, rawConst, displayPath) {
return {
...schemaNode,
constValue: formatSchemaConstValue(rawConst),
constValue: formatSchemaConstEditableValue(schemaNode, rawConst),
constDisplayValue: formatSchemaConstDisplayValue(rawConst),
constComparableValue: buildSchemaConstComparableValue(schemaNode, rawConst, displayPath)
};
}
@ -567,7 +618,7 @@ function buildSchemaConstObjectComparableValue(schemaNode, rawConst, displayPath
objectEntries.push([key, childComparableValue]);
}
objectEntries.sort((left, right) => left[0].localeCompare(right[0]));
objectEntries.sort((left, right) => compareStringsOrdinal(left[0], right[0]));
return objectEntries.map(([key, value]) => `${key.length}:${key}=${value.length}:${value}`).join("|");
}
@ -1224,7 +1275,7 @@ function validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnos
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.constMismatch, localizer, {
displayPath,
value: schemaNode.constValue
value: schemaNode.constDisplayValue ?? schemaNode.constValue
})
});
}
@ -1248,7 +1299,7 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
return Object.keys(schemaNode.properties)
.filter((key) => yamlNode.map.has(key))
.sort((left, right) => left.localeCompare(right))
.sort(compareStringsOrdinal)
.map((key) => {
const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key));
return `${key.length}:${key}=${valueKey.length}:${valueKey}`;
@ -2103,6 +2154,7 @@ module.exports = {
* description?: string,
* defaultValue?: string,
* constValue?: string,
* constDisplayValue?: string,
* constComparableValue?: string
* } | {
* type: "array",
@ -2111,6 +2163,7 @@ module.exports = {
* description?: string,
* defaultValue?: string,
* constValue?: string,
* constDisplayValue?: string,
* constComparableValue?: string,
* minItems?: number,
* maxItems?: number,
@ -2124,6 +2177,7 @@ module.exports = {
* description?: string,
* defaultValue?: string,
* constValue?: string,
* constDisplayValue?: string,
* constComparableValue?: string,
* minimum?: number,
* exclusiveMinimum?: number,

View File

@ -1372,7 +1372,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
label,
required: requiredSet.has(key),
depth,
value: getScalarFieldValue(propertyValue, propertySchema.constValue || propertySchema.defaultValue),
value: getScalarFieldValue(propertyValue, propertySchema.constValue ?? propertySchema.defaultValue),
schema: propertySchema,
comment: commentLookup[propertyPath] || ""
});
@ -1514,7 +1514,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
label,
required: requiredSet.has(key),
depth,
value: getScalarFieldValue(propertyValue, propertySchema.constValue || propertySchema.defaultValue),
value: getScalarFieldValue(propertyValue, propertySchema.constValue ?? propertySchema.defaultValue),
schema: propertySchema,
itemMode: true,
comment: commentLookup[itemDisplayPath] || ""
@ -1555,7 +1555,7 @@ function getScalarFieldValue(yamlNode, fallbackValue) {
return unquoteScalar(yamlNode.value || "");
}
return fallbackValue || "";
return fallbackValue ?? "";
}
/**
@ -1577,7 +1577,7 @@ function getScalarArrayValue(yamlNode) {
/**
* Render human-facing metadata hints for one schema field.
*
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], constValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
* @param {boolean} includeDescription Whether description text should be included in the hint output.
* @returns {string} HTML fragment.
@ -1593,8 +1593,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue})));
}
if (propertySchema.constValue) {
hints.push(escapeHtml(localizer.t("webview.hint.const", {value: propertySchema.constValue})));
if (propertySchema.constValue !== undefined) {
hints.push(escapeHtml(localizer.t("webview.hint.const", {
value: propertySchema.constDisplayValue ?? propertySchema.constValue
})));
}
const enumValues = isArrayField
@ -1662,8 +1664,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
}
if (isArrayField && propertySchema.items && propertySchema.items.constValue) {
hints.push(escapeHtml(localizer.t("webview.hint.itemConst", {value: propertySchema.items.constValue})));
if (isArrayField && propertySchema.items && propertySchema.items.constValue !== undefined) {
hints.push(escapeHtml(localizer.t("webview.hint.itemConst", {
value: propertySchema.items.constDisplayValue ?? propertySchema.items.constValue
})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.exclusiveMinimum === "number") {

View File

@ -97,8 +97,51 @@ test("parseSchemaContent should capture const metadata for scalar, object, and a
`);
assert.equal(schema.properties.rarity.constValue, "common");
assert.equal(schema.properties.rarity.constDisplayValue, "\"common\"");
assert.match(schema.properties.reward.constValue, /"currency":"coin"/u);
assert.match(schema.properties.reward.constDisplayValue, /"currency":"coin"/u);
assert.equal(schema.properties.dropItemIds.constValue, "[\"potion\",\"gem\"]");
assert.equal(schema.properties.dropItemIds.constDisplayValue, "[\"potion\",\"gem\"]");
});
test("parseSchemaContent should preserve empty-string const raw and display metadata", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"name": {
"type": "string",
"const": ""
}
}
}
`);
assert.equal(schema.properties.name.constValue, "");
assert.equal(schema.properties.name.constDisplayValue, "\"\"");
});
test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"payload": {
"type": "object",
"properties": {
"z": { "type": "integer" },
"ä": { "type": "integer" }
},
"const": {
"z": 1,
"ä": 2
}
}
}
}
`);
assert.match(schema.properties.payload.constComparableValue, /^1:z=/u);
});
test("parseTopLevelYaml should parse nested mappings and object arrays", () => {
@ -245,7 +288,7 @@ rarity: rare
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.match(diagnostics[0].message, /constant value common|固定值 common/u);
assert.match(diagnostics[0].message, /constant value "common"|固定值 "common"/u);
});
test("validateParsedConfig should report object and array const mismatches", () => {
@ -1182,6 +1225,42 @@ test("getEditableSchemaFields should keep batch editing limited to top-level sca
]);
});
test("getEditableSchemaFields should sort keys with ordinal semantics", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"a": { "type": "string" },
"A": { "type": "string" },
"ä": { "type": "string" },
"z": { "type": "string" }
}
}
`);
assert.deepEqual(
getEditableSchemaFields(schema).map((field) => field.key),
["A", "a", "z", "ä"]);
});
test("createSampleConfigYaml should preserve empty-string scalar const values", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"name": {
"type": "string",
"const": ""
}
}
}
`);
const sample = createSampleConfigYaml(schema);
assert.match(sample, /^name: ""$/mu);
});
test("parseBatchArrayValue should keep comma-separated batch editing behavior", () => {
assert.deepEqual(parseBatchArrayValue(" potion, bomb , ,elixir "), ["potion", "bomb", "elixir"]);
});