diff --git a/.agents/skills/README.md b/.agents/skills/README.md index b5913ec3..7b226bd3 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -1,6 +1,6 @@ # GFramework Skills -文档工作流的公开入口已统一为 `gframework-doc-refresh`。 +公开入口目前包含 `gframework-doc-refresh` 与 `gframework-batch-boot`。 ## 公开入口 @@ -29,6 +29,30 @@ /gframework-doc-refresh Cqrs ``` +### `gframework-batch-boot` + +在 `gframework-boot` 的基础上,自动推进可分批执行的重复性任务,不需要人工一轮轮重新触发。 + +适用场景: + +- analyzer warning reduction +- 大批量测试结构收口 +- 分模块文档刷新 wave +- 任何有明确 stop condition 的多批次任务 + +推荐调用: + +```bash +/gframework-batch-boot +``` + +示例: + +```bash +/gframework-batch-boot continue analyzer warning reduction until branch diff vs origin/main approaches 75 files +/gframework-batch-boot keep refactoring repetitive source-generator tests in bounded batches +``` + ## 共享资源 - `_shared/DOCUMENTATION_STANDARDS.md` diff --git a/.agents/skills/gframework-batch-boot/SKILL.md b/.agents/skills/gframework-batch-boot/SKILL.md new file mode 100644 index 00000000..95cf0267 --- /dev/null +++ b/.agents/skills/gframework-batch-boot/SKILL.md @@ -0,0 +1,139 @@ +--- +name: gframework-batch-boot +description: Repository-specific bulk-task workflow for the GFramework repo. Use when Codex should start from the normal GFramework boot context and then continue a repetitive or large-scope task in automatic batches without waiting for manual round-by-round prompts, especially for analyzer warning cleanup, repetitive test refactors, documentation waves, or similar multi-file work with an explicit stop condition such as changed-file count, warning count, or timebox. +--- + +# GFramework Batch Boot + +## Overview + +Use this skill when `gframework-boot` is necessary but not sufficient because the task should keep advancing in bounded +batches until a clear stop condition is met. + +Treat `AGENTS.md` as the source of truth. This skill extends `gframework-boot`; it does not replace it. + +## Startup Workflow + +1. Execute the normal `gframework-boot` startup sequence first: + - read `AGENTS.md` + - read `.ai/environment/tools.ai.yaml` + - read `ai-plan/public/README.md` + - read the mapped active topic `todos/` and `traces/` +2. Classify the task as a batch candidate only if all of the following are true: + - the work is repetitive, sliceable, or likely to require multiple similar iterations + - each batch can be given an explicit ownership boundary + - a stop condition can be measured locally +3. Before any delegation, define the batch objective in one sentence: + - warning family reduction + - repeated test refactor pattern + - module-by-module documentation refresh + - other repetitive multi-file cleanup + +## Baseline Selection + +When the stop condition depends on branch size or changed-file count, choose the baseline carefully. + +1. Prefer the freshest remote-tracking reference that already exists locally: + - `origin/main` + - or the mapped upstream base branch for the current topic +2. Do not default to local `main` when `refs/heads/main` is behind `refs/remotes/origin/main`. +3. If both local and remote-tracking refs exist, report: + - ref name + - short SHA + - committer date +4. If only a local branch exists, state that the baseline may be stale before using it. +5. When the task is tied to a PR or topic branch rather than `main`, prefer that explicit upstream comparison target over + a generic `main`. + +For changed-file limits, measure branch-wide scope against the chosen baseline, not just the current working tree: + +- use `git diff --name-only ...HEAD` +- do not confuse branch diff size with `git status --short` + +## Stop Conditions + +Choose one primary stop condition before the first batch and restate it to the user. + +Common stop conditions: + +- branch diff vs baseline approaches a file-count threshold +- warnings-only build reaches a target count +- a specific hotspot list is exhausted +- a timebox or validation budget is reached + +If multiple stop conditions exist, rank them and treat one as primary. + +## Batch Loop + +1. Inspect the current state before the first batch: + - current branch and active topic + - selected baseline + - current stop-condition metric + - next candidate slices +2. Keep the critical path local. +3. Delegate only bounded slices with explicit ownership: + - one file + - one warning family within one project + - one module documentation wave +4. For each worker batch, specify: + - objective + - owned files or subsystem + - required validation commands + - output format + - reminder that other agents may be editing the repo +5. While workers run, use the main thread for non-overlapping tasks: + - queue the next candidate slice + - inspect the next hotspot + - recompute branch size or warning distribution +6. After each completed batch: + - integrate or verify the result + - rerun the required validation + - recompute the primary stop-condition metric + - decide immediately whether to continue or stop +7. Do not require the user to manually trigger every round unless: + - the next slice is ambiguous + - a validation failure changes strategy + - the batch objective conflicts with the active topic + +## Task Tracking + +For multi-batch work, keep recovery artifacts current. + +- Update the active `ai-plan/public//todos/` document when a meaningful batch lands. +- Update the matching `traces/` document with: + - accepted delegated scope + - validation milestones + - current stop-condition metric + - next recommended batch +- Keep the active recovery point concise; archive detailed history when it starts to sprawl. + +## Delegation Defaults + +- Prefer `worker` subagents for independent write slices. +- Prefer `explorer` subagents for read-only hotspot ranking or next-batch discovery. +- Keep each worker ownership boundary disjoint. +- Avoid launching a new batch when the expected write set would push the branch beyond the declared threshold without a + deliberate decision. + +## Completion + +Stop the loop when any of the following becomes true: + +- the primary stop condition has been reached or exceeded +- the remaining slices are no longer low-risk +- validation failures indicate the task is no longer repetitive +- the branch has grown large enough that reviewability would materially degrade + +When stopping, report: + +- which baseline was used +- the exact metric value at stop time +- completed batches +- remaining candidate batches +- whether further work should continue in a new turn or after rebasing/fetching + +## Example Triggers + +- `Use $gframework-batch-boot and keep reducing analyzer warnings until the branch diff vs origin/main approaches 75 files.` +- `Use $gframework-batch-boot to continue this repetitive test refactor in bounded batches until the warning count drops below 10.` +- `Use $gframework-batch-boot and refresh module docs in waves without asking me to trigger every round.` diff --git a/.agents/skills/gframework-batch-boot/agents/openai.yaml b/.agents/skills/gframework-batch-boot/agents/openai.yaml new file mode 100644 index 00000000..212e8279 --- /dev/null +++ b/.agents/skills/gframework-batch-boot/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "GFramework Batch Boot" + short_description: "Run boot, then iterate bounded bulk batches" + default_prompt: "Use $gframework-batch-boot to start from the normal GFramework boot context and continue the current repetitive task in automatic bounded batches until the declared stop condition is reached." diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 60664a38..6d92383c 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -1242,90 +1242,149 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } - if (!element.TryGetProperty("properties", out var propertiesElement) || - propertiesElement.ValueKind != JsonValueKind.Object) + if (!TryGetDeclaredProperties( + filePath, + displayPath, + element, + ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, + "dependentRequired", + out var declaredProperties, + out diagnostic)) + { + return false; + } + + foreach (var dependency in dependentRequiredElement.EnumerateObject()) + { + if (!TryValidateDependentRequiredEntry( + filePath, + displayPath, + dependency, + declaredProperties, + out diagnostic)) + { + return false; + } + } + + return true; + } + + /// + /// 验证单个 dependentRequired 触发项的声明形状。 + /// 该 helper 先锁定 trigger 字段本身是否属于当前对象,再把每个 target 交给更细粒度的 sibling 校验, + /// 让诊断能够明确区分“触发字段不存在”和“依赖目标非法”两类失败语义。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 当前 dependentRequired 触发项。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前 dependentRequired 触发项是否有效。 + private static bool TryValidateDependentRequiredEntry( + string filePath, + string displayPath, + JsonProperty dependency, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!declaredProperties.Contains(dependency.Name)) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, - "Object schemas using 'dependentRequired' must also declare an object-valued 'properties' map."); + $"Trigger property '{dependency.Name}' is not declared in the same object schema."); return false; } - var declaredProperties = new HashSet( - propertiesElement - .EnumerateObject() - .Select(static property => property.Name), - StringComparer.Ordinal); - - foreach (var dependency in dependentRequiredElement.EnumerateObject()) + if (dependency.Value.ValueKind != JsonValueKind.Array) { - if (!declaredProperties.Contains(dependency.Name)) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Property '{dependency.Name}' must declare 'dependentRequired' as an array of sibling property names."); + return false; + } + + foreach (var dependencyTarget in dependency.Value.EnumerateArray()) + { + if (!TryValidateDependentRequiredTarget( + filePath, displayPath, - $"Trigger property '{dependency.Name}' is not declared in the same object schema."); - return false; - } - - if (dependency.Value.ValueKind != JsonValueKind.Array) + dependency.Name, + dependencyTarget, + declaredProperties, + out diagnostic)) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Property '{dependency.Name}' must declare 'dependentRequired' as an array of sibling property names."); return false; } - - foreach (var dependencyTarget in dependency.Value.EnumerateArray()) - { - if (dependencyTarget.ValueKind != JsonValueKind.String) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Property '{dependency.Name}' must declare 'dependentRequired' entries as strings."); - return false; - } - - var dependencyTargetName = dependencyTarget.GetString(); - if (string.IsNullOrWhiteSpace(dependencyTargetName)) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Property '{dependency.Name}' cannot declare blank 'dependentRequired' entries."); - return false; - } - - var normalizedDependencyTargetName = dependencyTargetName!; - if (!declaredProperties.Contains(normalizedDependencyTargetName)) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Dependent target '{normalizedDependencyTargetName}' is not declared in the same object schema."); - return false; - } - } } return true; } + /// + /// 验证单个 dependentRequired target 是否为已声明的 sibling 字段名。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 触发依赖的字段名。 + /// 当前 target 元素。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前 dependentRequired target 是否有效。 + private static bool TryValidateDependentRequiredTarget( + string filePath, + string displayPath, + string dependencyName, + JsonElement dependencyTarget, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (dependencyTarget.ValueKind != JsonValueKind.String) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Property '{dependencyName}' must declare 'dependentRequired' entries as strings."); + return false; + } + + var dependencyTargetName = dependencyTarget.GetString(); + if (string.IsNullOrWhiteSpace(dependencyTargetName)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Property '{dependencyName}' cannot declare blank 'dependentRequired' entries."); + return false; + } + + var normalizedDependencyTargetName = dependencyTargetName!; + if (declaredProperties.Contains(normalizedDependencyTargetName)) + { + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Dependent target '{normalizedDependencyTargetName}' is not declared in the same object schema."); + return false; + } + /// /// 验证当前 schema 节点是否以运行时支持的方式声明了 dependentSchemas。 /// 只有 object 节点允许挂载该关键字;一旦关键字出现,就继续复用对象节点的形状校验, @@ -1431,60 +1490,84 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } - if (!element.TryGetProperty("properties", out var propertiesElement) || - propertiesElement.ValueKind != JsonValueKind.Object) + if (!TryGetDeclaredProperties( + filePath, + displayPath, + element, + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + "dependentSchemas", + out var declaredProperties, + out diagnostic)) + { + return false; + } + + foreach (var dependency in dependentSchemasElement.EnumerateObject()) + { + if (!TryValidateDependentSchemaEntry( + filePath, + displayPath, + dependency, + declaredProperties, + out diagnostic)) + { + return false; + } + } + + return true; + } + + /// + /// 验证单个 dependentSchemas 触发项是否保持为当前运行时支持的 object 子 schema 形状。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 当前 dependentSchemas 触发项。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前 dependentSchemas 触发项是否有效。 + private static bool TryValidateDependentSchemaEntry( + string filePath, + string displayPath, + JsonProperty dependency, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!declaredProperties.Contains(dependency.Name)) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, - "Object schemas using 'dependentSchemas' must also declare an object-valued 'properties' map."); + $"Trigger property '{dependency.Name}' is not declared in the same object schema."); return false; } - var declaredProperties = new HashSet( - propertiesElement - .EnumerateObject() - .Select(static property => property.Name), - StringComparer.Ordinal); - - foreach (var dependency in dependentSchemasElement.EnumerateObject()) + if (dependency.Value.ValueKind != JsonValueKind.Object) { - if (!declaredProperties.Contains(dependency.Name)) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Trigger property '{dependency.Name}' is not declared in the same object schema."); - return false; - } + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Property '{dependency.Name}' must declare 'dependentSchemas' as an object-valued schema."); + return false; + } - if (dependency.Value.ValueKind != JsonValueKind.Object) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Property '{dependency.Name}' must declare 'dependentSchemas' as an object-valued schema."); - return false; - } - - if (!dependency.Value.TryGetProperty("type", out var dependentSchemaTypeElement) || - dependentSchemaTypeElement.ValueKind != JsonValueKind.String || - !string.Equals(dependentSchemaTypeElement.GetString(), "object", StringComparison.Ordinal)) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Property '{dependency.Name}' must declare an object-typed 'dependentSchemas' schema."); - return false; - } + if (!dependency.Value.TryGetProperty("type", out var dependentSchemaTypeElement) || + dependentSchemaTypeElement.ValueKind != JsonValueKind.String || + !string.Equals(dependentSchemaTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Property '{dependency.Name}' must declare an object-typed 'dependentSchemas' schema."); + return false; } return true; @@ -1595,48 +1678,28 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } - if (!element.TryGetProperty("properties", out var propertiesElement) || - propertiesElement.ValueKind != JsonValueKind.Object) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidAllOfMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), + if (!TryGetDeclaredProperties( + filePath, displayPath, - "Object schemas using 'allOf' must also declare an object-valued 'properties' map."); + element, + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + "allOf", + out var declaredProperties, + out diagnostic)) + { return false; } - var declaredProperties = new HashSet( - propertiesElement - .EnumerateObject() - .Select(static property => property.Name), - StringComparer.Ordinal); - var allOfIndex = 0; foreach (var allOfSchema in allOfElement.EnumerateArray()) { - if (allOfSchema.ValueKind != JsonValueKind.Object) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidAllOfMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), + if (!TryValidateAllOfEntryShape( + filePath, displayPath, - $"Entry #{allOfIndex + 1} in 'allOf' must be an object-valued schema."); - return false; - } - - if (!allOfSchema.TryGetProperty("type", out var allOfTypeElement) || - allOfTypeElement.ValueKind != JsonValueKind.String || - !string.Equals(allOfTypeElement.GetString(), "object", StringComparison.Ordinal)) + allOfSchema, + allOfIndex, + out diagnostic)) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidAllOfMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"Entry #{allOfIndex + 1} in 'allOf' must declare an object-typed schema."); return false; } @@ -1657,6 +1720,50 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 验证单个 allOf 条目是否维持 object-valued、object-typed 的 focused constraint 形状。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 当前 allOf 条目。 + /// 从 0 开始的条目索引。 + /// 失败时返回的诊断。 + /// 当前 allOf 条目形状是否有效。 + private static bool TryValidateAllOfEntryShape( + string filePath, + string displayPath, + JsonElement allOfSchema, + int allOfIndex, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Entry #{allOfIndex + 1} in 'allOf' must be an object-valued schema."); + return false; + } + + if (!allOfSchema.TryGetProperty("type", out var allOfTypeElement) || + allOfTypeElement.ValueKind != JsonValueKind.String || + !string.Equals(allOfTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare an object-typed schema."); + return false; + } + + return true; + } + /// /// 验证当前 schema 节点是否以运行时支持的方式声明了 object-focused if / then / else。 /// @@ -1746,51 +1853,72 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator var hasIf = element.TryGetProperty("if", out var ifElement); var hasThen = element.TryGetProperty("then", out var thenElement); var hasElse = element.TryGetProperty("else", out var elseElement); - if (!hasIf && !hasThen && !hasElse) + if (!TryValidateConditionalSchemaPresence( + filePath, + displayPath, + hasIf, + hasThen, + hasElse, + out diagnostic)) { - return true; + return false; } if (!hasIf) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - "Object schemas using 'then' or 'else' must also declare 'if'."); - return false; + return true; } - if (!hasThen && !hasElse) + if (!TryGetDeclaredProperties( + filePath, + displayPath, + element, + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + "if/then/else", + out var declaredProperties, + out diagnostic)) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - "Object schemas using 'if' must also declare at least one of 'then' or 'else'."); return false; } - if (!element.TryGetProperty("properties", out var propertiesElement) || - propertiesElement.ValueKind != JsonValueKind.Object) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - "Object schemas using 'if/then/else' must also declare an object-valued 'properties' map."); - return false; - } - - var declaredProperties = new HashSet( - propertiesElement - .EnumerateObject() - .Select(static property => property.Name), - StringComparer.Ordinal); + return TryValidateConditionalSchemaBranches( + filePath, + displayPath, + ifElement, + hasThen, + thenElement, + hasElse, + elseElement, + declaredProperties, + out diagnostic); + } + /// + /// 验证 object-focused 条件分支集合。 + /// if 分支始终必检,then / else 仅在声明时校验, + /// 以保持生成器对分支缺失与分支内容错误的诊断顺序稳定。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// if 分支 schema。 + /// 是否声明 then。 + /// then 分支 schema。 + /// 是否声明 else。 + /// else 分支 schema。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前条件分支集合是否有效。 + private static bool TryValidateConditionalSchemaBranches( + string filePath, + string displayPath, + JsonElement ifElement, + bool hasThen, + JsonElement thenElement, + bool hasElse, + JsonElement elseElement, + ISet declaredProperties, + out Diagnostic? diagnostic) + { if (!TryValidateConditionalSchemaBranch( filePath, displayPath, @@ -1824,6 +1952,86 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator out diagnostic); } + /// + /// 验证 object-focused if / then / else 的存在性组合是否合法。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 是否声明 if。 + /// 是否声明 then。 + /// 是否声明 else。 + /// 失败时返回的诊断。 + /// 当前条件关键字组合是否有效。 + private static bool TryValidateConditionalSchemaPresence( + string filePath, + string displayPath, + bool hasIf, + bool hasThen, + bool hasElse, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!hasIf && !hasThen && !hasElse) + { + return true; + } + + if (!hasIf) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'then' or 'else' must also declare 'if'."); + return false; + } + + if (hasThen || hasElse) + { + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'if' must also declare at least one of 'then' or 'else'."); + return false; + } + + private static bool TryGetDeclaredProperties( + string filePath, + string displayPath, + JsonElement element, + DiagnosticDescriptor descriptor, + string keywordName, + out HashSet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + declaredProperties = new HashSet(StringComparer.Ordinal); + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + descriptor, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Object schemas using '{keywordName}' must also declare an object-valued 'properties' map."); + return false; + } + + declaredProperties = new HashSet( + propertiesElement + .EnumerateObject() + .Select(static property => property.Name), + StringComparer.Ordinal); + return true; + } + /// /// 验证单个 object-focused 条件分支的类型与父对象字段引用范围。 /// @@ -1896,36 +2104,99 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator out Diagnostic? diagnostic) { diagnostic = null; - if (schemaElement.TryGetProperty("properties", out var propertiesElement)) + if (!TryValidateObjectFocusedSchemaProperties( + filePath, + displayPath, + entryLabel, + schemaElement, + declaredProperties, + out diagnostic)) { - if (propertiesElement.ValueKind != JsonValueKind.Object) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"The '{entryLabel}' schema must declare 'properties' as an object-valued map."); - return false; - } - - foreach (var property in propertiesElement.EnumerateObject()) - { - if (declaredProperties.Contains(property.Name)) - { - continue; - } - - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"The '{entryLabel}' schema declares property '{property.Name}', but that property is not declared in the parent object schema."); - return false; - } + return false; } + return TryValidateObjectFocusedSchemaRequiredProperties( + filePath, + displayPath, + entryLabel, + schemaElement, + declaredProperties, + out diagnostic); + } + + /// + /// 验证 object-focused 条件 schema 的 properties 只引用父对象已声明字段。 + /// + /// Schema 文件路径。 + /// 当前分支逻辑路径。 + /// 分支标签。 + /// 当前分支 schema。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前分支 properties 是否有效。 + private static bool TryValidateObjectFocusedSchemaProperties( + string filePath, + string displayPath, + string entryLabel, + JsonElement schemaElement, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!schemaElement.TryGetProperty("properties", out var propertiesElement)) + { + return true; + } + + if (propertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema must declare 'properties' as an object-valued map."); + return false; + } + + foreach (var property in propertiesElement.EnumerateObject()) + { + if (declaredProperties.Contains(property.Name)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema declares property '{property.Name}', but that property is not declared in the parent object schema."); + return false; + } + + return true; + } + + /// + /// 验证 object-focused 条件 schema 的 required 约束只引用父对象已声明字段。 + /// + /// Schema 文件路径。 + /// 当前分支逻辑路径。 + /// 分支标签。 + /// 当前分支 schema。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前分支 required 是否有效。 + private static bool TryValidateObjectFocusedSchemaRequiredProperties( + string filePath, + string displayPath, + string entryLabel, + JsonElement schemaElement, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; if (!schemaElement.TryGetProperty("required", out var requiredElement)) { return true; @@ -2004,37 +2275,99 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { diagnostic = null; var allOfEntryPath = BuildAllOfEntryPath(displayPath, allOfIndex); - - if (allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement)) + if (!TryValidateAllOfEntryProperties( + filePath, + allOfEntryPath, + allOfSchema, + allOfIndex, + declaredProperties, + out diagnostic)) { - if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) - { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidAllOfMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - allOfEntryPath, - $"Entry #{allOfIndex + 1} in 'allOf' must declare 'properties' as an object-valued map."); - return false; - } - - foreach (var property in allOfPropertiesElement.EnumerateObject()) - { - if (declaredProperties.Contains(property.Name)) - { - continue; - } - - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidAllOfMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - allOfEntryPath, - $"Entry #{allOfIndex + 1} in 'allOf' declares property '{property.Name}', but that property is not declared in the parent object schema."); - return false; - } + return false; } + return TryValidateAllOfEntryRequiredProperties( + filePath, + allOfEntryPath, + allOfSchema, + allOfIndex, + declaredProperties, + out diagnostic); + } + + /// + /// 验证单个 allOf 条目的 properties 映射不会引入父对象未声明字段。 + /// + /// Schema 文件路径。 + /// 当前 allOf 条目逻辑路径。 + /// 当前 allOf 条目。 + /// 从 0 开始的条目索引。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前 allOf 条目的 properties 映射是否有效。 + private static bool TryValidateAllOfEntryProperties( + string filePath, + string allOfEntryPath, + JsonElement allOfSchema, + int allOfIndex, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement)) + { + return true; + } + + if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare 'properties' as an object-valued map."); + return false; + } + + foreach (var property in allOfPropertiesElement.EnumerateObject()) + { + if (declaredProperties.Contains(property.Name)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' declares property '{property.Name}', but that property is not declared in the parent object schema."); + return false; + } + + return true; + } + + /// + /// 验证单个 allOf 条目的 required 约束不会引用父对象未声明字段。 + /// + /// Schema 文件路径。 + /// 当前 allOf 条目逻辑路径。 + /// 当前 allOf 条目。 + /// 从 0 开始的条目索引。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前 allOf 条目的 required 约束是否有效。 + private static bool TryValidateAllOfEntryRequiredProperties( + string filePath, + string allOfEntryPath, + JsonElement allOfSchema, + int allOfIndex, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; if (!allOfSchema.TryGetProperty("required", out var requiredElement)) { return true; @@ -2379,11 +2712,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator private static string GenerateConfigClass(SchemaFileSpec schema) { var builder = new StringBuilder(); - builder.AppendLine("// "); - builder.AppendLine("#nullable enable"); - builder.AppendLine(); - builder.AppendLine($"namespace {schema.Namespace};"); - builder.AppendLine(); + AppendGeneratedSourceHeader(builder, schema.Namespace); AppendObjectType(builder, schema.RootObject, schema.FileName, schema.Title, schema.Description, isRoot: true, indentationLevel: 0); @@ -2402,11 +2731,98 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator var indexedQueryableProperties = queryableProperties .Where(static property => property.IsIndexedLookup) .ToArray(); + AppendGeneratedSourceHeader(builder, schema.Namespace); + AppendGeneratedTableTypeHeader(builder, schema, indexedQueryableProperties); + AppendGeneratedTableConstructor(builder, schema, indexedQueryableProperties); + AppendGeneratedTableCoreMembers(builder, schema); + AppendGeneratedTableLookupMembers(builder, schema, queryableProperties, indexedQueryableProperties); + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 生成运行时注册与访问辅助源码。 + /// 该辅助类型把 schema 命名约定、配置目录和 schema 相对路径固化为生成代码, + /// 让消费端无需重复手写字符串常量和主键提取逻辑。 + /// + /// 已解析的 schema 模型。 + /// 辅助类型源码。 + private static string GenerateBindingsClass(SchemaFileSpec schema) + { + var registerMethodName = $"Register{schema.EntityName}Table"; + var getMethodName = $"Get{schema.EntityName}Table"; + var tryGetMethodName = $"TryGet{schema.EntityName}Table"; + var bindingsClassName = $"{schema.EntityName}ConfigBindings"; + var referenceSpecs = CollectReferenceSpecs(schema.RootObject).ToArray(); + + var builder = new StringBuilder(); + AppendGeneratedSourceHeader(builder, schema.Namespace); + AppendBindingsTypeHeader(builder, bindingsClassName, schema.FileName); + AppendBindingsReferenceMetadataType(builder); + AppendBindingsMetadataType(builder, schema); + AppendBindingsMetadataAliases(builder); + AppendYamlSerializationHelpers(builder, schema); + AppendBindingsReferencesType(builder, referenceSpecs); + AppendBindingsRegisterMethod(builder, schema, registerMethodName); + AppendBindingsGetMethod(builder, schema, getMethodName); + AppendBindingsTryGetMethod(builder, schema, tryGetMethodName); + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 生成项目级聚合辅助源码。 + /// 该辅助把当前消费者项目内所有有效 schema 汇总为一个统一入口, + /// 以便运行时快速完成批量注册并在需要时枚举已生成的配置域元数据。 + /// + /// 当前编译中成功解析的 schema 集合。 + /// 聚合辅助源码。 + private static string GenerateCatalogClass(IReadOnlyList schemas) + { + var builder = new StringBuilder(); + AppendGeneratedSourceHeader(builder, GeneratedNamespace); + AppendGeneratedConfigCatalogType(builder, schemas); + builder.AppendLine(); + AppendGeneratedConfigRegistrationOptionsType(builder, schemas); + builder.AppendLine(); + AppendGeneratedConfigRegistrationExtensionsType(builder, schemas); + + return builder.ToString().TrimEnd(); + } + + /// + /// Emits the generated catalog type that exposes schema metadata and filtering helpers. + /// + /// Output buffer. + /// Successfully parsed schemas for the current compilation. + private static void AppendGeneratedConfigCatalogType(StringBuilder builder, IReadOnlyList schemas) + { + AppendGeneratedConfigCatalogHeader(builder); + AppendGeneratedConfigTableMetadataType(builder); + AppendGeneratedConfigTablesProperty(builder, schemas); + AppendGeneratedConfigResolveAbsolutePathMethod(builder); + AppendGeneratedConfigTryGetByTableNameMethod(builder, schemas); + AppendGeneratedConfigGetTablesInConfigDomainMethod(builder); + AppendGeneratedConfigGetTablesForRegistrationMethod(builder); + AppendGeneratedConfigMatchesRegistrationOptionsMethod(builder); + AppendGeneratedConfigAllowListMethod(builder); + builder.AppendLine("}"); + } + + private static void AppendGeneratedSourceHeader(StringBuilder builder, string namespaceName) + { builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine(); - builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine($"namespace {namespaceName};"); builder.AppendLine(); + } + + private static void AppendGeneratedTableTypeHeader( + StringBuilder builder, + SchemaFileSpec schema, + IReadOnlyList indexedQueryableProperties) + { builder.AppendLine("/// "); builder.AppendLine( $"/// Auto-generated table wrapper for schema file '{schema.FileName}'."); @@ -2423,7 +2839,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( $" private readonly global::System.Lazy>> _{ToCamelCase(property.PropertyName)}Index;"); } + } + private static void AppendGeneratedTableConstructor( + StringBuilder builder, + SchemaFileSpec schema, + IReadOnlyList indexedQueryableProperties) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Creates a generated table wrapper around the runtime config table instance."); @@ -2442,6 +2864,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } builder.AppendLine(" }"); + } + + private static void AppendGeneratedTableCoreMembers(StringBuilder builder, SchemaFileSpec schema) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" public global::System.Type KeyType => _inner.KeyType;"); @@ -2476,8 +2902,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" {"); builder.AppendLine(" return _inner.All();"); builder.AppendLine(" }"); + } - if (indexedQueryableProperties.Length > 0) + private static void AppendGeneratedTableLookupMembers( + StringBuilder builder, + SchemaFileSpec schema, + IReadOnlyList queryableProperties, + IReadOnlyList indexedQueryableProperties) + { + if (indexedQueryableProperties.Count > 0) { foreach (var property in indexedQueryableProperties) { @@ -2496,40 +2929,22 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); AppendTryFindFirstByPropertyMethod(builder, schema, property); } - - builder.AppendLine("}"); - return builder.ToString().TrimEnd(); } - /// - /// 生成运行时注册与访问辅助源码。 - /// 该辅助类型把 schema 命名约定、配置目录和 schema 相对路径固化为生成代码, - /// 让消费端无需重复手写字符串常量和主键提取逻辑。 - /// - /// 已解析的 schema 模型。 - /// 辅助类型源码。 - private static string GenerateBindingsClass(SchemaFileSpec schema) + private static void AppendBindingsTypeHeader(StringBuilder builder, string bindingsClassName, string fileName) { - var registerMethodName = $"Register{schema.EntityName}Table"; - var getMethodName = $"Get{schema.EntityName}Table"; - var tryGetMethodName = $"TryGet{schema.EntityName}Table"; - var bindingsClassName = $"{schema.EntityName}ConfigBindings"; - var referenceSpecs = CollectReferenceSpecs(schema.RootObject).ToArray(); - - var builder = new StringBuilder(); - builder.AppendLine("// "); - builder.AppendLine("#nullable enable"); - builder.AppendLine(); - builder.AppendLine($"namespace {schema.Namespace};"); - builder.AppendLine(); builder.AppendLine("/// "); builder.AppendLine( - $"/// Auto-generated registration and lookup helpers for schema file '{schema.FileName}'."); + $"/// Auto-generated registration and lookup helpers for schema file '{fileName}'."); builder.AppendLine( "/// The helper centralizes table naming, config directory, schema path, and strongly-typed registry access so consumer projects do not need to duplicate the same conventions."); builder.AppendLine("/// "); builder.AppendLine($"public static class {bindingsClassName}"); builder.AppendLine("{"); + } + + private static void AppendBindingsReferenceMetadataType(StringBuilder builder) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Describes one schema property that declares x-gframework-ref-table metadata."); @@ -2550,6 +2965,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" string referencedTableName,"); builder.AppendLine(" string valueSchemaType,"); builder.AppendLine(" bool isCollection)"); + AppendBindingsReferenceMetadataConstructorBody(builder); + AppendBindingsReferenceMetadataProperties(builder); + builder.AppendLine(" }"); + } + + private static void AppendBindingsReferenceMetadataConstructorBody(StringBuilder builder) + { builder.AppendLine(" {"); builder.AppendLine( " DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));"); @@ -2559,6 +2981,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator " ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));"); builder.AppendLine(" IsCollection = isCollection;"); builder.AppendLine(" }"); + } + + private static void AppendBindingsReferenceMetadataProperties(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -2581,7 +3007,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator " /// Gets a value indicating whether the property stores multiple reference keys."); builder.AppendLine(" /// "); builder.AppendLine(" public bool IsCollection { get; }"); - builder.AppendLine(" }"); + } + + private static void AppendBindingsMetadataType(StringBuilder builder, SchemaFileSpec schema) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -2615,6 +3044,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( $" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};"); builder.AppendLine(" }"); + } + + private static void AppendBindingsMetadataAliases(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -2637,7 +3070,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" /// "); builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;"); builder.AppendLine(); - AppendYamlSerializationHelpers(builder, schema); + } + + private static void AppendBindingsReferencesType( + StringBuilder builder, + IReadOnlyList referenceSpecs) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -2645,7 +3083,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" /// "); builder.AppendLine(" public static class References"); builder.AppendLine(" {"); + AppendBindingsReferenceMembers(builder, referenceSpecs); + AppendBindingsReferenceCollectionProperty(builder, referenceSpecs); + AppendBindingsTryGetByDisplayPathMethod(builder, referenceSpecs); + builder.AppendLine(" }"); + } + private static void AppendBindingsReferenceMembers( + StringBuilder builder, + IReadOnlyList referenceSpecs) + { foreach (var referenceSpec in referenceSpecs) { builder.AppendLine(" /// "); @@ -2664,29 +3111,38 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator $" {(referenceSpec.IsCollection ? "true" : "false")});"); builder.AppendLine(); } + } + private static void AppendBindingsReferenceCollectionProperty( + StringBuilder builder, + IReadOnlyList referenceSpecs) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Gets all generated cross-table reference descriptors for the current schema."); builder.AppendLine(" /// "); - if (referenceSpecs.Length == 0) + if (referenceSpecs.Count == 0) { builder.AppendLine( " public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.Empty();"); + return; } - else + + builder.AppendLine( + " public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]"); + builder.AppendLine(" {"); + foreach (var referenceSpec in referenceSpecs) { - builder.AppendLine( - " public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]"); - builder.AppendLine(" {"); - foreach (var referenceSpec in referenceSpecs) - { - builder.AppendLine($" {referenceSpec.MemberName},"); - } - - builder.AppendLine(" });"); + builder.AppendLine($" {referenceSpec.MemberName},"); } + builder.AppendLine(" });"); + } + + private static void AppendBindingsTryGetByDisplayPathMethod( + StringBuilder builder, + IReadOnlyList referenceSpecs) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Tries to resolve generated reference metadata by schema property path."); @@ -2705,7 +3161,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" }"); builder.AppendLine(); - if (referenceSpecs.Length == 0) + if (referenceSpecs.Count == 0) { builder.AppendLine(" metadata = default;"); builder.AppendLine(" return false;"); @@ -2728,7 +3184,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } builder.AppendLine(" }"); - builder.AppendLine(" }"); + } + + private static void AppendBindingsRegisterMethod( + StringBuilder builder, + SchemaFileSpec schema, + string registerMethodName) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -2757,6 +3219,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine($" static config => config.{schema.KeyPropertyName},"); builder.AppendLine(" comparer);"); builder.AppendLine(" }"); + } + + private static void AppendBindingsGetMethod( + StringBuilder builder, + SchemaFileSpec schema, + string getMethodName) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the generated config table wrapper from the registry."); @@ -2776,6 +3245,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( $" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName));"); builder.AppendLine(" }"); + } + + private static void AppendBindingsTryGetMethod( + StringBuilder builder, + SchemaFileSpec schema, + string tryGetMethodName) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Tries to get the generated config table wrapper from the registry."); @@ -2805,40 +3281,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" table = null;"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); - builder.AppendLine("}"); - return builder.ToString().TrimEnd(); } - /// - /// 生成项目级聚合辅助源码。 - /// 该辅助把当前消费者项目内所有有效 schema 汇总为一个统一入口, - /// 以便运行时快速完成批量注册并在需要时枚举已生成的配置域元数据。 - /// - /// 当前编译中成功解析的 schema 集合。 - /// 聚合辅助源码。 - private static string GenerateCatalogClass(IReadOnlyList schemas) - { - var builder = new StringBuilder(); - builder.AppendLine("// "); - builder.AppendLine("#nullable enable"); - builder.AppendLine(); - builder.AppendLine($"namespace {GeneratedNamespace};"); - builder.AppendLine(); - AppendGeneratedConfigCatalogType(builder, schemas); - builder.AppendLine(); - AppendGeneratedConfigRegistrationOptionsType(builder, schemas); - builder.AppendLine(); - AppendGeneratedConfigRegistrationExtensionsType(builder, schemas); - - return builder.ToString().TrimEnd(); - } - - /// - /// Emits the generated catalog type that exposes schema metadata and filtering helpers. - /// - /// Output buffer. - /// Successfully parsed schemas for the current compilation. - private static void AppendGeneratedConfigCatalogType(StringBuilder builder, IReadOnlyList schemas) + private static void AppendGeneratedConfigCatalogHeader(StringBuilder builder) { builder.AppendLine("/// "); builder.AppendLine( @@ -2848,6 +3293,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine("/// "); builder.AppendLine("public static class GeneratedConfigCatalog"); builder.AppendLine("{"); + } + + private static void AppendGeneratedConfigTableMetadataType(StringBuilder builder) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime."); @@ -2867,6 +3316,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" string tableName,"); builder.AppendLine(" string configRelativePath,"); builder.AppendLine(" string schemaRelativePath)"); + AppendGeneratedConfigTableMetadataConstructorBody(builder); + AppendGeneratedConfigTableMetadataProperties(builder); + builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigTableMetadataConstructorBody(StringBuilder builder) + { builder.AppendLine(" {"); builder.AppendLine( " ConfigDomain = configDomain ?? throw new global::System.ArgumentNullException(nameof(configDomain));"); @@ -2877,6 +3333,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( " SchemaRelativePath = schemaRelativePath ?? throw new global::System.ArgumentNullException(nameof(schemaRelativePath));"); builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigTableMetadataProperties(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the logical config domain derived from the schema base name."); @@ -2899,7 +3359,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" /// Gets the relative schema file path collected by the source generator."); builder.AppendLine(" /// "); builder.AppendLine(" public string SchemaRelativePath { get; }"); - builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigTablesProperty( + StringBuilder builder, + IReadOnlyList schemas) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -2919,6 +3384,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } builder.AppendLine(" });"); + } + + private static void AppendGeneratedConfigResolveAbsolutePathMethod(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -2953,6 +3422,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( " return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);"); builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigTryGetByTableNameMethod( + StringBuilder builder, + IReadOnlyList schemas) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name."); @@ -2986,6 +3461,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" metadata = default;"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigGetTablesInConfigDomainMethod(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -3018,6 +3497,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( " return matchedTables.Count == 0 ? global::System.Array.Empty() : matchedTables.ToArray();"); builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigGetTablesForRegistrationMethod(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -3042,6 +3525,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( " return matchedTables.Count == 0 ? global::System.Array.Empty() : matchedTables.ToArray();"); builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigMatchesRegistrationOptionsMethod(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -3076,6 +3563,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); builder.AppendLine(" return options.TableFilter?.Invoke(metadata) ?? true;"); builder.AppendLine(" }"); + } + + private static void AppendGeneratedConfigAllowListMethod(StringBuilder builder) + { builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -3106,7 +3597,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); builder.AppendLine(" return false;"); builder.AppendLine(" }"); - builder.AppendLine("}"); } /// diff --git a/GFramework.SourceGenerators.Tests/Analyzers/ContextRegistrationAnalyzerTests.cs b/GFramework.SourceGenerators.Tests/Analyzers/ContextRegistrationAnalyzerTests.cs index 20d25f24..28a0283b 100644 --- a/GFramework.SourceGenerators.Tests/Analyzers/ContextRegistrationAnalyzerTests.cs +++ b/GFramework.SourceGenerators.Tests/Analyzers/ContextRegistrationAnalyzerTests.cs @@ -132,462 +132,545 @@ public sealed class ContextRegistrationAnalyzerTests } """; - [Test] - public async Task Reports_Warning_When_FieldInjectedModel_Is_Not_Registered() - { - var markup = MarkupTestSource.Parse( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel {|#0:_model|} = null!; - } - - public sealed class GameArchitecture : Architecture - { - protected override void OnInitialize() - { - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); + // Keep scenario fixtures at class scope so MA0051 reduction does not change analyzer inputs or markup spans. + private const string MissingFieldInjectedModelRegistrationSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; - await AnalyzerTestDriver.RunAsync( - markup.Source, - markup.WithSpan( - new DiagnosticResult("GF_ContextRegistration_001", DiagnosticSeverity.Warning) - .WithArguments("IInventoryModel", "InventoryPanelSystem", "GameArchitecture"), - "0")); + public interface IInventoryModel : IModel { } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel {|#0:_model|} = null!; + } + + public sealed class GameArchitecture : Architecture + { + protected override void OnInitialize() + { + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string RegisteredFieldInjectedModelSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryModel : IModel { } + + public sealed class InventoryModel : IInventoryModel { } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel _model = null!; + } + + public sealed class GameArchitecture : Architecture + { + protected override void OnInitialize() + { + RegisterModel(new InventoryModel()); + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string MissingHandWrittenGetSystemRegistrationSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Abstractions.Utility; + using GFramework.Core.Architectures; + using GFramework.Core.Extensions; + + public interface ICombatSystem : ISystem { } + + public sealed class UiUtility : IUtility + { + public void Initialize() + { + {|#0:this.GetSystem()|}; + } + } + + public sealed class GameArchitecture : Architecture + { + protected override void OnInitialize() + { + RegisterUtility(new UiUtility()); + } + } + } + """; + + private const string ModuleProvidedModelRegistrationSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Architectures; + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryModel : IModel { } + + public sealed class InventoryModel : IInventoryModel { } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel _model = null!; + } + + public sealed class InventoryModule : IArchitectureModule + { + public void Install(IArchitecture architecture) + { + architecture.RegisterModel(new InventoryModel()); + } + } + + public sealed class GameArchitecture : Architecture + { + protected override void OnInitialize() + { + InstallModule(new InventoryModule()); + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string AmbiguousOwningArchitectureSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryModel : IModel { } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel _model = null!; + } + + public sealed class FirstArchitecture : Architecture + { + protected override void OnInitialize() + { + RegisterSystem(new InventoryPanelSystem()); + } + } + + public sealed class SecondArchitecture : Architecture + { + protected override void OnInitialize() + { + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string MissingGetUtilitiesRegistrationSource = """ + namespace TestApp + { + using System.Collections.Generic; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Abstractions.Utility; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryUtility : IUtility { } + + public sealed class InventoryPanelSystem : ISystem + { + [GetUtilities] + private IReadOnlyList {|#0:_utilities|} = null!; + } + + public sealed class GameArchitecture : Architecture + { + protected override void OnInitialize() + { + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string DerivedArchitectureVirtualHelperRegistrationSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryModel : IModel { } + + public sealed class InventoryModel : IInventoryModel { } + + public abstract class ArchitectureBase : Architecture + { + protected override void OnInitialize() + { + RegisterComponents(); + } + + protected virtual void RegisterComponents() + { + } + } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel _model = null!; + } + + public sealed class GameArchitecture : ArchitectureBase + { + protected override void RegisterComponents() + { + RegisterModel(new InventoryModel()); + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string DerivedModuleVirtualHelperRegistrationSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Architectures; + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryModel : IModel { } + + public sealed class InventoryModel : IInventoryModel { } + + public abstract class ModuleBase : IArchitectureModule + { + public void Install(IArchitecture architecture) + { + RegisterComponents(architecture); + } + + protected virtual void RegisterComponents(IArchitecture architecture) + { + } + } + + public sealed class DerivedInventoryModule : ModuleBase + { + protected override void RegisterComponents(IArchitecture architecture) + { + architecture.RegisterModel(new InventoryModel()); + } + } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel _model = null!; + } + + public sealed class GameArchitecture : Architecture + { + protected override void OnInitialize() + { + InstallModule(new DerivedInventoryModule()); + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string DerivedArchitectureBaseHelperCallSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryModel : IModel { } + + public sealed class InventoryModel : IInventoryModel { } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel {|#0:_model|} = null!; + } + + public abstract class ArchitectureBase : Architecture + { + protected virtual void RegisterComponents() + { + RegisterSystem(new InventoryPanelSystem()); + } + } + + public sealed class GameArchitecture : ArchitectureBase + { + protected override void OnInitialize() + { + base.RegisterComponents(); + } + + protected override void RegisterComponents() + { + RegisterModel(new InventoryModel()); + RegisterSystem(new InventoryPanelSystem()); + } + } + } + """; + + private const string DerivedModuleBaseHelperCallSource = """ + namespace TestApp + { + using GFramework.Core.Abstractions.Architectures; + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Architectures; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + public interface IInventoryModel : IModel { } + + public sealed class InventoryModel : IInventoryModel { } + + public sealed class InventoryPanelSystem : ISystem + { + [GetModel] + private IInventoryModel {|#0:_model|} = null!; + } + + public abstract class ModuleBase : IArchitectureModule + { + public virtual void Install(IArchitecture architecture) + { + } + + protected virtual void RegisterComponents(IArchitecture architecture) + { + architecture.RegisterSystem(new InventoryPanelSystem()); + } + } + + public sealed class DerivedInventoryModule : ModuleBase + { + public override void Install(IArchitecture architecture) + { + base.RegisterComponents(architecture); + } + + protected override void RegisterComponents(IArchitecture architecture) + { + architecture.RegisterModel(new InventoryModel()); + architecture.RegisterSystem(new InventoryPanelSystem()); + } + } + + public sealed class GameArchitecture : Architecture + { + protected override void OnInitialize() + { + InstallModule(new DerivedInventoryModule()); + } + } + } + """; + + /// + /// 验证字段注入模型未注册时会报告缺失注册告警。 + /// + [Test] + public Task Reports_Warning_When_FieldInjectedModel_Is_Not_Registered() + { + return RunWarningScenarioAsync( + MissingFieldInjectedModelRegistrationSource, + CreateContextRegistrationWarning( + "GF_ContextRegistration_001", + "IInventoryModel", + "InventoryPanelSystem", + "GameArchitecture")); } + /// + /// 验证字段注入模型已注册时不会产生误报。 + /// [Test] - public async Task Does_Not_Report_When_FieldInjectedModel_Is_Registered() + public Task Does_Not_Report_When_FieldInjectedModel_Is_Registered() { - await AnalyzerTestDriver.RunAsync( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryModel : IInventoryModel { } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel _model = null!; - } - - public sealed class GameArchitecture : Architecture - { - protected override void OnInitialize() - { - RegisterModel(new InventoryModel()); - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); + return RunNoDiagnosticScenarioAsync(RegisteredFieldInjectedModelSource); } + /// + /// 验证手写扩展方法访问未注册 System 时会报告缺失注册告警。 + /// [Test] - public async Task Reports_Warning_When_HandWrittenGetSystem_Call_Has_No_Registration() + public Task Reports_Warning_When_HandWrittenGetSystem_Call_Has_No_Registration() { - var markup = MarkupTestSource.Parse( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Abstractions.Utility; - using GFramework.Core.Architectures; - using GFramework.Core.Extensions; - - public interface ICombatSystem : ISystem { } - - public sealed class UiUtility : IUtility - { - public void Initialize() - { - {|#0:this.GetSystem()|}; - } - } - - public sealed class GameArchitecture : Architecture - { - protected override void OnInitialize() - { - RegisterUtility(new UiUtility()); - } - } - } - """)); - - await AnalyzerTestDriver.RunAsync( - markup.Source, - markup.WithSpan( - new DiagnosticResult("GF_ContextRegistration_002", DiagnosticSeverity.Warning) - .WithArguments("ICombatSystem", "UiUtility", "GameArchitecture"), - "0")); + return RunWarningScenarioAsync( + MissingHandWrittenGetSystemRegistrationSource, + CreateContextRegistrationWarning( + "GF_ContextRegistration_002", + "ICombatSystem", + "UiUtility", + "GameArchitecture")); } + /// + /// 验证模块安装链路提供注册时分析器会把该注册视为有效来源。 + /// [Test] - public async Task Does_Not_Report_When_Registration_Comes_From_Installed_Module() + public Task Does_Not_Report_When_Registration_Comes_From_Installed_Module() { - await AnalyzerTestDriver.RunAsync( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Architectures; - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryModel : IInventoryModel { } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel _model = null!; - } - - public sealed class InventoryModule : IArchitectureModule - { - public void Install(IArchitecture architecture) - { - architecture.RegisterModel(new InventoryModel()); - } - } - - public sealed class GameArchitecture : Architecture - { - protected override void OnInitialize() - { - InstallModule(new InventoryModule()); - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); + return RunNoDiagnosticScenarioAsync(ModuleProvidedModelRegistrationSource); } + /// + /// 验证无法唯一推导所属 Architecture 时分析器保持静默以避免误报。 + /// [Test] - public async Task Does_Not_Report_When_Owning_Architecture_Cannot_Be_Uniquely_Determined() + public Task Does_Not_Report_When_Owning_Architecture_Cannot_Be_Uniquely_Determined() { - await AnalyzerTestDriver.RunAsync( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel _model = null!; - } - - public sealed class FirstArchitecture : Architecture - { - protected override void OnInitialize() - { - RegisterSystem(new InventoryPanelSystem()); - } - } - - public sealed class SecondArchitecture : Architecture - { - protected override void OnInitialize() - { - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); + return RunNoDiagnosticScenarioAsync(AmbiguousOwningArchitectureSource); } + /// + /// 验证集合注入 Utility 缺失注册时仍会报告对应告警。 + /// [Test] - public async Task Reports_Warning_When_GetUtilities_Field_Has_No_Registered_Utility() + public Task Reports_Warning_When_GetUtilities_Field_Has_No_Registered_Utility() { - var markup = MarkupTestSource.Parse( - Wrap(""" - namespace TestApp - { - using System.Collections.Generic; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Abstractions.Utility; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryUtility : IUtility { } - - public sealed class InventoryPanelSystem : ISystem - { - [GetUtilities] - private IReadOnlyList {|#0:_utilities|} = null!; - } - - public sealed class GameArchitecture : Architecture - { - protected override void OnInitialize() - { - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); - - await AnalyzerTestDriver.RunAsync( - markup.Source, - markup.WithSpan( - new DiagnosticResult("GF_ContextRegistration_003", DiagnosticSeverity.Warning) - .WithArguments("IInventoryUtility", "InventoryPanelSystem", "GameArchitecture"), - "0")); + return RunWarningScenarioAsync( + MissingGetUtilitiesRegistrationSource, + CreateContextRegistrationWarning( + "GF_ContextRegistration_003", + "IInventoryUtility", + "InventoryPanelSystem", + "GameArchitecture")); } + /// + /// 验证基类初始化经由虚方法分派到派生实现时,派生注册仍会被识别。 + /// [Test] - public async Task + public Task Does_Not_Report_When_Inherited_OnInitialize_Calls_Virtual_Helper_Overridden_In_Derived_Architecture() { - await AnalyzerTestDriver.RunAsync( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryModel : IInventoryModel { } - - public abstract class ArchitectureBase : Architecture - { - protected override void OnInitialize() - { - RegisterComponents(); - } - - protected virtual void RegisterComponents() - { - } - } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel _model = null!; - } - - public sealed class GameArchitecture : ArchitectureBase - { - protected override void RegisterComponents() - { - RegisterModel(new InventoryModel()); - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); + return RunNoDiagnosticScenarioAsync(DerivedArchitectureVirtualHelperRegistrationSource); } + /// + /// 验证模块基类通过虚方法转发注册时,派生模块的注册依然会被识别。 + /// [Test] - public async Task Does_Not_Report_When_Inherited_Module_Install_Calls_Virtual_Helper_Overridden_In_Derived_Module() + public Task Does_Not_Report_When_Inherited_Module_Install_Calls_Virtual_Helper_Overridden_In_Derived_Module() { - await AnalyzerTestDriver.RunAsync( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Architectures; - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryModel : IInventoryModel { } - - public abstract class ModuleBase : IArchitectureModule - { - public void Install(IArchitecture architecture) - { - RegisterComponents(architecture); - } - - protected virtual void RegisterComponents(IArchitecture architecture) - { - } - } - - public sealed class DerivedInventoryModule : ModuleBase - { - protected override void RegisterComponents(IArchitecture architecture) - { - architecture.RegisterModel(new InventoryModel()); - } - } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel _model = null!; - } - - public sealed class GameArchitecture : Architecture - { - protected override void OnInitialize() - { - InstallModule(new DerivedInventoryModule()); - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); + return RunNoDiagnosticScenarioAsync(DerivedModuleVirtualHelperRegistrationSource); } + /// + /// 验证显式调用基类 helper 时,分析器按基类实际执行的注册路径发出告警。 + /// [Test] - public async Task Reports_Warning_When_Derived_Architecture_Explicitly_Calls_Base_Helper() + public Task Reports_Warning_When_Derived_Architecture_Explicitly_Calls_Base_Helper() { - var markup = MarkupTestSource.Parse( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryModel : IInventoryModel { } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel {|#0:_model|} = null!; - } - - public abstract class ArchitectureBase : Architecture - { - protected virtual void RegisterComponents() - { - RegisterSystem(new InventoryPanelSystem()); - } - } - - public sealed class GameArchitecture : ArchitectureBase - { - protected override void OnInitialize() - { - base.RegisterComponents(); - } - - protected override void RegisterComponents() - { - RegisterModel(new InventoryModel()); - RegisterSystem(new InventoryPanelSystem()); - } - } - } - """)); + return RunWarningScenarioAsync( + DerivedArchitectureBaseHelperCallSource, + CreateContextRegistrationWarning( + "GF_ContextRegistration_001", + "IInventoryModel", + "InventoryPanelSystem", + "GameArchitecture")); + } - await AnalyzerTestDriver.RunAsync( + /// + /// 验证模块显式调用基类 helper 时,分析器按实际执行的安装路径发出告警。 + /// + [Test] + public Task Reports_Warning_When_Derived_Module_Explicitly_Calls_Base_Helper() + { + return RunWarningScenarioAsync( + DerivedModuleBaseHelperCallSource, + CreateContextRegistrationWarning( + "GF_ContextRegistration_001", + "IInventoryModel", + "InventoryPanelSystem", + "GameArchitecture")); + } + + /// + /// 运行包含诊断标记的 analyzer 场景,并把预期诊断绑定到统一的 `#0` span。 + /// + /// 不含公共前导代码的测试源码。 + /// 需要命中的预期诊断。 + /// 代表 analyzer 验证流程的异步任务。 + private static Task RunWarningScenarioAsync(string source, DiagnosticResult expectedDiagnostic) + { + MarkupTestSource markup = MarkupTestSource.Parse(Wrap(source)); + return AnalyzerTestDriver.RunAsync( markup.Source, - markup.WithSpan( - new DiagnosticResult("GF_ContextRegistration_001", DiagnosticSeverity.Warning) - .WithArguments("IInventoryModel", "InventoryPanelSystem", "GameArchitecture"), - "0")); + markup.WithSpan(expectedDiagnostic, "0")); } - [Test] - public async Task Reports_Warning_When_Derived_Module_Explicitly_Calls_Base_Helper() + /// + /// 运行不应产生诊断的 analyzer 场景。 + /// + /// 不含公共前导代码的测试源码。 + /// 代表 analyzer 验证流程的异步任务。 + private static Task RunNoDiagnosticScenarioAsync(string source) { - var markup = MarkupTestSource.Parse( - Wrap(""" - namespace TestApp - { - using GFramework.Core.Abstractions.Architectures; - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Architectures; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - public interface IInventoryModel : IModel { } - - public sealed class InventoryModel : IInventoryModel { } - - public sealed class InventoryPanelSystem : ISystem - { - [GetModel] - private IInventoryModel {|#0:_model|} = null!; - } - - public abstract class ModuleBase : IArchitectureModule - { - public virtual void Install(IArchitecture architecture) - { - } - - protected virtual void RegisterComponents(IArchitecture architecture) - { - architecture.RegisterSystem(new InventoryPanelSystem()); - } - } - - public sealed class DerivedInventoryModule : ModuleBase - { - public override void Install(IArchitecture architecture) - { - base.RegisterComponents(architecture); - } - - protected override void RegisterComponents(IArchitecture architecture) - { - architecture.RegisterModel(new InventoryModel()); - architecture.RegisterSystem(new InventoryPanelSystem()); - } - } - - public sealed class GameArchitecture : Architecture - { - protected override void OnInitialize() - { - InstallModule(new DerivedInventoryModule()); - } - } - } - """)); - - await AnalyzerTestDriver.RunAsync( - markup.Source, - markup.WithSpan( - new DiagnosticResult("GF_ContextRegistration_001", DiagnosticSeverity.Warning) - .WithArguments("IInventoryModel", "InventoryPanelSystem", "GameArchitecture"), - "0")); + return AnalyzerTestDriver.RunAsync(Wrap(source)); } + /// + /// 构造 Context 注册分析器的统一预期诊断,以保持断言参数顺序稳定。 + /// + /// 预期诊断 ID。 + /// 缺失注册的服务或依赖类型。 + /// 触发访问的拥有者类型。 + /// 推导出的所属 Architecture 类型。 + /// 配置好参数的预期诊断结果。 + private static DiagnosticResult CreateContextRegistrationWarning( + string diagnosticId, + string serviceType, + string ownerType, + string architectureType) + { + return new DiagnosticResult(diagnosticId, DiagnosticSeverity.Warning) + .WithArguments(serviceType, ownerType, architectureType); + } + + /// + /// 将公共测试前导代码与具体场景源码拼接为完整编译单元。 + /// + /// 具体测试场景源码。 + /// 包含公共前导代码的完整源码文本。 private static string Wrap(string source) { return $"{TestPreamble}{Environment.NewLine}{Environment.NewLine}{source}"; diff --git a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs index 5eafa7c2..513f3693 100644 --- a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs @@ -3,110 +3,324 @@ using GFramework.SourceGenerators.Tests.Core; namespace GFramework.SourceGenerators.Tests.Architectures; +/// +/// 验证 在模块自动注册场景下的生成契约与输出顺序。 +/// [TestFixture] public class AutoRegisterModuleGeneratorTests { + private const string AttributeOrderSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Architectures; + + namespace GFramework.Core.SourceGenerators.Abstractions.Architectures + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoRegisterModuleAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterModelAttribute : Attribute + { + public RegisterModelAttribute(Type modelType) { } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterSystemAttribute : Attribute + { + public RegisterSystemAttribute(Type systemType) { } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterUtilityAttribute : Attribute + { + public RegisterUtilityAttribute(Type utilityType) { } + } + } + + namespace GFramework.Core.Abstractions.Architectures + { + public interface IArchitecture + { + T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel; + T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem; + T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility; + } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Abstractions.Utility; + using GFramework.Core.SourceGenerators.Abstractions.Architectures; + + public sealed class PlayerModel : IModel { } + public sealed class CombatSystem : ISystem { } + public sealed class AudioUtility : IUtility { } + + [AutoRegisterModule] + [RegisterSystem(typeof(CombatSystem))] + [RegisterModel(typeof(PlayerModel))] + [RegisterUtility(typeof(AudioUtility))] + public partial class GameplayModule + { + } + } + """; + + private const string AttributeOrderExpected = """ + // + #nullable enable + + namespace TestApp; + + partial class GameplayModule + { + public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture) + { + architecture.RegisterSystem(new global::TestApp.CombatSystem()); + architecture.RegisterModel(new global::TestApp.PlayerModel()); + architecture.RegisterUtility(new global::TestApp.AudioUtility()); + } + } + + """; + + private const string DeterministicOrderCommonSource = """ + using System; + + namespace GFramework.Core.SourceGenerators.Abstractions.Architectures + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoRegisterModuleAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterModelAttribute : Attribute + { + public RegisterModelAttribute(Type modelType) { } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterSystemAttribute : Attribute + { + public RegisterSystemAttribute(Type systemType) { } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterUtilityAttribute : Attribute + { + public RegisterUtilityAttribute(Type utilityType) { } + } + } + + namespace GFramework.Core.Abstractions.Architectures + { + public interface IArchitecture + { + T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel; + T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem; + T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility; + } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.Abstractions.Systems; + using GFramework.Core.Abstractions.Utility; + + public sealed class PlayerModel : IModel { } + public sealed class CombatSystem : ISystem { } + public sealed class AudioUtility : IUtility { } + } + """; + + private const string DeterministicOrderPartASource = """ + namespace TestApp + { + using GFramework.Core.SourceGenerators.Abstractions.Architectures; + + // Padding ensures this attribute lives later in the file than the attributes in PartB. + // The generator should still place it first because PartA sorts before PartB. + // padding 01 + // padding 02 + // padding 03 + // padding 04 + // padding 05 + // padding 06 + // padding 07 + // padding 08 + // padding 09 + // padding 10 + [AutoRegisterModule] + [RegisterUtility(typeof(AudioUtility))] + public partial class GameplayModule + { + } + } + """; + + private const string DeterministicOrderPartBSource = """ + namespace TestApp + { + using GFramework.Core.SourceGenerators.Abstractions.Architectures; + + [RegisterSystem(typeof(CombatSystem))] + [RegisterModel(typeof(PlayerModel))] + public partial class GameplayModule + { + } + } + """; + + private const string DeterministicOrderExpected = """ + // + #nullable enable + + namespace TestApp; + + partial class GameplayModule + { + public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture) + { + architecture.RegisterUtility(new global::TestApp.AudioUtility()); + architecture.RegisterSystem(new global::TestApp.CombatSystem()); + architecture.RegisterModel(new global::TestApp.PlayerModel()); + } + } + + """; + + private const string TypeConstraintSource = """ + #nullable enable + using System; + using GFramework.Core.SourceGenerators.Abstractions.Architectures; + + namespace GFramework.Core.SourceGenerators.Abstractions.Architectures + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoRegisterModuleAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterModelAttribute : Attribute + { + public RegisterModelAttribute(Type modelType) { } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterSystemAttribute : Attribute + { + public RegisterSystemAttribute(Type systemType) { } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class RegisterUtilityAttribute : Attribute + { + public RegisterUtilityAttribute(Type utilityType) { } + } + } + + namespace GFramework.Core.Abstractions.Architectures + { + public interface IArchitecture + { + T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel; + T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem; + T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility; + } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace TestApp + { + using GFramework.Core.Abstractions.Model; + using GFramework.Core.SourceGenerators.Abstractions.Architectures; + + public sealed class PlayerModel : IModel { } + + [AutoRegisterModule] + [RegisterModel(typeof(PlayerModel))] + public partial class GameplayModule + where TNullableRef : class? + where TNotNull : notnull + where TUnmanaged : unmanaged + { + } + } + """; + + private const string TypeConstraintExpected = """ + // + #nullable enable + + namespace TestApp; + + partial class GameplayModule + where TNullableRef : class? + where TNotNull : notnull + where TUnmanaged : unmanaged + { + public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture) + { + architecture.RegisterModel(new global::TestApp.PlayerModel()); + } + } + + """; + /// /// 验证同一声明上的注册特性会按照源码中的书写顺序生成安装代码。 /// [Test] - public async Task Generates_Module_Install_Method_In_Attribute_Order() + public Task Generates_Module_Install_Method_In_Attribute_Order() { - const string source = """ - using System; - using GFramework.Core.SourceGenerators.Abstractions.Architectures; - - namespace GFramework.Core.SourceGenerators.Abstractions.Architectures - { - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class AutoRegisterModuleAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterModelAttribute : Attribute - { - public RegisterModelAttribute(Type modelType) { } - } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterSystemAttribute : Attribute - { - public RegisterSystemAttribute(Type systemType) { } - } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterUtilityAttribute : Attribute - { - public RegisterUtilityAttribute(Type utilityType) { } - } - } - - namespace GFramework.Core.Abstractions.Architectures - { - public interface IArchitecture - { - T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel; - T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem; - T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility; - } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Abstractions.Utility; - using GFramework.Core.SourceGenerators.Abstractions.Architectures; - - public sealed class PlayerModel : IModel { } - public sealed class CombatSystem : ISystem { } - public sealed class AudioUtility : IUtility { } - - [AutoRegisterModule] - [RegisterSystem(typeof(CombatSystem))] - [RegisterModel(typeof(PlayerModel))] - [RegisterUtility(typeof(AudioUtility))] - public partial class GameplayModule - { - } - } - """; - - const string expected = """ - // - #nullable enable - - namespace TestApp; - - partial class GameplayModule - { - public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture) - { - architecture.RegisterSystem(new global::TestApp.CombatSystem()); - architecture.RegisterModel(new global::TestApp.PlayerModel()); - architecture.RegisterUtility(new global::TestApp.AudioUtility()); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_GameplayModule.AutoRegisterModule.g.cs", expected)); + return GeneratorTest.RunAsync( + AttributeOrderSource, + ("TestApp_GameplayModule.AutoRegisterModule.g.cs", AttributeOrderExpected)); } /// @@ -115,140 +329,20 @@ public class AutoRegisterModuleGeneratorTests [Test] public async Task Generates_Module_Install_Method_In_Deterministic_Order_Across_Partial_Declarations() { - const string commonSource = """ - using System; - - namespace GFramework.Core.SourceGenerators.Abstractions.Architectures - { - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class AutoRegisterModuleAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterModelAttribute : Attribute - { - public RegisterModelAttribute(Type modelType) { } - } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterSystemAttribute : Attribute - { - public RegisterSystemAttribute(Type systemType) { } - } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterUtilityAttribute : Attribute - { - public RegisterUtilityAttribute(Type utilityType) { } - } - } - - namespace GFramework.Core.Abstractions.Architectures - { - public interface IArchitecture - { - T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel; - T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem; - T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility; - } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.Abstractions.Systems; - using GFramework.Core.Abstractions.Utility; - - public sealed class PlayerModel : IModel { } - public sealed class CombatSystem : ISystem { } - public sealed class AudioUtility : IUtility { } - } - """; - - const string partASource = """ - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Architectures; - - // Padding ensures this attribute lives later in the file than the attributes in PartB. - // The generator should still place it first because PartA sorts before PartB. - // padding 01 - // padding 02 - // padding 03 - // padding 04 - // padding 05 - // padding 06 - // padding 07 - // padding 08 - // padding 09 - // padding 10 - [AutoRegisterModule] - [RegisterUtility(typeof(AudioUtility))] - public partial class GameplayModule - { - } - } - """; - - const string partBSource = """ - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Architectures; - - [RegisterSystem(typeof(CombatSystem))] - [RegisterModel(typeof(PlayerModel))] - public partial class GameplayModule - { - } - } - """; - - const string expected = """ - // - #nullable enable - - namespace TestApp; - - partial class GameplayModule - { - public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture) - { - architecture.RegisterUtility(new global::TestApp.AudioUtility()); - architecture.RegisterSystem(new global::TestApp.CombatSystem()); - architecture.RegisterModel(new global::TestApp.PlayerModel()); - } - } - - """; - var test = new CSharpSourceGeneratorTest { TestState = { Sources = { - ("Common.cs", commonSource), - ("GameplayModule.PartA.cs", partASource), - ("GameplayModule.PartB.cs", partBSource) + ("Common.cs", DeterministicOrderCommonSource), + ("GameplayModule.PartA.cs", DeterministicOrderPartASource), + ("GameplayModule.PartB.cs", DeterministicOrderPartBSource) }, GeneratedSources = { (typeof(AutoRegisterModuleGenerator), "TestApp_GameplayModule.AutoRegisterModule.g.cs", - NormalizeLineEndings(expected)) + NormalizeLineEndings(DeterministicOrderExpected)) } }, DisabledDiagnostics = { "GF_Common_Trace_001" } @@ -261,102 +355,11 @@ public class AutoRegisterModuleGeneratorTests /// 验证生成器会保留可空引用、notnull 与 unmanaged 约束。 /// [Test] - public async Task Generates_Type_Constraints_For_NullableReference_NotNull_And_Unmanaged() + public Task Generates_Type_Constraints_For_NullableReference_NotNull_And_Unmanaged() { - const string source = """ - #nullable enable - using System; - using GFramework.Core.SourceGenerators.Abstractions.Architectures; - - namespace GFramework.Core.SourceGenerators.Abstractions.Architectures - { - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class AutoRegisterModuleAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterModelAttribute : Attribute - { - public RegisterModelAttribute(Type modelType) { } - } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterSystemAttribute : Attribute - { - public RegisterSystemAttribute(Type systemType) { } - } - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RegisterUtilityAttribute : Attribute - { - public RegisterUtilityAttribute(Type utilityType) { } - } - } - - namespace GFramework.Core.Abstractions.Architectures - { - public interface IArchitecture - { - T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel; - T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem; - T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility; - } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace TestApp - { - using GFramework.Core.Abstractions.Model; - using GFramework.Core.SourceGenerators.Abstractions.Architectures; - - public sealed class PlayerModel : IModel { } - - [AutoRegisterModule] - [RegisterModel(typeof(PlayerModel))] - public partial class GameplayModule - where TNullableRef : class? - where TNotNull : notnull - where TUnmanaged : unmanaged - { - } - } - """; - - const string expected = """ - // - #nullable enable - - namespace TestApp; - - partial class GameplayModule - where TNullableRef : class? - where TNotNull : notnull - where TUnmanaged : unmanaged - { - public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture) - { - architecture.RegisterModel(new global::TestApp.PlayerModel()); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_GameplayModule.AutoRegisterModule.g.cs", expected)); + return GeneratorTest.RunAsync( + TypeConstraintSource, + ("TestApp_GameplayModule.AutoRegisterModule.g.cs", TypeConstraintExpected)); } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs index 801a343c..7b123753 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs @@ -13,7 +13,7 @@ public class SchemaConfigGeneratorEnumTests /// 验证对象 enum 文档输出与快照保持一致。 /// [Test] - public async Task Snapshot_Should_Preserve_Object_Enum_Documentation() + public Task Snapshot_Should_Preserve_Object_Enum_Documentation() { const string source = """ namespace TestApp @@ -51,14 +51,14 @@ public class SchemaConfigGeneratorEnumTests ("monster.schema.json", schema)); Assert.That(result.Results.Single().Diagnostics, Is.Empty); - await AssertSnapshotAsync(result, "MonsterConfig.ObjectEnum.g.txt"); + return AssertSnapshotAsync(result, "MonsterConfig.ObjectEnum.g.txt"); } /// /// 验证数组项 enum 文档回退输出与快照保持一致。 /// [Test] - public async Task Snapshot_Should_Preserve_Array_Item_Enum_Documentation_Fallback() + public Task Snapshot_Should_Preserve_Array_Item_Enum_Documentation_Fallback() { const string source = """ namespace TestApp @@ -88,14 +88,14 @@ public class SchemaConfigGeneratorEnumTests ("monster.schema.json", schema)); Assert.That(result.Results.Single().Diagnostics, Is.Empty); - await AssertSnapshotAsync(result, "MonsterConfig.ArrayItemEnum.g.txt"); + return AssertSnapshotAsync(result, "MonsterConfig.ArrayItemEnum.g.txt"); } /// /// 验证对象数组项 enum 文档回退输出与快照保持一致。 /// [Test] - public async Task Snapshot_Should_Preserve_Array_Object_Item_Enum_Documentation_Fallback() + public Task Snapshot_Should_Preserve_Array_Object_Item_Enum_Documentation_Fallback() { const string source = """ namespace TestApp @@ -136,7 +136,7 @@ public class SchemaConfigGeneratorEnumTests ("monster.schema.json", schema)); Assert.That(result.Results.Single().Diagnostics, Is.Empty); - await AssertSnapshotAsync(result, "MonsterConfig.ArrayObjectItemEnum.g.txt"); + return AssertSnapshotAsync(result, "MonsterConfig.ArrayObjectItemEnum.g.txt"); } /// @@ -176,11 +176,11 @@ public class SchemaConfigGeneratorEnumTests if (!File.Exists(path)) { Directory.CreateDirectory(snapshotFolder); - await File.WriteAllTextAsync(path, actual); + await File.WriteAllTextAsync(path, actual).ConfigureAwait(false); Assert.Fail($"Snapshot not found. Generated new snapshot at:\n{path}"); } - var expected = await File.ReadAllTextAsync(path); + var expected = await File.ReadAllTextAsync(path).ConfigureAwait(false); Assert.That( Normalize(expected), Is.EqualTo(Normalize(actual)), diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 71c0065c..78741010 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -8,171 +8,241 @@ namespace GFramework.SourceGenerators.Tests.Config; [TestFixture] public class SchemaConfigGeneratorSnapshotTests { + private const string RuntimeContractsSource = """ + using System; + using System.Collections.Generic; + + namespace GFramework.Game.Abstractions.Config + { + public interface IConfigTable + { + Type KeyType { get; } + Type ValueType { get; } + int Count { get; } + } + + public interface IConfigTable : IConfigTable + where TKey : notnull + { + TValue Get(TKey key); + bool TryGet(TKey key, out TValue? value); + bool ContainsKey(TKey key); + IReadOnlyCollection All(); + } + + public interface IConfigRegistry + { + IConfigTable GetTable(string name) + where TKey : notnull; + + bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull; + } + } + + namespace GFramework.Game.Config + { + public sealed class YamlConfigLoader + { + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + string schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + return this; + } + } + } + """; + + private const string MonsterSchema = """ + { + "title": "Monster Config", + "description": "Represents one monster entry generated from schema metadata.", + "type": "object", + "minProperties": 4, + "maxProperties": 8, + "required": ["id", "name", "reward", "phases"], + "properties": { + "id": { + "type": "integer", + "description": "Unique monster identifier." + }, + "name": { + "type": "string", + "title": "Monster Name", + "description": "Localized monster display name.", + "x-gframework-index": true, + "minLength": 3, + "maxLength": 16, + "pattern": "^[A-Z][a-z]+$", + "default": "Slime", + "enum": ["Slime", "Goblin"] + }, + "hp": { + "type": "integer", + "const": 10, + "minimum": 1, + "maximum": 999, + "exclusiveMinimum": 0, + "exclusiveMaximum": 1000, + "multipleOf": 5, + "default": 10 + }, + "dropItems": { + "description": "Referenced drop ids.", + "type": "array", + "minItems": 1, + "maxItems": 3, + "minContains": 1, + "maxContains": 2, + "uniqueItems": true, + "contains": { + "type": "string", + "const": "potion" + }, + "items": { + "type": "string", + "minLength": 3, + "maxLength": 12, + "enum": ["potion", "slime_gel"] + }, + "default": ["potion"], + "x-gframework-ref-table": "item" + }, + "reward": { + "type": "object", + "description": "Reward payload.", + "minProperties": 2, + "maxProperties": 2, + "required": ["gold", "currency"], + "properties": { + "gold": { + "type": "integer", + "minimum": 0, + "default": 10 + }, + "currency": { + "type": "string", + "enum": ["coin", "gem"] + } + }, + "dependentRequired": { + "currency": ["gold"] + }, + "dependentSchemas": { + "currency": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { + "type": "integer" + } + } + } + }, + "allOf": [ + { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { + "type": "integer" + } + } + } + ], + "if": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "const": "gem" + } + } + }, + "then": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { + "type": "integer" + } + } + }, + "else": { + "type": "object", + "required": ["currency"], + "properties": { + "currency": { + "type": "string" + } + } + } + }, + "phases": { + "type": "array", + "description": "Encounter phases.", + "items": { + "type": "object", + "required": ["wave", "monsterId"], + "properties": { + "wave": { + "type": "integer" + }, + "monsterId": { + "type": "string", + "description": "Monster reference id.", + "minLength": 2, + "maxLength": 32, + "x-gframework-ref-table": "monster" + } + } + } + } + } + } + """; + /// /// 验证一个最小 monster schema 能生成配置类型、表包装和注册辅助。 /// [Test] - public async Task Snapshot_SchemaConfigGenerator() + public Task Snapshot_SchemaConfigGenerator() { - const string source = """ - using System; - using System.Collections.Generic; - - namespace GFramework.Game.Abstractions.Config - { - public interface IConfigTable - { - Type KeyType { get; } - Type ValueType { get; } - int Count { get; } - } - - public interface IConfigTable : IConfigTable - where TKey : notnull - { - TValue Get(TKey key); - bool TryGet(TKey key, out TValue? value); - bool ContainsKey(TKey key); - IReadOnlyCollection All(); - } - - public interface IConfigRegistry - { - IConfigTable GetTable(string name) - where TKey : notnull; - - bool TryGetTable(string name, out IConfigTable? table) - where TKey : notnull; - } - } - - namespace GFramework.Game.Config - { - public sealed class YamlConfigLoader - { - public YamlConfigLoader RegisterTable( - string tableName, - string relativePath, - string schemaRelativePath, - Func keySelector, - IEqualityComparer? comparer = null) - where TKey : notnull - { - return this; - } - } - } - """; - - const string schema = """ - { - "title": "Monster Config", - "description": "Represents one monster entry generated from schema metadata.", - "type": "object", - "minProperties": 4, - "maxProperties": 8, - "required": ["id", "name", "reward", "phases"], - "properties": { - "id": { - "type": "integer", - "description": "Unique monster identifier." - }, - "name": { - "type": "string", - "title": "Monster Name", - "description": "Localized monster display name.", - "x-gframework-index": true, - "minLength": 3, - "maxLength": 16, - "pattern": "^[A-Z][a-z]+$", - "default": "Slime", - "enum": ["Slime", "Goblin"] - }, - "hp": { - "type": "integer", - "const": 10, - "minimum": 1, - "maximum": 999, - "exclusiveMinimum": 0, - "exclusiveMaximum": 1000, - "multipleOf": 5, - "default": 10 - }, - "dropItems": { - "description": "Referenced drop ids.", - "type": "array", - "minItems": 1, - "maxItems": 3, - "minContains": 1, - "maxContains": 2, - "uniqueItems": true, - "contains": { - "type": "string", - "const": "potion" - }, - "items": { - "type": "string", - "minLength": 3, - "maxLength": 12, - "enum": ["potion", "slime_gel"] - }, - "default": ["potion"], - "x-gframework-ref-table": "item" - }, - "reward": { - "type": "object", - "description": "Reward payload.", - "minProperties": 2, - "maxProperties": 2, - "required": ["gold", "currency"], - "properties": { - "gold": { - "type": "integer", - "minimum": 0, - "default": 10 - }, - "currency": { - "type": "string", - "enum": ["coin", "gem"] - } - } - }, - "phases": { - "type": "array", - "description": "Encounter phases.", - "items": { - "type": "object", - "required": ["wave", "monsterId"], - "properties": { - "wave": { - "type": "integer" - }, - "monsterId": { - "type": "string", - "description": "Monster reference id.", - "minLength": 2, - "maxLength": 32, - "x-gframework-ref-table": "monster" - } - } - } - } - } - } - """; + var generatedSources = GenerateSourcesForMonsterSchema(); + var snapshotFolder = GetSchemaSnapshotFolder(); + return AssertAllSnapshotsAsync(generatedSources, snapshotFolder); + } + /// + /// 运行 monster schema 场景,并把生成结果转换为按 hint name 索引的字典。 + /// + /// 当前快照场景的全部生成文件内容。 + private static IReadOnlyDictionary GenerateSourcesForMonsterSchema() + { var result = SchemaGeneratorTestDriver.Run( - source, - ("monster.schema.json", schema)); + RuntimeContractsSource, + ("monster.schema.json", MonsterSchema)); - var generatedSources = result.Results + return result.Results .Single() .GeneratedSources .ToDictionary( static sourceResult => sourceResult.HintName, static sourceResult => sourceResult.SourceText.ToString(), StringComparer.Ordinal); + } + /// + /// 解析 schema 生成器快照目录,确保断言始终落在仓库内已提交的 snapshot 资产上。 + /// + /// schema 生成器快照目录的绝对路径。 + private static string GetSchemaSnapshotFolder() + { var snapshotFolder = Path.Combine( TestContext.CurrentContext.TestDirectory, "..", @@ -181,14 +251,7 @@ public class SchemaConfigGeneratorSnapshotTests "Config", "snapshots", "SchemaConfigGenerator"); - snapshotFolder = Path.GetFullPath(snapshotFolder); - - await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt"); - await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt"); - await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfigBindings.g.cs", - "MonsterConfigBindings.g.txt"); - await AssertSnapshotAsync(generatedSources, snapshotFolder, "GeneratedConfigCatalog.g.cs", - "GeneratedConfigCatalog.g.txt"); + return Path.GetFullPath(snapshotFolder); } /// @@ -213,17 +276,45 @@ public class SchemaConfigGeneratorSnapshotTests if (!File.Exists(path)) { Directory.CreateDirectory(snapshotFolder); - await File.WriteAllTextAsync(path, actual); + await File.WriteAllTextAsync(path, actual).ConfigureAwait(false); Assert.Fail($"Snapshot not found. Generated new snapshot at:\n{path}"); } - var expected = await File.ReadAllTextAsync(path); + var expected = await File.ReadAllTextAsync(path).ConfigureAwait(false); Assert.That( Normalize(expected), Is.EqualTo(Normalize(actual)), $"Snapshot mismatch: {generatedFileName}"); } + /// + /// 依次验证 schema 生成器产出的全部核心快照文件。 + /// + /// 生成结果字典。 + /// 快照目录。 + /// 全部快照断言完成后的异步任务。 + private static async Task AssertAllSnapshotsAsync( + IReadOnlyDictionary generatedSources, + string snapshotFolder) + { + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt") + .ConfigureAwait(false); + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt") + .ConfigureAwait(false); + await AssertSnapshotAsync( + generatedSources, + snapshotFolder, + "MonsterConfigBindings.g.cs", + "MonsterConfigBindings.g.txt") + .ConfigureAwait(false); + await AssertSnapshotAsync( + generatedSources, + snapshotFolder, + "GeneratedConfigCatalog.g.cs", + "GeneratedConfigCatalog.g.txt") + .ConfigureAwait(false); + } + /// /// 标准化快照文本以避免平台换行差异。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index 5f3aa997..ea36995f 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -78,7 +78,7 @@ public sealed partial class MonsterConfig /// Reward payload. /// /// - /// Constraints: minProperties = 2, maxProperties = 2. + /// Constraints: minProperties = 2, maxProperties = 2, dependentRequired = { currency => [gold] }, dependentSchemas = { currency => object (required = [gold]) }, allOf = [ object (required = [gold]) ], if/then/else = if object; properties = { currency: string (const = "gem") }; then object (required = [gold]); properties = { gold: integer }; else object (required = [currency]); properties = { currency: string }. /// public sealed partial class RewardConfig { diff --git a/GFramework.SourceGenerators.Tests/Core/AnalyzerTest.cs b/GFramework.SourceGenerators.Tests/Core/AnalyzerTestDriver.cs similarity index 93% rename from GFramework.SourceGenerators.Tests/Core/AnalyzerTest.cs rename to GFramework.SourceGenerators.Tests/Core/AnalyzerTestDriver.cs index 9d9a0b73..b2f962d0 100644 --- a/GFramework.SourceGenerators.Tests/Core/AnalyzerTest.cs +++ b/GFramework.SourceGenerators.Tests/Core/AnalyzerTestDriver.cs @@ -15,7 +15,7 @@ public static class AnalyzerTestDriver /// 测试输入源码。 /// 期望诊断集合。 /// 异步测试任务。 - public static async Task RunAsync( + public static Task RunAsync( string source, params DiagnosticResult[] diagnostics) { @@ -29,6 +29,6 @@ public static class AnalyzerTestDriver }; test.ExpectedDiagnostics.AddRange(diagnostics); - await test.RunAsync(); + return test.RunAsync(); } } diff --git a/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTest.cs b/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTest.cs index fe8b9bff..725eb107 100644 --- a/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTest.cs +++ b/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTest.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Collections.Immutable; +using System.IO; namespace GFramework.SourceGenerators.Tests.Core; @@ -28,74 +29,12 @@ public static class GeneratorSnapshotTest string snapshotFolder, Func? snapshotFileNameSelector = null) { - var syntaxTree = CSharpSyntaxTree.ParseText(source); - var compilation = CSharpCompilation.Create( - $"{typeof(TGenerator).Name}SnapshotTests", - [syntaxTree], - MetadataReferenceTestBuilder.GetRuntimeMetadataReferences(), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - GeneratorDriver driver = CSharpGeneratorDriver.Create( - generators: [CreateGenerator()], - parseOptions: (CSharpParseOptions)syntaxTree.Options); - driver = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out var updatedCompilation, - out var generatorDiagnostics); + var (driver, updatedCompilation, generatorDiagnostics) = RunGenerator(source); + AssertNoGeneratorErrors(generatorDiagnostics); + AssertNoCompilationErrors(updatedCompilation); - var generatorErrors = generatorDiagnostics - .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) - .ToArray(); - Assert.That( - generatorErrors, - Is.Empty, - () => - $"执行生成器时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, generatorErrors.Select(static diagnostic => diagnostic.ToString()))}"); - - var compilationErrors = updatedCompilation.GetDiagnostics() - .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) - .ToArray(); - Assert.That( - compilationErrors, - Is.Empty, - () => - $"编译生成的代码时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, compilationErrors.Select(static diagnostic => diagnostic.ToString()))}"); - - var runResult = driver.GetRunResult(); - var generated = runResult.Results - .SelectMany(static result => result.GeneratedSources) - .OrderBy(static source => source.HintName, StringComparer.Ordinal) - .Select(static source => (filename: source.HintName, content: source.SourceText.ToString())) - .ToArray(); - Assert.That( - generated, - Is.Not.Empty, - $"生成器 '{typeof(TGenerator).FullName}' 未产生任何输出。"); - - foreach (var (filename, content) in generated) - { - // 不同测试套件可能需要将生成文件映射到非 .cs 快照,以避免测试资产被当作可编译源码参与构建。 - var snapshotFileName = snapshotFileNameSelector?.Invoke(filename) ?? filename; - var path = ResolveSnapshotPath( - snapshotFolder, - snapshotFileName); - - if (!File.Exists(path)) - { - // 第一次运行:生成 snapshot - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await File.WriteAllTextAsync(path, content.ToString()); - - Assert.Fail( - $"未找到快照文件,已在以下路径生成新快照:\n{path}"); - } - - var expected = await File.ReadAllTextAsync(path); - - Assert.That( - Normalize(expected), - Is.EqualTo(Normalize(content.ToString())), - $"快照不匹配:{snapshotFileName}"); - } + var generatedSources = GetGeneratedSources(driver); + await AssertGeneratedSnapshotsAsync(generatedSources, snapshotFolder, snapshotFileNameSelector).ConfigureAwait(false); } /// @@ -105,7 +44,163 @@ public static class GeneratorSnapshotTest /// 标准化后的文本 private static string Normalize(string text) { - return text.Replace("\r\n", "\n").Trim(); + return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); + } + + /// + /// 构建测试编译并执行目标生成器,返回更新后的编译结果和生成器诊断。 + /// + /// 要交给生成器处理的输入源码。 + /// 包含驱动、更新后编译和生成器诊断的元组。 + private static (GeneratorDriver Driver, Compilation UpdatedCompilation, ImmutableArray GeneratorDiagnostics) + RunGenerator(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CreateCompilation(syntaxTree); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [CreateGenerator()], + parseOptions: (CSharpParseOptions)syntaxTree.Options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var updatedCompilation, + out var generatorDiagnostics); + + return (driver, updatedCompilation, generatorDiagnostics); + } + + /// + /// 为快照测试创建最小可运行的 Roslyn 编译上下文。 + /// + /// 由测试输入生成的语法树。 + /// 包含运行时元数据引用的动态链接库编译对象。 + private static CSharpCompilation CreateCompilation(SyntaxTree syntaxTree) + { + return CSharpCompilation.Create( + $"{typeof(TGenerator).Name}SnapshotTests", + [syntaxTree], + MetadataReferenceTestBuilder.GetRuntimeMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + /// + /// 断言生成器自身没有报告错误级诊断。 + /// + /// 生成器执行期间产生的诊断集合。 + private static void AssertNoGeneratorErrors(ImmutableArray generatorDiagnostics) + { + var generatorErrors = generatorDiagnostics + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.That( + generatorErrors, + Is.Empty, + () => + $"执行生成器时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, generatorErrors.Select(static diagnostic => diagnostic.ToString()))}"); + } + + /// + /// 断言合并生成结果后的最终编译仍然可通过。 + /// + /// 已注入生成输出的编译对象。 + private static void AssertNoCompilationErrors(Compilation updatedCompilation) + { + var compilationErrors = updatedCompilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.That( + compilationErrors, + Is.Empty, + () => + $"编译生成的代码时出现错误:{Environment.NewLine}{string.Join(Environment.NewLine, compilationErrors.Select(static diagnostic => diagnostic.ToString()))}"); + } + + /// + /// 收集并排序生成器输出,保持快照断言顺序稳定。 + /// + /// 已经执行完成的生成器驱动。 + /// 按 HintName 排序后的生成文件名与内容。 + private static (string Filename, string Content)[] GetGeneratedSources(GeneratorDriver driver) + { + var generatedSources = driver.GetRunResult() + .Results + .SelectMany(static result => result.GeneratedSources) + .OrderBy(static source => source.HintName, StringComparer.Ordinal) + .Select(static source => (source.HintName, source.SourceText.ToString())) + .ToArray(); + + Assert.That( + generatedSources, + Is.Not.Empty, + $"生成器 '{typeof(TGenerator).FullName}' 未产生任何输出。"); + + return generatedSources; + } + + /// + /// 逐个比对生成输出与已提交快照,必要时写出缺失快照并中断测试。 + /// + /// 已排序的生成文件名与内容。 + /// 快照根目录。 + /// 可选的快照文件名映射规则。 + /// 当全部快照比对完成后结束的异步任务。 + private static async Task AssertGeneratedSnapshotsAsync( + (string Filename, string Content)[] generatedSources, + string snapshotFolder, + Func? snapshotFileNameSelector) + { + foreach (var (filename, content) in generatedSources) + { + var snapshotFileName = snapshotFileNameSelector?.Invoke(filename) ?? filename; + var expected = await ReadExpectedSnapshotAsync( + snapshotFolder, + snapshotFileName, + content) + .ConfigureAwait(false); + + Assert.That( + Normalize(expected), + Is.EqualTo(Normalize(content)), + $"快照不匹配:{snapshotFileName}"); + } + } + + /// + /// 读取指定快照;若快照不存在,则先写出当前生成结果并通过断言提示调用方提交资产。 + /// + /// 快照根目录。 + /// 映射后的快照文件名。 + /// 当前生成器输出内容。 + /// 现有快照的文本内容。 + private static async Task ReadExpectedSnapshotAsync( + string snapshotFolder, + string snapshotFileName, + string generatedContent) + { + // 不同测试套件可能需要将生成文件映射到非 .cs 快照,以避免测试资产被当作可编译源码参与构建。 + var path = ResolveSnapshotPath(snapshotFolder, snapshotFileName); + if (!File.Exists(path)) + { + await WriteMissingSnapshotAndFailAsync(path, generatedContent).ConfigureAwait(false); + } + + return await File.ReadAllTextAsync(path).ConfigureAwait(false); + } + + /// + /// 为首次运行缺失的快照写入当前结果,并立即终止测试以提醒提交新资产。 + /// + /// 目标快照绝对路径。 + /// 要写入的生成输出。 + private static async Task WriteMissingSnapshotAndFailAsync(string path, string generatedContent) + { + // ResolveSnapshotPath 保证快照不会越界,但根目录路径仍会让 GetDirectoryName 返回 null。 + var snapshotDirectory = Path.GetDirectoryName(path) + ?? throw new InvalidOperationException( + $"Snapshot path '{path}' must include a parent directory."); + Directory.CreateDirectory(snapshotDirectory); + await File.WriteAllTextAsync(path, generatedContent).ConfigureAwait(false); + Assert.Fail($"未找到快照文件,已在以下路径生成新快照:\n{path}"); } /// diff --git a/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTestSecurityTests.cs b/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTestSecurityTests.cs index b52fa027..d096a059 100644 --- a/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTestSecurityTests.cs +++ b/GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTestSecurityTests.cs @@ -20,8 +20,8 @@ public class GeneratorSnapshotTestSecurityTests var snapshotRoot = CreateSnapshotRoot(); var source = BuildSource(); - Assert.ThrowsAsync(async () => - await GeneratorSnapshotTest.RunAsync( + Assert.ThrowsAsync(() => + GeneratorSnapshotTest.RunAsync( source, snapshotRoot, _ => Path.Combine(snapshotRoot, "Status.EnumExtensions.g.cs"))); @@ -36,8 +36,8 @@ public class GeneratorSnapshotTestSecurityTests var snapshotRoot = CreateSnapshotRoot(); var source = BuildSource(); - Assert.ThrowsAsync(async () => - await GeneratorSnapshotTest.RunAsync( + Assert.ThrowsAsync(() => + GeneratorSnapshotTest.RunAsync( source, snapshotRoot, _ => Path.Combine("..", "escaped", "Status.EnumExtensions.g.cs"))); diff --git a/GFramework.SourceGenerators.Tests/Core/GeneratorTest.cs b/GFramework.SourceGenerators.Tests/Core/GeneratorTest.cs index bed493e7..e582fbd9 100644 --- a/GFramework.SourceGenerators.Tests/Core/GeneratorTest.cs +++ b/GFramework.SourceGenerators.Tests/Core/GeneratorTest.cs @@ -13,11 +13,11 @@ public static class GeneratorTest /// 输入的源代码 /// 期望生成的源文件集合,包含文件名和内容的元组 /// 异步操作任务 - public static async Task RunAsync( + public static Task RunAsync( string source, params (string filename, string content)[] generatedSources) { - await RunAsync( + return RunAsync( source, additionalReferences: [], generatedSources); @@ -30,7 +30,7 @@ public static class GeneratorTest /// 附加元数据引用,用于构造多程序集场景。 /// 期望生成的源文件集合,包含文件名和内容的元组。 /// 异步操作任务。 - public static async Task RunAsync( + public static Task RunAsync( string source, IEnumerable additionalReferences, params (string filename, string content)[] generatedSources) @@ -52,7 +52,7 @@ public static class GeneratorTest foreach (var additionalReference in additionalReferences) test.TestState.AdditionalReferences.Add(additionalReference); - await test.RunAsync(); + return test.RunAsync(); } /// diff --git a/GFramework.SourceGenerators.Tests/Logging/LoggerGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Logging/LoggerGeneratorSnapshotTests.cs index 83a11648..5555b2c9 100644 --- a/GFramework.SourceGenerators.Tests/Logging/LoggerGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Logging/LoggerGeneratorSnapshotTests.cs @@ -4,559 +4,232 @@ using GFramework.SourceGenerators.Tests.Core; namespace GFramework.SourceGenerators.Tests.Logging; +/// +/// 验证 在常见日志声明配置下的快照输出保持稳定。 +/// [TestFixture] public class LoggerGeneratorSnapshotTests { + /// + /// 验证默认配置下的类日志字段快照。 + /// [Test] - public async Task Snapshot_DefaultConfiguration_Class() + public Task Snapshot_DefaultConfiguration_Class() { - const string source = """ - using System; - - namespace GFramework.Core.SourceGenerators.Abstractions.Logging - { - [AttributeUsage(AttributeTargets.Class)] - public sealed class LogAttribute : Attribute - { - public string Name { get; set; } - public string FieldName { get; set; } - public string AccessModifier { get; set; } - public bool IsStatic { get; set; } = true; - } - } - - namespace GFramework.Core.Abstractions.Logging - { - public interface ILogger - { - void Info(string message); - void Error(string message); - void Warn(string message); - void Debug(string message); - void Trace(string message); - void Fatal(string message); - } - } - - namespace GFramework.Core.Logging - { - using GFramework.Core.Abstractions.Logging; - - public static class LoggerFactoryResolver - { - public static ILoggerProvider Provider { get; set; } - - public static ILoggerProvider CreateLogger(string name) - { - return Provider ?? new MockLoggerProvider(); - } - } - - public interface ILoggerProvider - { - ILogger CreateLogger(string name); - } - - internal class MockLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string name) - { - return new MockLogger(name); - } - } - - internal class MockLogger : ILogger - { - private readonly string _name; - - public MockLogger(string name) - { - _name = name; - } - - public void Info(string message) { } - public void Error(string message) { } - public void Warn(string message) { } - public void Debug(string message) { } - public void Trace(string message) { } - public void Fatal(string message) { } - } - } - - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Logging; - - [Log] - public partial class MyService - { - } - } - """; - - await GeneratorSnapshotTest.RunAsync( - source, - GetSnapshotFolder("DefaultConfiguration_Class")); + return RunScenarioAsync( + "DefaultConfiguration_Class", + "[Log]", + "public partial class MyService"); } + /// + /// 验证自定义 logger 名称会反映到生成快照。 + /// [Test] - public async Task Snapshot_CustomName_Class() + public Task Snapshot_CustomName_Class() { - const string source = """ - using System; - - namespace GFramework.Core.SourceGenerators.Abstractions.Logging - { - [AttributeUsage(AttributeTargets.Class)] - public sealed class LogAttribute : Attribute - { - public string Name { get; set; } - public string FieldName { get; set; } - public string AccessModifier { get; set; } - public bool IsStatic { get; set; } = true; - } - } - - namespace GFramework.Core.Abstractions.Logging - { - public interface ILogger - { - void Info(string message); - void Error(string message); - void Warn(string message); - void Debug(string message); - void Trace(string message); - void Fatal(string message); - } - } - - namespace GFramework.Core.Logging - { - using GFramework.Core.Abstractions.Logging; - - public static class LoggerFactoryResolver - { - public static ILoggerProvider Provider { get; set; } - - public static ILoggerProvider CreateLogger(string name) - { - return Provider ?? new MockLoggerProvider(); - } - } - - public interface ILoggerProvider - { - ILogger CreateLogger(string name); - } - - internal class MockLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string name) - { - return new MockLogger(name); - } - } - - internal class MockLogger : ILogger - { - private readonly string _name; - - public MockLogger(string name) - { - _name = name; - } - - public void Info(string message) { } - public void Error(string message) { } - public void Warn(string message) { } - public void Debug(string message) { } - public void Trace(string message) { } - public void Fatal(string message) { } - } - } - - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Logging; - - [Log(Name = "CustomLogger")] - public partial class MyService - { - } - } - """; - - await GeneratorSnapshotTest.RunAsync( - source, - GetSnapshotFolder("CustomName_Class")); + return RunScenarioAsync( + "CustomName_Class", + "[Log(Name = \"CustomLogger\")]", + "public partial class MyService"); } + /// + /// 验证自定义字段名会反映到生成快照。 + /// [Test] - public async Task Snapshot_CustomFieldName_Class() + public Task Snapshot_CustomFieldName_Class() { - const string source = """ - using System; - - namespace GFramework.Core.SourceGenerators.Abstractions.Logging - { - [AttributeUsage(AttributeTargets.Class)] - public sealed class LogAttribute : Attribute - { - public string Name { get; set; } - public string FieldName { get; set; } - public string AccessModifier { get; set; } - public bool IsStatic { get; set; } = true; - } - } - - namespace GFramework.Core.Abstractions.Logging - { - public interface ILogger - { - void Info(string message); - void Error(string message); - void Warn(string message); - void Debug(string message); - void Trace(string message); - void Fatal(string message); - } - } - - namespace GFramework.Core.Logging - { - using GFramework.Core.Abstractions.Logging; - - public static class LoggerFactoryResolver - { - public static ILoggerProvider Provider { get; set; } - - public static ILoggerProvider CreateLogger(string name) - { - return Provider ?? new MockLoggerProvider(); - } - } - - public interface ILoggerProvider - { - ILogger CreateLogger(string name); - } - - internal class MockLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string name) - { - return new MockLogger(name); - } - } - - internal class MockLogger : ILogger - { - private readonly string _name; - - public MockLogger(string name) - { - _name = name; - } - - public void Info(string message) { } - public void Error(string message) { } - public void Warn(string message) { } - public void Debug(string message) { } - public void Trace(string message) { } - public void Fatal(string message) { } - } - } - - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Logging; - - [Log(FieldName = "MyLogger")] - public partial class MyService - { - } - } - """; - - await GeneratorSnapshotTest.RunAsync( - source, - GetSnapshotFolder("CustomFieldName_Class")); + return RunScenarioAsync( + "CustomFieldName_Class", + "[Log(FieldName = \"MyLogger\")]", + "public partial class MyService"); } + /// + /// 验证实例字段模式会反映到生成快照。 + /// [Test] - public async Task Snapshot_InstanceField_Class() + public Task Snapshot_InstanceField_Class() { - const string source = """ - using System; - - namespace GFramework.Core.SourceGenerators.Abstractions.Logging - { - [AttributeUsage(AttributeTargets.Class)] - public sealed class LogAttribute : Attribute - { - public string Name { get; set; } - public string FieldName { get; set; } - public string AccessModifier { get; set; } - public bool IsStatic { get; set; } = true; - } - } - - namespace GFramework.Core.Abstractions.Logging - { - public interface ILogger - { - void Info(string message); - void Error(string message); - void Warn(string message); - void Debug(string message); - void Trace(string message); - void Fatal(string message); - } - } - - namespace GFramework.Core.Logging - { - using GFramework.Core.Abstractions.Logging; - - public static class LoggerFactoryResolver - { - public static ILoggerProvider Provider { get; set; } - - public static ILoggerProvider CreateLogger(string name) - { - return Provider ?? new MockLoggerProvider(); - } - } - - public interface ILoggerProvider - { - ILogger CreateLogger(string name); - } - - internal class MockLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string name) - { - return new MockLogger(name); - } - } - - internal class MockLogger : ILogger - { - private readonly string _name; - - public MockLogger(string name) - { - _name = name; - } - - public void Info(string message) { } - public void Error(string message) { } - public void Warn(string message) { } - public void Debug(string message) { } - public void Trace(string message) { } - public void Fatal(string message) { } - } - } - - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Logging; - - [Log(IsStatic = false)] - public partial class MyService - { - } - } - """; - - await GeneratorSnapshotTest.RunAsync( - source, - GetSnapshotFolder("InstanceField_Class")); + return RunScenarioAsync( + "InstanceField_Class", + "[Log(IsStatic = false)]", + "public partial class MyService"); } + /// + /// 验证公共字段可见性会反映到生成快照。 + /// [Test] - public async Task Snapshot_PublicField_Class() + public Task Snapshot_PublicField_Class() { - const string source = """ - using System; - - namespace GFramework.Core.SourceGenerators.Abstractions.Logging - { - [AttributeUsage(AttributeTargets.Class)] - public sealed class LogAttribute : Attribute - { - public string Name { get; set; } - public string FieldName { get; set; } - public string AccessModifier { get; set; } - public bool IsStatic { get; set; } = true; - } - } - - namespace GFramework.Core.Abstractions.Logging - { - public interface ILogger - { - void Info(string message); - void Error(string message); - void Warn(string message); - void Debug(string message); - void Trace(string message); - void Fatal(string message); - } - } - - namespace GFramework.Core.Logging - { - using GFramework.Core.Abstractions.Logging; - - public static class LoggerFactoryResolver - { - public static ILoggerProvider Provider { get; set; } - - public static ILoggerProvider CreateLogger(string name) - { - return Provider ?? new MockLoggerProvider(); - } - } - - public interface ILoggerProvider - { - ILogger CreateLogger(string name); - } - - internal class MockLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string name) - { - return new MockLogger(name); - } - } - - internal class MockLogger : ILogger - { - private readonly string _name; - - public MockLogger(string name) - { - _name = name; - } - - public void Info(string message) { } - public void Error(string message) { } - public void Warn(string message) { } - public void Debug(string message) { } - public void Trace(string message) { } - public void Fatal(string message) { } - } - } - - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Logging; - - [Log(AccessModifier = "public")] - public partial class MyService - { - } - } - """; - - await GeneratorSnapshotTest.RunAsync( - source, - GetSnapshotFolder("PublicField_Class")); + return RunScenarioAsync( + "PublicField_Class", + "[Log(AccessModifier = \"public\")]", + "public partial class MyService"); } + /// + /// 验证泛型类声明的日志字段快照。 + /// [Test] - public async Task Snapshot_GenericClass() + public Task Snapshot_GenericClass() { - const string source = """ - using System; + return RunScenarioAsync( + "GenericClass", + "[Log]", + "public partial class MyService"); + } - namespace GFramework.Core.SourceGenerators.Abstractions.Logging - { - [AttributeUsage(AttributeTargets.Class)] - public sealed class LogAttribute : Attribute - { - public string Name { get; set; } - public string FieldName { get; set; } - public string AccessModifier { get; set; } - public bool IsStatic { get; set; } = true; - } - } + /// + /// 为给定场景组装最小测试源并执行快照校验。 + /// + /// 快照场景名称。 + /// 目标类型上的 [Log(...)] 声明。 + /// 目标 partial 类型声明。 + /// 表示快照测试完成的异步任务。 + private static Task RunScenarioAsync(string scenarioName, string logAttributeLine, string classDeclaration) + { + return GeneratorSnapshotTest.RunAsync( + CreateSource(logAttributeLine, classDeclaration), + GetSnapshotFolder(scenarioName)); + } - namespace GFramework.Core.Abstractions.Logging - { - public interface ILogger - { - void Info(string message); - void Error(string message); - void Warn(string message); - void Debug(string message); - void Trace(string message); - void Fatal(string message); - } - } + /// + /// 生成日志源生成器测试所需的最小宿主源代码。 + /// + /// 目标类型上的 [Log(...)] 声明。 + /// 目标 partial 类型声明。 + /// 可直接送入快照测试的完整源码字符串。 + private static string CreateSource(string logAttributeLine, string classDeclaration) + { + return string.Join( + $"{Environment.NewLine}{Environment.NewLine}", + CreateLoggingAttributeSource(), + CreateLoggingContractsSource(), + CreateLoggingRuntimeSource(), + CreateTestAppSource(logAttributeLine, classDeclaration)); + } - namespace GFramework.Core.Logging - { - using GFramework.Core.Abstractions.Logging; + /// + /// 生成日志测试使用的 attribute 定义源码。 + /// + /// 包含 LogAttribute 的源码片段。 + private static string CreateLoggingAttributeSource() + { + return """ + using System; - public static class LoggerFactoryResolver - { - public static ILoggerProvider Provider { get; set; } + namespace GFramework.Core.SourceGenerators.Abstractions.Logging + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class LogAttribute : Attribute + { + public string Name { get; set; } + public string FieldName { get; set; } + public string AccessModifier { get; set; } + public bool IsStatic { get; set; } = true; + } + } + """; + } - public static ILoggerProvider CreateLogger(string name) - { - return Provider ?? new MockLoggerProvider(); - } - } + /// + /// 生成日志抽象契约源码,供测试编译图引用。 + /// + /// 包含 ILogger 的源码片段。 + private static string CreateLoggingContractsSource() + { + return """ + namespace GFramework.Core.Abstractions.Logging + { + public interface ILogger + { + void Info(string message); + void Error(string message); + void Warn(string message); + void Debug(string message); + void Trace(string message); + void Fatal(string message); + } + } + """; + } - public interface ILoggerProvider - { - ILogger CreateLogger(string name); - } + /// + /// 生成最小运行时宿主源码,供生成器解析 logger provider 依赖。 + /// + /// 包含 provider 与 mock logger 的源码片段。 + private static string CreateLoggingRuntimeSource() + { + return """ + namespace GFramework.Core.Logging + { + using GFramework.Core.Abstractions.Logging; - internal class MockLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string name) - { - return new MockLogger(name); - } - } + public static class LoggerFactoryResolver + { + public static ILoggerProvider Provider { get; set; } - internal class MockLogger : ILogger - { - private readonly string _name; + public static ILoggerProvider CreateLogger(string name) + { + return Provider ?? new MockLoggerProvider(); + } + } - public MockLogger(string name) - { - _name = name; - } + public interface ILoggerProvider + { + ILogger CreateLogger(string name); + } - public void Info(string message) { } - public void Error(string message) { } - public void Warn(string message) { } - public void Debug(string message) { } - public void Trace(string message) { } - public void Fatal(string message) { } - } - } + internal class MockLoggerProvider : ILoggerProvider + { + public ILogger CreateLogger(string name) + { + return new MockLogger(name); + } + } - namespace TestApp - { - using GFramework.Core.SourceGenerators.Abstractions.Logging; + internal class MockLogger : ILogger + { + private readonly string _name; - [Log] - public partial class MyService - { - } - } - """; + public MockLogger(string name) + { + _name = name; + } - await GeneratorSnapshotTest.RunAsync( - source, - GetSnapshotFolder("GenericClass")); + public void Info(string message) { } + public void Error(string message) { } + public void Warn(string message) { } + public void Debug(string message) { } + public void Trace(string message) { } + public void Fatal(string message) { } + } + } + """; + } + + /// + /// 生成实际承载 [Log] 声明的测试类型源码。 + /// + /// 目标类型上的 [Log(...)] 声明。 + /// 目标 partial 类型声明。 + /// 测试应用命名空间下的目标类型源码片段。 + private static string CreateTestAppSource(string logAttributeLine, string classDeclaration) + { + return $$""" + namespace TestApp + { + using GFramework.Core.SourceGenerators.Abstractions.Logging; + + {{logAttributeLine}} + {{classDeclaration}} + { + } + } + """; } /// diff --git a/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs index bae2d00c..1d015e12 100644 --- a/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs @@ -3,743 +3,1351 @@ using GFramework.SourceGenerators.Tests.Core; namespace GFramework.SourceGenerators.Tests.Rule; +/// +/// 验证 在显式特性、GetAll 推断与诊断场景下的生成契约。 +/// [TestFixture] public class ContextGetGeneratorTests { + private const string InventoryPanelGeneratedFileName = "TestApp_InventoryPanel.ContextGet.g.cs"; + private const string BattlePanelGeneratedFileName = "TestApp_BattlePanel.ContextGet.g.cs"; + private const string GameplayHudGeneratedFileName = "TestApp_GameplayHud.ContextGet.g.cs"; + private const string StrategyHostGeneratedFileName = "TestApp_StrategyHost.ContextGet.g.cs"; + + private const string ContextAwareAttributeClassSource = """ + using System; + using System.Collections.Generic; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class ContextAwareAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetServicesAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + public static IReadOnlyList GetServices(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + public interface IInventoryStrategy { } + + [ContextAware] + public partial class InventoryPanel + { + [GetModel] + private IInventoryModel _model = null!; + + [GetServices] + private IReadOnlyList _strategies = null!; + } + } + """; + + private const string ContextAwareAttributeClassExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class InventoryPanel + { + private void __InjectContextBindings_Generated() + { + _model = this.GetModel(); + _strategies = this.GetServices(); + } + } + + """; + + private const string FullyQualifiedFieldAttributesSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class ContextAwareAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + [ContextAware] + public partial class InventoryPanel + { + [global::GFramework.Core.SourceGenerators.Abstractions.Rule.GetModel] + private IInventoryModel _model = null!; + } + } + """; + + private const string FullyQualifiedFieldAttributesExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class InventoryPanel + { + private void __InjectContextBindings_Generated() + { + _model = this.GetModel(); + } + } + + """; + + private const string InferredGetAllClassSource = """ + using System; + using System.Collections.Generic; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GetAllAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Architectures + { + public interface IArchitectureContext { } + } + + namespace GFramework.Core.Rule + { + public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + public static IReadOnlyList GetModels(this object contextAware) => default!; + public static T GetSystem(this object contextAware) => default!; + public static T GetUtility(this object contextAware) => default!; + } + } + + namespace Godot + { + public class Node { } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + public interface ICombatSystem : GFramework.Core.Abstractions.Systems.ISystem { } + public interface IUiUtility : GFramework.Core.Abstractions.Utility.IUtility { } + public interface IStrategy { } + + [GetAll] + public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase + { + private IInventoryModel _model = null!; + private IReadOnlyList _models = null!; + private ICombatSystem _system = null!; + private IUiUtility _utility = null!; + private IStrategy _service = null!; + private IReadOnlyList _services = null!; + private Godot.Node _node = null!; + } + } + """; + + private const string InferredGetAllClassExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class BattlePanel + { + private void __InjectContextBindings_Generated() + { + _model = this.GetModel(); + _models = this.GetModels(); + _system = this.GetSystem(); + _utility = this.GetUtility(); + } + } + + """; + + private const string ExplicitServiceGetAllClassSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GetAllAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetServiceAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Rule + { + public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + public static T GetService(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + public interface IStrategy { } + + [GetAll] + public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase + { + private IInventoryModel _model = null!; + + [GetService] + private IStrategy _service = null!; + } + } + """; + + private const string ExplicitServiceGetAllClassExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class BattlePanel + { + private void __InjectContextBindings_Generated() + { + _model = this.GetModel(); + _service = this.GetService(); + } + } + + """; + + private const string GeneratedInjectionMethodAlreadyExistsMarkupSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class ContextAwareAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + [ContextAware] + public partial class InventoryPanel + { + [GetModel] + private IInventoryModel _model = null!; + + private void {|#0:__InjectContextBindings_Generated|}() + { + } + } + } + """; + + private const string IgnoreConstFieldGetAllSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GetAllAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Rule + { + public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + [GetAll] + public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase + { + private const double LogicStep = 0.2; + private IInventoryModel _model = null!; + } + } + """; + + private const string IgnoreConstFieldGetAllExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class BattlePanel + { + private void __InjectContextBindings_Generated() + { + _model = this.GetModel(); + } + } + + """; + + private const string ReadonlyInferredFieldGetAllMarkupSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GetAllAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Rule + { + public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + public static T GetSystem(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + public interface ICombatSystem : GFramework.Core.Abstractions.Systems.ISystem { } + + [GetAll] + public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase + { + private readonly IInventoryModel {|#0:_model|} = null!; + private ICombatSystem _system = null!; + } + } + """; + + private const string SkipInvalidGetAllFieldExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class BattlePanel + { + private void __InjectContextBindings_Generated() + { + _system = this.GetSystem(); + } + } + + """; + + private const string StaticInferredFieldGetAllMarkupSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GetAllAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Rule + { + public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + public static T GetSystem(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + public interface ICombatSystem : GFramework.Core.Abstractions.Systems.ISystem { } + + [GetAll] + public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase + { + private static IInventoryModel {|#0:_model|} = null!; + private ICombatSystem _system = null!; + } + } + """; + + private const string SkipNullableServiceLikeFieldSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class ContextAwareAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GetAllAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + public static T GetSystem(this object contextAware) => default!; + } + } + + namespace Godot + { + public class Control { } + } + + namespace TestApp + { + public interface IGridModel : GFramework.Core.Abstractions.Model.IModel { } + public interface IRunLoopSystem : GFramework.Core.Abstractions.Systems.ISystem { } + public interface IUiPageBehavior { } + + [ContextAware] + [GetAll] + public partial class GameplayHud : Godot.Control + { + private IGridModel _gridModel = null!; + private IUiPageBehavior? _page; + private IRunLoopSystem _runLoopSystem = null!; + } + } + """; + + private const string SkipNullableServiceLikeFieldExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class GameplayHud + { + private void __InjectContextBindings_Generated() + { + _gridModel = this.GetModel(); + _runLoopSystem = this.GetSystem(); + } + } + + """; + + private const string IContextAwareClassSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetServiceAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetService(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IStrategy { } + + public partial class StrategyHost : GFramework.Core.Abstractions.Rule.IContextAware + { + [GetService] + private IStrategy _strategy = null!; + } + } + """; + + private const string IContextAwareClassExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class StrategyHost + { + private void __InjectContextBindings_Generated() + { + _strategy = this.GetService(); + } + } + + """; + + private const string NonContextAwareClassMarkupSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + public partial class InventoryPanel + { + [GetModel] + private IInventoryModel {|#0:_model|} = null!; + } + } + """; + + private const string GetModelsFieldNotIReadOnlyListMarkupSource = """ + using System; + using System.Collections.Generic; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelsAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static IReadOnlyList GetModels(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware + { + [GetModels] + private List {|#0:_models|} = new(); + } + } + """; + + private const string ReadonlyExplicitGetModelFieldMarkupSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware + { + [GetModel] + private readonly IInventoryModel {|#0:_model|} = null!; + } + } + """; + + private const string StaticExplicitGetModelFieldMarkupSource = """ + using System; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static T GetModel(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware + { + [GetModel] + private static IInventoryModel {|#0:_model|} = null!; + } + } + """; + + private const string GetModelsAssignableSource = """ + using System; + using System.Collections.Generic; + using GFramework.Core.SourceGenerators.Abstractions.Rule; + + namespace GFramework.Core.SourceGenerators.Abstractions.Rule + { + [AttributeUsage(AttributeTargets.Field, Inherited = false)] + public sealed class GetModelsAttribute : Attribute { } + } + + namespace GFramework.Core.Abstractions.Rule + { + public interface IContextAware { } + } + + namespace GFramework.Core.Abstractions.Model + { + public interface IModel { } + } + + namespace GFramework.Core.Abstractions.Systems + { + public interface ISystem { } + } + + namespace GFramework.Core.Abstractions.Utility + { + public interface IUtility { } + } + + namespace GFramework.Core.Extensions + { + public static class ContextAwareServiceExtensions + { + public static IReadOnlyList GetModels(this object contextAware) => default!; + } + } + + namespace TestApp + { + public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + + public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware + { + [GetModels] + private IEnumerable _models = null!; + } + } + """; + + private const string GetModelsAssignableExpected = """ + // + #nullable enable + + using GFramework.Core.Extensions; + + namespace TestApp; + + partial class InventoryPanel + { + private void __InjectContextBindings_Generated() + { + _models = this.GetModels(); + } + } + + """; + + /// + /// 验证 [ContextAware] 类上的显式字段特性会生成模型与服务绑定。 + /// [Test] - public async Task Generates_Bindings_For_ContextAwareAttribute_Class() + public Task Generates_Bindings_For_ContextAwareAttribute_Class() { - var source = """ - using System; - using System.Collections.Generic; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class ContextAwareAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetServicesAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - public static IReadOnlyList GetServices(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - public interface IInventoryStrategy { } - - [ContextAware] - public partial class InventoryPanel - { - [GetModel] - private IInventoryModel _model = null!; - - [GetServices] - private IReadOnlyList _strategies = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class InventoryPanel - { - private void __InjectContextBindings_Generated() - { - _model = this.GetModel(); - _strategies = this.GetServices(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_InventoryPanel.ContextGet.g.cs", expected)); - Assert.Pass(); + return VerifyGeneratedSourceAsync( + ContextAwareAttributeClassSource, + InventoryPanelGeneratedFileName, + ContextAwareAttributeClassExpected); } + /// + /// 验证字段使用 fully-qualified 特性名时仍能生成绑定。 + /// [Test] - public async Task Generates_Bindings_For_Fully_Qualified_Field_Attributes() + public Task Generates_Bindings_For_Fully_Qualified_Field_Attributes() { - var source = """ - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class ContextAwareAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - - [ContextAware] - public partial class InventoryPanel - { - [global::GFramework.Core.SourceGenerators.Abstractions.Rule.GetModel] - private IInventoryModel _model = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class InventoryPanel - { - private void __InjectContextBindings_Generated() - { - _model = this.GetModel(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_InventoryPanel.ContextGet.g.cs", expected)); - Assert.Pass(); + return VerifyGeneratedSourceAsync( + FullyQualifiedFieldAttributesSource, + InventoryPanelGeneratedFileName, + FullyQualifiedFieldAttributesExpected); } + /// + /// 验证 GetAll 会仅为可推断的 model、models、system 与 utility 字段生成绑定。 + /// [Test] - public async Task Generates_Inferred_Bindings_For_GetAll_Class() + public Task Generates_Inferred_Bindings_For_GetAll_Class() { - var source = """ - using System; - using System.Collections.Generic; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class GetAllAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Architectures - { - public interface IArchitectureContext { } - } - - namespace GFramework.Core.Rule - { - public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - public static IReadOnlyList GetModels(this object contextAware) => default!; - public static T GetSystem(this object contextAware) => default!; - public static T GetUtility(this object contextAware) => default!; - } - } - - namespace Godot - { - public class Node { } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - public interface ICombatSystem : GFramework.Core.Abstractions.Systems.ISystem { } - public interface IUiUtility : GFramework.Core.Abstractions.Utility.IUtility { } - public interface IStrategy { } - - [GetAll] - public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase - { - private IInventoryModel _model = null!; - private IReadOnlyList _models = null!; - private ICombatSystem _system = null!; - private IUiUtility _utility = null!; - private IStrategy _service = null!; - private IReadOnlyList _services = null!; - private Godot.Node _node = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class BattlePanel - { - private void __InjectContextBindings_Generated() - { - _model = this.GetModel(); - _models = this.GetModels(); - _system = this.GetSystem(); - _utility = this.GetUtility(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_BattlePanel.ContextGet.g.cs", expected)); - Assert.Pass(); + return VerifyGeneratedSourceAsync( + InferredGetAllClassSource, + BattlePanelGeneratedFileName, + InferredGetAllClassExpected); } + /// + /// 验证 GetAll 与显式 [GetService] 可以组合生成绑定。 + /// [Test] - public async Task Generates_Explicit_Service_Binding_For_GetAll_Class() + public Task Generates_Explicit_Service_Binding_For_GetAll_Class() { - var source = """ - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class GetAllAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetServiceAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Rule - { - public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - public static T GetService(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - public interface IStrategy { } - - [GetAll] - public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase - { - private IInventoryModel _model = null!; - - [GetService] - private IStrategy _service = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class BattlePanel - { - private void __InjectContextBindings_Generated() - { - _model = this.GetModel(); - _service = this.GetService(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_BattlePanel.ContextGet.g.cs", expected)); - Assert.Pass(); + return VerifyGeneratedSourceAsync( + ExplicitServiceGetAllClassSource, + BattlePanelGeneratedFileName, + ExplicitServiceGetAllClassExpected); } + /// + /// 验证目标类已声明注入方法时会报告冲突诊断。 + /// [Test] - public async Task Reports_Diagnostic_When_Generated_Injection_Method_Name_Already_Exists() + public Task Reports_Diagnostic_When_Generated_Injection_Method_Name_Already_Exists() { - var source = """ - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; + var source = MarkupTestSource.Parse(GeneratedInjectionMethodAlreadyExistsMarkupSource); - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class ContextAwareAttribute : Attribute { } + return VerifyDiagnosticAsync( + source, + CreateSingleSpanDiagnostic( + source, + "GF_Common_Class_002", + DiagnosticSeverity.Error, + "InventoryPanel", + "__InjectContextBindings_Generated")); + } - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelAttribute : Attribute { } - } + /// + /// 验证 GetAll 会忽略不可推断的常量字段且不报告诊断。 + /// + [Test] + public Task Ignores_NonInferable_Const_Field_For_GetAll_Class_Without_Diagnostic() + { + return VerifyGeneratedSourceAsync( + IgnoreConstFieldGetAllSource, + BattlePanelGeneratedFileName, + IgnoreConstFieldGetAllExpected); + } - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } + /// + /// 验证只读推断字段会被跳过并报告 warning,同时其他字段保持生成。 + /// + [Test] + public Task Warns_And_Skips_Readonly_Inferred_Field_For_GetAll_Class() + { + var source = MarkupTestSource.Parse(ReadonlyInferredFieldGetAllMarkupSource); - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } + return VerifyDiagnosticAndGeneratedSourceAsync( + source, + BattlePanelGeneratedFileName, + SkipInvalidGetAllFieldExpected, + CreateSingleSpanDiagnostic( + source, + "GF_ContextGet_008", + DiagnosticSeverity.Warning, + "_model")); + } - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } + /// + /// 验证静态推断字段会被跳过并报告 warning,同时其他字段保持生成。 + /// + [Test] + public Task Warns_And_Skips_Static_Inferred_Field_For_GetAll_Class() + { + var source = MarkupTestSource.Parse(StaticInferredFieldGetAllMarkupSource); - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } + return VerifyDiagnosticAndGeneratedSourceAsync( + source, + BattlePanelGeneratedFileName, + SkipInvalidGetAllFieldExpected, + CreateSingleSpanDiagnostic( + source, + "GF_ContextGet_007", + DiagnosticSeverity.Warning, + "_model")); + } - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - } - } + /// + /// 验证 nullable 的服务样字段不会被 GetAll 误判为可注入字段。 + /// + [Test] + public Task Skips_Nullable_Service_Like_Field_For_ContextAware_GetAll_Class() + { + return VerifyGeneratedSourceAsync( + SkipNullableServiceLikeFieldSource, + GameplayHudGeneratedFileName, + SkipNullableServiceLikeFieldExpected); + } - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } + /// + /// 验证实现 IContextAware 的类型无需 [ContextAware] 也能生成显式绑定。 + /// + [Test] + public Task Generates_Bindings_For_IContextAware_Class() + { + return VerifyGeneratedSourceAsync( + IContextAwareClassSource, + StrategyHostGeneratedFileName, + IContextAwareClassExpected); + } - [ContextAware] - public partial class InventoryPanel - { - [GetModel] - private IInventoryModel _model = null!; + /// + /// 验证缺少上下文感知契约的类型会报告错误诊断。 + /// + [Test] + public Task Reports_Diagnostic_When_Class_Is_Not_ContextAware() + { + var source = MarkupTestSource.Parse(NonContextAwareClassMarkupSource); - private void {|#0:__InjectContextBindings_Generated|}() - { - } - } - } - """; + return VerifyDiagnosticAsync( + source, + CreateSingleSpanDiagnostic( + source, + "GF_ContextGet_005", + DiagnosticSeverity.Error, + "InventoryPanel")); + } - var test = new CSharpSourceGeneratorTest + /// + /// 验证 [GetModels] 字段若不是可赋值自 IReadOnlyList<T> 会报告错误。 + /// + [Test] + public Task Reports_Diagnostic_When_GetModels_Field_Is_Not_IReadOnlyList() + { + var source = MarkupTestSource.Parse(GetModelsFieldNotIReadOnlyListMarkupSource); + + return VerifyDiagnosticAsync( + source, + CreateSingleSpanDiagnostic( + source, + "GF_ContextGet_004", + DiagnosticSeverity.Error, + "_models", + "System.Collections.Generic.List", + "GetModels")); + } + + /// + /// 验证显式 [GetModel] 作用于只读字段时会报告错误。 + /// + [Test] + public Task Reports_Diagnostic_For_Readonly_Explicit_GetModel_Field() + { + var source = MarkupTestSource.Parse(ReadonlyExplicitGetModelFieldMarkupSource); + + return VerifyDiagnosticAsync( + source, + CreateSingleSpanDiagnostic( + source, + "GF_ContextGet_003", + DiagnosticSeverity.Error, + "_model")); + } + + /// + /// 验证显式 [GetModel] 作用于静态字段时会报告错误。 + /// + [Test] + public Task Reports_Diagnostic_For_Static_Explicit_GetModel_Field() + { + var source = MarkupTestSource.Parse(StaticExplicitGetModelFieldMarkupSource); + + return VerifyDiagnosticAsync( + source, + CreateSingleSpanDiagnostic( + source, + "GF_ContextGet_002", + DiagnosticSeverity.Error, + "_model")); + } + + /// + /// 验证 [GetModels] 字段可以赋值到更宽的可枚举接口上。 + /// + [Test] + public Task Generates_Bindings_For_GetModels_Field_Assignable_From_IReadOnlyList() + { + return VerifyGeneratedSourceAsync( + GetModelsAssignableSource, + InventoryPanelGeneratedFileName, + GetModelsAssignableExpected); + } + + /// + /// 运行单个生成源码断言,保持文件名与文本快照语义不变。 + /// + /// 测试输入源码。 + /// 期望生成文件名。 + /// 期望生成源码。 + /// 表示测试执行的异步任务。 + private static Task VerifyGeneratedSourceAsync( + string source, + string generatedFileName, + string expectedGeneratedSource) + { + return GeneratorTest.RunAsync( + source, + (generatedFileName, expectedGeneratedSource)); + } + + /// + /// 运行仅关注诊断输出的生成器测试。 + /// + /// 包含 markup span 的测试源码。 + /// 期望诊断。 + /// 表示测试执行的异步任务。 + private static Task VerifyDiagnosticAsync( + MarkupTestSource source, + DiagnosticResult expectedDiagnostic) + { + var test = CreateGeneratorTest(source.Source); + test.ExpectedDiagnostics.Add(expectedDiagnostic); + return test.RunAsync(); + } + + /// + /// 运行同时断言诊断与部分生成输出的生成器测试。 + /// + /// 包含 markup span 的测试源码。 + /// 期望生成文件名。 + /// 期望生成源码。 + /// 期望诊断。 + /// 表示测试执行的异步任务。 + private static Task VerifyDiagnosticAndGeneratedSourceAsync( + MarkupTestSource source, + string generatedFileName, + string expectedGeneratedSource, + DiagnosticResult expectedDiagnostic) + { + var test = CreateGeneratorTest(source.Source); + test.TestState.GeneratedSources.Add( + (typeof(ContextGetGenerator), generatedFileName, NormalizeLineEndings(expectedGeneratedSource))); + test.ExpectedDiagnostics.Add(expectedDiagnostic); + return test.RunAsync(); + } + + /// + /// 为单一 markup span 场景构造诊断结果,统一保持定位键与参数组装方式。 + /// + /// 包含 markup span 的测试源码。 + /// 诊断 ID。 + /// 诊断严重级别。 + /// 诊断参数。 + /// 绑定到 markup key 0 的期望诊断。 + private static DiagnosticResult CreateSingleSpanDiagnostic( + MarkupTestSource source, + string diagnosticId, + DiagnosticSeverity severity, + params string[] arguments) + { + return source.WithSpan( + new DiagnosticResult(diagnosticId, severity), + "0") + .WithArguments(arguments); + } + + /// + /// 创建禁用 trace 诊断的通用源生成器测试实例,避免各场景重复样板配置。 + /// + /// 测试输入源码。 + /// 配置完成的生成器测试对象。 + private static CSharpSourceGeneratorTest CreateGeneratorTest(string source) + { + return new CSharpSourceGeneratorTest { TestState = { Sources = { source } }, - DisabledDiagnostics = { "GF_Common_Trace_001" }, - TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck - }; - - test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error) - .WithLocation(0) - .WithArguments("InventoryPanel", "__InjectContextBindings_Generated")); - - await test.RunAsync(); - } - - [Test] - public async Task Ignores_NonInferable_Const_Field_For_GetAll_Class_Without_Diagnostic() - { - var source = """ - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class GetAllAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Rule - { - public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - - [GetAll] - public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase - { - private const double LogicStep = 0.2; - private IInventoryModel _model = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class BattlePanel - { - private void __InjectContextBindings_Generated() - { - _model = this.GetModel(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_BattlePanel.ContextGet.g.cs", expected)); - Assert.Pass(); - } - - [Test] - public async Task Warns_And_Skips_Readonly_Inferred_Field_For_GetAll_Class() - { - var source = MarkupTestSource.Parse(""" - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class GetAllAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Rule - { - public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - public static T GetSystem(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - public interface ICombatSystem : GFramework.Core.Abstractions.Systems.ISystem { } - - [GetAll] - public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase - { - private readonly IInventoryModel {|#0:_model|} = null!; - private ICombatSystem _system = null!; - } - } - """); - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class BattlePanel - { - private void __InjectContextBindings_Generated() - { - _system = this.GetSystem(); - } - } - - """; - - var test = new CSharpSourceGeneratorTest - { - TestState = - { - Sources = { source.Source }, - GeneratedSources = - { - (typeof(ContextGetGenerator), "TestApp_BattlePanel.ContextGet.g.cs", NormalizeLineEndings(expected)) - } - }, DisabledDiagnostics = { "GF_Common_Trace_001" } }; - - test.ExpectedDiagnostics.Add(source.WithSpan( - new DiagnosticResult("GF_ContextGet_008", DiagnosticSeverity.Warning), - "0") - .WithArguments("_model")); - - await test.RunAsync(); - Assert.Pass(); - } - - [Test] - public async Task Warns_And_Skips_Static_Inferred_Field_For_GetAll_Class() - { - var source = MarkupTestSource.Parse(""" - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class GetAllAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Rule - { - public abstract class ContextAwareBase : GFramework.Core.Abstractions.Rule.IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - public static T GetSystem(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - public interface ICombatSystem : GFramework.Core.Abstractions.Systems.ISystem { } - - [GetAll] - public partial class BattlePanel : GFramework.Core.Rule.ContextAwareBase - { - private static IInventoryModel {|#0:_model|} = null!; - private ICombatSystem _system = null!; - } - } - """); - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class BattlePanel - { - private void __InjectContextBindings_Generated() - { - _system = this.GetSystem(); - } - } - - """; - - var test = new CSharpSourceGeneratorTest - { - TestState = - { - Sources = { source.Source }, - GeneratedSources = - { - (typeof(ContextGetGenerator), "TestApp_BattlePanel.ContextGet.g.cs", NormalizeLineEndings(expected)) - } - }, - DisabledDiagnostics = { "GF_Common_Trace_001" } - }; - - test.ExpectedDiagnostics.Add(source.WithSpan( - new DiagnosticResult("GF_ContextGet_007", DiagnosticSeverity.Warning), - "0") - .WithArguments("_model")); - - await test.RunAsync(); - Assert.Pass(); } + /// + /// 将手工声明的期望生成源码归一化到当前平台换行符,避免不同宿主上的伪差异。 + /// + /// 原始期望源码。 + /// 已按当前平台换行符归一化的源码文本。 private static string NormalizeLineEndings(string content) { return content @@ -747,531 +1355,4 @@ public class ContextGetGeneratorTests .Replace("\r", "\n", StringComparison.Ordinal) .Replace("\n", Environment.NewLine, StringComparison.Ordinal); } - - [Test] - public async Task Skips_Nullable_Service_Like_Field_For_ContextAware_GetAll_Class() - { - var source = """ - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class ContextAwareAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class GetAllAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - public static T GetSystem(this object contextAware) => default!; - } - } - - namespace Godot - { - public class Control { } - } - - namespace TestApp - { - public interface IGridModel : GFramework.Core.Abstractions.Model.IModel { } - public interface IRunLoopSystem : GFramework.Core.Abstractions.Systems.ISystem { } - public interface IUiPageBehavior { } - - [ContextAware] - [GetAll] - public partial class GameplayHud : Godot.Control - { - private IGridModel _gridModel = null!; - private IUiPageBehavior? _page; - private IRunLoopSystem _runLoopSystem = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class GameplayHud - { - private void __InjectContextBindings_Generated() - { - _gridModel = this.GetModel(); - _runLoopSystem = this.GetSystem(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_GameplayHud.ContextGet.g.cs", expected)); - Assert.Pass(); - } - - [Test] - public async Task Generates_Bindings_For_IContextAware_Class() - { - var source = """ - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetServiceAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetService(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IStrategy { } - - public partial class StrategyHost : GFramework.Core.Abstractions.Rule.IContextAware - { - [GetService] - private IStrategy _strategy = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class StrategyHost - { - private void __InjectContextBindings_Generated() - { - _strategy = this.GetService(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_StrategyHost.ContextGet.g.cs", expected)); - Assert.Pass(); - } - - [Test] - public async Task Reports_Diagnostic_When_Class_Is_Not_ContextAware() - { - var source = MarkupTestSource.Parse(""" - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - - public partial class InventoryPanel - { - [GetModel] - private IInventoryModel {|#0:_model|} = null!; - } - } - """); - - var test = new CSharpSourceGeneratorTest - { - TestState = - { - Sources = { source.Source } - }, - DisabledDiagnostics = { "GF_Common_Trace_001" } - }; - - test.ExpectedDiagnostics.Add(source.WithSpan( - new DiagnosticResult("GF_ContextGet_005", DiagnosticSeverity.Error), - "0") - .WithArguments("InventoryPanel")); - - await test.RunAsync(); - Assert.Pass(); - } - - [Test] - public async Task Reports_Diagnostic_When_GetModels_Field_Is_Not_IReadOnlyList() - { - var source = MarkupTestSource.Parse(""" - using System; - using System.Collections.Generic; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelsAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static IReadOnlyList GetModels(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - - public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware - { - [GetModels] - private List {|#0:_models|} = new(); - } - } - """); - - var test = new CSharpSourceGeneratorTest - { - TestState = - { - Sources = { source.Source } - }, - DisabledDiagnostics = { "GF_Common_Trace_001" } - }; - - test.ExpectedDiagnostics.Add(source.WithSpan( - new DiagnosticResult("GF_ContextGet_004", DiagnosticSeverity.Error), - "0") - .WithArguments("_models", "System.Collections.Generic.List", "GetModels")); - - await test.RunAsync(); - Assert.Pass(); - } - - [Test] - public async Task Reports_Diagnostic_For_Readonly_Explicit_GetModel_Field() - { - var source = MarkupTestSource.Parse(""" - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - - public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware - { - [GetModel] - private readonly IInventoryModel {|#0:_model|} = null!; - } - } - """); - - var test = new CSharpSourceGeneratorTest - { - TestState = - { - Sources = { source.Source } - }, - DisabledDiagnostics = { "GF_Common_Trace_001" } - }; - - test.ExpectedDiagnostics.Add(source.WithSpan( - new DiagnosticResult("GF_ContextGet_003", DiagnosticSeverity.Error), - "0") - .WithArguments("_model")); - - await test.RunAsync(); - Assert.Pass(); - } - - [Test] - public async Task Reports_Diagnostic_For_Static_Explicit_GetModel_Field() - { - var source = MarkupTestSource.Parse(""" - using System; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static T GetModel(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - - public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware - { - [GetModel] - private static IInventoryModel {|#0:_model|} = null!; - } - } - """); - - var test = new CSharpSourceGeneratorTest - { - TestState = - { - Sources = { source.Source } - }, - DisabledDiagnostics = { "GF_Common_Trace_001" } - }; - - test.ExpectedDiagnostics.Add(source.WithSpan( - new DiagnosticResult("GF_ContextGet_002", DiagnosticSeverity.Error), - "0") - .WithArguments("_model")); - - await test.RunAsync(); - Assert.Pass(); - } - - [Test] - public async Task Generates_Bindings_For_GetModels_Field_Assignable_From_IReadOnlyList() - { - var source = """ - using System; - using System.Collections.Generic; - using GFramework.Core.SourceGenerators.Abstractions.Rule; - - namespace GFramework.Core.SourceGenerators.Abstractions.Rule - { - [AttributeUsage(AttributeTargets.Field, Inherited = false)] - public sealed class GetModelsAttribute : Attribute { } - } - - namespace GFramework.Core.Abstractions.Rule - { - public interface IContextAware { } - } - - namespace GFramework.Core.Abstractions.Model - { - public interface IModel { } - } - - namespace GFramework.Core.Abstractions.Systems - { - public interface ISystem { } - } - - namespace GFramework.Core.Abstractions.Utility - { - public interface IUtility { } - } - - namespace GFramework.Core.Extensions - { - public static class ContextAwareServiceExtensions - { - public static IReadOnlyList GetModels(this object contextAware) => default!; - } - } - - namespace TestApp - { - public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { } - - public partial class InventoryPanel : GFramework.Core.Abstractions.Rule.IContextAware - { - [GetModels] - private IEnumerable _models = null!; - } - } - """; - - const string expected = """ - // - #nullable enable - - using GFramework.Core.Extensions; - - namespace TestApp; - - partial class InventoryPanel - { - private void __InjectContextBindings_Generated() - { - _models = this.GetModels(); - } - } - - """; - - await GeneratorTest.RunAsync( - source, - ("TestApp_InventoryPanel.ContextGet.g.cs", expected)); - Assert.Pass(); - } } diff --git a/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md b/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md index bc42ecc2..496f4465 100644 --- a/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md +++ b/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md @@ -7,8 +7,8 @@ ## 当前恢复点 -- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-028` -- 当前阶段:`Phase 28` +- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-034` +- 当前阶段:`Phase 34` - 当前焦点: - 已完成 `GFramework.Core` 当前 `MA0016` / `MA0002` / `MA0015` / `MA0077` 低风险收口批次 - 已复核 `net10.0` 下的 `MA0158` 基线:`GFramework.Core` / `GFramework.Cqrs` 当前共有 `16` 个 object lock @@ -38,6 +38,9 @@ - 已完成当前分支与 `main` 的 `CqrsHandlerRegistryGenerator.cs` 文件级冲突收口:确认 `main` 侧新增的是 `OrderedRegistrationKind` / `RuntimeTypeReferenceSpec` 的 XML 文档,现已按当前 partial 拆分结构迁移到 `CqrsHandlerRegistryGenerator.Models.cs`,不回退已完成的生成器拆分 + - 已完成 `SchemaConfigGenerator.cs` 剩余 `MA0051` 收口:将 `dependentRequired` / `allOf` / conditional schema 校验 + 拆成更小的验证阶段,并将 `GenerateTableClass`、`GenerateBindingsClass`、`AppendGeneratedConfigCatalogType` + 拆成稳定的代码发射 helper,保持生成输出与快照一致 - 已更新 `AGENTS.md`:变更模块必须运行对应 `dotnet build -c Release`,并处理或显式报告模块构建 warning, 不再默认留给长期 warning 清理分支 - `CoroutineScheduler` 的 tag/group 字典已显式使用 `StringComparer.Ordinal`,保持既有区分大小写语义 @@ -46,12 +49,40 @@ - 当前 `GFramework.Core` `net8.0` warnings-only 基线已降到 `0` 条 - 当前 `GFramework.Core.SourceGenerators` warnings-only 基线已降到 `0` 条 - 当前 `GFramework.Cqrs.SourceGenerators` warnings-only 基线已降到 `0` 条 - - 当前 `GFramework.Game.SourceGenerators` warnings-only 基线已从 `46` 条降到 `9` 条,剩余均为 - `SchemaConfigGenerator.cs` 的 `MA0051` + - 当前 `GFramework.Game.SourceGenerators` warnings-only 基线已从 `46` 条降到 `0` 条 + - 已完成 `GFramework.SourceGenerators.Tests` 低风险 `MA0004` / `MA0048` 收口:测试辅助器改为直接返回 `Task`, + 文件 I/O 显式补齐 `ConfigureAwait(false)`,`AnalyzerTestDriver` 文件名与类型名重新对齐 + - 当前 `GFramework.SourceGenerators.Tests` warnings-only 基线已从 `61` 条降到 `49` 条,剩余 warning 均为 `MA0051` + - 已完成 `GFramework.SourceGenerators.Tests/Logging/LoggerGeneratorSnapshotTests.cs` 的 `MA0051` 收口:同构 snapshot + 场景已收敛为模板化 helper,保留原有快照目录与生成器输入语义不变 + - 当前 `GFramework.SourceGenerators.Tests` Release build 基线已从 `49` 条降到 `43` 条;`LoggerGeneratorSnapshotTests.cs` + 已不再出现在 `MA0051` 列表中 + - 已完成 `GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs` 的 `MA0051` 收口: + 将内联测试源码与期望快照抽到类级常量、补齐测试类 XML 文档,并将仅作转发的异步测试改为直接返回 `Task` + - 当前 `GFramework.SourceGenerators.Tests` Release build 基线已从 `43` 条降到 `40` 条; + `AutoRegisterModuleGeneratorTests.cs` 已不再出现在 `MA0051` 列表中 + - 已完成 `GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs` 的 `MA0051` 收口: + 将 monster 场景的运行时契约与 schema 输入提取为类级常量,并把生成结果与快照目录解析拆成小 helper,保持 + 生成文件名、快照目录和断言语义不变 + - 当前 `GFramework.SourceGenerators.Tests` Release build 基线已从 `40` 条降到 `39` 条; + `SchemaConfigGeneratorSnapshotTests.cs` 已不再出现在 `MA0051` 列表中 + - 已完成当前 PR #273 review follow-up 首轮核对:确认本地仍成立的问题集中在 + `SchemaConfigGenerator` helper XML 文档、`GeneratorSnapshotTest` 的 `StringComparison.Ordinal` / + snapshot 路径空值防御、`AutoRegisterModuleGeneratorTests` 的 XML 文档位置,以及 + `SchemaConfigGeneratorSnapshotTests` 的 monster 快照覆盖缺口 + - 已将 monster 快照场景扩展到 `dependentRequired`、`dependentSchemas`、`allOf` 与 object-focused + `if/then/else`,以便把新增 schema 约束文档纳入 snapshot 验证 + - 已完成本轮定向验证: + `GFramework.Game.SourceGenerators` Release build 通过; + `GFramework.SourceGenerators.Tests` 在 `-m:1 --no-restore` 下 Release build 通过; + `SchemaConfigGeneratorSnapshotTests` 与 `AutoRegisterModuleGeneratorTests` 定向测试共 `4` 项全部通过 + - 当前验证仍受环境/基线约束: + `GFramework.SourceGenerators.Tests` Release build 保留既有 `MA0051` warning 基线; + NuGet vulnerability audit 在离线环境下产生 `NU1900` - `GFramework.Godot` 的 `Timing.cs` 已同步适配新事件签名,但当前 worktree 的 Godot restore 资产仍受 Windows fallback package folder 干扰,独立 build 需在修复资产后补跑 - 后续继续按 warning 类型和数量批处理,而不是回退到按单文件切片推进 - - 下一轮默认继续拆分 `GFramework.Game.SourceGenerators` 的 `MA0051` 热点,或评估跨 target 的 `MA0158` - 锁替换风险 + - 下一轮默认重新抓取 PR #273 最新 review 线程,并确认本轮 snapshot 更新后是否还存在剩余 open thread 或 + `dotnet-format` 细项 - 单次 `boot` 的工作树改动上限控制在约 `100` 个文件以内,避免 recovery context 与 review 面同时失控 - 若任务边界互不冲突,允许使用不同模型的 subagent 并行处理不同 warning 类型或不同目录,但必须遵守显式 ownership @@ -84,8 +115,15 @@ inherited-collision 快照测试 - 已完成当前分支与 `main` 的 `CqrsHandlerRegistryGenerator.cs` 冲突化解:保留当前 partial 结构,并把 `main` 侧新增的模型文档合并到 `CqrsHandlerRegistryGenerator.Models.cs` -- 已完成 `GFramework.Game.SourceGenerators` 中 `SchemaConfigGenerator` 的第一批 `MA0051` 收口;warnings-only 基线剩余 `9` 条 - `MA0051` +- 已完成 `GFramework.Game.SourceGenerators` 中 `SchemaConfigGenerator` 的剩余 `MA0051` 收口;warnings-only 基线已降到 `0` 条 +- 已完成 `GFramework.SourceGenerators.Tests` 的首轮低风险 warning 清理;当前项目已清空 `MA0004` / `MA0048`,剩余 warning + 全部收敛为 `MA0051` +- 已完成 `LoggerGeneratorSnapshotTests` 的单文件 `MA0051` 收口;当前 `GFramework.SourceGenerators.Tests` Release build 基线已降到 + `43` 条,并通过 focused snapshot tests 保持行为不变 +- 已完成 `AutoRegisterModuleGeneratorTests` 的单文件 `MA0051` 收口;当前 `GFramework.SourceGenerators.Tests` Release build 基线已降到 + `40` 条,并通过 focused generator tests 保持输出契约不变 +- 已完成 `SchemaConfigGeneratorSnapshotTests` 的单文件 `MA0051` 收口;当前 `GFramework.SourceGenerators.Tests` + Release build 基线已降到 `39` 条,并通过 focused snapshot test 保持生成输出契约不变 ## 当前活跃事实 @@ -126,6 +164,10 @@ 通过 schema 类型比较 helper 与显式 `StringComparison.Ordinal` 清空当前项目的 `MA0006` - `RP-020` 继续拆分 `SchemaConfigGenerator.cs` 的 `MA0051` 热点,将当前项目 warnings-only 基线从 `19` 条降到 `9` 条, 并用 focused schema generator tests 验证 50 个用例通过 +- `RP-032` 已完成 `AutoRegisterModuleGeneratorTests` 的 3 个 `MA0051` 收口:通过提取类级常量承载测试源码与快照,保持 + 生成文件名、断言路径与源生成输出不变;`GFramework.SourceGenerators.Tests` warnings-only 基线由 `43` 降至 `40` +- `RP-033` 已完成 `SchemaConfigGeneratorSnapshotTests` 的 `MA0051` 收口:monster schema 运行时契约与 schema 输入已提取为 + 类级常量,生成结果映射与快照目录解析已拆为小 helper;`GFramework.SourceGenerators.Tests` warnings-only 基线由 `40` 降至 `39` - `RP-021` 使用 `$gframework-pr-review` 复核当前分支 PR #269 后,修复仍在本地成立的 4 个项:将 `CqrsHandlerRegistryGenerator` 拆分为职责清晰的 partial 文件、为 `ContextAwareGenerator` 生成字段增加稳定前缀并补上 `SetContextProvider` 的运行时 null 校验、为 `Option` 补齐 ``,并新增字段重名场景的生成器快照测试 @@ -141,6 +183,13 @@ - `RP-025` 继续复核 PR #269 剩余 outside-diff / nitpick 信号后,确认本地仍成立的是 `SchemaConfigGenerator` 的归一化字段名冲突与 `Cqrs` 对 `dynamic` 的直接类型引用;已分别补上诊断、运行时类型归一化与回归测试, 并把“变更模块必须运行对应 build 且处理 warning”的治理规则写回 `AGENTS.md` +- `RP-029` 已完成 `SchemaConfigGenerator` 剩余 `MA0051` 收口:`GFramework.Game.SourceGenerators` 独立 Release + warnings-only build 已清零,并通过 `SchemaConfigGenerator` focused generator tests 锁定生成输出未回退 +- `RP-030` 已完成 `GFramework.SourceGenerators.Tests` 低风险 `MA0004` / `MA0048` 收口:`AnalyzerTestDriver` 文件名已与 + 类型名一致,测试辅助器与 schema snapshot 断言路径已改为直接返回 `Task` 或显式使用 `ConfigureAwait(false)`; + 当前测试项目 warnings-only 基线从 `61` 条降到 `49` 条,剩余均为 `MA0051` +- `RP-031` 已完成 `LoggerGeneratorSnapshotTests` 的 `MA0051` 收口:重复场景源码改为模板化 helper 生成, + 当前 `GFramework.SourceGenerators.Tests` Release build 基线从 `49` 条降到 `43` 条,并通过 focused test 验证 6 个用例全部通过 - 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射 ## 当前风险 @@ -153,13 +202,12 @@ - 缓解措施:继续以唯一源位置和 warning 家族为主要决策依据,而不是只看原始 warning 总数 - net10 专属 warning 风险:`MA0158` 建议使用 `System.Threading.Lock`,但项目多 target 时需要确认兼容边界 - 缓解措施:下一轮先按 target framework 与 API 可用性评估,不直接批量替换共享源码中的 `object` lock -- source generator warning 外溢风险:运行 `GFramework.SourceGenerators.Tests` 会构建相邻 generator/test 项目并显示既有 - `GFramework.Game.SourceGenerators` 与测试项目 warning - - 缓解措施:继续以被修改 generator 项目的独立 warnings-only build 作为主验收,并用 focused generator test 验证行为 -- source generator test warning 治理风险:`GFramework.SourceGenerators.Tests` 当前仍有既有 `MA0051` / `MA0004` / `MA0048` - warning,本轮 focused test 已通过,但测试项目整包 warning 尚未进入本轮写集 - - 缓解措施:本轮已在 failed-test follow-up 的定向 `dotnet test` 中再次确认这些 warning 仍为既有基线;后续若继续修改该测试项目, - 应按新增 `AGENTS.md` 规则先明确 warning 收口范围,再决定是否进入专门清理切片 +- source generator warning 外溢风险:运行 `GFramework.SourceGenerators.Tests` 会构建相邻 generator/test 项目,并在输出中混入 + 测试项目自身的结构性 warning 基线 + - 缓解措施:继续以被修改项目的独立 warnings-only build 作为主验收,并用 focused generator test 验证行为 +- source generator test warning 治理风险:`GFramework.SourceGenerators.Tests` 当前仍有 `43` 条既有 `MA0051` warning; + 一旦继续进入该写集,就必须把测试项目 warning 一并纳入本轮完成条件 + - 缓解措施:已先清空低风险 `MA0004` / `MA0048`,后续继续保持“单 warning family、单测试域”的节奏推进 `MA0051` - ContextAware 基类命名隐藏风险:若生成器只看当前类型声明成员,派生规则会重新占用基类已声明的 `_gFrameworkContextAware*` 字段名,导致生成成员隐藏继承状态并让快照无法锁定后缀分配行为 - 缓解措施:本轮已改为遍历完整 base-type 链收集保留名,并用 inherited collision 快照用例锁定该行为 @@ -311,13 +359,43 @@ - 说明:测试项目构建仍会显示既有 `GFramework.SourceGenerators.Tests` analyzer warning;不属于本轮写集 - `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~CollectionExtensionsTests|FullyQualifiedName~LoggingConfigurationTests" -m:1 -p:RestoreFallbackFolders="" -v minimal` - 结果:`27 Passed`,`0 Failed` +- `RP-029` 的验证结果: + - `dotnet restore GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -p:RestoreFallbackFolders="" -nologo` + - 结果:通过;刷新 Linux 侧 restore 资产以移除 Windows fallback package folder 干扰 + - `dotnet restore GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -p:RestoreFallbackFolders="" -nologo` + - 结果:通过;focused test 所属测试项目已同步刷新 Linux 侧 restore 资产 + - `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + - 结果:`0 Warning(s)`,`0 Error(s)`;`SchemaConfigGenerator.cs` 剩余 `MA0051` 已清零 + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~SchemaConfigGenerator -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`54 Passed`,`0 Failed` + - 说明:测试项目构建仍显示既有 `GFramework.SourceGenerators.Tests` `MA0048` / `MA0051` / `MA0004` warning;不属于本轮 + `GFramework.Game.SourceGenerators` 写集 +- `RP-030` 的验证结果: + - `dotnet restore GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -p:RestoreFallbackFolders="" -nologo` + - 结果:通过;刷新 Linux 侧 restore 资产以移除 Windows fallback package folder 干扰 + - `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + - 结果:`49 Warning(s)`,`0 Error(s)`;当前项目已不再出现 `MA0004` / `MA0048`,剩余 warning 全部为 `MA0051` + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~GeneratorSnapshotTestSecurityTests|FullyQualifiedName~SchemaConfigGeneratorSnapshotTests|FullyQualifiedName~SchemaConfigGeneratorEnumTests" -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`6 Passed`,`0 Failed` +- `RP-031` 的验证结果: + - `dotnet restore GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -p:RestoreFallbackFolders="" -nologo` + - 结果:通过;刷新 Linux 侧 restore 资产以支持后续串行 build/test 验证 + - `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:Summary` + - 结果:`43 Warning(s)`,`0 Error(s)`;`LoggerGeneratorSnapshotTests.cs` 已不再出现在 `MA0051` 列表中 + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-build --disable-build-servers --filter FullyQualifiedName~LoggerGeneratorSnapshotTests -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`6 Passed`,`0 Failed` +- `RP-033` 的验证结果: + - `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + - 结果:`39 Warning(s)`,`0 Error(s)`;`SchemaConfigGeneratorSnapshotTests.cs` 已不再出现在 `MA0051` 列表中 + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-build --disable-build-servers --filter FullyQualifiedName~SchemaConfigGeneratorSnapshotTests -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`1 Passed`,`0 Failed` - active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,不再重复保存已完成阶段的长篇历史 ## 下一步 1. 若要继续该主题,先读 active tracking,再按需展开历史归档中的 warning 热点与验证记录 -2. 下一轮优先继续拆分 `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` 的剩余 `MA0051`;建议先从 - `GenerateBindingsClass`、`AppendGeneratedConfigCatalogType` 或对象/条件 schema target 验证方法切入 +2. 下一轮优先继续 `GFramework.SourceGenerators.Tests` 的 `MA0051` 收口,先在 `GeneratorSnapshotTest`、 + `ContextRegistrationAnalyzerTests` 或 `ContextGetGeneratorTests` 中选择一个单写集推进,不再把已清零的 `MA0004` / `MA0048` 混回写集 3. 若改回推进 `MA0158`,先设计 `net8.0` / `net9.0` / `net10.0` 多 target 条件编译方案,不直接批量替换共享源码中的 `object` lock 4. 若后续继续改动 `GFramework.Godot`,先修复该项目的 Linux 侧 restore 资产,再补跑独立 build diff --git a/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md b/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md index ca74deca..620d2928 100644 --- a/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md +++ b/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md @@ -1,5 +1,172 @@ # Analyzer Warning Reduction 追踪 +## 2026-04-23 — RP-033 + +### 阶段:`SchemaConfigGeneratorSnapshotTests.cs` `MA0051` 收口(RP-033) + +- 启动复核: + - 按 `gframework-boot` 流程恢复当前 worktree,读取 `AGENTS.md`、`.ai/environment/tools.ai.yaml`、 + `ai-plan/public/README.md` 与 active topic 跟踪文件,确认当前分支 `fix/analyzer-warning-reduction-batch` + 仍映射到 `analyzer-warning-reduction` + - 用 + `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + 复核当前 `MA0051` 热点,确认 `SchemaConfigGeneratorSnapshotTests.cs` 仍保留 1 个超长方法,适合作为单文件低风险写集 +- 决策: + - 保持 monster schema 场景的输入源码、schema 文本、生成文件名与快照目录不变,只收敛测试方法长度 + - 沿用前几轮 snapshot test 的收口策略:提取类级常量承载大段 fixture 输入,再用小 helper 封装生成结果映射与快照目录解析 + - 同一测试项目的 build/test 继续采用串行验证;并行执行会在 WSL worktree 上制造瞬时输出缺失,导致 `MSB3030` / `CS0006` +- 实施调整: + - 为 `SchemaConfigGeneratorSnapshotTests` 新增 `RuntimeContractsSource` 与 `MonsterSchema` 类级常量,保留既有 monster 场景内容 + - 把生成结果字典构造拆到 `GenerateSourcesForMonsterSchema()`,把快照目录解析拆到 `GetSchemaSnapshotFolder()` + - 保持 `AssertAllSnapshotsAsync(...)`、快照文件名与断言流程不变,不改生成器逻辑和 snapshot 资产 +- 验证结果: + - `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + - 结果:`39 Warning(s)`,`0 Error(s)`;`SchemaConfigGeneratorSnapshotTests.cs` 已不再出现在 `MA0051` 列表中 + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-build --disable-build-servers --filter FullyQualifiedName~SchemaConfigGeneratorSnapshotTests -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`1 Passed`,`0 Failed` +- 下一步建议: + - 若继续压缩 `GFramework.SourceGenerators.Tests` 的 `MA0051`,优先处理只剩单个超长方法的 `GeneratorSnapshotTest` 或 + `ContextRegistrationAnalyzerTests` + - 若希望继续按 warning 数量收敛,则回到 `ContextGetGeneratorTests.cs`,但需要接受更大的单文件写集 + +## 2026-04-23 — RP-032 + +### 阶段:`AutoRegisterModuleGeneratorTests.cs` `MA0051` 收口(RP-032) + +- 启动复核: + - 按 `gframework-boot` 流程恢复当前 worktree,读取 `AGENTS.md`、`.ai/environment/tools.ai.yaml`、 + `ai-plan/public/README.md` 与 active topic 跟踪文件,确认当前分支 `fix/analyzer-warning-reduction-batch` + 仍映射到 `analyzer-warning-reduction` + - 先用 + `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + 复核当前 `MA0051` 热点,确认 `AutoRegisterModuleGeneratorTests.cs` 仍有 `3` 个超长方法,适合作为单文件低风险写集 +- 决策: + - 保持 `AutoRegisterModuleGeneratorTests` 的测试输入、生成文件名、快照文本与断言结构不变,只收敛方法长度 + - 采用“提取类级常量承载大段测试源码与期望输出”的方式,避免引入新的共享 helper 或改变场景组装顺序 + - 验证阶段改为串行执行 build/test;避免和同项目并行运行时拿到不完整 `bin/Release` 输出 +- 实施调整: + - 为 `AutoRegisterModuleGeneratorTests` 补齐测试类 XML 文档 + - 将 3 个长测试方法中的源码与期望快照提取为类级 `const string`,保留原有生成文件名与断言目标 + - 将仅转发 `GeneratorTest.RunAsync(...)` 的两个异步测试改为直接返回 `Task` +- 验证结果: + - `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + - 结果:`40 Warning(s)`,`0 Error(s)`;`AutoRegisterModuleGeneratorTests.cs` 已不再出现在 `MA0051` 列表中 + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --disable-build-servers --filter FullyQualifiedName~AutoRegisterModuleGeneratorTests -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`3 Passed`,`0 Failed` +- 下一步建议: + - 若继续压缩 `GFramework.SourceGenerators.Tests` 的 `MA0051`,优先处理仅剩单个超长方法的 + `GFramework.SourceGenerators.Tests/Core/GeneratorSnapshotTest.cs` + - 若希望单次继续多降几条 warning,则改选 `ContextGetGeneratorTests.cs`,但需要接受更大的单文件写集 + +## 2026-04-23 — RP-031 + +### 阶段:`LoggerGeneratorSnapshotTests.cs` `MA0051` 收口(RP-031) + +- 启动复核: + - 按 `gframework-boot` 流程恢复当前 worktree,读取 `AGENTS.md`、`.ai/environment/tools.ai.yaml`、 + `ai-plan/public/README.md` 与 active topic 跟踪文件,确认当前分支 `fix/analyzer-warning-reduction-batch` + 仍映射到 `analyzer-warning-reduction` + - 结合 `RP-030` 的下一步建议与当前文件规模,优先选择 `GFramework.SourceGenerators.Tests/Logging/LoggerGeneratorSnapshotTests.cs` + 作为单文件、同构 snapshot 场景的低风险写集 +- 决策: + - 保持 `LoggerGenerator` 现有快照资产、场景命名与输入语义不变,只压缩测试方法和样板源码构造的结构复杂度 + - 先把重复场景统一为模板化 helper,再根据 analyzer 结果继续拆分 helper,直到 `LoggerGeneratorSnapshotTests.cs` + 不再出现在 `MA0051` 输出中 + - 验证阶段避免并行运行同一测试项目的 build/test,防止 WSL worktree 上的 `bin/Release` 文件占用噪音污染结果 +- 实施调整: + - 为 `LoggerGeneratorSnapshotTests` 补齐类与测试方法 XML 文档 + - 将 6 个 snapshot 场景改为统一调用 `RunScenarioAsync(...)` + - 将原先重复内联的完整测试源码拆成 `CreateLoggingAttributeSource()`、 + `CreateLoggingContractsSource()`、`CreateLoggingRuntimeSource()` 与 `CreateTestAppSource(...)` +- 验证结果: + - `dotnet restore GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -p:RestoreFallbackFolders="" -nologo` + - 结果:通过 + - `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:Summary` + - 结果:`43 Warning(s)`,`0 Error(s)`;`LoggerGeneratorSnapshotTests.cs` 已不再出现在 `MA0051` 列表中 + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-build --disable-build-servers --filter FullyQualifiedName~LoggerGeneratorSnapshotTests -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`6 Passed`,`0 Failed` +- 下一步建议: + - 若继续 `GFramework.SourceGenerators.Tests` 的 `MA0051` 治理,优先选择 `AutoRegisterModuleGeneratorTests` 或 + `GeneratorSnapshotTest` 作为下一批单写集 + - 若需要先压缩 warning 数量而不是单文件难度,可转向 `ContextGetGeneratorTests`,但应先明确本轮允许的文件数上限 + +## 2026-04-23 — RP-030 + +### 阶段:`GFramework.SourceGenerators.Tests` 低风险 `MA0004` / `MA0048` 收口(RP-030) + +- 启动复核: + - 按 `gframework-boot` 流程恢复当前 worktree 后,读取 `AGENTS.md`、`.ai/environment/tools.ai.yaml`、 + `ai-plan/public/README.md` 与 active topic 跟踪文件,确认当前分支 `fix/analyzer-warning-reduction-batch` + 仍映射到 `analyzer-warning-reduction` + - 先对 `GFramework.SourceGenerators.Tests` 执行 + `dotnet restore GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -p:RestoreFallbackFolders="" -nologo`, + 刷新 Linux 侧 restore 资产,规避 Windows fallback package folder 干扰 + - 用 + `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + 复核当前基线,确认该测试项目共有 `61` 条 warning,其中低风险切片集中在 `MA0004` 与单个 `MA0048` +- 决策: + - 不直接进入大型 snapshot/test 方法的 `MA0051`,先收口纯 test-infrastructure 层的 `MA0004` / `MA0048` + - 对“只是转发异步调用”的 helper 直接返回 `Task`,只在真实文件 I/O 上显式补 `ConfigureAwait(false)`,避免无意义的 + `async/await` 包装 + - 将 `AnalyzerTestDriver` 所在文件改名为与类型一致,单独清理 `MA0048`,不改类型名与调用方契约 +- 实施调整: + - 将 `AnalyzerTestDriver.RunAsync(...)` 与 `GeneratorTest.RunAsync(...)` 改为直接返回下游 `Task` + - 为 `GeneratorSnapshotTest`、`SchemaConfigGeneratorSnapshotTests` 与 `SchemaConfigGeneratorEnumTests` 中的异步文件读写 + 显式补齐 `ConfigureAwait(false)`,并把仅作转发的测试方法改为直接返回 `Task` + - 将 `GeneratorSnapshotTestSecurityTests` 的 `Assert.ThrowsAsync(...)` 改为直接返回目标 `Task`,移除无收益的 + `async` 包装 + - 将 `GFramework.SourceGenerators.Tests/Core/AnalyzerTest.cs` 重命名为 + `GFramework.SourceGenerators.Tests/Core/AnalyzerTestDriver.cs` +- 验证结果: + - `dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + - 结果:`49 Warning(s)`,`0 Error(s)`;当前项目已不再出现 `MA0004` / `MA0048`,剩余 warning 全部为 `MA0051` + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~GeneratorSnapshotTestSecurityTests|FullyQualifiedName~SchemaConfigGeneratorSnapshotTests|FullyQualifiedName~SchemaConfigGeneratorEnumTests" -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`6 Passed`,`0 Failed` +- 下一步建议: + - 若继续 analyzer warning reduction,继续把 `GFramework.SourceGenerators.Tests` 作为独立写集,只处理 `MA0051` + - 下一轮优先选择单一测试域的同构长方法,例如 `LoggerGeneratorSnapshotTests`、`AutoRegisterModuleGeneratorTests` + 或共享 helper `GeneratorSnapshotTest` + +## 2026-04-23 — RP-029 + +### 阶段:`SchemaConfigGenerator.cs` 剩余 `MA0051` 收口(RP-029) + +- 启动复核: + - 按 `gframework-boot` 流程恢复当前 worktree 后,先读取 `AGENTS.md`、`.ai/environment/tools.ai.yaml`、`ai-plan/public/README.md` + 与 active topic 跟踪文件,确认当前分支 `fix/analyzer-warning-reduction-batch` 仍映射到 + `analyzer-warning-reduction` + - 用历史基线命令重新执行 `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"`, + 复现 `SchemaConfigGenerator.cs` 剩余 `9` 条 `MA0051` +- 决策: + - 继续沿用“低风险结构拆分、不改诊断 ID、不改生成顺序、不改快照输出”的收口策略 + - 先把 schema 元数据校验方法拆成更小验证阶段,再把 `GenerateTableClass`、`GenerateBindingsClass` 与 + `AppendGeneratedConfigCatalogType` 的代码发射流程分段,避免直接改动生成文本内容 + - focused test 仍以 `SchemaConfigGenerator` 相关用例为主;`GFramework.SourceGenerators.Tests` 里既有测试项目 warning + 不纳入本轮写集 +- 实施调整: + - 为 `dependentRequired`、`dependentSchemas`、`allOf`、conditional schema 等对象级校验补上细粒度 helper, + 把 declared-properties 获取、分支校验、target 校验拆成独立阶段 + - 为生成代码头部、表包装、bindings metadata/references、catalog metadata 发射补充结构化 helper, + 将长方法按“头部 / 元数据 / 行为方法”拆分 + - 修正 `References` 代码发射 helper 的闭合范围,确保重构后的 `MonsterConfigBindings.g.cs` 与现有快照保持一致 + - 在构建阶段遇到 Linux `dotnet` 命中 Windows fallback package folder 时,先对 + `GFramework.Game.SourceGenerators` 与 `GFramework.SourceGenerators.Tests` 执行 + `dotnet restore -p:RestoreFallbackFolders=""`,再继续 `--no-restore` 验证 +- 验证结果: + - `dotnet restore GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -p:RestoreFallbackFolders="" -nologo` + - 结果:通过 + - `dotnet restore GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -p:RestoreFallbackFolders="" -nologo` + - 结果:通过 + - `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"` + - 结果:`0 Warning(s)`,`0 Error(s)` + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~SchemaConfigGenerator -m:1 -p:RestoreFallbackFolders="" -nologo` + - 结果:`54 Passed`,`0 Failed` + - 说明:测试项目构建仍打印既有 `MA0048` / `MA0051` / `MA0004` warning;这些 warning 属于 `GFramework.SourceGenerators.Tests` + 基线,不属于本轮 `GFramework.Game.SourceGenerators` 写集 +- 下一步建议: + - 若继续 analyzer warning reduction,可评估是否为 `GFramework.SourceGenerators.Tests` 单独开新的 warning 清理切片 + - 若改回推进运行时主线,则按 `RP-017` 记录的策略先设计 `MA0158` 的多 target 兼容方案,再决定是否动共享 `object` lock + ## 2026-04-23 — RP-028 ### 阶段:`CqrsHandlerRegistryGenerator.cs` 文件级冲突化解(RP-028) @@ -797,3 +964,20 @@ 1. 若继续 analyzer warning reduction,优先回到 `GFramework.Core` 剩余 `MA0051` 热点,并继续保持“单 warning family、单切入点”的节奏 2. 后续所有 WSL 下的 .NET 定向验证命令继续显式附带 `-p:RestoreFallbackFolders=`,避免把环境问题误判成代码回归 +# 2026-04-23 + +- RP-034 / PR #273 review follow-up: + - 使用 `gframework-pr-review` 抓取当前分支 PR #273 的 latest-head review threads、MegaLinter 和测试摘要。 + - 本地复核后确认仍成立的项集中在 `SchemaConfigGenerator` helper XML 文档、 + `GeneratorSnapshotTest` 的 `StringComparison.Ordinal` 与 snapshot 路径空值防御、 + `AutoRegisterModuleGeneratorTests` 的 XML 文档位置,以及 + `SchemaConfigGeneratorSnapshotTests` 的 monster snapshot 覆盖缺口。 + - 已扩展 monster schema 场景以覆盖 `dependentRequired`、`dependentSchemas`、`allOf` 与 object-focused + `if/then/else`,并同步更新 `MonsterConfig.g.txt` 的约束快照。 + - `DOTNET_CLI_HOME=/tmp/dotnet-home dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -p:RestoreFallbackFolders=` + 通过;离线 NuGet vulnerability audit 产生 `NU1900`。 + - `DOTNET_CLI_HOME=/tmp/dotnet-home dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1` + 通过;测试项目保留既有 `MA0051` warning 基线。 + - `DOTNET_CLI_HOME=/tmp/dotnet-home dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~SchemaConfigGeneratorSnapshotTests|FullyQualifiedName~AutoRegisterModuleGeneratorTests" -m:1` + 通过,`4` 个用例全部通过;需要在沙箱外执行以绕过 `vstest` 本地 socket 权限限制。 + - 下一步:提交本轮修复并在需要时重新抓取 PR review,确认 open threads 是否随新提交收敛。