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