diff --git a/Trax.Core.slnx b/Trax.Core.slnx index 6ee19ef..fa22ffd 100644 --- a/Trax.Core.slnx +++ b/Trax.Core.slnx @@ -1,6 +1,7 @@ + @@ -11,5 +12,6 @@ + diff --git a/src/Trax.Core.Testing/ArchitectureGuardOptions.cs b/src/Trax.Core.Testing/ArchitectureGuardOptions.cs new file mode 100644 index 0000000..2de1aea --- /dev/null +++ b/src/Trax.Core.Testing/ArchitectureGuardOptions.cs @@ -0,0 +1,46 @@ +namespace Trax.Core.Testing; + +/// +/// Configuration for the architecture-guard checkers. Defaults match the Trax conventions; a consumer +/// overrides only what differs (scan roots, allowlists, expected versions). Allowlist paths are +/// repo-relative and use forward slashes. +/// +public sealed record ArchitectureGuardOptions +{ + /// + /// Overrides the repository root the guards scan. Defaults to the auto-detected root (walk up to a + /// *.slnx). Set this only to point guards at a specific tree (primarily for testing the + /// guards themselves against a synthetic fixture directory). + /// + public string? RepoRootOverride { get; init; } + + /// Top-level folders containing test code (scanned by the test-hygiene guards). + public IReadOnlyList TestScanRoots { get; init; } = ["tests"]; + + /// + /// Top-level folders containing production source (scanned by the data-layer / GraphQL / train + /// convention guards). Defaults to src; a consumer overrides (e.g. ["samples", "lib"]). + /// + public IReadOnlyList SourceScanRoots { get; init; } = ["src"]; + + /// Files exempt from the no-[Ignore] guard (each should carry a justification in source). + public IReadOnlySet NoIgnoreKnownExceptions { get; init; } = + new HashSet(StringComparer.Ordinal); + + /// Files exempt from the no-fixed-delay guard. + public IReadOnlySet FixedDelayKnownExceptions { get; init; } = + new HashSet(StringComparer.Ordinal); + + /// The exact <Version> the root Directory.Build.props must declare for local dev. + public string ExpectedDirectoryBuildPropsVersion { get; init; } = "1.99.99"; + + /// Package-name prefix treated as a cross-repo Trax dependency. + public string TraxPackagePrefix { get; init; } = "Trax."; + + /// The floating version every cross-repo Trax package reference must use. + public string ExpectedTraxPackageVersion { get; init; } = "1.*"; + + /// Project files exempt from the cross-repo package-version guard. + public IReadOnlySet CrossRepoPackageKnownExceptions { get; init; } = + new HashSet(StringComparer.Ordinal); +} diff --git a/src/Trax.Core.Testing/Fixtures/HygieneGuardFixtures.cs b/src/Trax.Core.Testing/Fixtures/HygieneGuardFixtures.cs new file mode 100644 index 0000000..8e57607 --- /dev/null +++ b/src/Trax.Core.Testing/Fixtures/HygieneGuardFixtures.cs @@ -0,0 +1,75 @@ +using NUnit.Framework; +using Trax.Core.Testing.Guards; + +// The [Test] method names are the documentation; XML doc comments on them would be pure redundancy. +#pragma warning disable CS1591 + +namespace Trax.Core.Testing.Fixtures; + +/// +/// Pre-written test-hygiene guards. A consumer subclasses this, overrides if the +/// defaults do not fit, and runs dotnet test; the inherited [Test] methods are discovered +/// in the consumer's assembly. No test bodies to write. +/// +/// +/// Example: +/// +/// [TestFixture] +/// public sealed class MyHygieneGuards : HygieneGuardFixture +/// { +/// protected override ArchitectureGuardOptions Options => new() { TestScanRoots = ["tests"] }; +/// } +/// +/// +[TestFixture] +public abstract class HygieneGuardFixture +{ + /// Guard configuration. Defaults to scanning tests/; override to change roots or allowlists. + protected virtual ArchitectureGuardOptions Options => new(); + + [Test] + public void Tests_do_not_use_the_Ignore_attribute() + { + var result = HygieneGuards.NoIgnoreAttribute(Options); + Assert.That(result.Offenders, Is.Empty, result.FailureMessage); + } + + [Test] + public void Tests_do_not_use_legacy_asserts() + { + var result = HygieneGuards.NoLegacyAsserts(Options); + Assert.That(result.Offenders, Is.Empty, result.FailureMessage); + } + + [Test] + public void Tests_do_not_use_fixed_delays() + { + var result = HygieneGuards.NoFixedDelays(Options); + Assert.That(result.Offenders, Is.Empty, result.FailureMessage); + } +} + +/// +/// Pre-written repo-structure guards (Directory.Build.props version, cross-repo package +/// versions). Subclass and override as needed. +/// +[TestFixture] +public abstract class RepoConventionGuardFixture +{ + /// Guard configuration. Override to change the expected versions or package prefix. + protected virtual ArchitectureGuardOptions Options => new(); + + [Test] + public void Directory_build_props_pins_the_expected_version() + { + var result = RepoConventionGuards.DirectoryBuildPropsVersion(Options); + Assert.That(result.Offenders, Is.Empty, result.FailureMessage); + } + + [Test] + public void Cross_repo_package_references_use_the_floating_version() + { + var result = RepoConventionGuards.CrossRepoPackageVersions(Options); + Assert.That(result.Offenders, Is.Empty, result.FailureMessage); + } +} diff --git a/src/Trax.Core.Testing/GuardResult.cs b/src/Trax.Core.Testing/GuardResult.cs new file mode 100644 index 0000000..56acc7e --- /dev/null +++ b/src/Trax.Core.Testing/GuardResult.cs @@ -0,0 +1,23 @@ +namespace Trax.Core.Testing; + +/// +/// The outcome of an architecture-guard check. A guard returns the offenders it found, how many items +/// it inspected (so a misconfigured scan can't silently pass), and a ready-to-use failure message. +/// +/// Repo-relative offender descriptions (often path:line (reason)). +/// How many candidate items the guard examined. +/// A message explaining the rule and how to fix a violation, with the offender list appended. +/// +/// Consumers assert on this with their own test framework, e.g. +/// result.Offenders.Should().BeEmpty(result.FailureMessage) and, where a guard must find work, +/// result.Inspected.Should().BeGreaterThan(0). +/// +public sealed record GuardResult( + IReadOnlyList Offenders, + int Inspected, + string FailureMessage +) +{ + /// True when no offenders were found. + public bool Passed => Offenders.Count == 0; +} diff --git a/src/Trax.Core.Testing/Guards/HygieneGuards.cs b/src/Trax.Core.Testing/Guards/HygieneGuards.cs new file mode 100644 index 0000000..6f3630e --- /dev/null +++ b/src/Trax.Core.Testing/Guards/HygieneGuards.cs @@ -0,0 +1,171 @@ +using System.Text.RegularExpressions; +using Trax.Core.Testing.Infrastructure; + +namespace Trax.Core.Testing.Guards; + +/// +/// Test-hygiene guard checkers. Each scans the configured test roots and returns a +/// ; the consumer asserts Offenders is empty with its own framework. +/// +public static class HygieneGuards +{ + // Matches an [Ignore] attribute whether standalone ([Ignore] / [Ignore("...")]) or combined with + // others ([Test, Ignore(...)]), i.e. preceded by an open bracket or a comma. + private static readonly Regex IgnoreAttribute = new( + @"(?:\[|,)\s*Ignore(\s*\(|\s*\])", + RegexOptions.Compiled + ); + + private static readonly (string Name, Regex Pattern)[] LegacyAssertPatterns = + [ + ("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)), + ]; + + private static readonly Regex FixedDelay = new( + @"\b(Task\.Delay|Thread\.Sleep)\s*\(", + RegexOptions.Compiled + ); + + private static readonly string[] DelayJustifications = + [ + "determinism:", + "allowed-delay:", + "measuring-interval:", + "negative-wait:", + ]; + + /// Flags [Ignore] attributes in test sources (they silently hide failures). + public static GuardResult NoIgnoreAttribute(ArchitectureGuardOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var root = options.RepoRootOverride ?? RepoRoot.Path; + var offenders = new List(); + var inspected = 0; + + foreach (var file in SourceFiles.CSharpUnder(root, [.. options.TestScanRoots])) + { + inspected++; + var rel = Rel(root, file); + if (options.NoIgnoreKnownExceptions.Contains(rel)) + continue; + + var stripped = SourceText.StripCommentsAndStrings(File.ReadAllText(file)); + foreach (var (line, _) in SourceText.MatchingLines(stripped, IgnoreAttribute)) + offenders.Add($"{rel}:{line}"); + } + + var message = + "[Ignore] silently hides failing tests. Fix the underlying code or the test premise, or " + + "use Assert.Ignore(\"reason\") at runtime with a reachability check. If a file must be " + + "opt-in via [Ignore] (e.g. a placeholder gated on an upstream feature), add it to " + + "NoIgnoreKnownExceptions with a justification. Offenders:\n " + + string.Join("\n ", offenders); + + return new GuardResult(offenders, inspected, message); + } + + /// Flags classic NUnit asserts in test sources (the convention is one assertion library, exclusively). + public static GuardResult NoLegacyAsserts(ArchitectureGuardOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var root = options.RepoRootOverride ?? RepoRoot.Path; + var offenders = new List(); + var inspected = 0; + + foreach (var file in SourceFiles.CSharpUnder(root, [.. options.TestScanRoots])) + { + inspected++; + var rel = Rel(root, file); + var stripped = SourceText.StripCommentsAndStrings(File.ReadAllText(file)); + + foreach (var (name, pattern) in LegacyAssertPatterns) + { + foreach (var (line, _) in SourceText.MatchingLines(stripped, pattern)) + offenders.Add($"{rel}:{line} ({name})"); + } + } + + var message = + "Use the project's chosen assertion library exclusively. Replace classic NUnit asserts " + + "with the fluent equivalents. Assert.Pass / Assert.Fail / Assert.Ignore remain " + + "acceptable. Offenders:\n " + + string.Join("\n ", offenders); + + return new GuardResult(offenders, inspected, message); + } + + /// + /// Flags fixed-duration Task.Delay / Thread.Sleep in test sources unless the line + /// (or up to three lines above) carries a justification marker, or the file is allowlisted. + /// + public static GuardResult NoFixedDelays(ArchitectureGuardOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var root = options.RepoRootOverride ?? RepoRoot.Path; + var offenders = new List(); + var inspected = 0; + + foreach (var file in SourceFiles.CSharpUnder(root, [.. options.TestScanRoots])) + { + inspected++; + var rel = Rel(root, file); + if (options.FixedDelayKnownExceptions.Contains(rel)) + continue; + + var raw = File.ReadAllText(file).Replace("\r\n", "\n").Split('\n'); + var stripped = SourceText + .StripCommentsAndStrings(File.ReadAllText(file)) + .Replace("\r\n", "\n") + .Split('\n'); + + for (var i = 0; i < stripped.Length && i < raw.Length; i++) + { + if (!FixedDelay.IsMatch(stripped[i])) + continue; + if (HasJustification(raw, i)) + continue; + offenders.Add($"{rel}:{i + 1} -> {raw[i].Trim()}"); + } + } + + var message = + "Fixed-duration Task.Delay / Thread.Sleep make tests flaky. Synchronise on the completion " + + "signal (TaskCompletionSource, polling) with a generous timeout. If a fixed delay is " + + "genuinely required, add a same-line or up-to-3-lines-above comment containing one of: " + + string.Join(", ", DelayJustifications) + + ". Offenders:\n " + + string.Join("\n ", offenders); + + return new GuardResult(offenders, inspected, message); + } + + private static string Rel(string root, string file) => + Path.GetRelativePath(root, file).Replace('\\', '/'); + + private static bool HasJustification(string[] rawLines, int delayLineIndex) + { + var start = Math.Max(0, delayLineIndex - 3); + for (var j = start; j <= delayLineIndex && j < rawLines.Length; j++) + { + var lower = rawLines[j].ToLowerInvariant(); + foreach (var marker in DelayJustifications) + { + if (lower.Contains(marker, StringComparison.Ordinal)) + return true; + } + } + + return false; + } +} diff --git a/src/Trax.Core.Testing/Guards/RepoConventionGuards.cs b/src/Trax.Core.Testing/Guards/RepoConventionGuards.cs new file mode 100644 index 0000000..bdc5ef3 --- /dev/null +++ b/src/Trax.Core.Testing/Guards/RepoConventionGuards.cs @@ -0,0 +1,104 @@ +using System.Xml.Linq; +using Trax.Core.Testing.Infrastructure; + +namespace Trax.Core.Testing.Guards; + +/// +/// Repo-structure / packaging guard checkers (read .csproj and Directory.Build.props). +/// +public static class RepoConventionGuards +{ + /// + /// Asserts the root Directory.Build.props pins <Version> to the expected + /// local-dev value, so locally-packed feed packages always win over nuget.org. + /// + public static GuardResult DirectoryBuildPropsVersion(ArchitectureGuardOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var root = options.RepoRootOverride ?? RepoRoot.Path; + var path = Path.Combine(root, "Directory.Build.props"); + var offenders = new List(); + + if (!File.Exists(path)) + { + offenders.Add("Directory.Build.props (missing at repo root)"); + } + else + { + var version = XDocument + .Load(path) + .Descendants("Version") + .FirstOrDefault() + ?.Value.Trim(); + + if (version != options.ExpectedDirectoryBuildPropsVersion) + offenders.Add( + $"Directory.Build.props is '{version ?? ""}', expected " + + $"'{options.ExpectedDirectoryBuildPropsVersion}'" + ); + } + + var message = + $"The root Directory.Build.props must be '{options.ExpectedDirectoryBuildPropsVersion}' " + + "for local development; CI overrides it via -p:Version=. Changing it breaks the " + + "local-feed-wins-over-nuget.org guarantee. Offenders:\n " + + string.Join("\n ", offenders); + + return new GuardResult(offenders, 1, message); + } + + /// + /// Asserts every cross-repo Trax package reference uses the expected floating version, so local + /// feed builds resolve consistently. + /// + public static GuardResult CrossRepoPackageVersions(ArchitectureGuardOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var root = options.RepoRootOverride ?? RepoRoot.Path; + var offenders = new List(); + var inspected = 0; + + foreach (var csproj in SourceFiles.ProjectsUnder(root)) + { + inspected++; + var rel = Path.GetRelativePath(root, csproj).Replace('\\', '/'); + if (options.CrossRepoPackageKnownExceptions.Contains(rel)) + continue; + + XDocument doc; + try + { + doc = XDocument.Load(csproj); + } + catch (Exception ex) + { + offenders.Add($"{rel} (could not parse: {ex.Message})"); + continue; + } + + foreach (var reference in doc.Descendants("PackageReference")) + { + var include = reference.Attribute("Include")?.Value; + if ( + include is null + || !include.StartsWith(options.TraxPackagePrefix, StringComparison.Ordinal) + ) + continue; + + var version = + reference.Attribute("Version")?.Value ?? reference.Element("Version")?.Value; + + if (version != options.ExpectedTraxPackageVersion) + offenders.Add($"{rel} -> {include} Version=\"{version ?? ""}\""); + } + } + + var message = + $"Cross-repo Trax package references (Include starts with '{options.TraxPackagePrefix}') must use " + + $"Version=\"{options.ExpectedTraxPackageVersion}\" so the local feed resolves correctly. " + + "Offenders:\n " + + string.Join("\n ", offenders); + + return new GuardResult(offenders, inspected, message); + } +} diff --git a/src/Trax.Core.Testing/Infrastructure/RepoRoot.cs b/src/Trax.Core.Testing/Infrastructure/RepoRoot.cs new file mode 100644 index 0000000..bf7afbf --- /dev/null +++ b/src/Trax.Core.Testing/Infrastructure/RepoRoot.cs @@ -0,0 +1,36 @@ +namespace Trax.Core.Testing.Infrastructure; + +/// +/// Locates the repository root by walking up from the test assembly's base directory until it finds a +/// directory containing a *.slnx solution file. Architecture guards scan the tree from here. +/// +public static class RepoRoot +{ + private static readonly Lazy Cached = new(Resolve); + + /// The absolute path of the repository root (the directory containing a *.slnx). + public static string Path => Cached.Value; + + /// Combines path segments onto the repository root. + public static string Combine(params string[] segments) => + System.IO.Path.Combine([Path, .. segments]); + + /// Returns as a path relative to the repository root. + 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/src/Trax.Core.Testing/Infrastructure/SourceFiles.cs b/src/Trax.Core.Testing/Infrastructure/SourceFiles.cs new file mode 100644 index 0000000..e3c625f --- /dev/null +++ b/src/Trax.Core.Testing/Infrastructure/SourceFiles.cs @@ -0,0 +1,60 @@ +namespace Trax.Core.Testing.Infrastructure; + +/// +/// Enumerates source files under the repository root, excluding build output and dependency +/// directories. Pass subdirectories to scope the scan (e.g. "src", "tests"); pass none +/// to scan the whole repo. +/// +public static class SourceFiles +{ + /// Enumerates *.cs files under the given subdirectories of the detected repo root (or the whole repo). + public static IEnumerable CSharp(params string[] subdirs) => + CSharpUnder(RepoRoot.Path, subdirs); + + /// Enumerates *.csproj files under the detected repo root. + public static IEnumerable Projects(params string[] subdirs) => + ProjectsUnder(RepoRoot.Path, subdirs); + + /// Enumerates *.md files under the detected repo root. + public static IEnumerable Markdown(params string[] subdirs) => + Enumerate(RepoRoot.Path, "*.md", subdirs); + + /// Enumerates *.cs files under an explicit root (for testing or custom roots). + public static IEnumerable CSharpUnder(string root, params string[] subdirs) => + Enumerate(root, "*.cs", subdirs); + + /// Enumerates *.csproj files under an explicit root (for testing or custom roots). + public static IEnumerable ProjectsUnder(string root, params string[] subdirs) => + Enumerate(root, "*.csproj", subdirs); + + private static IEnumerable Enumerate(string repoRoot, string pattern, string[] subdirs) + { + var roots = + subdirs.Length == 0 + ? [repoRoot] + : subdirs.Select(s => Path.Combine(repoRoot, 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)) + 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/src/Trax.Core.Testing/Infrastructure/SourceText.cs b/src/Trax.Core.Testing/Infrastructure/SourceText.cs new file mode 100644 index 0000000..8b54a55 --- /dev/null +++ b/src/Trax.Core.Testing/Infrastructure/SourceText.cs @@ -0,0 +1,59 @@ +using System.Text.RegularExpressions; + +namespace Trax.Core.Testing.Infrastructure; + +/// +/// Helpers for scanning C# source as text: stripping comments and string literals before a regex +/// match (so a pattern doesn't false-positive on a comment or string), and reporting matching lines. +/// +public 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 + ); + + /// + /// Removes block/line comments and string literals (replacing strings with "") so a + /// keyword scan ignores commented-out or quoted occurrences. + /// + 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; + } + + /// Returns the 1-based line number and text of every line in matching . + 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/src/Trax.Core.Testing/Trax.Core.Testing.csproj b/src/Trax.Core.Testing/Trax.Core.Testing.csproj new file mode 100644 index 0000000..ce26303 --- /dev/null +++ b/src/Trax.Core.Testing/Trax.Core.Testing.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + Trax.Core.Testing + Theauxm,mark-keaton + Reusable architecture-guard infrastructure and hygiene checkers for Trax codebases, with NUnit base fixtures: subclass a fixture, supply options, and run dotnet test (no test bodies to write). The underlying checkers return offender lists if you prefer your own framework. + trax;testing;architecture;guards;conventions;nunit + https://github.com/TraxSharp/Trax.Core + true + + + + + + + diff --git a/tests/Trax.Core.Testing.Tests/FixturesAndInfraTests.cs b/tests/Trax.Core.Testing.Tests/FixturesAndInfraTests.cs new file mode 100644 index 0000000..849b6b9 --- /dev/null +++ b/tests/Trax.Core.Testing.Tests/FixturesAndInfraTests.cs @@ -0,0 +1,85 @@ +using Trax.Core.Testing; +using Trax.Core.Testing.Guards; +using Trax.Core.Testing.Infrastructure; + +namespace Trax.Core.Testing.Tests; + +/// +/// Direct coverage for the repo-root resolution and the source-file / repo-convention edge branches +/// the checker tests do not reach. The NUnit base fixtures themselves are covered by the self-test +/// fixtures in SelfTestFixtures.cs. +/// +[TestFixture] +public class FixturesAndInfraTests +{ + #region RepoRoot + + [Test] + public void RepoRoot_resolves_to_a_directory_containing_a_solution() + { + var root = RepoRoot.Path; + + Directory + .EnumerateFiles(root, "*.slnx") + .Should() + .NotBeEmpty("RepoRoot walks up to the directory holding the .slnx"); + } + + [Test] + public void RepoRoot_Combine_and_Relative_round_trip() + { + var absolute = RepoRoot.Combine("src", "Example.cs"); + + RepoRoot.Relative(absolute).Replace('\\', '/').Should().Be("src/Example.cs"); + } + + #endregion + + #region SourceFiles + + [Test] + public void SourceFiles_excludes_build_output() + { + using var repo = new TempRepo() + .Write("src/A.cs", "// a") + .Write("src/bin/B.cs", "// b") + .Write("src/obj/C.cs", "// c"); + + var names = SourceFiles.CSharpUnder(repo.Root, "src").Select(Path.GetFileName).ToList(); + + names.Should().Contain("A.cs"); + names.Should().NotContain("B.cs"); + names.Should().NotContain("C.cs"); + } + + #endregion + + #region RepoConventionGuards edge branches + + [Test] + public void DirectoryBuildPropsVersion_flags_a_missing_file() + { + using var repo = new TempRepo(); + + var result = RepoConventionGuards.DirectoryBuildPropsVersion( + new() { RepoRootOverride = repo.Root } + ); + + result.Passed.Should().BeFalse(); + result.Offenders.Should().ContainSingle(o => o.Contains("missing")); + } + + [Test] + public void CrossRepoPackageVersions_flags_an_unparseable_project() + { + using var repo = new TempRepo().Write("src/X/X.csproj", " o.Contains("could not parse")); + } + + #endregion +} diff --git a/tests/Trax.Core.Testing.Tests/GlobalUsings.cs b/tests/Trax.Core.Testing.Tests/GlobalUsings.cs new file mode 100644 index 0000000..91a038c --- /dev/null +++ b/tests/Trax.Core.Testing.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using FluentAssertions; +global using NUnit.Framework; diff --git a/tests/Trax.Core.Testing.Tests/HygieneGuardsTests.cs b/tests/Trax.Core.Testing.Tests/HygieneGuardsTests.cs new file mode 100644 index 0000000..379d5f6 --- /dev/null +++ b/tests/Trax.Core.Testing.Tests/HygieneGuardsTests.cs @@ -0,0 +1,118 @@ +using Trax.Core.Testing; +using Trax.Core.Testing.Guards; + +namespace Trax.Core.Testing.Tests; + +[TestFixture] +public class HygieneGuardsTests +{ + private static ArchitectureGuardOptions OptionsFor(TempRepo repo) => + new() { RepoRootOverride = repo.Root, TestScanRoots = ["tests"] }; + + #region NoIgnoreAttribute + + [Test] + public void NoIgnoreAttribute_FlagsIgnoredTest() + { + using var repo = new TempRepo().Write( + "tests/Sample/FooTests.cs", + "public class FooTests { [Test, Ignore(\"x\")] public void A() {} }" + ); + + var result = HygieneGuards.NoIgnoreAttribute(OptionsFor(repo)); + + result.Passed.Should().BeFalse(); + result.Offenders.Should().ContainSingle(o => o.Contains("tests/Sample/FooTests.cs")); + result.Inspected.Should().Be(1); + } + + [Test] + public void NoIgnoreAttribute_IgnoresCommentedOccurrence() + { + using var repo = new TempRepo().Write( + "tests/Sample/FooTests.cs", + "public class FooTests { /* [Ignore] in a comment */ public void A() {} }" + ); + + HygieneGuards.NoIgnoreAttribute(OptionsFor(repo)).Passed.Should().BeTrue(); + } + + [Test] + public void NoIgnoreAttribute_RespectsAllowlist() + { + using var repo = new TempRepo().Write( + "tests/Sample/FooTests.cs", + "public class FooTests { [Ignore(\"gated\")] public void A() {} }" + ); + + var options = OptionsFor(repo) with + { + NoIgnoreKnownExceptions = new HashSet { "tests/Sample/FooTests.cs" }, + }; + + HygieneGuards.NoIgnoreAttribute(options).Passed.Should().BeTrue(); + } + + #endregion + + #region NoLegacyAsserts + + [Test] + public void NoLegacyAsserts_FlagsClassicAssert() + { + using var repo = new TempRepo().Write( + "tests/Sample/FooTests.cs", + "public class FooTests { public void A() { Assert.AreEqual(1, 1); } }" + ); + + var result = HygieneGuards.NoLegacyAsserts(OptionsFor(repo)); + + result.Passed.Should().BeFalse(); + result.Offenders.Should().ContainSingle(o => o.Contains("Assert.AreEqual")); + } + + [Test] + public void NoLegacyAsserts_AllowsFluentStyle() + { + using var repo = new TempRepo().Write( + "tests/Sample/FooTests.cs", + "public class FooTests { public void A() { result.Should().Be(1); } }" + ); + + HygieneGuards.NoLegacyAsserts(OptionsFor(repo)).Passed.Should().BeTrue(); + } + + #endregion + + #region NoFixedDelays + + [Test] + public void NoFixedDelays_FlagsUnjustifiedDelay() + { + using var repo = new TempRepo().Write( + "tests/Sample/FooTests.cs", + "public class FooTests { public async Task A() { await Task.Delay(2000); } }" + ); + + var result = HygieneGuards.NoFixedDelays(OptionsFor(repo)); + + result.Passed.Should().BeFalse(); + result.Offenders.Should().ContainSingle(o => o.Contains("Task.Delay")); + } + + [Test] + public void NoFixedDelays_AllowsJustifiedDelay() + { + using var repo = new TempRepo().Write( + "tests/Sample/FooTests.cs", + "public class FooTests { public async Task A() {\n" + + " // measuring-interval: prove two stamps are 50ms apart\n" + + " await Task.Delay(50);\n" + + "} }" + ); + + HygieneGuards.NoFixedDelays(OptionsFor(repo)).Passed.Should().BeTrue(); + } + + #endregion +} diff --git a/tests/Trax.Core.Testing.Tests/RepoConventionGuardsTests.cs b/tests/Trax.Core.Testing.Tests/RepoConventionGuardsTests.cs new file mode 100644 index 0000000..9b103e2 --- /dev/null +++ b/tests/Trax.Core.Testing.Tests/RepoConventionGuardsTests.cs @@ -0,0 +1,83 @@ +using Trax.Core.Testing; +using Trax.Core.Testing.Guards; + +namespace Trax.Core.Testing.Tests; + +[TestFixture] +public class RepoConventionGuardsTests +{ + #region DirectoryBuildPropsVersion + + [Test] + public void DirectoryBuildPropsVersion_PassesAtExpectedVersion() + { + using var repo = new TempRepo().Write( + "Directory.Build.props", + "1.99.99" + ); + + RepoConventionGuards + .DirectoryBuildPropsVersion(new() { RepoRootOverride = repo.Root }) + .Passed.Should() + .BeTrue(); + } + + [Test] + public void DirectoryBuildPropsVersion_FlagsWrongVersion() + { + using var repo = new TempRepo().Write( + "Directory.Build.props", + "2.0.0" + ); + + var result = RepoConventionGuards.DirectoryBuildPropsVersion( + new() { RepoRootOverride = repo.Root } + ); + + result.Passed.Should().BeFalse(); + result.Offenders.Should().ContainSingle(o => o.Contains("2.0.0")); + } + + #endregion + + #region CrossRepoPackageVersions + + [Test] + public void CrossRepoPackageVersions_PassesWithFloatingVersion() + { + using var repo = new TempRepo().Write( + "src/App/App.csproj", + "" + + "" + + "" + + "" + ); + + RepoConventionGuards + .CrossRepoPackageVersions(new() { RepoRootOverride = repo.Root }) + .Passed.Should() + .BeTrue("only Trax.* refs are checked; NUnit is ignored"); + } + + [Test] + public void CrossRepoPackageVersions_FlagsPinnedTraxReference() + { + using var repo = new TempRepo().Write( + "src/App/App.csproj", + "" + + "" + + "" + ); + + var result = RepoConventionGuards.CrossRepoPackageVersions( + new() { RepoRootOverride = repo.Root } + ); + + result.Passed.Should().BeFalse(); + result + .Offenders.Should() + .ContainSingle(o => o.Contains("Trax.Effect") && o.Contains("2.0.0")); + } + + #endregion +} diff --git a/tests/Trax.Core.Testing.Tests/SelfTestFixtures.cs b/tests/Trax.Core.Testing.Tests/SelfTestFixtures.cs new file mode 100644 index 0000000..ef6e483 --- /dev/null +++ b/tests/Trax.Core.Testing.Tests/SelfTestFixtures.cs @@ -0,0 +1,50 @@ +using Trax.Core.Testing; +using Trax.Core.Testing.Fixtures; + +namespace Trax.Core.Testing.Tests; + +// Self-tests that run the shipped base fixtures exactly as a consumer would: subclass, configure +// against a deterministic synthetic repo, and let NUnit discover and run the inherited [Test] methods. +// These exercise the fixture bodies end to end and double as the dogfood for the turnkey path. + +[TestFixture] +public sealed class HygieneGuardFixtureSelfTest : HygieneGuardFixture +{ + private TempRepo _repo = null!; + + protected override ArchitectureGuardOptions Options => + new() { RepoRootOverride = _repo.Root, TestScanRoots = ["tests"] }; + + [OneTimeSetUp] + public void CreateCleanRepo() => + _repo = new TempRepo().Write( + "tests/CleanTests.cs", + "public class CleanTests { public void A() { result.Should().Be(1); } }" + ); + + [OneTimeTearDown] + public void Cleanup() => _repo.Dispose(); +} + +[TestFixture] +public sealed class RepoConventionGuardFixtureSelfTest : RepoConventionGuardFixture +{ + private TempRepo _repo = null!; + + protected override ArchitectureGuardOptions Options => new() { RepoRootOverride = _repo.Root }; + + [OneTimeSetUp] + public void CreateConformingRepo() => + _repo = new TempRepo() + .Write( + "Directory.Build.props", + "1.99.99" + ) + .Write( + "src/App/App.csproj", + "" + ); + + [OneTimeTearDown] + public void Cleanup() => _repo.Dispose(); +} diff --git a/tests/Trax.Core.Testing.Tests/TempRepo.cs b/tests/Trax.Core.Testing.Tests/TempRepo.cs new file mode 100644 index 0000000..9d95cf6 --- /dev/null +++ b/tests/Trax.Core.Testing.Tests/TempRepo.cs @@ -0,0 +1,36 @@ +namespace Trax.Core.Testing.Tests; + +/// +/// Builds a throwaway directory tree on disk so the guard checkers can be exercised against +/// synthetic fixtures via , with no dependency +/// on the real repository contents. Disposing deletes the tree. +/// +public sealed class TempRepo : IDisposable +{ + public string Root { get; } = + Path.Combine(Path.GetTempPath(), "trax-guard-tests", Guid.NewGuid().ToString("N")); + + public TempRepo() => Directory.CreateDirectory(Root); + + /// Writes a file at a repo-relative path, creating directories as needed. + public TempRepo Write(string relativePath, string content) + { + var full = Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + return this; + } + + public void Dispose() + { + try + { + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup; a leaked temp dir must never fail a test. + } + } +} diff --git a/tests/Trax.Core.Testing.Tests/Trax.Core.Testing.Tests.csproj b/tests/Trax.Core.Testing.Tests/Trax.Core.Testing.Tests.csproj new file mode 100644 index 0000000..1eb407f --- /dev/null +++ b/tests/Trax.Core.Testing.Tests/Trax.Core.Testing.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + +