From c9f725d91676709e086aef108e7f87b071204647 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 18:26:31 +0200 Subject: [PATCH 01/14] Move member analysis into rule analysis --- .../Analysis/{Members => Rules}/AnalyzedMemberAccess.cs | 2 +- .../Analysis/{Members => Rules}/MemberAccessAnalyzer.cs | 2 +- .../Analysis/Rules/RuleInvocationAnalyzer.cs | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) rename src/TinyValidations.SourceGen/Analysis/{Members => Rules}/AnalyzedMemberAccess.cs (83%) rename src/TinyValidations.SourceGen/Analysis/{Members => Rules}/MemberAccessAnalyzer.cs (98%) diff --git a/src/TinyValidations.SourceGen/Analysis/Members/AnalyzedMemberAccess.cs b/src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs similarity index 83% rename from src/TinyValidations.SourceGen/Analysis/Members/AnalyzedMemberAccess.cs rename to src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs index b657c93..72cf99b 100644 --- a/src/TinyValidations.SourceGen/Analysis/Members/AnalyzedMemberAccess.cs +++ b/src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs @@ -1,4 +1,4 @@ -namespace TinyValidations.SourceGen.Analysis.Members +namespace TinyValidations.SourceGen.Analysis.Rules { internal sealed class AnalyzedMemberAccess { diff --git a/src/TinyValidations.SourceGen/Analysis/Members/MemberAccessAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs similarity index 98% rename from src/TinyValidations.SourceGen/Analysis/Members/MemberAccessAnalyzer.cs rename to src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs index 5e07359..a08b99f 100644 --- a/src/TinyValidations.SourceGen/Analysis/Members/MemberAccessAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace TinyValidations.SourceGen.Analysis.Members +namespace TinyValidations.SourceGen.Analysis.Rules { internal sealed class MemberAccessAnalyzer { diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs index 0d1cb9e..1f64f56 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs @@ -1,6 +1,5 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using TinyValidations.SourceGen.Analysis.Members; using TinyValidations.SourceGen.Model; using TinyValidations.SourceGen.Validation; From 3f4c2cbe139409c01b71476ee66b46579b4e3f92 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 18:31:19 +0200 Subject: [PATCH 02/14] Harden Define method diagnostics --- .../Declarations/DefineMethodAnalyzer.cs | 42 +++++++++++++++++-- .../GeneratorSmokeTests.cs | 24 +++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs index e8772b7..d56a018 100644 --- a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs @@ -21,7 +21,7 @@ internal sealed class DefineMethodAnalyzer { var defineMethod = declaration.Members .OfType() - .FirstOrDefault(IsDefineMethod); + .FirstOrDefault(method => IsDefineMethod(semanticModel, method, commandType, validationRules)); if (defineMethod == null) { @@ -58,19 +58,55 @@ internal sealed class DefineMethodAnalyzer rules); } - private static bool IsDefineMethod(MethodDeclarationSyntax method) + private static bool IsDefineMethod( + SemanticModel semanticModel, + MethodDeclarationSyntax method, + INamedTypeSymbol commandType, + INamedTypeSymbol validationRules) { if (method.Identifier.ValueText != "Define") { return false; } - return HasSingleParameter(method); + if (!HasSingleParameter(method)) + { + return false; + } + + return HasValidationRulesParameter(semanticModel, method, commandType, validationRules); } private static bool HasSingleParameter(MethodDeclarationSyntax method) { return method.ParameterList.Parameters.Count == 1; } + + private static bool HasValidationRulesParameter( + SemanticModel semanticModel, + MethodDeclarationSyntax method, + INamedTypeSymbol commandType, + INamedTypeSymbol validationRules) + { + var parameter = method.ParameterList.Parameters[0]; + var type = semanticModel.GetTypeInfo(parameter.Type!).Type; + + if (!(type is INamedTypeSymbol namedType)) + { + return false; + } + + if (!SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, validationRules)) + { + return false; + } + + if (namedType.TypeArguments.Length != 1) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(namedType.TypeArguments[0], commandType); + } } } diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs index 55a5967..2e59ff6 100644 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs @@ -119,6 +119,30 @@ public void Define() } } +public sealed class CreateUser +{ +} +"""; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0001", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_define_parameter_type_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(string rules) + { + } +} + public sealed class CreateUser { } From c6dc082ebb4c7913401dc75a064180bd2d65d6a4 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 18:50:04 +0200 Subject: [PATCH 03/14] Require Define methods to return void --- .../Declarations/DefineMethodAnalyzer.cs | 16 ++++++++++++ .../GeneratorSmokeTests.cs | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs index d56a018..b5ed211 100644 --- a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs @@ -74,6 +74,11 @@ private static bool IsDefineMethod( return false; } + if (!ReturnsVoid(semanticModel, method)) + { + return false; + } + return HasValidationRulesParameter(semanticModel, method, commandType, validationRules); } @@ -82,6 +87,17 @@ private static bool HasSingleParameter(MethodDeclarationSyntax method) return method.ParameterList.Parameters.Count == 1; } + private static bool ReturnsVoid(SemanticModel semanticModel, MethodDeclarationSyntax method) + { + var symbol = semanticModel.GetDeclaredSymbol(method); + if (!(symbol is IMethodSymbol methodSymbol)) + { + return false; + } + + return methodSymbol.ReturnsVoid; + } + private static bool HasValidationRulesParameter( SemanticModel semanticModel, MethodDeclarationSyntax method, diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs index 2e59ff6..d19ed00 100644 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs @@ -143,6 +143,31 @@ public void Define(string rules) } } +public sealed class CreateUser +{ +} +"""; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0001", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_define_return_type_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public int Define(ValidationRules rules) + { + return 0; + } +} + public sealed class CreateUser { } From 7b4031374c6c84995f330b528d23706497c57267 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 18:53:53 +0200 Subject: [PATCH 04/14] Cover explicit Define implementations --- .../GeneratorSmokeTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs index d19ed00..9cc0341 100644 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs @@ -68,6 +68,35 @@ public sealed class CreateUser Assert.DoesNotContain("typeof(T)", text); } + [Fact] + public void Generates_rules_from_explicit_define_implementation() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + void IValidation.Define(ValidationRules rules) + { + rules.Required(x => x.Email); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = RunGenerator(source); + var generated = Assert.Single(result.GeneratedTrees); + var text = generated.GetText().ToString(); + + Assert.Empty(result.Diagnostics); + Assert.Contains("ITinyValidationRunner", text); + Assert.Contains("Email is required.", text); + } + [Fact] public void Reports_diagnostic_when_validation_has_no_supported_rules() { From b63762959ee7c593a51d99af0183ce913124b0a0 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 18:57:29 +0200 Subject: [PATCH 05/14] Cover Define diagnostic details --- .../TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs index 9cc0341..3b56533 100644 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs @@ -157,6 +157,12 @@ public sealed class CreateUser var diagnostic = Assert.Single(result.Diagnostics); Assert.Equal("TV0001", diagnostic.Id); + Assert.Equal( + "Validation declaration 'CreateUserValidation' must contain a Define method with one rules parameter", + diagnostic.GetMessage()); + Assert.Equal( + source.IndexOf("CreateUserValidation", StringComparison.Ordinal), + diagnostic.Location.SourceSpan.Start); } [Fact] From 765e7380caae27caa808595a9cf485fac4bf6b2f Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 18:59:41 +0200 Subject: [PATCH 06/14] Cover nonliteral message diagnostics --- .../GeneratorSmokeTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs index 3b56533..35d6e24 100644 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs @@ -281,6 +281,33 @@ public void Define(ValidationRules rules) } } +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_message_is_not_literal() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var message = "Email is required."; + rules.Required(x => x.Email, message); + } +} + public sealed class CreateUser { public string? Email { get; init; } From 16da36c4c2ed6dcc3268f883ca09272fc964ef58 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:01:54 +0200 Subject: [PATCH 07/14] Cover Requires return diagnostics --- .../GeneratorSmokeTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs index 35d6e24..2a4dc8d 100644 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs @@ -380,6 +380,40 @@ public bool HasOrderPrefix(string? value) } } +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_does_not_return_bool() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); + } +} + +public static class OrderNumberRequirements +{ + public static string HasOrderPrefix(string? value) + { + return ""; + } +} + public sealed class CreateOrder { public string? OrderNumber { get; init; } From 9ba83f273c62a17f55dd15a20e2bd09f7a1a0384 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:03:56 +0200 Subject: [PATCH 08/14] Cover Requires parameter diagnostics --- .../GeneratorSmokeTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs index 2a4dc8d..5cfe3a3 100644 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs @@ -414,6 +414,40 @@ public static string HasOrderPrefix(string? value) } } +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_has_wrong_parameter_count() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); + } +} + +public static class OrderNumberRequirements +{ + public static bool HasOrderPrefix(string? value, string prefix) + { + return value is not null && value.StartsWith(prefix); + } +} + public sealed class CreateOrder { public string? OrderNumber { get; init; } From 3f766df3cc37b2444a3376e7b182a8eed35d5c2d Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:08:14 +0200 Subject: [PATCH 09/14] Split source generator tests --- .../DiagnosticTests.cs | 348 ++++++++++ .../DiscoveryTests.cs | 66 ++ .../GenerationTests.cs | 120 ++++ .../GeneratorSmokeTests.cs | 597 ------------------ .../SourceGeneratorTestHost.cs | 27 + 5 files changed, 561 insertions(+), 597 deletions(-) create mode 100644 tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs create mode 100644 tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs create mode 100644 tests/TinyValidations.SourceGen.Tests/GenerationTests.cs delete mode 100644 tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs create mode 100644 tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs diff --git a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs new file mode 100644 index 0000000..06e72ba --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs @@ -0,0 +1,348 @@ +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +public sealed class DiagnosticTests +{ + [Fact] + public void Reports_diagnostic_when_validation_has_no_supported_rules() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0006", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_define_signature_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define() + { + } +} + +public sealed class CreateUser +{ +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0001", diagnostic.Id); + Assert.Equal( + "Validation declaration 'CreateUserValidation' must contain a Define method with one rules parameter", + diagnostic.GetMessage()); + Assert.Equal( + source.IndexOf("CreateUserValidation", StringComparison.Ordinal), + diagnostic.Location.SourceSpan.Start); + } + + [Fact] + public void Reports_diagnostic_when_define_parameter_type_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(string rules) + { + } +} + +public sealed class CreateUser +{ +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0001", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_define_return_type_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public int Define(ValidationRules rules) + { + return 0; + } +} + +public sealed class CreateUser +{ +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0001", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_rule_call_is_unsupported() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Unknown(x => x.Email); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0002", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_selector_is_unsupported() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email!.ToString()); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0003", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_argument_is_not_literal() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var length = 2; + rules.TextLengthAtLeast(x => x.Email, length); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_message_is_not_literal() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var message = "Email is required."; + rules.Required(x => x.Email, message); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_is_not_static() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var requirements = new OrderNumberRequirements(); + rules.Requires(x => x.OrderNumber, requirements.HasOrderPrefix, "Order number must start with ORD-."); + } +} + +public sealed class OrderNumberRequirements +{ + public bool HasOrderPrefix(string? value) + { + return value is not null && value.StartsWith("ORD-"); + } +} + +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_does_not_return_bool() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); + } +} + +public static class OrderNumberRequirements +{ + public static string HasOrderPrefix(string? value) + { + return ""; + } +} + +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_has_wrong_parameter_count() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); + } +} + +public static class OrderNumberRequirements +{ + public static bool HasOrderPrefix(string? value, string prefix) + { + return value is not null && value.StartsWith(prefix); + } +} + +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_custom_rule_type_is_invalid() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Use(); + } +} + +public sealed class NotACustomRule +{ +} + +public sealed class CreateUser +{ +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = Assert.Single(result.Diagnostics); + + Assert.Equal("TV0005", diagnostic.Id); + } +} diff --git a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs new file mode 100644 index 0000000..b90ffd5 --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs @@ -0,0 +1,66 @@ +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +public sealed class DiscoveryTests +{ + [Fact] + public void Ignores_unrelated_i_validation_interfaces() + { + var source = """ +namespace Other; + +public interface IValidation +{ +} + +public sealed class CreateUserValidation : IValidation +{ +} + +public sealed class CreateUser +{ +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + + Assert.Empty(result.Diagnostics); + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public void Ignores_methods_that_only_look_like_validation_rules() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var other = new OtherRules(); + other.Required(x => x.Email); + } +} + +public sealed class OtherRules +{ + public void Required(System.Func member) + { + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var generated = Assert.Single(result.GeneratedTrees); + var text = generated.GetText().ToString(); + + Assert.DoesNotContain("Email is required.", text); + } +} diff --git a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs new file mode 100644 index 0000000..62ae14c --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs @@ -0,0 +1,120 @@ +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +public sealed class GenerationTests +{ + [Fact] + public void Generates_runner_contribution_and_module_initializer() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email); + rules.Email(x => x.Email); + rules.AtLeast(x => x.Age, 18); + rules.Use(); + } +} + +public sealed class UniqueEmailRule : IAsyncValidationRule +{ + public System.Threading.Tasks.ValueTask ValidateAsync(CreateUser instance, ValidationErrorCollection errors, System.Threading.CancellationToken cancellationToken) => System.Threading.Tasks.ValueTask.CompletedTask; +} + +public sealed class CreateUser +{ + public string? Email { get; init; } + public int Age { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var generated = Assert.Single(result.GeneratedTrees); + var text = generated.GetText().ToString(); + + Assert.Contains("ITinyValidationRunner", text); + Assert.Contains("TinyGeneratedValidationContribution", text); + Assert.Contains("TinyGeneratedValidationModuleInitializer", text); + Assert.Contains("TinyValidationBootstrap.AddContribution", text); + Assert.Contains("TinyValidationContributionAttribute(typeof(global::CreateUser)", text); + Assert.Contains("UniqueEmailRule", text); + Assert.Contains("await _uniqueemailrule.ValidateAsync", text); + Assert.Contains("ServiceDescriptor.Scoped", text); + Assert.Contains("TryAddEnumerable", text); + Assert.Contains("TryAddScoped", text); + Assert.DoesNotContain("System.Reflection", text); + Assert.DoesNotContain("GetType()", text); + Assert.DoesNotContain("typeof(T)", text); + } + + [Fact] + public void Generates_rules_from_explicit_define_implementation() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + void IValidation.Define(ValidationRules rules) + { + rules.Required(x => x.Email); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var generated = Assert.Single(result.GeneratedTrees); + var text = generated.GetText().ToString(); + + Assert.Empty(result.Diagnostics); + Assert.Contains("ITinyValidationRunner", text); + Assert.Contains("Email is required.", text); + } + + [Fact] + public void Generates_static_requires_rule_call() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); + } +} + +public static class OrderNumberRequirements +{ + public static bool HasOrderPrefix(string? value) + { + return value is not null && value.StartsWith("ORD-"); + } +} + +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var generated = Assert.Single(result.GeneratedTrees); + var text = generated.GetText().ToString(); + + Assert.Empty(result.Diagnostics); + Assert.Contains("if (!global::OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber))", text); + Assert.Contains("errors.Add(\"OrderNumber\", \"Order number must start with ORD-.\");", text); + } +} diff --git a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs deleted file mode 100644 index 5cfe3a3..0000000 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ /dev/null @@ -1,597 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using TinyValidations.SourceGen; -using Xunit; - -namespace TinyValidations.SourceGen.Tests; - -public sealed class GeneratorSmokeTests -{ - [Fact] - public void Generates_runner_contribution_and_module_initializer() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Required(x => x.Email); - rules.Email(x => x.Email); - rules.AtLeast(x => x.Age, 18); - rules.Use(); - } -} - -public sealed class UniqueEmailRule : IAsyncValidationRule -{ - public System.Threading.Tasks.ValueTask ValidateAsync(CreateUser instance, ValidationErrorCollection errors, System.Threading.CancellationToken cancellationToken) => System.Threading.Tasks.ValueTask.CompletedTask; -} - -public sealed class CreateUser -{ - public string? Email { get; init; } - public int Age { get; init; } -} -"""; - - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); - - Assert.Contains("ITinyValidationRunner", text); - Assert.Contains("TinyGeneratedValidationContribution", text); - Assert.Contains("TinyGeneratedValidationModuleInitializer", text); - Assert.Contains("TinyValidationBootstrap.AddContribution", text); - Assert.Contains("TinyValidationContributionAttribute(typeof(global::CreateUser)", text); - Assert.Contains("UniqueEmailRule", text); - Assert.Contains("await _uniqueemailrule.ValidateAsync", text); - Assert.Contains("ServiceDescriptor.Scoped", text); - Assert.Contains("TryAddEnumerable", text); - Assert.Contains("TryAddScoped", text); - Assert.DoesNotContain("System.Reflection", text); - Assert.DoesNotContain("GetType()", text); - Assert.DoesNotContain("typeof(T)", text); - } - - [Fact] - public void Generates_rules_from_explicit_define_implementation() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - void IValidation.Define(ValidationRules rules) - { - rules.Required(x => x.Email); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); - - Assert.Empty(result.Diagnostics); - Assert.Contains("ITinyValidationRunner", text); - Assert.Contains("Email is required.", text); - } - - [Fact] - public void Reports_diagnostic_when_validation_has_no_supported_rules() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0006", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_define_signature_is_wrong() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define() - { - } -} - -public sealed class CreateUser -{ -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0001", diagnostic.Id); - Assert.Equal( - "Validation declaration 'CreateUserValidation' must contain a Define method with one rules parameter", - diagnostic.GetMessage()); - Assert.Equal( - source.IndexOf("CreateUserValidation", StringComparison.Ordinal), - diagnostic.Location.SourceSpan.Start); - } - - [Fact] - public void Reports_diagnostic_when_define_parameter_type_is_wrong() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(string rules) - { - } -} - -public sealed class CreateUser -{ -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0001", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_define_return_type_is_wrong() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public int Define(ValidationRules rules) - { - return 0; - } -} - -public sealed class CreateUser -{ -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0001", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_rule_call_is_unsupported() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Unknown(x => x.Email); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0002", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_selector_is_unsupported() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Required(x => x.Email!.ToString()); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0003", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_argument_is_not_literal() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - var length = 2; - rules.TextLengthAtLeast(x => x.Email, length); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0004", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_message_is_not_literal() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - var message = "Email is required."; - rules.Required(x => x.Email, message); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0004", diagnostic.Id); - } - - [Fact] - public void Generates_static_requires_rule_call() - { - var source = """ -using TinyValidations; - -public sealed class CreateOrderValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); - } -} - -public static class OrderNumberRequirements -{ - public static bool HasOrderPrefix(string? value) - { - return value is not null && value.StartsWith("ORD-"); - } -} - -public sealed class CreateOrder -{ - public string? OrderNumber { get; init; } -} -"""; - - var result = RunGenerator(source); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); - - Assert.Empty(result.Diagnostics); - Assert.Contains("if (!global::OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber))", text); - Assert.Contains("errors.Add(\"OrderNumber\", \"Order number must start with ORD-.\");", text); - } - - [Fact] - public void Reports_diagnostic_when_requires_method_is_not_static() - { - var source = """ -using TinyValidations; - -public sealed class CreateOrderValidation : IValidation -{ - public void Define(ValidationRules rules) - { - var requirements = new OrderNumberRequirements(); - rules.Requires(x => x.OrderNumber, requirements.HasOrderPrefix, "Order number must start with ORD-."); - } -} - -public sealed class OrderNumberRequirements -{ - public bool HasOrderPrefix(string? value) - { - return value is not null && value.StartsWith("ORD-"); - } -} - -public sealed class CreateOrder -{ - public string? OrderNumber { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0004", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_requires_method_does_not_return_bool() - { - var source = """ -using TinyValidations; - -public sealed class CreateOrderValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); - } -} - -public static class OrderNumberRequirements -{ - public static string HasOrderPrefix(string? value) - { - return ""; - } -} - -public sealed class CreateOrder -{ - public string? OrderNumber { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0004", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_requires_method_has_wrong_parameter_count() - { - var source = """ -using TinyValidations; - -public sealed class CreateOrderValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-."); - } -} - -public static class OrderNumberRequirements -{ - public static bool HasOrderPrefix(string? value, string prefix) - { - return value is not null && value.StartsWith(prefix); - } -} - -public sealed class CreateOrder -{ - public string? OrderNumber { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0004", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_custom_rule_type_is_invalid() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Use(); - } -} - -public sealed class NotACustomRule -{ -} - -public sealed class CreateUser -{ -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0005", diagnostic.Id); - } - - [Fact] - public void Ignores_unrelated_i_validation_interfaces() - { - var source = """ -namespace Other; - -public interface IValidation -{ -} - -public sealed class CreateUserValidation : IValidation -{ -} - -public sealed class CreateUser -{ -} -"""; - - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - - Assert.Empty(result.Diagnostics); - Assert.Empty(result.GeneratedTrees); - } - - [Fact] - public void Ignores_methods_that_only_look_like_validation_rules() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - var other = new OtherRules(); - other.Required(x => x.Email); - } -} - -public sealed class OtherRules -{ - public void Required(System.Func member) - { - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.Linq.Expressions.Expression).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); - - Assert.DoesNotContain("Email is required.", text); - } - - private static GeneratorDriverRunResult RunGenerator(string source) - { - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.Linq.Expressions.Expression).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - return driver.GetRunResult(); - } -} diff --git a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs new file mode 100644 index 0000000..671096b --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TinyValidations.SourceGen; + +namespace TinyValidations.SourceGen.Tests; + +internal static class SourceGeneratorTestHost +{ + public static GeneratorDriverRunResult Run(string source) + { + var compilation = CSharpCompilation.Create( + "Tests", + new[] { CSharpSyntaxTree.ParseText(source) }, + new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Expressions.Expression).Assembly.Location), + MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) + }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); + driver = driver.RunGenerators(compilation); + + return driver.GetRunResult(); + } +} From b9fdcad9a73cae3e5a2413dae1d3e5f37ad2ffe7 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:10:05 +0200 Subject: [PATCH 10/14] Simplify source generator diagnostic tests --- .../DiagnosticTests.cs | 36 +++++++------------ .../SourceGeneratorTestHost.cs | 7 ++++ 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs index 06e72ba..f6beb00 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs @@ -23,8 +23,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0006", diagnostic.Id); } @@ -47,8 +46,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0001", diagnostic.Id); Assert.Equal( @@ -77,8 +75,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0001", diagnostic.Id); } @@ -102,8 +99,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0001", diagnostic.Id); } @@ -128,8 +124,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0002", diagnostic.Id); } @@ -154,8 +149,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0003", diagnostic.Id); } @@ -181,8 +175,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -208,8 +201,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -243,8 +235,7 @@ public sealed class CreateOrder } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -277,8 +268,7 @@ public sealed class CreateOrder } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -311,8 +301,7 @@ public sealed class CreateOrder } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -340,8 +329,7 @@ public sealed class CreateUser } """; - var result = SourceGeneratorTestHost.Run(source); - var diagnostic = Assert.Single(result.Diagnostics); + var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); Assert.Equal("TV0005", diagnostic.Id); } diff --git a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs index 671096b..6079837 100644 --- a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs +++ b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using TinyValidations.SourceGen; +using Xunit; namespace TinyValidations.SourceGen.Tests; @@ -24,4 +25,10 @@ public static GeneratorDriverRunResult Run(string source) return driver.GetRunResult(); } + + public static Diagnostic GetSingleDiagnostic(string source) + { + var result = Run(source); + return Assert.Single(result.Diagnostics); + } } From 222e70f97493a9d27ffeeea251ed72ae0de58c5c Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:21:23 +0200 Subject: [PATCH 11/14] Add source generator test harness --- .../DiagnosticTests.cs | 24 ++++++------- .../DiscoveryTests.cs | 7 ++-- .../GenerationTests.cs | 13 +++---- .../SourceGeneratorTestHost.cs | 35 ++++++++++++++++--- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs index f6beb00..52ab100 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs @@ -23,7 +23,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0006", diagnostic.Id); } @@ -46,7 +46,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0001", diagnostic.Id); Assert.Equal( @@ -75,7 +75,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0001", diagnostic.Id); } @@ -99,7 +99,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0001", diagnostic.Id); } @@ -124,7 +124,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0002", diagnostic.Id); } @@ -149,7 +149,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0003", diagnostic.Id); } @@ -175,7 +175,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0004", diagnostic.Id); } @@ -201,7 +201,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0004", diagnostic.Id); } @@ -235,7 +235,7 @@ public sealed class CreateOrder } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0004", diagnostic.Id); } @@ -268,7 +268,7 @@ public sealed class CreateOrder } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0004", diagnostic.Id); } @@ -301,7 +301,7 @@ public sealed class CreateOrder } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0004", diagnostic.Id); } @@ -329,7 +329,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.GetSingleDiagnostic(source); + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); Assert.Equal("TV0005", diagnostic.Id); } diff --git a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs index b90ffd5..26b8fc3 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs @@ -25,8 +25,8 @@ public sealed class CreateUser var result = SourceGeneratorTestHost.Run(source); - Assert.Empty(result.Diagnostics); - Assert.Empty(result.GeneratedTrees); + result.ShouldHaveNoDiagnostics(); + result.ShouldGenerateNoSource(); } [Fact] @@ -58,8 +58,7 @@ public sealed class CreateUser """; var result = SourceGeneratorTestHost.Run(source); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); + var text = result.SingleGeneratedSource(); Assert.DoesNotContain("Email is required.", text); } diff --git a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs index 62ae14c..fb01a1c 100644 --- a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs @@ -34,8 +34,7 @@ public sealed class CreateUser """; var result = SourceGeneratorTestHost.Run(source); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); + var text = result.SingleGeneratedSource(); Assert.Contains("ITinyValidationRunner", text); Assert.Contains("TinyGeneratedValidationContribution", text); @@ -73,10 +72,9 @@ public sealed class CreateUser """; var result = SourceGeneratorTestHost.Run(source); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); + var text = result.SingleGeneratedSource(); - Assert.Empty(result.Diagnostics); + result.ShouldHaveNoDiagnostics(); Assert.Contains("ITinyValidationRunner", text); Assert.Contains("Email is required.", text); } @@ -110,10 +108,9 @@ public sealed class CreateOrder """; var result = SourceGeneratorTestHost.Run(source); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); + var text = result.SingleGeneratedSource(); - Assert.Empty(result.Diagnostics); + result.ShouldHaveNoDiagnostics(); Assert.Contains("if (!global::OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber))", text); Assert.Contains("errors.Add(\"OrderNumber\", \"Order number must start with ORD-.\");", text); } diff --git a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs index 6079837..9bac42b 100644 --- a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs +++ b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs @@ -7,7 +7,7 @@ namespace TinyValidations.SourceGen.Tests; internal static class SourceGeneratorTestHost { - public static GeneratorDriverRunResult Run(string source) + public static SourceGeneratorRun Run(string source) { var compilation = CSharpCompilation.Create( "Tests", @@ -23,12 +23,37 @@ public static GeneratorDriverRunResult Run(string source) GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); driver = driver.RunGenerators(compilation); - return driver.GetRunResult(); + return new SourceGeneratorRun(driver.GetRunResult()); + } +} + +internal sealed class SourceGeneratorRun +{ + private readonly GeneratorDriverRunResult _result; + + public SourceGeneratorRun(GeneratorDriverRunResult result) + { + _result = result; + } + + public Diagnostic SingleDiagnostic() + { + return Assert.Single(_result.Diagnostics); + } + + public string SingleGeneratedSource() + { + var generated = Assert.Single(_result.GeneratedTrees); + return generated.GetText().ToString(); + } + + public void ShouldHaveNoDiagnostics() + { + Assert.Empty(_result.Diagnostics); } - public static Diagnostic GetSingleDiagnostic(string source) + public void ShouldGenerateNoSource() { - var result = Run(source); - return Assert.Single(result.Diagnostics); + Assert.Empty(_result.GeneratedTrees); } } From 23dcc2c0d81b05c3323fb3ea83b43ef4167aec10 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:24:03 +0200 Subject: [PATCH 12/14] Cover custom messages for built-in rules --- .../ValidationBehaviorTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs index dff9aa1..05cc064 100644 --- a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs +++ b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs @@ -26,6 +26,27 @@ public async Task Built_in_rules_return_validation_errors() AssertHasError(result, nameof(CreateProfile.Roles), "Roles must contain at least one item."); } + [Fact] + public async Task Built_in_rules_use_custom_messages() + { + var validator = BuildValidator(); + var command = new CreateProfileWithCustomMessages( + string.Empty, + "A", + 17, + "abc", + Array.Empty()); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Email), "Please provide an email."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.DisplayName), "Display name is too short."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Age), "Adults only."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Code), "Code must be three uppercase letters."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Roles), "Choose at least one role."); + } + [Fact] public async Task Custom_rules_are_resolved_from_dependency_injection() { @@ -165,6 +186,25 @@ private static async Task ValidateWithScopeAsync(ServiceProvider provide } } +public sealed record CreateProfileWithCustomMessages( + string Email, + string DisplayName, + int Age, + string Code, + IReadOnlyCollection Roles); + +public sealed class CreateProfileWithCustomMessagesValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email, "Please provide an email."); + rules.TextLengthAtLeast(x => x.DisplayName, 2, "Display name is too short."); + rules.AtLeast(x => x.Age, 18, "Adults only."); + rules.Matches(x => x.Code, "^[A-Z]{3}$", "Code must be three uppercase letters."); + rules.HasItems(x => x.Roles, "Choose at least one role."); + } +} + public sealed record CreateProfile( string Email, string DisplayName, From 948cea0f8e23d2c3594b3f53a025885b3f230120 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:29:57 +0200 Subject: [PATCH 13/14] Support null-safe nested member paths --- docs/extending.md | 2 +- docs/rules.md | 10 ++++++ .../Analysis/Rules/MemberAccessAnalyzer.cs | 12 ++++++- .../ValidationBehaviorTests.cs | 36 +++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/extending.md b/docs/extending.md index ca41dc4..a28817e 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -80,7 +80,7 @@ if (!OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber)) ## Requirement Rule Requirements -- The member selector must be a simple member access, such as `x => x.OrderNumber`. +- The member selector must be a simple member path, such as `x => x.OrderNumber` or `x => x.Profile.Email`. - The requirement must be a static method group. - The requirement must return `bool`. - The requirement must have exactly one parameter. diff --git a/docs/rules.md b/docs/rules.md index 8f709c1..7218030 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -11,6 +11,16 @@ public void Define(ValidationRules rules) Built-in rules are generated as direct C# checks. +Member selectors can target direct members or nested member paths: + +```csharp +rules.Required(x => x.Email); +rules.Required(x => x.Profile.Email); +``` + +Nested selectors report dotted member paths, such as `Profile.Email`. +Intermediate null values are handled as missing nested values. + ## Required Requires the value to be present. For strings, whitespace is treated as missing. diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs index a08b99f..87a5982 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs @@ -25,7 +25,7 @@ internal sealed class MemberAccessAnalyzer } var path = string.Join(".", members); - return new AnalyzedMemberAccess(path, "instance." + path); + return new AnalyzedMemberAccess(path, CreateAccess(members)); } private static string GetParameterName(LambdaExpressionSyntax lambda) @@ -86,5 +86,15 @@ private static bool IsOriginalParameter(ExpressionSyntax? expression, string par return identifier.Identifier.ValueText == parameterName; } + + private static string CreateAccess(List members) + { + if (members.Count == 1) + { + return "instance." + members[0]; + } + + return "instance." + string.Join("?.", members); + } } } diff --git a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs index 05cc064..22cd44d 100644 --- a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs +++ b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs @@ -72,6 +72,30 @@ public async Task Multiple_validations_for_the_same_command_are_aggregated() AssertHasError(result, nameof(ShipPackage.TrackingCode), "TrackingCode has an invalid format."); } + [Fact] + public async Task Nested_member_rules_return_dotted_member_paths() + { + var validator = BuildValidator(); + var command = new UpdateAccount(new AccountProfile(string.Empty)); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, "Profile.Email", "Profile email is required."); + } + + [Fact] + public async Task Nested_member_rules_handle_null_intermediate_members() + { + var validator = BuildValidator(); + var command = new UpdateAccount(null!); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, "Profile.Email", "Profile email is required."); + } + [Fact] public async Task Requires_rules_call_static_requirement_methods() { @@ -284,6 +308,18 @@ public void Define(ValidationRules rules) } } +public sealed record UpdateAccount(AccountProfile Profile); + +public sealed record AccountProfile(string Email); + +public sealed class UpdateAccountValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Profile.Email, "Profile email is required."); + } +} + public sealed record CreateOrder(string OrderNumber); public sealed class CreateOrderValidation : IValidation From 7810c9059575cb42b2161ddc29d884284f310504 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 29 May 2026 19:33:05 +0200 Subject: [PATCH 14/14] Cover remaining built-in rule behavior --- .../ValidationBehaviorTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs index 22cd44d..2c38342 100644 --- a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs +++ b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs @@ -47,6 +47,29 @@ public async Task Built_in_rules_use_custom_messages() AssertHasError(result, nameof(CreateProfileWithCustomMessages.Roles), "Choose at least one role."); } + [Fact] + public async Task Remaining_built_in_rules_return_validation_errors() + { + var validator = BuildValidator(); + var command = new ConfigureProduct( + string.Empty, + null, + "TOO-LONG", + 0, + 11, + 6); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, nameof(ConfigureProduct.Name), "Name must contain text."); + AssertHasError(result, nameof(ConfigureProduct.Category), "Category must not be null."); + AssertHasError(result, nameof(ConfigureProduct.Code), "Code must contain at most 3 characters."); + AssertHasError(result, nameof(ConfigureProduct.MinimumQuantity), "MinimumQuantity must be above 0."); + AssertHasError(result, nameof(ConfigureProduct.DiscountPercent), "DiscountPercent must be below 10."); + AssertHasError(result, nameof(ConfigureProduct.Rating), "Rating must be at most 5."); + } + [Fact] public async Task Custom_rules_are_resolved_from_dependency_injection() { @@ -229,6 +252,27 @@ public void Define(ValidationRules rules) } } +public sealed record ConfigureProduct( + string Name, + string? Category, + string Code, + int MinimumQuantity, + int DiscountPercent, + int Rating); + +public sealed class ConfigureProductValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.HasText(x => x.Name); + rules.NotNull(x => x.Category); + rules.TextLengthAtMost(x => x.Code, 3); + rules.Above(x => x.MinimumQuantity, 0); + rules.Below(x => x.DiscountPercent, 10); + rules.AtMost(x => x.Rating, 5); + } +} + public sealed record CreateProfile( string Email, string DisplayName,