diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml index 09b18c6..52e1929 100644 --- a/.github/workflows/benchmarks.yaml +++ b/.github/workflows/benchmarks.yaml @@ -1,7 +1,6 @@ name: Benchmarks on: - pull_request: workflow_dispatch: jobs: @@ -18,7 +17,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Run Benchmarks run: dotnet run --project ${{ env.PROJECT }} --configuration Release -- --filter '*' diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 9bc8a46..123d9f7 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -14,4 +14,14 @@ jobs: with: platform: ${{ matrix.platform }} dotnet-version: 9.0.x - test-project-path: tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj \ No newline at end of file + test-project-path: tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj + unit-tests-standard: + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, windows-latest, macos-latest] + uses: dusrdev/actions/.github/workflows/reusable-dotnet-test.yaml@main + with: + platform: ${{ matrix.platform }} + dotnet-version: 8.0.x + test-project-path: tests/Verifast.Tests.Unit.Standard/Verifast.Tests.Unit.Standard.csproj \ No newline at end of file diff --git a/.gitignore b/.gitignore index d71bbee..bc78471 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,6 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs -*WARP.md -*AGENTS.md # Mono auto generated files mono_crash.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fb74b74 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +# AGENTS.md + +This file provides guidance to agents when working with code in this repository. + +## Project Overview +- Tech: Multi‑targeted .NET library (`src/Verifast`) targeting `net9.0`, `netstandard2.1`, and `netstandard2.0`, focused on fast, allocation‑aware validation. Unit tests live in `tests/Verifast.Tests.Unit` (net9.0) using xUnit v3 with the Microsoft Testing Platform, and `tests/Verifast.Tests.Unit.Standard` (net8.0) using xUnit v2 to exercise the `netstandard2.0` target. A minimal benchmarks project exists under `benchmarks/Verifast.Benchmarks` (net9.0). +- Design: The library centers on simple, interface‑driven validators and a lightweight result type that captures errors and warnings only when needed. + +## Big‑Picture Architecture +- Validation contracts: + - `IValidator` and `IValidator`: synchronous validators. On `net9.0`, both use `where T : allows ref struct` so validators can be used with stack‑only types without forcing `ref struct` everywhere. On `netstandard2.1`/`netstandard2.0`, the constraint is omitted. + - `IAsyncValidator` and `IAsyncValidator`: asynchronous validators returning `ValueTask>` across targets. The `netstandard2.0` forms are declared with `in T` variance. +- Orchestrator APIs: + - Static `Validator` class: extension methods for synchronous validation and `TryValidate` overloads. Only sync helpers exist (no async orchestrator today). On `net9.0` the extensions include `allows ref struct` constraints. +- Result type: + - `ValidationResult`: a struct that tracks `IsValid`, `Errors`, and `Warnings`. Message lists are allocated on‑demand when the first message is added; `Errors`/`Warnings` return `ReadOnlyCollection` wrappers. + +## Usage Model +- Implement `IValidator` (or `IAsyncValidator`) on the type being validated or in a separate validator type. +- Sync validators populate results via `Validate(in T instance, ref ValidationResult<...> result)`. +- Execute via `validator.Validate(instance)` / `validator.TryValidate(instance, out var result)` or `validator.ValidateAsync(instance, ct)`. +- Choose `string` messages for simplicity or a custom `TMessage` type for structured metadata. + +## Repository Layout +- `src/Verifast`: Library code (`IValidator`, `IAsyncValidator`, `Validator`, `ValidationResult`). +- `tests/Verifast.Tests.Unit`: xUnit v3 tests referencing the library. Configured as `OutputType Exe` to support Microsoft Testing Platform. +- `tests/Verifast.Tests.Unit.Standard`: xUnit v2 tests targeting `net8.0` so the library resolves to its `netstandard2.0` target. +- `benchmarks/Verifast.Benchmarks`: BenchmarkDotNet project (scaffolded). +- `Verifast.slnx`: Solution file exists, but project‑scoped commands are preferred. + +## Commands You’ll Commonly Use +Note: Favor project‑scoped commands (operate on `src/Verifast` or `tests/Verifast.Tests.Unit` explicitly). Avoid explicit `dotnet build` before running tests; `dotnet run` builds implicitly. + +- Build the library (project‑scoped): + - `dotnet build src/Verifast/Verifast.csproj` + +- Format/lint (EditorConfig conventions assumed): + - Analyze only: `dotnet format analyze --severity info` + - Apply style/whitespace fixes: `dotnet format` + - If `dotnet format` isn’t installed: `dotnet tool update -g dotnet-format` + +- Run all tests (Microsoft Testing Platform via dotnet run): + - From repo root: `dotnet run --project tests/Verifast.Tests.Unit` + +- Run netstandard2.0 coverage tests (xUnit v2 via dotnet test): + - `dotnet test tests/Verifast.Tests.Unit.Standard` + +- List tests (Microsoft Testing Platform semantics): + - `dotnet run --project tests/Verifast.Tests.Unit -- --list-tests` + +- Run a single test (recommended: filter by fully qualified name): + - By method: `dotnet run --project tests/Verifast.Tests.Unit --filter-method="*TestMethodPattern*"` + - By class: `dotnet run --project tests/Verifast.Tests.Unit --filter-class="*TestClassPattern*"` + - Alternative (traditional): `dotnet test tests/Verifast.Tests.Unit --filter "FullyQualifiedName~Pattern"` + +- Run benchmarks (Release, no debugger): + - `dotnet run --project benchmarks/Verifast.Benchmarks -c Release` + +## Style and Conventions +- C# 10+ idioms; file‑scoped namespaces. +- Braces required around blocks. +- Private fields: `_camelCase`; private static fields: `s_camelCase`. +- Interfaces start with `I`; type parameters start with `T`. +- Prefer explicit, straightforward logic (no heavy reflection or expression trees). Keep hot paths allocation‑aware. + +## Test Stack Specifics +- xUnit v3 with `Microsoft.NET.Test.Sdk`. The test project sets `OutputType Exe` and includes `xunit.runner.json`. +- Filters and test listing use Microsoft Testing Platform semantics when using `dotnet run`. +- You can also use `dotnet test` with similar filter semantics if preferred. + +## AOT/Trimming Notes +- `IsAotCompatible` and `IsTrimmable` are enabled for the `net9.0` target. Avoid patterns that rely on runtime code generation or deep reflection without proper annotations. + +## Notes for Future Agents +- The `allows ref struct` constraints are intentional to enable stack‑only scenarios. Avoid introducing APIs that disallow `ref struct` usage unless necessary. +- Keep the API surface minimal and allocation‑friendly. `ValidationResult` should remain a lightweight struct that only allocates when messages are added. +- Prefer project‑scoped commands; the solution file exists but isn’t required for everyday operations. diff --git a/README.md b/README.md index 88cc841..ca3c92d 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![NuGet](https://img.shields.io/nuget/v/Verifast.svg?style=flat-square)](https://www.nuget.org/packages/Verifast) [![NuGet Downloads](https://img.shields.io/nuget/dt/Verifast?style=flat&label=Downloads)](https://www.nuget.org/packages/Verifast) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) -[![.NET](https://img.shields.io/badge/.NET-9.0-512BD4?style=flat-square)](#) +[![.NET](https://img.shields.io/badge/.NET-net9.0%20%7C%20netstandard2.1%20%7C%20netstandard2.0-512BD4?style=flat-square)](#) -High‑performance, allocation‑friendly validation for .NET 9 and above. No complicated APIs, no expression trees - just plain C#. Implement a tiny interface, add errors or warnings, and you’re done. +High‑performance, allocation‑friendly validation for .NET 9 and netstandard (2.1/2.0). No complicated APIs, no expression trees - just plain C#. Implement a tiny interface, add errors or warnings, and you’re done. ## Why Verifast @@ -15,16 +15,28 @@ High‑performance, allocation‑friendly validation for .NET 9 and above. No co - Plays well with `ref struct`: APIs are designed to enable stack‑only scenarios. - Your messages, your way: use `string` or a custom `TMessage` for richer metadata. +## Benchmarks + +[BenchmarkDotNet](https://benchmarkdotnet.org/) results comparing `Verifast` to [FluentValidation](https://github.com/FluentValidation/FluentValidation) (baseline, industry standard). Full report: [BenchmarkRun-joined.md](benchmarks/Verifast.Benchmarks/BenchmarkRun-joined.md). + +| Type | Method | ValidDto | Mean | Ratio | Allocated | Alloc Ratio | +|---------------- |----------------- |--------- |------------:|----------------:|----------:|--------------:| +| AsyncValidation | FluentValidation | False | 10,935.2 ns | baseline | 14104 B | | +| AsyncValidation | Verifast | False | 2,203.2 ns | 4.96x faster | 1096 B | 12.87x less | +| AsyncValidation | FluentValidation | True | 8,470.8 ns | baseline | 9808 B | | +| AsyncValidation | Verifast | True | 2,101.9 ns | 5.20x faster | 1016 B | 13.88x less | +| | | | | | | | +| SyncValidation | FluentValidation | False | 35,687.7 ns | baseline | 63112 B | | +| SyncValidation | Verifast | False | 141.4 ns | 252.377x faster | 328 B | 192.415x less | +| SyncValidation | FluentValidation | True | 26,091.2 ns | baseline | 50063 B | | +| SyncValidation | Verifast | True | 112.3 ns | 320.264x faster | - | NA | + ## Install ```bash dotnet add package Verifast ``` -## Versioning - -While the version is below `1.0.0.0` minor versions can change the public API without warning (SemVer will not be followed until `1.0.0.0` is reached). - ## Quick Start Define your model and implement its validator. Then call the extension helpers. @@ -34,7 +46,7 @@ using Verifast; public readonly record struct User(string? Name, int Age); -public readonly ref struct UserValidator : IValidator { +public readonly struct UserValidator : IValidator { public void Validate(in User instance, ref ValidationResult result) { if (string.IsNullOrWhiteSpace(instance.Name)) result.AddError("'Name' must be non-empty"); @@ -95,7 +107,7 @@ using Verifast; public readonly record struct Msg(string Code, string Text); -public readonly ref struct EvenValidator : IValidator { +public readonly struct EvenValidator : IValidator { public void Validate(in int value, ref ValidationResult result) { if ((value & 1) != 0) result.AddError(new Msg("NotEven", "Value must be even")); diff --git a/Verifast.slnx b/Verifast.slnx index 69c0933..98a72e7 100644 --- a/Verifast.slnx +++ b/Verifast.slnx @@ -6,6 +6,7 @@ + diff --git a/benchmarks/Verifast.Benchmarks/BenchmarkRun-joined.md b/benchmarks/Verifast.Benchmarks/BenchmarkRun-joined.md new file mode 100644 index 0000000..2dd69fa --- /dev/null +++ b/benchmarks/Verifast.Benchmarks/BenchmarkRun-joined.md @@ -0,0 +1,23 @@ +``` + +BenchmarkDotNet v0.15.8, macOS Tahoe 26.1 (25B78) [Darwin 25.1.0] +Apple M2 Pro, 1 CPU, 10 logical and 10 physical cores +.NET SDK 10.0.101 + [Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a + MediumRun : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a + +Job=MediumRun OutlierMode=RemoveAll IterationCount=15 +IterationTime=100ms LaunchCount=2 WarmupCount=10 + +``` +| Type | Method | ValidDto | Mean | Ratio | Allocated | Alloc Ratio | +|---------------- |----------------- |--------- |------------:|----------------:|----------:|--------------:| +| AsyncValidation | FluentValidation | False | 10,935.2 ns | baseline | 14104 B | | +| AsyncValidation | Verifast | False | 2,203.2 ns | 4.96x faster | 1096 B | 12.87x less | +| AsyncValidation | FluentValidation | True | 8,470.8 ns | baseline | 9808 B | | +| AsyncValidation | Verifast | True | 2,101.9 ns | 5.20x faster | 1016 B | 13.88x less | +| | | | | | | | +| SyncValidation | FluentValidation | False | 35,687.7 ns | baseline | 63112 B | | +| SyncValidation | Verifast | False | 141.4 ns | 252.377x faster | 328 B | 192.415x less | +| SyncValidation | FluentValidation | True | 26,091.2 ns | baseline | 50063 B | | +| SyncValidation | Verifast | True | 112.3 ns | 320.264x faster | - | NA | diff --git a/benchmarks/Verifast.Benchmarks/Benchmarks/AsyncValidation.cs b/benchmarks/Verifast.Benchmarks/Benchmarks/AsyncValidation.cs index 3b48622..5af8321 100644 --- a/benchmarks/Verifast.Benchmarks/Benchmarks/AsyncValidation.cs +++ b/benchmarks/Verifast.Benchmarks/Benchmarks/AsyncValidation.cs @@ -6,7 +6,7 @@ namespace Verifast.Benchmarks.Benchmarks; public class AsyncValidation { [Params(true, false)] - public bool DtoValid { get; set; } + public bool ValidDto { get; set; } private UserProfile? _dto; private FakeUserRepository _repo = null!; @@ -18,19 +18,19 @@ public async Task Setup() { // Simulate seeding work await _repo.AddAsync("taken@spam.com"); - _dto = DtoValid + _dto = ValidDto ? UserProfileFactory.CreateValid("valid@example.com") : UserProfileFactory.CreateInvalid("taken@spam.com"); } - [Benchmark(Baseline = true)] + [Benchmark(Baseline = true, Description = "FluentValidation")] public async Task FluentValidation_Async() { var validator = new UserProfileFluentAsyncValidator(_repo); var result = await validator.ValidateAsync(_dto!); return result.Errors.Count; } - [Benchmark] + [Benchmark(Description = "Verifast")] public async Task Verifast_Async() { var validator = new AsyncUserProfileVerifastValidator(_repo); var result = await validator.ValidateAsync(_dto!); diff --git a/benchmarks/Verifast.Benchmarks/Benchmarks/SyncValidation.cs b/benchmarks/Verifast.Benchmarks/Benchmarks/SyncValidation.cs index dc9951a..0529dd5 100644 --- a/benchmarks/Verifast.Benchmarks/Benchmarks/SyncValidation.cs +++ b/benchmarks/Verifast.Benchmarks/Benchmarks/SyncValidation.cs @@ -7,25 +7,25 @@ namespace Verifast.Benchmarks.Benchmarks; [ReturnValueValidator] public class SyncValidation { [Params(true, false)] - public bool DtoValid { get; set; } + public bool ValidDto { get; set; } private UserProfile? _dto; [GlobalSetup] public void Setup() { - _dto = DtoValid + _dto = ValidDto ? UserProfileFactory.CreateValid() : UserProfileFactory.CreateInvalid(); } - [Benchmark(Baseline = true)] + [Benchmark(Baseline = true, Description = "FluentValidation")] public int FluentValidation() { var validator = new UserProfileFluentValidator(); var result = validator.Validate(_dto!); return result.Errors.Count; } - [Benchmark] + [Benchmark(Description = "Verifast")] public int Verifast() { var validator = new UserProfileVerifastValidator(); var result = validator.Validate(_dto!); diff --git a/benchmarks/Verifast.Benchmarks/Models/BenchmarkConfig.cs b/benchmarks/Verifast.Benchmarks/Models/BenchmarkConfig.cs index 56006ab..5d767ff 100644 --- a/benchmarks/Verifast.Benchmarks/Models/BenchmarkConfig.cs +++ b/benchmarks/Verifast.Benchmarks/Models/BenchmarkConfig.cs @@ -3,12 +3,14 @@ using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Loggers; using BenchmarkDotNet.Order; using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; +using Perfolizer.Horology; using Perfolizer.Mathematics.OutlierDetection; namespace Verifast.Benchmarks.Models; @@ -17,14 +19,15 @@ public class BenchmarkConfig : ManualConfig { public BenchmarkConfig() { SummaryStyle = SummaryStyle.Default.WithRatioStyle(RatioStyle.Trend); AddDiagnoser(MemoryDiagnoser.Default); - AddJob(Job.MediumRun.WithOutlierMode(OutlierMode.RemoveAll)); + AddJob(Job.MediumRun.WithOutlierMode(OutlierMode.RemoveAll).WithIterationTime(TimeInterval.FromMilliseconds(100))); AddColumnProvider(DefaultColumnProviders.Instance); AddColumn(RankColumn.Arabic); - HideColumns(Column.Error, Column.StdDev, Column.Median, Column.RatioSD); + HideColumns(Column.Error, Column.StdDev, Column.Median, Column.RatioSD, Column.Gen0, Column.Gen1, Column.Gen2, Column.Rank); WithOrderer(new GroupByTypeOrderer()); WithOptions(ConfigOptions.JoinSummary); WithOptions(ConfigOptions.StopOnFirstError); WithOptions(ConfigOptions.DisableLogFile); + AddExporter(MarkdownExporter.GitHub); AddLogger(ConsoleLogger.Default); } } diff --git a/benchmarks/Verifast.Benchmarks/Verifast.Benchmarks.csproj b/benchmarks/Verifast.Benchmarks/Verifast.Benchmarks.csproj index cc0fe65..e8c5c9c 100644 --- a/benchmarks/Verifast.Benchmarks/Verifast.Benchmarks.csproj +++ b/benchmarks/Verifast.Benchmarks/Verifast.Benchmarks.csproj @@ -2,15 +2,15 @@ Exe - net9.0 + net10.0 enable enable - - - + + + diff --git a/src/Verifast/IAsyncValidator.cs b/src/Verifast/IAsyncValidator.cs new file mode 100644 index 0000000..aad2e0a --- /dev/null +++ b/src/Verifast/IAsyncValidator.cs @@ -0,0 +1,30 @@ +namespace Verifast; + +/// +/// Interface for implementing an asynchronous validator for with message type +/// +/// +/// +public interface IAsyncValidator { + /// + /// ValidateAsync method + /// + /// + /// + /// + ValueTask> ValidateAsync(T instance, CancellationToken ct = default); +} + +/// +/// Interface for implementing an asynchronous validator for +/// +/// +public interface IAsyncValidator { + /// + /// ValidateAsync method + /// + /// + /// + /// + ValueTask> ValidateAsync(T instance, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/Verifast/IValidator.cs b/src/Verifast/IValidator.cs index f837c4b..6c8959a 100644 --- a/src/Verifast/IValidator.cs +++ b/src/Verifast/IValidator.cs @@ -1,5 +1,6 @@ namespace Verifast; +#if NET9_0_OR_GREATER /// /// Interface for implementing a synchronous validator for with message type /// @@ -26,32 +27,31 @@ public interface IValidator where T : allows ref struct { /// void Validate(in T instance, ref ValidationResult result); } - +#else /// -/// Interface for implementing an asynchronous validator for with message type +/// Interface for implementing a synchronous validator for with message type /// /// /// -public interface IAsyncValidator { +public interface IValidator { /// - /// ValidateAsync method + /// Validate method /// /// - /// - /// - ValueTask> ValidateAsync(T instance, CancellationToken ct = default); + /// + void Validate(in T instance, ref ValidationResult result); } /// -/// Interface for implementing an asynchronous validator for +/// Interface for implementing a synchronous validator for /// /// -public interface IAsyncValidator { +public interface IValidator { /// - /// ValidateAsync method - /// - /// - /// - /// - ValueTask> ValidateAsync(T instance, CancellationToken ct = default); -} \ No newline at end of file + /// Validate method + /// + /// + /// + void Validate(in T instance, ref ValidationResult result); +} +#endif \ No newline at end of file diff --git a/src/Verifast/ValidationResult.cs b/src/Verifast/ValidationResult.cs index 56cbca5..1a5efdd 100644 --- a/src/Verifast/ValidationResult.cs +++ b/src/Verifast/ValidationResult.cs @@ -9,6 +9,13 @@ public struct ValidationResult { private List? _errors; private List? _warnings; + private static readonly ReadOnlyCollection Empty = +#if NET + ReadOnlyCollection.Empty; +#else + new(Array.Empty()); +#endif + /// /// The result is valid if no errors were found. /// @@ -20,7 +27,7 @@ public struct ValidationResult { public readonly ReadOnlyCollection Errors => _errors is not null ? new ReadOnlyCollection(_errors) - : ReadOnlyCollection.Empty; + : Empty; /// /// A of the @@ -28,14 +35,14 @@ public readonly ReadOnlyCollection Errors public readonly ReadOnlyCollection Warnings => _warnings is not null ? new ReadOnlyCollection(_warnings) - : ReadOnlyCollection.Empty; + : Empty; /// /// Adds an error to the validation result /// /// public void AddError(TMessage message) { - _errors ??= new List(); + _errors ??= []; _errors.Add(message); } @@ -44,7 +51,7 @@ public void AddError(TMessage message) { /// /// public void AddWarning(TMessage message) { - _warnings ??= new List(); + _warnings ??= []; _warnings.Add(message); } } \ No newline at end of file diff --git a/src/Verifast/Validator.cs b/src/Verifast/Validator.cs index 4127f4a..882fe0a 100644 --- a/src/Verifast/Validator.cs +++ b/src/Verifast/Validator.cs @@ -4,6 +4,7 @@ /// A static class providing the main APIs for validation /// public static class Validator { +#if NET9_0_OR_GREATER /// /// Validate /// @@ -71,4 +72,69 @@ public static bool TryValidate(this TValidator validator, in T in validator.Validate(in instance, ref result); return result.IsValid; } +#else + /// + /// Validate + /// + /// + /// + /// + /// + /// + /// + public static ValidationResult Validate(this TValidator validator, in T instance) + where TValidator : IValidator { + ValidationResult result = default; + validator.Validate(in instance, ref result); + return result; + } + + /// + /// Validate + /// + /// + /// + /// + /// + /// + public static ValidationResult Validate(this TValidator validator, in T instance) + where TValidator : IValidator { + ValidationResult result = default; + validator.Validate(in instance, ref result); + return result; + } + + /// + /// Try Validate + /// + /// + /// + /// + /// + /// + /// + /// True if is valid. + public static bool TryValidate(this TValidator validator, in T instance, out ValidationResult result) + where TValidator : IValidator { + result = default; + validator.Validate(in instance, ref result); + return result.IsValid; + } + + /// + /// Try Validate + /// + /// + /// + /// + /// + /// + /// True if is valid. + public static bool TryValidate(this TValidator validator, in T instance, out ValidationResult result) + where TValidator : IValidator { + result = default; + validator.Validate(in instance, ref result); + return result.IsValid; + } +#endif } \ No newline at end of file diff --git a/src/Verifast/Verifast.csproj b/src/Verifast/Verifast.csproj index f8df1d7..7d3f1db 100644 --- a/src/Verifast/Verifast.csproj +++ b/src/Verifast/Verifast.csproj @@ -1,18 +1,21 @@  - 0.1.0.0 + 1.0.0.0 - net9.0 + net9.0;netstandard2.1;netstandard2.0 enable enable + latest true true true true latest-recommended - true - true + true + true David Shnayder David Shnayder @@ -34,9 +37,15 @@ + + + + portable true + true + snupkg $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb @@ -47,8 +56,7 @@ - - + Added support for netstandard2.0 and netstandard2.1. - \ No newline at end of file + diff --git a/tests/Verifast.Tests.Unit.Standard/AsyncUser.cs b/tests/Verifast.Tests.Unit.Standard/AsyncUser.cs new file mode 100644 index 0000000..d46cb36 --- /dev/null +++ b/tests/Verifast.Tests.Unit.Standard/AsyncUser.cs @@ -0,0 +1,112 @@ +namespace Verifast.Tests.Unit.Standard; + +public class AsyncUserDto : IAsyncValidator { + public string? Name { get; set; } + public int Age { get; set; } + public string? Email { get; set; } + public string? Password { get; set; } + public string? Phone { get; set; } + + public async ValueTask> ValidateAsync(AsyncUserDto instance, CancellationToken ct = default) { + // Emulate asynchronous work so tests exercise the async path + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + + var result = new ValidationResult(); + + // Name: required, length 1..100 + if (string.IsNullOrWhiteSpace(instance.Name)) { + result.AddError($"'{nameof(Name)}' must be non-empty"); + } else if (instance.Name!.Length > 100) { + result.AddError($"'{nameof(Name)}' must be at most 100 characters"); + } + + // Age: 18..120 inclusive + if (instance.Age is < 18 or > 120) { + result.AddError($"'{nameof(Age)}' must be between 18 and 120"); + } + + // Email: very lightweight format check (no regex) + if (!string.IsNullOrWhiteSpace(instance.Email)) { + if (!IsLikelyEmail(instance.Email!)) { + result.AddError($"'{nameof(Email)}' must be a valid email"); + } + } + + // Password: required, min 8, has upper, lower, digit + if (string.IsNullOrEmpty(instance.Password)) { + result.AddError($"'{nameof(Password)}' must be non-empty"); + } else { + var pwd = instance.Password!; + if (pwd.Length < 8) { + result.AddError($"'{nameof(Password)}' must be at least 8 characters"); + } + + bool hasUpper = false, hasLower = false, hasDigit = false; + for (int i = 0; i < pwd.Length; i++) { + char c = pwd[i]; + if (!hasUpper && char.IsUpper(c)) { + hasUpper = true; + } else if (!hasLower && char.IsLower(c)) { + hasLower = true; + } else if (!hasDigit && char.IsDigit(c)) { + hasDigit = true; + } + + if (hasUpper && hasLower && hasDigit) { + break; + } + } + + if (!hasUpper) { + result.AddError($"'{nameof(Password)}' must contain an uppercase letter"); + } + + if (!hasLower) { + result.AddError($"'{nameof(Password)}' must contain a lowercase letter"); + } + + if (!hasDigit) { + result.AddError($"'{nameof(Password)}' must contain a digit"); + } + } + + // Phone: optional; if provided, basic E.164-ish (+?[0-9]{10,15}) + if (!string.IsNullOrWhiteSpace(instance.Phone)) { + if (!IsLikelyPhone(instance.Phone!)) { + result.AddWarning($"'{nameof(Phone)}' format looks invalid"); + } + } + + return result; + + static bool IsLikelyEmail(string s) { + // Minimal: exactly one '@', non-empty local/domain, domain has a '.' after '@' + int at = s.IndexOf('@'); + if (at <= 0 || at >= s.Length - 1) return false; + int dot = s.IndexOf('.', at + 1); + if (dot <= at + 1 || dot >= s.Length - 1) return false; + return true; + } + + static bool IsLikelyPhone(string s) { + int i = 0; + int digits = 0; + if (s.Length > 0 && s[0] == '+') { + i = 1; + } + + for (; i < s.Length; i++) { + char c = s[i]; + if (char.IsDigit(c)) { + digits++; + continue; + } + + return false; + } + + return digits >= 10 && digits <= 15; + } + } +} diff --git a/tests/Verifast.Tests.Unit.Standard/AsyncUserValidatorTests.cs b/tests/Verifast.Tests.Unit.Standard/AsyncUserValidatorTests.cs new file mode 100644 index 0000000..285c9c6 --- /dev/null +++ b/tests/Verifast.Tests.Unit.Standard/AsyncUserValidatorTests.cs @@ -0,0 +1,93 @@ +namespace Verifast.Tests.Unit.Standard; + +public class AsyncUserValidatorTests { + [Fact] + public async Task Valid_User_Has_No_Errors_Async() { + var user = new AsyncUserDto { + Name = "Alice", + Age = 30, + Email = "alice@example.com", + Password = "Secret123", + Phone = "+14155552671" + }; + + var result = await user.ValidateAsync(user, CancellationToken.None); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + Assert.Empty(result.Warnings); + } + + [Fact] + public async Task Missing_Name_Is_Error_Async() { + var user = new AsyncUserDto { + Name = "", + Age = 25, + Email = "bob@example.com", + Password = "Abcdef12" + }; + + var result = await user.ValidateAsync(user, CancellationToken.None); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Name")); + } + + [Fact] + public async Task Age_Out_Of_Range_Is_Error_Async() { + var user = new AsyncUserDto { + Name = "Bob", + Age = 10, + Email = "bob@example.com", + Password = "Abcdef12" + }; + + var result = await user.ValidateAsync(user, CancellationToken.None); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Age")); + } + + [Fact] + public async Task Invalid_Email_Is_Error_Async() { + var user = new AsyncUserDto { + Name = "Carol", + Age = 40, + Email = "invalid-email", + Password = "Abcdef12" + }; + + var result = await user.ValidateAsync(user, CancellationToken.None); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Email")); + } + + [Fact] + public async Task Password_Rules_Are_Validated_Async() { + var user = new AsyncUserDto { + Name = "Dan", + Age = 22, + Email = "dan@example.com", + Password = "short" + }; + + var result = await user.ValidateAsync(user, CancellationToken.None); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("at least 8")); + Assert.True(result.Errors.Count >= 2); + } + + [Fact] + public async Task Invalid_Phone_Is_Warning_Not_Error_Async() { + var user = new AsyncUserDto { + Name = "Erin", + Age = 28, + Email = "erin@example.com", + Password = "Abcdef12", + Phone = "123-456" // too few digits + }; + + var result = await user.ValidateAsync(user, CancellationToken.None); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + Assert.NotEmpty(result.Warnings); + Assert.Contains(result.Warnings, w => w.Contains("Phone")); + } +} diff --git a/tests/Verifast.Tests.Unit.Standard/User.cs b/tests/Verifast.Tests.Unit.Standard/User.cs new file mode 100644 index 0000000..c3e6dff --- /dev/null +++ b/tests/Verifast.Tests.Unit.Standard/User.cs @@ -0,0 +1,104 @@ +namespace Verifast.Tests.Unit.Standard; + +public class UserDto : IValidator { + public string? Name { get; set; } + public int Age { get; set; } + public string? Email { get; set; } + public string? Password { get; set; } + public string? Phone { get; set; } + + public void Validate(in UserDto instance, ref ValidationResult result) { + // Name: required, length 1..100 + if (string.IsNullOrWhiteSpace(instance.Name)) { + result.AddError($"'{nameof(Name)}' must be non-empty"); + } else if (instance.Name!.Length > 100) { + result.AddError($"'{nameof(Name)}' must be at most 100 characters"); + } + + // Age: 18..120 inclusive + if (instance.Age is < 18 or > 120) { + result.AddError($"'{nameof(Age)}' must be between 18 and 120"); + } + + // Email: very lightweight format check (no regex) + if (!string.IsNullOrWhiteSpace(instance.Email)) { + if (!IsLikelyEmail(instance.Email!)) { + result.AddError($"'{nameof(Email)}' must be a valid email"); + } + } + + // Password: required, min 8, has upper, lower, digit + if (string.IsNullOrEmpty(instance.Password)) { + result.AddError($"'{nameof(Password)}' must be non-empty"); + } else { + var pwd = instance.Password!; + if (pwd.Length < 8) { + result.AddError($"'{nameof(Password)}' must be at least 8 characters"); + } + + bool hasUpper = false, hasLower = false, hasDigit = false; + for (int i = 0; i < pwd.Length; i++) { + char c = pwd[i]; + if (!hasUpper && char.IsUpper(c)) { + hasUpper = true; + } else if (!hasLower && char.IsLower(c)) { + hasLower = true; + } else if (!hasDigit && char.IsDigit(c)) { + hasDigit = true; + } + + if (hasUpper && hasLower && hasDigit) { + break; + } + } + + if (!hasUpper) { + result.AddError($"'{nameof(Password)}' must contain an uppercase letter"); + } + + if (!hasLower) { + result.AddError($"'{nameof(Password)}' must contain a lowercase letter"); + } + + if (!hasDigit) { + result.AddError($"'{nameof(Password)}' must contain a digit"); + } + } + + // Phone: optional; if provided, basic E.164-ish (+?[0-9]{10,15}) + if (!string.IsNullOrWhiteSpace(instance.Phone)) { + if (!IsLikelyPhone(instance.Phone!)) { + result.AddWarning($"'{nameof(Phone)}' format looks invalid"); + } + } + + static bool IsLikelyEmail(string s) { + // Minimal: exactly one '@', non-empty local/domain, domain has a '.' after '@' + int at = s.IndexOf('@'); + if (at <= 0 || at >= s.Length - 1) return false; + int dot = s.IndexOf('.', at + 1); + if (dot <= at + 1 || dot >= s.Length - 1) return false; + return true; + } + + static bool IsLikelyPhone(string s) { + int i = 0; + int digits = 0; + if (s[0] == '+') { + i = 1; + } + + for (; i < s.Length; i++) { + char c = s[i]; + if (char.IsDigit(c)) { + digits++; + continue; + } + + return false; + } + + return digits >= 10 && digits <= 15; + } + } +} diff --git a/tests/Verifast.Tests.Unit.Standard/UserTryValidateTests.cs b/tests/Verifast.Tests.Unit.Standard/UserTryValidateTests.cs new file mode 100644 index 0000000..2116fc6 --- /dev/null +++ b/tests/Verifast.Tests.Unit.Standard/UserTryValidateTests.cs @@ -0,0 +1,99 @@ +namespace Verifast.Tests.Unit.Standard; + +public class UserTryValidateTests { + [Fact] + public void TryValidate_Valid_User_Has_No_Errors() { + var user = new UserDto { + Name = "Alice", + Age = 30, + Email = "alice@example.com", + Password = "Secret123", + Phone = "+14155552671" + }; + + var ok = user.TryValidate(user, out var result); + Assert.True(ok); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + Assert.Empty(result.Warnings); + } + + [Fact] + public void TryValidate_Missing_Name_Is_Error() { + var user = new UserDto { + Name = "", + Age = 25, + Email = "bob@example.com", + Password = "Abcdef12" + }; + + var ok = user.TryValidate(user, out var result); + Assert.False(ok); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Name")); + } + + [Fact] + public void TryValidate_Age_Out_Of_Range_Is_Error() { + var user = new UserDto { + Name = "Bob", + Age = 10, + Email = "bob@example.com", + Password = "Abcdef12" + }; + + var ok = user.TryValidate(user, out var result); + Assert.False(ok); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Age")); + } + + [Fact] + public void TryValidate_Invalid_Email_Is_Error() { + var user = new UserDto { + Name = "Carol", + Age = 40, + Email = "invalid-email", + Password = "Abcdef12" + }; + + var ok = user.TryValidate(user, out var result); + Assert.False(ok); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Email")); + } + + [Fact] + public void TryValidate_Password_Rules_Are_Validated() { + var user = new UserDto { + Name = "Dan", + Age = 22, + Email = "dan@example.com", + Password = "short" + }; + + var ok = user.TryValidate(user, out var result); + Assert.False(ok); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("at least 8")); + Assert.True(result.Errors.Count >= 2); + } + + [Fact] + public void TryValidate_Invalid_Phone_Is_Warning_Not_Error() { + var user = new UserDto { + Name = "Erin", + Age = 28, + Email = "erin@example.com", + Password = "Abcdef12", + Phone = "123-456" // too few digits + }; + + var ok = user.TryValidate(user, out var result); + Assert.True(ok); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + Assert.NotEmpty(result.Warnings); + Assert.Contains(result.Warnings, w => w.Contains("Phone")); + } +} diff --git a/tests/Verifast.Tests.Unit.Standard/UserValidatorTests.cs b/tests/Verifast.Tests.Unit.Standard/UserValidatorTests.cs new file mode 100644 index 0000000..74fa35e --- /dev/null +++ b/tests/Verifast.Tests.Unit.Standard/UserValidatorTests.cs @@ -0,0 +1,94 @@ +namespace Verifast.Tests.Unit.Standard; + +public class UserValidatorTests { + [Fact] + public void Valid_User_Has_No_Errors() { + var user = new UserDto { + Name = "Alice", + Age = 30, + Email = "alice@example.com", + Password = "Secret123", + Phone = "+14155552671" + }; + + var result = user.Validate(user); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + Assert.Empty(result.Warnings); + } + + [Fact] + public void Missing_Name_Is_Error() { + var user = new UserDto { + Name = "", + Age = 5, + Email = "bob@example.com", + Password = "Abcdef12" + }; + + var result = user.Validate(user); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Name")); + } + + [Fact] + public void Age_Out_Of_Range_Is_Error() { + var user = new UserDto { + Name = "Bob", + Age = 10, + Email = "bob@example.com", + Password = "Abcdef12" + }; + + var result = user.Validate(user); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Age")); + } + + [Fact] + public void Invalid_Email_Is_Error() { + var user = new UserDto { + Name = "Carol", + Age = 40, + Email = "invalid-email", + Password = "Abcdef12" + }; + + var result = user.Validate(user); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Email")); + } + + [Fact] + public void Password_Rules_Are_Validated() { + var user = new UserDto { + Name = "Dan", + Age = 22, + Email = "dan@example.com", + Password = "short" + }; + + var result = user.Validate(user); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("at least 8")); + // Missing upper/lower/digit should trigger multiple errors + Assert.True(result.Errors.Count >= 2); + } + + [Fact] + public void Invalid_Phone_Is_Warning_Not_Error() { + var user = new UserDto { + Name = "Erin", + Age = 28, + Email = "erin@example.com", + Password = "Abcdef12", + Phone = "123-456" // too few digits + }; + + var result = user.Validate(user); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + Assert.NotEmpty(result.Warnings); + Assert.Contains(result.Warnings, w => w.Contains("Phone")); + } +} diff --git a/tests/Verifast.Tests.Unit.Standard/Verifast.Tests.Unit.Standard.csproj b/tests/Verifast.Tests.Unit.Standard/Verifast.Tests.Unit.Standard.csproj new file mode 100644 index 0000000..4853328 --- /dev/null +++ b/tests/Verifast.Tests.Unit.Standard/Verifast.Tests.Unit.Standard.csproj @@ -0,0 +1,32 @@ + + + + enable + enable + false + net8.0 + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/Verifast.Tests.Unit.Standard/xunit.runner.json b/tests/Verifast.Tests.Unit.Standard/xunit.runner.json new file mode 100644 index 0000000..86c7ea0 --- /dev/null +++ b/tests/Verifast.Tests.Unit.Standard/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} diff --git a/tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj b/tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj index 1c67b6b..557bbfc 100644 --- a/tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj +++ b/tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj @@ -4,18 +4,7 @@ enable enable Exe - Verifast.Tests.Unit net9.0 - @@ -27,9 +16,12 @@ - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all +