From b045c49e109764d2e06f43feefa76e04ce2bb2f3 Mon Sep 17 00:00:00 2001 From: "Steven T. Cramer" Date: Thu, 2 Jul 2026 23:23:41 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20release=201.0.0=20=E2=80=94=20tests,=20?= =?UTF-8?q?readme,=20context=20regions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the v1.0.0 blockers from kanban task 001. - Add 21 Jaribu runfile tests under tests/ covering the four scope extensions (happy path, chaining, value types, null guards) and both builder interfaces via concrete builders modeled on TimeWarp.Nuru's real usage, including covariance assertions - Rewire 'dev test' to execute Jaribu runfiles and aggregate exit codes; dotnet test on the slnx never ran these, and --filter now maps to JARIBU_FILTER_TAG - Rewrite readme: badges, installation, requirements, Also-vs-Apply rationale, used-by, and testing sections - Add #region Purpose/Design context blocks to all library source files - Remove the seven global IL* AOT suppressions; the library has no reflection and both builds verify clean without them - Bump TimeWarp.Amuru to 1.0.0-beta.34 and NetAnalyzers to 10.0.301 - Bump version to 1.0.0 šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 2 +- Directory.Packages.props | 8 +- ...s-code-review-blockers-for-v100-release.md | 18 +-- readme.md | 64 +++++++++- source/Directory.Build.props | 2 +- source/timewarp-builder/global-usings.cs | 4 + source/timewarp-builder/i-builder.cs | 10 ++ source/timewarp-builder/i-nested-builder.cs | 11 ++ source/timewarp-builder/scope-extensions.cs | 12 ++ tests/.gitkeep | 0 tests/Directory.Build.props | 36 ++++++ tests/i-builder.build.cs | 74 ++++++++++++ tests/i-nested-builder.done.cs | 113 ++++++++++++++++++ tests/scope-extensions.also.cs | 57 +++++++++ tests/scope-extensions.apply.cs | 55 +++++++++ tests/scope-extensions.let.cs | 51 ++++++++ tests/scope-extensions.run.cs | 47 ++++++++ tools/dev-cli/endpoints/test.cs | 62 ++++++---- 18 files changed, 585 insertions(+), 41 deletions(-) delete mode 100644 tests/.gitkeep create mode 100644 tests/Directory.Build.props create mode 100755 tests/i-builder.build.cs create mode 100755 tests/i-nested-builder.done.cs create mode 100755 tests/scope-extensions.also.cs create mode 100755 tests/scope-extensions.apply.cs create mode 100755 tests/scope-extensions.let.cs create mode 100755 tests/scope-extensions.run.cs diff --git a/Directory.Build.props b/Directory.Build.props index 9e072ba..97917cc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -56,7 +56,7 @@ - $(NoWarn);CA1014;CA1724;CA1812;IDE0290;IL2026;IL2067;IL2070;IL2075;IL3050;IL2104;IL3053 + $(NoWarn);CA1014;CA1724;CA1812;IDE0290 diff --git a/Directory.Packages.props b/Directory.Packages.props index 9aed975..c3e4c8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,15 +5,19 @@ true - + + + + + - + diff --git a/kanban/in-progress/001-address-code-review-blockers-for-v100-release.md b/kanban/in-progress/001-address-code-review-blockers-for-v100-release.md index 1a60da4..6052949 100644 --- a/kanban/in-progress/001-address-code-review-blockers-for-v100-release.md +++ b/kanban/in-progress/001-address-code-review-blockers-for-v100-release.md @@ -16,8 +16,10 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re - Add `ToolsDirectory` for future tooling support - Note: TestsDirectory, SamplesDirectory, BenchmarksDirectory left in place for future use -- [ ] **Add automated tests** - - Create test project (suggested: `tests/TimeWarp.Builder.Tests/`) +- [x] **Add automated tests** (2026-07-02) + - Implemented as TimeWarp.Jaribu runfiles under `tests/` (repo convention) rather than a dotnet-test project; `dev test` runs each runfile and aggregates exit codes + - 21 tests across 6 files: 4 scope-extension files + 2 interface integration files (concrete builders modeled on TimeWarp.Nuru's usage), covering null guards and covariance + - Original scope: - Test all 4 scope extension methods: - `Also` - executes action, returns original object - `Apply` - executes action, returns original object @@ -37,7 +39,7 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re ### āš ļø High Priority (strongly recommended) -- [ ] **Improve README.md** +- [x] **Improve README.md** (2026-07-02) - Add installation section with `dotnet add package TimeWarp.Builder` - Add requirements section (target framework: .NET 10.0) - Add license badge and link to LICENSE file @@ -45,7 +47,8 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re - Add design rationale explaining `Also` vs `Apply` distinction - Link to GitHub repository -- [ ] **Wire up package icon** +- [x] **Wire up package icon** — resolved with `logo.png` in `source/Directory.Build.props`; ganda's nuget-package-icon audit check passes + - Original suggestion: - Add `timewarp-builder-avatar.png` (convert SVG to PNG if needed, NuGet prefers PNG) - Add icon file reference to `.csproj`: ```xml @@ -55,7 +58,7 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re ``` - Note: `assets/timewarp-builder-avatar.svg` exists but SVG icons in NuGet packages have limited client support -- [ ] **Prune `Directory.Packages.props`** +- [x] **Prune `Directory.Packages.props`** (2026-07-02, ganda audit CPM cleanup; Jaribu/Shouldly re-added for tests) - Remove unused package groups: - Serilog (Logging - Serilog section) - OpenTelemetry @@ -67,14 +70,15 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re - Keep only packages actually referenced by this library (likely just analyzers and Microsoft.Extensions if needed) - Note: `TimeWarp.Builder` self-reference can also be removed -- [ ] **Review AOT warning suppressions** +- [x] **Review AOT warning suppressions** (2026-07-02) — removed all seven IL* suppressions; both the library build and the dev-cli AOT publish verified clean without them + - Original scope: - Current global suppressions in `Directory.Build.props`: `IL2026;IL2067;IL2070;IL2075;IL3050;IL2104;IL3053` - Evaluate if these are truly needed for this library (it has no reflection/dynamic code) - If they are false positives, consider removing the global suppression and testing AOT build ### Nice to Have (low priority) -- [ ] Add `#region Purpose` / `#region Design` context blocks to source files per csharp skill conventions +- [x] Add `#region Purpose` / `#region Design` context blocks to source files per csharp skill conventions (2026-07-02) - [ ] Add `CHANGELOG.md` for v1.0.0 release notes - [ ] Consider `IBuildAsync` interface for async build scenarios (future v1.x) diff --git a/readme.md b/readme.md index e87d454..0e453a7 100644 --- a/readme.md +++ b/readme.md @@ -1,12 +1,28 @@ # TimeWarp.Builder +[![NuGet](https://img.shields.io/nuget/vpre/TimeWarp.Builder.svg)](https://www.nuget.org/packages/TimeWarp.Builder) +[![NuGet Downloads](https://img.shields.io/nuget/dt/TimeWarp.Builder.svg)](https://www.nuget.org/packages/TimeWarp.Builder) +[![CI/CD](https://github.com/TimeWarpEngineering/timewarp-builder/actions/workflows/workflow.yml/badge.svg)](https://github.com/TimeWarpEngineering/timewarp-builder/actions/workflows/workflow.yml) +[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](LICENSE) + Fluent builder interfaces and Kotlin-inspired scope extensions for .NET. +## Installation + +```bash +dotnet add package TimeWarp.Builder --prerelease +``` + +## Requirements + +- .NET 10.0 or later +- Fully AOT- and trim-compatible (no reflection, no dynamic code) + ## Interfaces ### IBuilder\ -Interface for standalone builders that create objects via `Build()`. +Interface for standalone builders that create objects via `Build()`. `TBuilt` is covariant, so an `IBuilder` can be used wherever an `IBuilder` is expected. ```csharp public class MyWidgetBuilder : IBuilder @@ -23,7 +39,7 @@ Widget widget = new MyWidgetBuilder() ### INestedBuilder\ -Interface for nested builders that return to a parent context via `Done()`. +Interface for nested builders that return to a parent context via `Done()`. `Done()` performs three things: builds the child, hands the result to the parent, and returns the parent for continued chaining. ```csharp // Nested builder returns to parent after building @@ -36,21 +52,38 @@ app.Map(route => route ## Scope Extensions -Kotlin-inspired extension methods for fluent object manipulation. +Kotlin-inspired extension methods for fluent object manipulation. Because they attach to every type (unconstrained `T`), any object can participate in a fluent chain without its type opting in. + +| Method | Returns | Use for | +|--------|---------|---------| +| `Also` | The original object | Side effects mid-chain (logging, diagnostics) | +| `Apply` | The original object | Configuring the object mid-chain | +| `Let` | The transform result | Converting to a different type/value | +| `Run` | Nothing (`void`) | Terminal action at the end of a chain | + +### Also vs Apply + +`Also` and `Apply` are mechanically identical — both execute an action and return the original object. They exist separately to signal *intent* at the call site, mirroring Kotlin's `also`/`apply` distinction: use `Apply` when the action configures the object itself, and `Also` when the action is an incidental side effect like logging. + +```csharp +app.Map("status", handler) + .Apply(r => r.AsQuery()) // configures the route + .Also(r => logger.LogDebug("mapped {r}", r)); // side effect, not configuration +``` ### Also -Executes an action on the object and returns the original object. Useful for side effects during method chaining. +Executes an action on the object and returns the original object. ```csharp var builder = new AppBuilder() - .Also(b => Console.WriteLine("Building app...")) + .Also(b => logger.LogDebug("Building app...")) .Configure(options); ``` ### Apply -Configures the object and returns the original object. Semantically similar to `Also` but with clearer intent for configuration. +Configures the object and returns the original object. ```csharp app.Map("status", handler) @@ -72,3 +105,22 @@ Executes an action on the object with no return value. Terminal operation in a m ```csharp app.Build().Run(a => a.RunAsync(args)); ``` + +All four methods throw `ArgumentNullException` when the delegate is null. + +## Used By + +- [TimeWarp.Nuru](https://github.com/TimeWarpEngineering/timewarp-nuru) — route, endpoint, group, and key-binding builders implement `IBuilder` / `INestedBuilder` +- [TimeWarp.Terminal](https://github.com/TimeWarpEngineering/timewarp-terminal) + +## Testing + +Tests are [TimeWarp.Jaribu](https://github.com/TimeWarpEngineering/timewarp-jaribu) runfiles under `tests/`. Run them all with `dev test`, or any file directly: + +```bash +dotnet run tests/scope-extensions.also.cs +``` + +## Unlicense + +This is free and unencumbered software released into the public domain — see [LICENSE](LICENSE). diff --git a/source/Directory.Build.props b/source/Directory.Build.props index 35cb2bd..dcf5e81 100644 --- a/source/Directory.Build.props +++ b/source/Directory.Build.props @@ -4,7 +4,7 @@ - 1.0.0-beta.3 + 1.0.0 Steven T. Cramer https://github.com/TimeWarpEngineering/timewarp-builder Unlicense diff --git a/source/timewarp-builder/global-usings.cs b/source/timewarp-builder/global-usings.cs index 871318d..ac3de30 100644 --- a/source/timewarp-builder/global-usings.cs +++ b/source/timewarp-builder/global-usings.cs @@ -1 +1,5 @@ +#region Purpose +// Global using directives for the TimeWarp.Builder library. +#endregion + global using System; diff --git a/source/timewarp-builder/i-builder.cs b/source/timewarp-builder/i-builder.cs index 0d05760..bb55cb3 100644 --- a/source/timewarp-builder/i-builder.cs +++ b/source/timewarp-builder/i-builder.cs @@ -1,3 +1,13 @@ +#region Purpose +// Contract for standalone builders that produce their configured object via Build(). +#endregion + +#region Design +// TBuilt is covariant (out) so an IBuilder is usable where an IBuilder is +// expected. Builders that finish by returning to a parent context instead implement +// INestedBuilder; the two interfaces are intentionally minimal and independent. +#endregion + namespace TimeWarp.Builder; /// diff --git a/source/timewarp-builder/i-nested-builder.cs b/source/timewarp-builder/i-nested-builder.cs index ae3d160..162ff4b 100644 --- a/source/timewarp-builder/i-nested-builder.cs +++ b/source/timewarp-builder/i-nested-builder.cs @@ -1,3 +1,14 @@ +#region Purpose +// Contract for nested builders that finish via Done() and return control to a parent builder. +#endregion + +#region Design +// Done() bundles three steps: build the child, hand the result to the parent, return the +// parent — enabling deep fluent chains without the caller juggling intermediate results. +// TParent is covariant (out) and constrained to class; nested builders typically wrap a +// standalone IBuilder internally (see TimeWarp.Nuru's NestedCompiledRouteBuilder). +#endregion + namespace TimeWarp.Builder; /// diff --git a/source/timewarp-builder/scope-extensions.cs b/source/timewarp-builder/scope-extensions.cs index 7f4d844..5ded074 100644 --- a/source/timewarp-builder/scope-extensions.cs +++ b/source/timewarp-builder/scope-extensions.cs @@ -1,3 +1,15 @@ +#region Purpose +// Kotlin-inspired scope extension methods (Also, Apply, Let, Run) for fluent object manipulation. +#endregion + +#region Design +// Also and Apply share an implementation; they exist separately to signal intent at the call +// site (Apply = configuration, Also = incidental side effect), mirroring Kotlin's apply/also. +// T is deliberately unconstrained so the extensions attach to every type — any object can join +// a fluent chain without its type opting in; the IntelliSense noise is the accepted trade-off. +// Delegates are null-guarded; the receiver is not, matching BCL extension-method convention. +#endregion + namespace TimeWarp.Builder; /// diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..42ddb3b --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + $(NoWarn);CA1707;CA1515;CA1812;CA1859;CA1861;CA2007;CS1591;IDE0058;IDE0210;IDE0211;RCS1046 + + + + + + + + + + + + + + + + + + + + diff --git a/tests/i-builder.build.cs b/tests/i-builder.build.cs new file mode 100755 index 0000000..e49d54c --- /dev/null +++ b/tests/i-builder.build.cs @@ -0,0 +1,74 @@ +#!/usr/bin/env -S dotnet -- + +#region Purpose +// Tests for IBuilder: standalone builders produce their configured object via Build(). +#endregion + +#if !JARIBU_MULTI +return await RunAllTests(); +#endif + +namespace IBuilder_ +{ + [TestTag("Interfaces")] + public sealed class Build_Given_ + { + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task ConcreteBuilder_Should_ReturnConfiguredObject() + { + Widget widget = new WidgetBuilder() + .WithName("gizmo") + .WithSize(10) + .Build(); + + widget.Name.ShouldBe("gizmo"); + widget.Size.ShouldBe(10); + await Task.CompletedTask; + } + + public static async Task CovariantTBuilt_Should_AssignToBaseTypedInterface() + { + // IBuilder covariance: a Widget builder is usable where an object builder is expected + IBuilder builder = new WidgetBuilder().WithName("gizmo"); + + builder.Build().ShouldBeOfType(); + await Task.CompletedTask; + } + + public static async Task RepeatedBuild_Should_ProduceIndependentObjects() + { + WidgetBuilder builder = new WidgetBuilder().WithName("gizmo"); + + Widget first = builder.Build(); + Widget second = builder.Build(); + + first.ShouldNotBeSameAs(second); + first.ShouldBe(second); + await Task.CompletedTask; + } + } + + internal sealed record Widget(string Name, int Size); + + internal sealed class WidgetBuilder : IBuilder + { + private string Name = ""; + private int Size; + + public WidgetBuilder WithName(string name) + { + Name = name; + return this; + } + + public WidgetBuilder WithSize(int size) + { + Size = size; + return this; + } + + public Widget Build() => new(Name, Size); + } +} diff --git a/tests/i-nested-builder.done.cs b/tests/i-nested-builder.done.cs new file mode 100755 index 0000000..d0861c6 --- /dev/null +++ b/tests/i-nested-builder.done.cs @@ -0,0 +1,113 @@ +#!/usr/bin/env -S dotnet -- + +#region Purpose +// Tests for INestedBuilder: nested builders build their child, hand it to the +// parent, and return the parent via Done() for continued fluent chaining. +#endregion + +#if !JARIBU_MULTI +return await RunAllTests(); +#endif + +namespace INestedBuilder_ +{ + [TestTag("Interfaces")] + public sealed class Done_Given_ + { + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task NestedBuilder_Should_ReturnParentInstance() + { + RobotBuilder parent = new(); + + RobotBuilder returned = parent.AddArm(arm => arm.WithLength(2).Done()); + + returned.ShouldBeSameAs(parent); + await Task.CompletedTask; + } + + public static async Task Done_Should_PassBuiltChildToParent() + { + Robot robot = new RobotBuilder() + .AddArm(arm => arm.WithLength(2).Done()) + .AddArm(arm => arm.WithLength(3).Done()) + .Build(); + + robot.Arms.Count.ShouldBe(2); + robot.Arms[0].Length.ShouldBe(2); + robot.Arms[1].Length.ShouldBe(3); + await Task.CompletedTask; + } + + public static async Task CovariantTParent_Should_AssignToBaseTypedInterface() + { + // INestedBuilder covariance: a RobotBuilder-parented nested builder is + // usable where an object-parented one is expected + RobotBuilder parent = new(); + INestedBuilder nested = new NestedArmBuilder(parent, _ => { }); + + nested.Done().ShouldBeSameAs(parent); + await Task.CompletedTask; + } + + public static async Task FluentChain_Should_ComposeWithScopeExtensions() + { + List observedLengths = []; + + Robot robot = new RobotBuilder() + .AddArm(arm => arm.WithLength(5).Done()) + .Also(builder => builder.AddArm(arm => arm.WithLength(7).Done())) + .Build() + .Apply(built => observedLengths.AddRange(built.Arms.Select(a => a.Length))); + + robot.Arms.Count.ShouldBe(2); + observedLengths.ShouldBe(new[] { 5, 7 }); + await Task.CompletedTask; + } + } + + internal sealed record Arm(int Length); + + internal sealed record Robot(IReadOnlyList Arms); + + // Mirrors the pattern used by TimeWarp.Nuru's NestedCompiledRouteBuilder: + // wraps the child state, and Done() = build child + hand to parent + return parent. + internal sealed class NestedArmBuilder : INestedBuilder where TParent : class + { + private readonly TParent Parent; + private readonly Action OnBuilt; + private int Length; + + public NestedArmBuilder(TParent parent, Action onBuilt) + { + Parent = parent; + OnBuilt = onBuilt; + } + + public NestedArmBuilder WithLength(int length) + { + Length = length; + return this; + } + + public TParent Done() + { + OnBuilt(new Arm(Length)); + return Parent; + } + } + + internal sealed class RobotBuilder : IBuilder + { + private readonly List Arms = []; + + public RobotBuilder AddArm(Func, RobotBuilder> configure) + { + ArgumentNullException.ThrowIfNull(configure); + return configure(new NestedArmBuilder(this, Arms.Add)); + } + + public Robot Build() => new([.. Arms]); + } +} diff --git a/tests/scope-extensions.also.cs b/tests/scope-extensions.also.cs new file mode 100755 index 0000000..55078e1 --- /dev/null +++ b/tests/scope-extensions.also.cs @@ -0,0 +1,57 @@ +#!/usr/bin/env -S dotnet -- + +#region Purpose +// Tests for ScopeExtensions.Also: executes a side effect and returns the original object. +#endregion + +#if !JARIBU_MULTI +return await RunAllTests(); +#endif + +namespace ScopeExtensions_ +{ + [TestTag("ScopeExtensions")] + public sealed class Also_Given_ + { + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task ReferenceType_Should_ExecuteActionAndReturnSameInstance() + { + List subject = []; + + List result = subject.Also(list => list.Add("side effect")); + + result.ShouldBeSameAs(subject); + subject.ShouldContain("side effect"); + await Task.CompletedTask; + } + + public static async Task ChainedCalls_Should_ExecuteActionsInOrder() + { + List executionOrder = []; + + string result = "subject" + .Also(_ => executionOrder.Add(1)) + .Also(_ => executionOrder.Add(2)); + + result.ShouldBe("subject"); + executionOrder.ShouldBe(new[] { 1, 2 }); + await Task.CompletedTask; + } + + public static async Task ValueType_Should_ReturnOriginalValue() + { + int result = 42.Also(_ => { }); + + result.ShouldBe(42); + await Task.CompletedTask; + } + + public static async Task NullAction_Should_ThrowArgumentNullException() + { + Should.Throw(() => "subject".Also(null!)); + await Task.CompletedTask; + } + } +} diff --git a/tests/scope-extensions.apply.cs b/tests/scope-extensions.apply.cs new file mode 100755 index 0000000..b2e291e --- /dev/null +++ b/tests/scope-extensions.apply.cs @@ -0,0 +1,55 @@ +#!/usr/bin/env -S dotnet -- + +#region Purpose +// Tests for ScopeExtensions.Apply: configures the object and returns the original object. +#endregion + +#if !JARIBU_MULTI +return await RunAllTests(); +#endif + +namespace ScopeExtensions_ +{ + [TestTag("ScopeExtensions")] + public sealed class Apply_Given_ + { + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task MutableObject_Should_ConfigureAndReturnSameInstance() + { + Options subject = new(); + + Options result = subject.Apply(options => options.Name = "configured"); + + result.ShouldBeSameAs(subject); + subject.Name.ShouldBe("configured"); + await Task.CompletedTask; + } + + public static async Task MidChainConfiguration_Should_PreserveFluentFlow() + { + List log = []; + + Options result = new Options() + .Apply(options => options.Name = "first") + .Also(options => log.Add(options.Name)) + .Apply(options => options.Name = "second"); + + result.Name.ShouldBe("second"); + log.ShouldBe(new[] { "first" }); + await Task.CompletedTask; + } + + public static async Task NullAction_Should_ThrowArgumentNullException() + { + Should.Throw(() => new Options().Apply(null!)); + await Task.CompletedTask; + } + } + + internal sealed class Options + { + public string Name { get; set; } = ""; + } +} diff --git a/tests/scope-extensions.let.cs b/tests/scope-extensions.let.cs new file mode 100755 index 0000000..66d99eb --- /dev/null +++ b/tests/scope-extensions.let.cs @@ -0,0 +1,51 @@ +#!/usr/bin/env -S dotnet -- + +#region Purpose +// Tests for ScopeExtensions.Let: transforms the object to a different type or value. +#endregion + +#if !JARIBU_MULTI +return await RunAllTests(); +#endif + +namespace ScopeExtensions_ +{ + [TestTag("ScopeExtensions")] + public sealed class Let_Given_ + { + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task String_Should_TransformToDifferentType() + { + int length = "hello".Let(s => s.Length); + + length.ShouldBe(5); + await Task.CompletedTask; + } + + public static async Task ValueType_Should_TransformValue() + { + int doubled = 21.Let(x => x * 2); + + doubled.ShouldBe(42); + await Task.CompletedTask; + } + + public static async Task ChainedTransforms_Should_ComposeLeftToRight() + { + string result = 7 + .Let(x => x * 10) + .Let(x => $"value-{x}"); + + result.ShouldBe("value-70"); + await Task.CompletedTask; + } + + public static async Task NullTransform_Should_ThrowArgumentNullException() + { + Should.Throw(() => "subject".Let((Func)null!)); + await Task.CompletedTask; + } + } +} diff --git a/tests/scope-extensions.run.cs b/tests/scope-extensions.run.cs new file mode 100755 index 0000000..e2f8a75 --- /dev/null +++ b/tests/scope-extensions.run.cs @@ -0,0 +1,47 @@ +#!/usr/bin/env -S dotnet -- + +#region Purpose +// Tests for ScopeExtensions.Run: executes a terminal action on the object with no return value. +#endregion + +#if !JARIBU_MULTI +return await RunAllTests(); +#endif + +namespace ScopeExtensions_ +{ + [TestTag("ScopeExtensions")] + public sealed class Run_Given_ + { + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Object_Should_ExecuteActionWithReceiver() + { + string? observed = null; + + "subject".Run(s => observed = s); + + observed.ShouldBe("subject"); + await Task.CompletedTask; + } + + public static async Task EndOfChain_Should_ActAsTerminalOperation() + { + List log = []; + + "subject" + .Also(_ => log.Add("first")) + .Run(_ => log.Add("terminal")); + + log.ShouldBe(new[] { "first", "terminal" }); + await Task.CompletedTask; + } + + public static async Task NullAction_Should_ThrowArgumentNullException() + { + Should.Throw(() => "subject".Run(null!)); + await Task.CompletedTask; + } + } +} diff --git a/tools/dev-cli/endpoints/test.cs b/tools/dev-cli/endpoints/test.cs index a5dda08..ad234f4 100644 --- a/tools/dev-cli/endpoints/test.cs +++ b/tools/dev-cli/endpoints/test.cs @@ -4,7 +4,13 @@ // Runs the test suite for TimeWarp.Builder. #region Purpose -// 'dev test' endpoint: runs the solution test suite, skipping gracefully when tests/ is absent. +// 'dev test' endpoint: runs each Jaribu test runfile under tests/, skipping gracefully when none exist. +#endregion + +#region Design +// Tests are standalone Jaribu runfiles (see the jaribu skill), not dotnet-test projects, +// so each *.cs under tests/ is executed via 'dotnet run' and exit codes are aggregated. +// --filter maps to the JARIBU_FILTER_TAG environment variable Jaribu uses for tag filtering. #endregion namespace DevCli.Commands; @@ -15,15 +21,9 @@ namespace DevCli.Commands; [NuruRoute("test", Description = "Run the test suite")] internal sealed class TestCommand : ICommand { - [Option("filter", "f", Description = "Test filter expression")] + [Option("filter", "f", Description = "Jaribu tag filter (sets JARIBU_FILTER_TAG)")] public string? Filter { get; set; } - [Option("no-build", null, Description = "Skip build before testing")] - public bool NoBuild { get; set; } - - [Option("verbose", "v", Description = "Verbose output")] - public bool Verbose { get; set; } - internal sealed class Handler : ICommandHandler { private readonly ITerminal Terminal; @@ -43,38 +43,52 @@ public async ValueTask Handle(TestCommand command, CancellationToken ct) string testsDirectory = Path.Combine(repoRoot, "tests"); - if (!Directory.Exists(testsDirectory) || !Directory.EnumerateFiles(testsDirectory, "*.cs", SearchOption.AllDirectories).Any()) + string[] testFiles = Directory.Exists(testsDirectory) + ? [.. Directory.GetFiles(testsDirectory, "*.cs", SearchOption.AllDirectories).Order(StringComparer.Ordinal)] + : []; + + if (testFiles.Length == 0) { Terminal.WriteLine("No tests found. Skipping test step."); return Value; } - Terminal.WriteLine("Running TimeWarp.Builder tests..."); + Terminal.WriteLine($"Running {testFiles.Length} Jaribu test file(s)..."); Terminal.WriteLine($"Working from: {repoRoot}"); - ShellBuilder testBuilder = Shell.Builder("dotnet") - .WithArguments("test", Path.Combine(repoRoot, "timewarp-builder.slnx"), "--verbosity", command.Verbose ? "normal" : "minimal"); + int failedCount = 0; - if (command.NoBuild) + foreach (string testFile in testFiles) { - testBuilder = testBuilder.WithArguments("--no-build"); - } + string relativePath = Path.GetRelativePath(repoRoot, testFile); + Terminal.WriteLine($"\nā–¶ {relativePath}"); - if (command.Filter is not null) - { - testBuilder = testBuilder.WithArguments("--filter", command.Filter); - } + ShellBuilder runBuilder = Shell.Builder("dotnet") + .WithArguments("run", testFile) + .WithNoValidation(); + + if (command.Filter is not null) + { + runBuilder = runBuilder.WithEnvironmentVariable("JARIBU_FILTER_TAG", command.Filter); + } - int exitCode = await testBuilder.WithNoValidation().RunAsync(); + int exitCode = await runBuilder.RunAsync(); + + if (exitCode != 0) + { + failedCount++; + Terminal.WriteErrorLine($"āŒ Failed: {relativePath}"); + } + } - if (exitCode != 0) + if (failedCount > 0) { - Environment.ExitCode = exitCode; - Terminal.WriteErrorLine($"Tests failed with exit code {exitCode}"); + Environment.ExitCode = 1; + Terminal.WriteErrorLine($"\nāŒ {failedCount} of {testFiles.Length} test file(s) failed"); return Value; } - Terminal.WriteLine("\nāœ… Tests completed successfully!"); + Terminal.WriteLine($"\nāœ… All {testFiles.Length} test file(s) passed!"); return Value; } }