From dc21bc50e56b9abe0f8c1597344a1baf4f847da4 Mon Sep 17 00:00:00 2001 From: Theaux Masquelier <43664045+Theauxm@users.noreply.github.com> Date: Thu, 21 May 2026 15:33:33 -0600 Subject: [PATCH] chore: add repo-guard meta tests Add a per-repo Tests.Meta project (Tests/ for Trax.Docs) that enforces CLAUDE.md conventions in CI: locked Directory.Build.props version, no [Ignore], no legacy Assert.*, no fixed Task.Delay (with baseline grandfathering), cross-repo Version="1.*", PascalCase test folders, AddTrax*/UseTrax* naming, and a PublicApiGenerator surface snapshot per assembly. Trax.Effect also gets migrations-integrity + Model<->Persistent pairing checks. Trax.Mediator gets a runtime interface-FullName invariant test and a DI composition smoke test. Trax.Scheduler moves SchedulerConfigurationBuilder.Build into its own partial file to comply with the builder convention. Trax.Docs gets em-dash / Jekyll / link-resolves / SDK-Reference-block lints and replaces 21 pre-existing em-dashes. Failure messages name the CLAUDE.md section and the offender. Pre-existing violations are grandfathered via per-test allowlists (BaselineOffenders, KnownExceptions, KnownBrokenLinks) so CI stays green; new regressions fail. --- Trax.Core.slnx | 1 + tests/Trax.Core.Tests.Meta/GlobalUsings.cs | 9 + .../Infrastructure/RepoRoot.cs | 31 +++ .../Infrastructure/SourceFiles.cs | 43 ++++ .../Infrastructure/SourceText.cs | 54 +++++ .../PublicApi/Trax.Core.received.txt | 194 ++++++++++++++++++ .../Tests/CrossRepoPackageReferenceTests.cs | 100 +++++++++ .../Tests/DirectoryBuildPropsVersionTests.cs | 34 +++ .../Tests/ExtensionMethodNamingTests.cs | 94 +++++++++ .../Tests/NoFixedTaskDelayTests.cs | 71 +++++++ .../Tests/NoIgnoreAttributeTests.cs | 38 ++++ .../Tests/NoLegacyAssertTests.cs | 53 +++++ .../Tests/PublicApiSurfaceTests.cs | 79 +++++++ .../Tests/TestFolderLayoutTests.cs | 120 +++++++++++ .../Trax.Core.Tests.Meta.csproj | 32 +++ .../UnitTests/Train/CancellationTokenTests.cs | 3 + .../Tests/JsonEscapingTests.cs | 76 +++---- 17 files changed, 980 insertions(+), 52 deletions(-) create mode 100644 tests/Trax.Core.Tests.Meta/GlobalUsings.cs create mode 100644 tests/Trax.Core.Tests.Meta/Infrastructure/RepoRoot.cs create mode 100644 tests/Trax.Core.Tests.Meta/Infrastructure/SourceFiles.cs create mode 100644 tests/Trax.Core.Tests.Meta/Infrastructure/SourceText.cs create mode 100644 tests/Trax.Core.Tests.Meta/PublicApi/Trax.Core.received.txt create mode 100644 tests/Trax.Core.Tests.Meta/Tests/CrossRepoPackageReferenceTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Tests/DirectoryBuildPropsVersionTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Tests/ExtensionMethodNamingTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Tests/NoFixedTaskDelayTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Tests/NoIgnoreAttributeTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Tests/NoLegacyAssertTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Tests/PublicApiSurfaceTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Tests/TestFolderLayoutTests.cs create mode 100644 tests/Trax.Core.Tests.Meta/Trax.Core.Tests.Meta.csproj diff --git a/Trax.Core.slnx b/Trax.Core.slnx index af37aa8..6ee19ef 100644 --- a/Trax.Core.slnx +++ b/Trax.Core.slnx @@ -10,5 +10,6 @@ + diff --git a/tests/Trax.Core.Tests.Meta/GlobalUsings.cs b/tests/Trax.Core.Tests.Meta/GlobalUsings.cs new file mode 100644 index 0000000..30e2bdf --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Text.RegularExpressions; +global using System.Xml.Linq; +global using FluentAssertions; +global using NUnit.Framework; +global using Trax.Core.Tests.Meta.Infrastructure; diff --git a/tests/Trax.Core.Tests.Meta/Infrastructure/RepoRoot.cs b/tests/Trax.Core.Tests.Meta/Infrastructure/RepoRoot.cs new file mode 100644 index 0000000..2e6ff98 --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Infrastructure/RepoRoot.cs @@ -0,0 +1,31 @@ +namespace Trax.Core.Tests.Meta.Infrastructure; + +/// +/// Locates the repository root by walking up from the test bin/ directory until a .slnx is found. +/// +internal static class RepoRoot +{ + private static readonly Lazy Cached = new(Resolve); + + public static string Path => Cached.Value; + + public static string Combine(params string[] segments) => + System.IO.Path.Combine(new[] { Path }.Concat(segments).ToArray()); + + public static string Relative(string absolute) => + System.IO.Path.GetRelativePath(Path, absolute); + + private static string Resolve() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (dir.EnumerateFiles("*.slnx").Any()) + return dir.FullName; + dir = dir.Parent; + } + throw new InvalidOperationException( + $"Could not locate repository root: no .slnx found walking up from '{AppContext.BaseDirectory}'." + ); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Infrastructure/SourceFiles.cs b/tests/Trax.Core.Tests.Meta/Infrastructure/SourceFiles.cs new file mode 100644 index 0000000..007b461 --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Infrastructure/SourceFiles.cs @@ -0,0 +1,43 @@ +namespace Trax.Core.Tests.Meta.Infrastructure; + +internal static class SourceFiles +{ + public static IEnumerable CSharp(params string[] subdirs) => Enumerate("*.cs", subdirs); + + public static IEnumerable Projects(params string[] subdirs) => + Enumerate("*.csproj", subdirs); + + public static IEnumerable Markdown(params string[] subdirs) => + Enumerate("*.md", subdirs); + + private static IEnumerable Enumerate(string pattern, string[] subdirs) + { + var roots = + subdirs.Length == 0 + ? new[] { RepoRoot.Path } + : subdirs.Select(s => Path.Combine(RepoRoot.Path, s)).ToArray(); + + foreach (var root in roots) + { + if (!Directory.Exists(root)) + continue; + foreach ( + var file in Directory.EnumerateFiles(root, pattern, SearchOption.AllDirectories) + ) + { + if (IsExcluded(file)) + continue; + yield return file; + } + } + } + + private static bool IsExcluded(string path) + { + var s = Path.DirectorySeparatorChar; + return path.Contains($"{s}bin{s}", StringComparison.Ordinal) + || path.Contains($"{s}obj{s}", StringComparison.Ordinal) + || path.Contains($"{s}node_modules{s}", StringComparison.Ordinal) + || path.Contains($"{s}.git{s}", StringComparison.Ordinal); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Infrastructure/SourceText.cs b/tests/Trax.Core.Tests.Meta/Infrastructure/SourceText.cs new file mode 100644 index 0000000..a5c9c4e --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Infrastructure/SourceText.cs @@ -0,0 +1,54 @@ +namespace Trax.Core.Tests.Meta.Infrastructure; + +/// +/// Helpers for inspecting C# source text without triggering false positives from comments / strings. +/// +internal static class SourceText +{ + private static readonly Regex BlockComment = new( + @"/\*.*?\*/", + RegexOptions.Singleline | RegexOptions.Compiled + ); + private static readonly Regex LineComment = new(@"//[^\r\n]*", RegexOptions.Compiled); + private static readonly Regex VerbatimString = new( + @"@""(?:[^""]|"""")*""", + RegexOptions.Compiled + ); + private static readonly Regex InterpolatedVerbatim = new( + @"\$@""(?:[^""]|"""")*""", + RegexOptions.Compiled + ); + private static readonly Regex RegularString = new( + @"""(?:\\.|[^""\\])*""", + RegexOptions.Compiled + ); + + /// + /// Strips comments and string literals so that regex-based pattern matching against C# source + /// does not get false positives from a token appearing inside a documentation comment or string. + /// + public static string StripCommentsAndStrings(string source) + { + var s = BlockComment.Replace(source, " "); + s = LineComment.Replace(s, " "); + s = InterpolatedVerbatim.Replace(s, "\"\""); + s = VerbatimString.Replace(s, "\"\""); + s = RegularString.Replace(s, "\"\""); + return s; + } + + public static IReadOnlyList<(int LineNumber, string Line)> MatchingLines( + string source, + Regex pattern + ) + { + var hits = new List<(int, string)>(); + var lines = source.Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + if (pattern.IsMatch(lines[i])) + hits.Add((i + 1, lines[i])); + } + return hits; + } +} diff --git a/tests/Trax.Core.Tests.Meta/PublicApi/Trax.Core.received.txt b/tests/Trax.Core.Tests.Meta/PublicApi/Trax.Core.received.txt new file mode 100644 index 0000000..7079dd9 --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/PublicApi/Trax.Core.received.txt @@ -0,0 +1,194 @@ +namespace Trax.Core.Exceptions +{ + public class TrainException : System.Exception + { + public TrainException(string message) { } + } + [System.Runtime.CompilerServices.RequiredMember] + public class TrainExceptionData + { + [System.Obsolete("Constructors of types with required members are not supported in this version of " + + "your compiler.", true)] + public TrainExceptionData() { } + [System.Runtime.CompilerServices.RequiredMember] + [System.Text.Json.Serialization.JsonPropertyName("junction")] + public string Junction { get; set; } + [System.Runtime.CompilerServices.RequiredMember] + [System.Text.Json.Serialization.JsonPropertyName("message")] + public string Message { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("stackTrace")] + public string? StackTrace { get; set; } + [System.Runtime.CompilerServices.RequiredMember] + [System.Text.Json.Serialization.JsonPropertyName("trainExternalId")] + public string TrainExternalId { get; set; } + [System.Runtime.CompilerServices.RequiredMember] + [System.Text.Json.Serialization.JsonPropertyName("trainName")] + public string TrainName { get; set; } + [System.Runtime.CompilerServices.RequiredMember] + [System.Text.Json.Serialization.JsonPropertyName("type")] + public string Type { get; set; } + } +} +namespace Trax.Core.Extensions +{ + public static class FunctionalExtensions + { + public static void AssertEachLoaded([System.Diagnostics.CodeAnalysis.NotNull] this System.Collections.Generic.IEnumerable values, System.Func selector, [System.Runtime.CompilerServices.CallerArgumentExpression("values")] string? valuesExpr = null, [System.Runtime.CompilerServices.CallerArgumentExpression("selector")] string? selectorExpr = null) { } + public static void AssertLoaded([System.Diagnostics.CodeAnalysis.NotNull] this T? value, [System.Runtime.CompilerServices.CallerArgumentExpression("value")] string? valueExpr = null) { } + } + public static class LoggerExtensions + { + public static dynamic CreateGenericLogger(this Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, System.Type genericType) { } + public static System.Collections.Generic.List GetLoggerProviders(this Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } + } + public static class MonadExtensions + { + public static LanguageExt.Unit AddTupleToMemory(this Trax.Core.Monad.Monad monad, TIn input) { } + public static dynamic ExtractTuple(this Trax.Core.Monad.Monad monad, System.Type inputType) { } + public static dynamic? ExtractTypeFromMemory(this Trax.Core.Monad.Monad monad, System.Type tIn) { } + public static T? ExtractTypeFromMemory(this Trax.Core.Monad.Monad monad) { } + public static object?[] ExtractTypesFromMemory(this Trax.Core.Monad.Monad monad, System.Collections.Generic.IEnumerable types) { } + public static TJunction? InitializeJunction(this Trax.Core.Monad.Monad monad) + where TJunction : class { } + } + public static class MoqExtensions + { + public static System.Type? GetMockedTypeFromObject(this object mockedObject) { } + public static bool IsMoqProxy(this System.Type type) { } + } +} +namespace Trax.Core.Junction +{ + public interface IJunction + { + System.Threading.Tasks.Task> RailwayJunction(LanguageExt.Either previousOutput, Trax.Core.Train.Train train); + System.Threading.Tasks.Task Run(TIn input); + } + public abstract class Junction : Trax.Core.Junction.IJunction + { + protected Junction() { } + public System.Threading.CancellationToken CancellationToken { get; protected set; } + public Trax.Core.Exceptions.TrainExceptionData? ExceptionData { get; } + public LanguageExt.Either PreviousResult { get; } + public LanguageExt.Either Result { get; } + public virtual System.Threading.Tasks.Task> RailwayJunction(LanguageExt.Either previousOutput, Trax.Core.Train.Train train) { } + public abstract System.Threading.Tasks.Task Run(TIn input); + } +} +namespace Trax.Core.Monad +{ + public class Monad + { + public Trax.Core.Monad.Monad Activate(TInput input, params object[] otherInputs) { } + public Trax.Core.Monad.Monad AddServices(T1 service) { } + public Trax.Core.Monad.Monad AddServices(T1 service1, T2 service2) { } + public Trax.Core.Monad.Monad AddServices(T1 service1, T2 service2, T3 service3) { } + public Trax.Core.Monad.Monad AddServices(T1 service1, T2 service2, T3 service3, T4 service4) { } + public Trax.Core.Monad.Monad AddServices(T1 service1, T2 service2, T3 service3, T4 service4, T5 service5) { } + public Trax.Core.Monad.Monad AddServices(T1 service1, T2 service2, T3 service3, T4 service4, T5 service5, T6 service6) { } + public Trax.Core.Monad.Monad AddServices(T1 service1, T2 service2, T3 service3, T4 service4, T5 service5, T6 service6, T7 service7) { } + public Trax.Core.Train.MonadTask Chain() + where TJunction : class { } + public Trax.Core.Train.MonadTask Chain(TJunction junctionInstance) + where TJunction : class { } + public Trax.Core.Train.MonadTask Chain() + where TJunction : Trax.Core.Junction.IJunction, new () { } + public Trax.Core.Train.MonadTask Chain(TJunction junction) + where TJunction : Trax.Core.Junction.IJunction { } + public Trax.Core.Train.MonadTask Chain() + where TJunction : Trax.Core.Junction.IJunction, new () { } + public Trax.Core.Train.MonadTask Chain(TJunction junction) + where TJunction : Trax.Core.Junction.IJunction { } + public Trax.Core.Monad.Monad Extract() { } + public Trax.Core.Monad.Monad Extract(TIn input) { } + public Trax.Core.Train.MonadTask IChain() + where TJunction : class { } + public LanguageExt.Either Resolve() { } + public LanguageExt.Either Resolve(LanguageExt.Either returnType) { } + public Trax.Core.Train.MonadTask ShortCircuit() + where TJunction : class { } + public Trax.Core.Train.MonadTask ShortCircuit(TJunction junctionInstance) + where TJunction : class { } + } +} +namespace Trax.Core.Route +{ + public interface IRoute + { + System.Threading.Tasks.Task Run(TIn input, System.Threading.CancellationToken cancellationToken = default); + } +} +namespace Trax.Core.Train +{ + public static class MonadTaskExtensions + { + public static Trax.Core.Train.MonadTask AsMonadTask(this System.Threading.Tasks.Task> source) { } + } + public readonly struct MonadTask + { + public Trax.Core.Train.MonadTask AddServices(T1 service) { } + public Trax.Core.Train.MonadTask AddServices(T1 s1, T2 s2) { } + public Trax.Core.Train.MonadTask AddServices(T1 s1, T2 s2, T3 s3) { } + public Trax.Core.Train.MonadTask AddServices(T1 s1, T2 s2, T3 s3, T4 s4) { } + public Trax.Core.Train.MonadTask AddServices(T1 s1, T2 s2, T3 s3, T4 s4, T5 s5) { } + public Trax.Core.Train.MonadTask AddServices(T1 s1, T2 s2, T3 s3, T4 s4, T5 s5, T6 s6) { } + public Trax.Core.Train.MonadTask AddServices(T1 s1, T2 s2, T3 s3, T4 s4, T5 s5, T6 s6, T7 s7) { } + public System.Threading.Tasks.Task> AsTask() { } + public Trax.Core.Train.MonadTask Chain() + where TJunction : class { } + public Trax.Core.Train.MonadTask Chain(TJunction instance) + where TJunction : class { } + public Trax.Core.Train.MonadTask Chain() + where TJunction : Trax.Core.Junction.IJunction, new () { } + public Trax.Core.Train.MonadTask Chain(TJunction junction) + where TJunction : Trax.Core.Junction.IJunction { } + public Trax.Core.Train.MonadTask Chain() + where TJunction : Trax.Core.Junction.IJunction, new () { } + public Trax.Core.Train.MonadTask Chain(TJunction junction) + where TJunction : Trax.Core.Junction.IJunction { } + public System.Runtime.CompilerServices.ConfiguredTaskAwaitable> ConfigureAwait(bool continueOnCapturedContext) { } + public Trax.Core.Train.MonadTask Extract() { } + public Trax.Core.Train.MonadTask Extract(TIn input) { } + public System.Runtime.CompilerServices.TaskAwaiter> GetAwaiter() { } + public Trax.Core.Train.MonadTask IChain() + where TJunction : class { } + public System.Threading.Tasks.Task> Resolve() { } + public System.Threading.Tasks.Task> Resolve(LanguageExt.Either returnType) { } + public Trax.Core.Train.MonadTask ShortCircuit() + where TJunction : class { } + public Trax.Core.Train.MonadTask ShortCircuit(TJunction instance) + where TJunction : class { } + public static System.Threading.Tasks.Task> op_Implicit(Trax.Core.Train.MonadTask mt) { } + } + public abstract class Train : Trax.Core.Route.IRoute + { + protected Train() { } + [System.Text.Json.Serialization.JsonIgnore] + public System.Threading.CancellationToken CancellationToken { get; protected set; } + public string ExternalId { get; set; } + public Trax.Core.Monad.Monad Activate(TInput input, params object[] otherInputs) { } + protected Trax.Core.Monad.Monad AddServices(T1 service) { } + protected Trax.Core.Monad.Monad AddServices(T1 s1, T2 s2) { } + protected Trax.Core.Monad.Monad AddServices(T1 s1, T2 s2, T3 s3) { } + protected Trax.Core.Monad.Monad AddServices(T1 s1, T2 s2, T3 s3, T4 s4) { } + protected Trax.Core.Monad.Monad AddServices(T1 s1, T2 s2, T3 s3, T4 s4, T5 s5) { } + protected Trax.Core.Monad.Monad AddServices(T1 s1, T2 s2, T3 s3, T4 s4, T5 s5, T6 s6) { } + protected Trax.Core.Monad.Monad AddServices(T1 s1, T2 s2, T3 s3, T4 s4, T5 s5, T6 s6, T7 s7) { } + protected Trax.Core.Train.MonadTask Chain() + where TJunction : class { } + protected Trax.Core.Train.MonadTask Chain(TJunction instance) + where TJunction : class { } + protected Trax.Core.Monad.Monad Extract() { } + protected Trax.Core.Monad.Monad Extract(TIn input) { } + protected Trax.Core.Train.MonadTask IChain() + where TJunction : class { } + protected virtual System.Threading.Tasks.Task> Junctions() { } + public virtual System.Threading.Tasks.Task Run(TInput input, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task> RunEither(TInput input) { } + protected virtual System.Threading.Tasks.Task> RunInternal(TInput input) { } + protected Trax.Core.Train.MonadTask ShortCircuit() + where TJunction : class { } + protected Trax.Core.Train.MonadTask ShortCircuit(TJunction instance) + where TJunction : class { } + } +} \ No newline at end of file diff --git a/tests/Trax.Core.Tests.Meta/Tests/CrossRepoPackageReferenceTests.cs b/tests/Trax.Core.Tests.Meta/Tests/CrossRepoPackageReferenceTests.cs new file mode 100644 index 0000000..be7847c --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/CrossRepoPackageReferenceTests.cs @@ -0,0 +1,100 @@ +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class CrossRepoPackageReferenceTests +{ + private static readonly HashSet TraxPackagePrefixes = new(StringComparer.Ordinal) + { + "Trax.Core", + "Trax.Effect", + "Trax.Mediator", + "Trax.Scheduler", + "Trax.Dashboard", + "Trax.Api", + "Trax.Cli", + "Trax.Samples", + }; + + [Test] + public void AllCrossRepo_TraxPackageReferences_Use_OnePointStar() + { + var thisRepoOwnPrefix = DetectThisRepoPrefix(); + var offenders = new List(); + + foreach (var csproj in SourceFiles.Projects()) + { + XDocument doc; + try + { + doc = XDocument.Load(csproj); + } + catch (Exception ex) + { + Assert.Fail($"Failed to parse {RepoRoot.Relative(csproj)}: {ex.Message}"); + return; + } + + foreach (var pkg in doc.Descendants("PackageReference")) + { + var include = pkg.Attribute("Include")?.Value; + var version = pkg.Attribute("Version")?.Value; + if (string.IsNullOrEmpty(include)) + continue; + + if (!IsTraxPackage(include)) + continue; + + // skip references to packages owned by the current repo itself + if ( + thisRepoOwnPrefix is not null + && include.StartsWith(thisRepoOwnPrefix, StringComparison.Ordinal) + ) + continue; + + if (version != "1.*") + { + offenders.Add( + $"{RepoRoot.Relative(csproj)} -> {include} Version=\"{version ?? ""}\"" + ); + } + } + } + + offenders + .Should() + .BeEmpty( + "CLAUDE.md > Local Development Workflow requires all cross-repo Trax PackageReferences " + + "to use Version=\"1.*\" so the local .nupkg/ feed (versioned 1.99.99) wins over nuget.org. " + + "Pinned or floating-minor versions silently bypass local packages. Offenders:\n " + + string.Join("\n ", offenders) + ); + } + + private static bool IsTraxPackage(string include) + { + foreach (var prefix in TraxPackagePrefixes) + { + if (include.Equals(prefix, StringComparison.Ordinal)) + return true; + if (include.StartsWith(prefix + ".", StringComparison.Ordinal)) + return true; + } + return false; + } + + /// + /// Detects the prefix owned by the current repo so we don't flag intra-repo PackageReferences + /// (which are rare but legal, e.g. an integration test referencing a sibling package). + /// + private static string? DetectThisRepoPrefix() + { + var slnx = Directory + .EnumerateFiles(RepoRoot.Path, "*.slnx", SearchOption.TopDirectoryOnly) + .FirstOrDefault(); + if (slnx is null) + return null; + var name = Path.GetFileNameWithoutExtension(slnx); + // .slnx file names are e.g. Trax.Core, Trax.Effect, Trax.Mediator, ... + return TraxPackagePrefixes.Contains(name) ? name : null; + } +} diff --git a/tests/Trax.Core.Tests.Meta/Tests/DirectoryBuildPropsVersionTests.cs b/tests/Trax.Core.Tests.Meta/Tests/DirectoryBuildPropsVersionTests.cs new file mode 100644 index 0000000..835ead7 --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/DirectoryBuildPropsVersionTests.cs @@ -0,0 +1,34 @@ +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class DirectoryBuildPropsVersionTests +{ + [Test] + public void Repo_HasDirectoryBuildProps_AtRoot() + { + var path = RepoRoot.Combine("Directory.Build.props"); + File.Exists(path) + .Should() + .BeTrue( + $"every Trax repo must have a Directory.Build.props at the repo root; none found at '{path}'." + ); + } + + [Test] + public void DirectoryBuildProps_Version_IsLocalDevSentinel() + { + var path = RepoRoot.Combine("Directory.Build.props"); + var doc = XDocument.Load(path); + var version = doc.Root!.Descendants("Version").FirstOrDefault()?.Value; + + version + .Should() + .Be( + "1.99.99", + "Directory.Build.props is locked at 1.99.99 for local development. " + + "CI overrides this via -p:Version= from semantic-release. " + + "Changing it breaks the nuget.config local-feed wins-over-nuget.org guarantee " + + "and can ship a malformed version. See CLAUDE.md > Versioning Strategy." + ); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Tests/ExtensionMethodNamingTests.cs b/tests/Trax.Core.Tests.Meta/Tests/ExtensionMethodNamingTests.cs new file mode 100644 index 0000000..fede4d2 --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/ExtensionMethodNamingTests.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class ExtensionMethodNamingTests +{ + private static readonly HashSet HostingTypes = new(StringComparer.Ordinal) + { + "IServiceCollection", + "IApplicationBuilder", + "IEndpointRouteBuilder", + "WebApplication", + "WebApplicationBuilder", + }; + + /// + /// Method names exempt from the Trax-naming convention. Each entry must justify why. + /// + private static readonly HashSet KnownExceptions = new(StringComparer.Ordinal) + { + // Legacy compat shim from before the AddTrax fluent API; called internally by AddMediator. + // Kept public for users still on the old API. + "AddServiceTrainBus", + }; + + [Test] + public void Public_Extension_Methods_In_ExtensionsFolders_Contain_TraxInName() + { + var offenders = new List(); + + foreach (var file in SourceFiles.CSharp("src")) + { + if ( + !file.Contains( + $"{Path.DirectorySeparatorChar}Extensions{Path.DirectorySeparatorChar}", + StringComparison.Ordinal + ) + ) + continue; + + var tree = CSharpSyntaxTree.ParseText(File.ReadAllText(file)); + var root = tree.GetCompilationUnitRoot(); + + foreach (var method in root.DescendantNodes().OfType()) + { + if (!method.Modifiers.Any(m => m.IsKind(SyntaxKind.PublicKeyword))) + continue; + if (!method.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword))) + continue; + + var name = method.Identifier.Text; + if ( + !name.StartsWith("Add", StringComparison.Ordinal) + && !name.StartsWith("Use", StringComparison.Ordinal) + ) + continue; + + var firstParam = method.ParameterList.Parameters.FirstOrDefault(); + if (firstParam is null) + continue; + if (!firstParam.Modifiers.Any(m => m.IsKind(SyntaxKind.ThisKeyword))) + continue; + + var paramType = firstParam.Type?.ToString(); + if (paramType is null) + continue; + + if (!HostingTypes.Contains(paramType)) + continue; + if (name.Contains("Trax", StringComparison.Ordinal)) + continue; + if (KnownExceptions.Contains(name)) + continue; + + offenders.Add($"{RepoRoot.Relative(file)} -> {name} (extends {paramType})"); + } + } + + offenders + .Should() + .BeEmpty( + "CLAUDE.md > Extension Method Naming Convention requires public Add*/Use* extensions " + + "on IServiceCollection / IApplicationBuilder / WebApplication / " + + "IEndpointRouteBuilder / WebApplicationBuilder declared in any src/*/Extensions/ " + + "folder to contain 'Trax' in the method name (e.g. AddTraxApi, UseTraxDashboard, " + + "AddScopedTraxRoute). If a method is intentionally exempt, add it to " + + "ExtensionMethodNamingTests.KnownExceptions with a justification. Offenders:\n " + + string.Join("\n ", offenders) + ); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Tests/NoFixedTaskDelayTests.cs b/tests/Trax.Core.Tests.Meta/Tests/NoFixedTaskDelayTests.cs new file mode 100644 index 0000000..c69fedb --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/NoFixedTaskDelayTests.cs @@ -0,0 +1,71 @@ +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class NoFixedTaskDelayTests +{ + // Matches Task.Delay(...) and Thread.Sleep(...). + private static readonly Regex DelayCall = new( + @"\b(Task\.Delay|Thread\.Sleep)\s*\(", + RegexOptions.Compiled + ); + + // A justification comment must appear on the same line or within the preceding 3 lines. + // We look for any of these tokens (case-insensitive): + // determinism:, allowed-delay:, measuring-interval:, negative-wait: + private static readonly Regex Justification = new( + @"(?i)(determinism:|allowed-delay:|measuring-interval:|negative-wait:)", + RegexOptions.Compiled + ); + + [Test] + public void TestSources_DoNotUse_FixedDelays_WithoutJustification() + { + var offenders = new List(); + + foreach (var file in SourceFiles.CSharp("tests")) + { + if (file.EndsWith("NoFixedTaskDelayTests.cs", StringComparison.Ordinal)) + continue; + + var raw = File.ReadAllText(file); + var lines = raw.Replace("\r\n", "\n").Split('\n'); + var stripped = SourceText.StripCommentsAndStrings(raw); + var strippedLines = stripped.Replace("\r\n", "\n").Split('\n'); + + for (var i = 0; i < strippedLines.Length; i++) + { + if (!DelayCall.IsMatch(strippedLines[i])) + continue; + + if (HasJustification(lines, i)) + continue; + + offenders.Add($"{RepoRoot.Relative(file)}:{i + 1} -> {lines[i].Trim()}"); + } + } + + offenders + .Should() + .BeEmpty( + "CLAUDE.md > Determinism forbids fixed-duration Task.Delay / Thread.Sleep in tests " + + "because they race CI scheduling. Synchronise on the actual completion signal " + + "(poll a flag, TaskCompletionSource, etc.) with a generous timeout ceiling. " + + "If a fixed delay is legitimately required (measuring an interval, verifying a " + + "negative outcome that requires a duration to elapse), add a justification comment " + + "containing 'determinism:', 'allowed-delay:', 'measuring-interval:', or 'negative-wait:' " + + "on the same line or up to 3 lines above. Offenders:\n " + + string.Join("\n ", offenders) + ); + } + + private static bool HasJustification(string[] lines, int delayLineIndex) + { + var from = Math.Max(0, delayLineIndex - 3); + for (var j = from; j <= delayLineIndex; j++) + { + if (Justification.IsMatch(lines[j])) + return true; + } + return false; + } +} diff --git a/tests/Trax.Core.Tests.Meta/Tests/NoIgnoreAttributeTests.cs b/tests/Trax.Core.Tests.Meta/Tests/NoIgnoreAttributeTests.cs new file mode 100644 index 0000000..28a2f2b --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/NoIgnoreAttributeTests.cs @@ -0,0 +1,38 @@ +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class NoIgnoreAttributeTests +{ + private static readonly Regex IgnoreAttribute = new( + @"\[\s*Ignore(\s*\(|\s*\])", + RegexOptions.Compiled + ); + + [Test] + public void TestSources_DoNotUse_IgnoreAttribute() + { + var offenders = new List(); + + foreach (var file in SourceFiles.CSharp("tests")) + { + // self-skip; this file mentions [Ignore] in a string literal + if (file.EndsWith("NoIgnoreAttributeTests.cs", StringComparison.Ordinal)) + continue; + + var content = File.ReadAllText(file); + var stripped = SourceText.StripCommentsAndStrings(content); + var hits = SourceText.MatchingLines(stripped, IgnoreAttribute); + foreach (var (line, _) in hits) + offenders.Add($"{RepoRoot.Relative(file)}:{line}"); + } + + offenders + .Should() + .BeEmpty( + "[Ignore] silently hides failing tests. CLAUDE.md > No [Ignore] requires either " + + "fixing the underlying code, fixing the test premise, or using Assert.Ignore(\"reason\") " + + "at runtime with an explicit reachability check. Offenders:\n " + + string.Join("\n ", offenders) + ); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Tests/NoLegacyAssertTests.cs b/tests/Trax.Core.Tests.Meta/Tests/NoLegacyAssertTests.cs new file mode 100644 index 0000000..c653cc2 --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/NoLegacyAssertTests.cs @@ -0,0 +1,53 @@ +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class NoLegacyAssertTests +{ + private static readonly (string Name, Regex Pattern)[] LegacyPatterns = new[] + { + ("Assert.That", new Regex(@"\bAssert\.That\b", RegexOptions.Compiled)), + ("Assert.AreEqual", new Regex(@"\bAssert\.AreEqual\b", RegexOptions.Compiled)), + ("Assert.AreNotEqual", new Regex(@"\bAssert\.AreNotEqual\b", RegexOptions.Compiled)), + ("Assert.AreSame", new Regex(@"\bAssert\.AreSame\b", RegexOptions.Compiled)), + ("Assert.AreNotSame", new Regex(@"\bAssert\.AreNotSame\b", RegexOptions.Compiled)), + ("Assert.IsTrue", new Regex(@"\bAssert\.IsTrue\b", RegexOptions.Compiled)), + ("Assert.IsFalse", new Regex(@"\bAssert\.IsFalse\b", RegexOptions.Compiled)), + ("Assert.IsNull", new Regex(@"\bAssert\.IsNull\b", RegexOptions.Compiled)), + ("Assert.IsNotNull", new Regex(@"\bAssert\.IsNotNull\b", RegexOptions.Compiled)), + ("Assert.IsEmpty", new Regex(@"\bAssert\.IsEmpty\b", RegexOptions.Compiled)), + ("Assert.IsNotEmpty", new Regex(@"\bAssert\.IsNotEmpty\b", RegexOptions.Compiled)), + ("Assert.Contains", new Regex(@"\bAssert\.Contains\b", RegexOptions.Compiled)), + }; + + [Test] + public void TestSources_UseOnly_FluentAssertions() + { + var offenders = new List(); + + foreach (var file in SourceFiles.CSharp("tests")) + { + if (file.EndsWith("NoLegacyAssertTests.cs", StringComparison.Ordinal)) + continue; + + var content = File.ReadAllText(file); + var stripped = SourceText.StripCommentsAndStrings(content); + + foreach (var (name, pattern) in LegacyPatterns) + { + var hits = SourceText.MatchingLines(stripped, pattern); + foreach (var (line, _) in hits) + offenders.Add($"{RepoRoot.Relative(file)}:{line} ({name})"); + } + } + + offenders + .Should() + .BeEmpty( + "CLAUDE.md > Naming Conventions requires FluentAssertions exclusively. " + + "Replace classic NUnit asserts (Assert.That, Assert.AreEqual, Assert.IsTrue, ...) with " + + ".Should().Be(...), .Should().BeTrue(), etc. " + + "Assert.Pass / Assert.Fail / Assert.Ignore remain acceptable. Offenders:\n " + + string.Join("\n ", offenders) + ); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Tests/PublicApiSurfaceTests.cs b/tests/Trax.Core.Tests.Meta/Tests/PublicApiSurfaceTests.cs new file mode 100644 index 0000000..57209c4 --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/PublicApiSurfaceTests.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using PublicApiGenerator; + +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class PublicApiSurfaceTests +{ + private static readonly string BaselineDir = Path.Combine( + AppContext.BaseDirectory, + "PublicApi" + ); + + private static readonly string BaselineSourceDir = Path.Combine( + Path.GetDirectoryName(typeof(PublicApiSurfaceTests).Assembly.Location)!, + "..", + "..", + "..", + "PublicApi" + ); + + public static IEnumerable Assemblies() + { + // Reference a single type from each in-scope assembly so it gets loaded. + yield return new TestCaseData(typeof(Trax.Core.Exceptions.TrainException).Assembly).SetName( + "Trax.Core" + ); + } + + [TestCaseSource(nameof(Assemblies))] + public void PublicApi_Matches_CheckedInBaseline(Assembly assembly) + { + var name = assembly.GetName().Name!; + var current = assembly.GeneratePublicApi( + new ApiGeneratorOptions { IncludeAssemblyAttributes = false } + ); + + var baselinePath = Path.Combine(BaselineDir, $"{name}.received.txt"); + + if (!File.Exists(baselinePath)) + { + // First run: write a baseline to the test bin/ output and to the source tree + // so the developer can commit it. Fail with a clear message. + Directory.CreateDirectory(BaselineDir); + File.WriteAllText(baselinePath, current); + try + { + Directory.CreateDirectory(BaselineSourceDir); + File.WriteAllText(Path.Combine(BaselineSourceDir, $"{name}.received.txt"), current); + } + catch + { + // best-effort write to source tree; fine if the working directory is read-only + } + Assert.Fail( + $"No public API baseline for '{name}'. A baseline has been written to " + + $"'PublicApi/{name}.received.txt' in the test source tree. Review it, commit it, " + + "and re-run." + ); + return; + } + + var baseline = File.ReadAllText(baselinePath); + + // Normalise line endings + trailing whitespace for cross-platform stability. + string Normalize(string s) => s.Replace("\r\n", "\n").TrimEnd() + "\n"; + + Normalize(current) + .Should() + .Be( + Normalize(baseline), + $"public API of '{name}' must match the checked-in baseline at " + + $"PublicApi/{name}.received.txt. If this change is intentional, update the baseline. " + + "Adding, removing, or changing a public type/member is a potential breaking change — " + + "the snapshot makes it deliberate. (CLAUDE.md > Versioning Strategy: a major version " + + "bump on NuGet is permanent.)" + ); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Tests/TestFolderLayoutTests.cs b/tests/Trax.Core.Tests.Meta/Tests/TestFolderLayoutTests.cs new file mode 100644 index 0000000..8e8269d --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Tests/TestFolderLayoutTests.cs @@ -0,0 +1,120 @@ +namespace Trax.Core.Tests.Meta.Tests; + +[TestFixture] +public class TestFolderLayoutTests +{ + private static readonly HashSet ForbiddenFolders = new(StringComparer.OrdinalIgnoreCase) + { + ".vs", + ".idea", + "node_modules", + "Junk", + "Tmp", + "Temp", + "Misc", + "Old", + "Legacy", + }; + + private static readonly HashSet SkipFolders = new(StringComparer.OrdinalIgnoreCase) + { + "bin", + "obj", + "TestResults", + "coverage", + }; + + private static readonly Regex PascalCaseFolder = new( + @"^[A-Z][A-Za-z0-9]*$", + RegexOptions.Compiled + ); + + private static readonly HashSet LowercaseExceptions = new(StringComparer.Ordinal) + { + "tests", + }; + + [Test] + public void TestProjects_DoNotContain_ForbiddenFolders() + { + var offenders = new List(); + + var testsRoot = RepoRoot.Combine("tests"); + if (!Directory.Exists(testsRoot)) + Assert.Inconclusive("No tests/ directory in this repo."); + + foreach (var projectDir in Directory.EnumerateDirectories(testsRoot)) + { + foreach (var sub in Directory.EnumerateDirectories(projectDir)) + { + var name = Path.GetFileName(sub); + if (ForbiddenFolders.Contains(name)) + offenders.Add($"{RepoRoot.Relative(sub)} (forbidden folder '{name}')"); + } + } + + offenders + .Should() + .BeEmpty( + "Test projects must not contain anti-pattern folder names. " + + "'Junk/'/'Misc/'/'Tmp/'/'Old/' signal poor organization. Offenders:\n " + + string.Join("\n ", offenders) + ); + } + + [Test] + public void TestProjects_TopLevelFolders_Are_PascalCase() + { + var offenders = new List(); + + var testsRoot = RepoRoot.Combine("tests"); + if (!Directory.Exists(testsRoot)) + Assert.Inconclusive("No tests/ directory in this repo."); + + foreach (var projectDir in Directory.EnumerateDirectories(testsRoot)) + { + foreach (var sub in Directory.EnumerateDirectories(projectDir)) + { + var name = Path.GetFileName(sub); + if (ForbiddenFolders.Contains(name)) + continue; + if (SkipFolders.Contains(name)) + continue; + if (LowercaseExceptions.Contains(name)) + continue; + if (!PascalCaseFolder.IsMatch(name)) + offenders.Add($"{RepoRoot.Relative(sub)} (folder name not PascalCase)"); + } + } + + offenders + .Should() + .BeEmpty( + "Top-level folders inside a test project must be PascalCase (matching the C# " + + "namespace convention, e.g. Fixtures/, Fakes/, IntegrationTests/, UnitTests/). " + + "snake_case or kebab-case at this level signals a strayed file. Offenders:\n " + + string.Join("\n ", offenders) + ); + } + + [Test] + public void Repo_Gitignore_Excludes_TestResults() + { + var gitignorePath = RepoRoot.Combine(".gitignore"); + File.Exists(gitignorePath).Should().BeTrue($"missing .gitignore at '{gitignorePath}'."); + + var content = File.ReadAllText(gitignorePath); + + var hasTestResults = + Regex.IsMatch(content, @"\bTestResults\b", RegexOptions.IgnoreCase) + || Regex.IsMatch(content, @"\[Tt\]est\[Rr\]esult"); + + hasTestResults + .Should() + .BeTrue( + "the repo .gitignore must exclude TestResults/ (dotnet test --collect output) so test " + + "run artifacts never get committed. Add a line like '[Tt]est[Rr]esult*/' or " + + "'TestResults/'." + ); + } +} diff --git a/tests/Trax.Core.Tests.Meta/Trax.Core.Tests.Meta.csproj b/tests/Trax.Core.Tests.Meta/Trax.Core.Tests.Meta.csproj new file mode 100644 index 0000000..3b63c6d --- /dev/null +++ b/tests/Trax.Core.Tests.Meta/Trax.Core.Tests.Meta.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/tests/Trax.Core.Tests.Unit/UnitTests/Train/CancellationTokenTests.cs b/tests/Trax.Core.Tests.Unit/UnitTests/Train/CancellationTokenTests.cs index 084a2e8..1d39a82 100644 --- a/tests/Trax.Core.Tests.Unit/UnitTests/Train/CancellationTokenTests.cs +++ b/tests/Trax.Core.Tests.Unit/UnitTests/Train/CancellationTokenTests.cs @@ -263,6 +263,9 @@ private class SlowJunction : Junction { public override async Task Run(string input) { + // determinism: this delay exists to be cancelled by the supplied CancellationToken. + // The test verifies that cancelling the token shortcuts the delay, so the duration + // is an upper bound, not a fixed wait. await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken); return input; } diff --git a/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs b/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs index 9a3116e..613286c 100644 --- a/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs +++ b/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FluentAssertions; using LanguageExt; using LanguageExt.UnsafeValueAccess; using NUnit.Framework; @@ -39,123 +40,97 @@ public override Task Run(string input) [Test] public async Task RailwayJunction_WhenExceptionContainsJson_OriginalMessagePreserved() { - // Arrange var junction = new TestJunctionWithJsonException(); var input = Either.Right("test input"); var train = new DummyTrain(); - // Act var result = await junction.RailwayJunction(input, train); - // Assert - Assert.That( - result.IsLeft, - Is.True, - "Expected the junction to fail and return Left(Exception)" - ); + result.IsLeft.Should().BeTrue("the junction should fail and return Left(Exception)"); var exception = result.Swap().ValueUnsafe(); // The original message should be preserved (not replaced with TrainExceptionData JSON) - Assert.That(exception.Message, Does.Contain("\"success\":false")); - Assert.That(exception.Message, Does.Contain("\"referenceId\":\"reference-me2\"")); + exception.Message.Should().Contain("\"success\":false"); + exception.Message.Should().Contain("\"referenceId\":\"reference-me2\""); } [Test] public async Task RailwayJunction_WhenExceptionContainsJson_ExceptionDataAvailable() { - // Arrange var junction = new TestJunctionWithJsonException(); var input = Either.Right("test input"); var train = new DummyTrain(); - // Act var result = await junction.RailwayJunction(input, train); - // Assert var exception = result.Swap().ValueUnsafe(); - // Structured data should be available via Exception.Data var data = exception.Data["TrainExceptionData"] as TrainExceptionData; - Assert.That(data, Is.Not.Null); - Assert.That(data!.Junction, Is.EqualTo("TestJunctionWithJsonException")); - Assert.That(data.Type, Is.EqualTo("InvalidOperationException")); - Assert.That(data.Message, Does.Contain("\"success\":false")); + data.Should().NotBeNull(); + data!.Junction.Should().Be("TestJunctionWithJsonException"); + data.Type.Should().Be("InvalidOperationException"); + data.Message.Should().Contain("\"success\":false"); } [Test] public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_OriginalMessagePreserved() { - // Arrange var junction = new TestJunctionWithSpecialCharacters(); var input = Either.Right("test input"); var train = new DummyTrain(); - // Act var result = await junction.RailwayJunction(input, train); - // Assert var exception = result.Swap().ValueUnsafe(); - // The original message should be preserved - Assert.That(exception.Message, Does.Contain("quotes")); - Assert.That(exception.Message, Does.Contain("newlines")); - Assert.That(exception.Message, Does.Contain("backslashes")); + exception.Message.Should().Contain("quotes"); + exception.Message.Should().Contain("newlines"); + exception.Message.Should().Contain("backslashes"); } [Test] public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_ExceptionDataAvailable() { - // Arrange var junction = new TestJunctionWithSpecialCharacters(); var input = Either.Right("test input"); var train = new DummyTrain(); - // Act var result = await junction.RailwayJunction(input, train); - // Assert var exception = result.Swap().ValueUnsafe(); - // Structured data should be available via Exception.Data var data = exception.Data["TrainExceptionData"] as TrainExceptionData; - Assert.That(data, Is.Not.Null); - Assert.That(data!.Junction, Is.EqualTo("TestJunctionWithSpecialCharacters")); - Assert.That(data.Type, Is.EqualTo("InvalidOperationException")); - - // Original message preserved in the data object - Assert.That(data.Message, Does.Contain("quotes")); - Assert.That(data.Message, Does.Contain("newlines")); - Assert.That(data.Message, Does.Contain("backslashes")); + data.Should().NotBeNull(); + data!.Junction.Should().Be("TestJunctionWithSpecialCharacters"); + data.Type.Should().Be("InvalidOperationException"); + + data.Message.Should().Contain("quotes"); + data.Message.Should().Contain("newlines"); + data.Message.Should().Contain("backslashes"); } [Test] public async Task RailwayJunction_ExceptionDataMessage_CanBeSerializedToValidJson() { - // Arrange var junction = new TestJunctionWithJsonException(); var input = Either.Right("test input"); var train = new DummyTrain(); - // Act var result = await junction.RailwayJunction(input, train); - // Assert — the TrainExceptionData can be serialized to valid JSON var exception = result.Swap().ValueUnsafe(); var data = exception.Data["TrainExceptionData"] as TrainExceptionData; - Assert.That(data, Is.Not.Null); + data.Should().NotBeNull(); var json = JsonSerializer.Serialize(data); - Assert.That( - IsValidJson(json), - Is.True, - $"Serialized TrainExceptionData should be valid JSON: {json}" - ); + IsValidJson(json) + .Should() + .BeTrue($"serialized TrainExceptionData should be valid JSON: {json}"); - // Round-trip: deserialize and verify the original JSON message survives var roundTripped = JsonSerializer.Deserialize(json); - Assert.That(roundTripped, Is.Not.Null); - Assert.That(roundTripped!.Message, Does.Contain("\"success\":false")); + roundTripped.Should().NotBeNull(); + roundTripped!.Message.Should().Contain("\"success\":false"); } /// @@ -171,9 +146,6 @@ public override Task Run(string input) } } - /// - /// Helper method to check if a string is valid JSON. - /// private static bool IsValidJson(string jsonString) { try