From c025dfc0149f9bac043a270eb0899c8fd6cbfa6c Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Fri, 31 Jul 2026 14:24:58 -0500 Subject: [PATCH 1/2] Add monad assertions to the test project Asserting on Maybe and Either meant `Assert.That(result.IsRight, Is.True)`, which reports only `Expected: True, But was: False` and reveals nothing about the state the monad was actually in. Reaching the value then took a second step through a lambda or an *OrDefault call. The new assertions report the actual state and its payload on failure, and return the matched value so a test can assert and bind in one statement. NUnit's Assert cannot be extended, so these live on a subclass of it declared in the Crypter.Test namespace. Unqualified `Assert` in a test resolves there and reaches the inherited assertions through the same name as before, which is why no existing test changed. Co-Authored-By: Claude Opus 5 --- Crypter.Test/Assert.cs | 223 +++++++++++++++++ Crypter.Test/Common_Tests/Assert_Tests.cs | 276 ++++++++++++++++++++++ 2 files changed, 499 insertions(+) create mode 100644 Crypter.Test/Assert.cs create mode 100644 Crypter.Test/Common_Tests/Assert_Tests.cs diff --git a/Crypter.Test/Assert.cs b/Crypter.Test/Assert.cs new file mode 100644 index 00000000..e07a2633 --- /dev/null +++ b/Crypter.Test/Assert.cs @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System.Threading.Tasks; +using EasyMonads; +using NUnit.Framework; + +namespace Crypter.Test; + +/// +/// NUnit's assertions plus assertions for and . +/// +/// +/// This type shadows for every test in the Crypter.Test namespace, +/// so the inherited assertions are reached through the same Assert name they always were. +/// The monad assertions return the matched value, which lets a test assert the state and bind the +/// value in one statement. +/// +internal abstract class Assert : NUnit.Framework.Assert +{ + /// + /// Assert that the maybe is in the Some state and return the value it holds. + /// + internal static T IsSome(Maybe maybe) + { + if (maybe.IsNone) + { + Fail($"Expected Some, but was {Describe(maybe)}."); + } + + return maybe.SomeOrDefault()!; + } + + /// + /// Assert that the maybe is in the Some state and holds , then + /// return the value it holds. + /// + internal static T IsSome(Maybe maybe, T expectedValue) + { + T value = IsSome(maybe); + That(value, Is.EqualTo(expectedValue)); + return value; + } + + /// + /// Assert that the maybe is in the None state. + /// + internal static void IsNone(Maybe maybe) + { + if (maybe.IsSome) + { + Fail($"Expected None, but was {Describe(maybe)}."); + } + } + + /// + /// Assert that the either is in the Right state and return the value it holds. + /// + internal static TRight IsRight(Either either) + { + if (!either.IsRight) + { + Fail($"Expected Right, but was {Describe(either)}."); + } + + return either.RightOrDefault(default!)!; + } + + /// + /// Assert that the either is in the Right state and holds , then + /// return the value it holds. + /// + internal static TRight IsRight(Either either, TRight expectedValue) + { + TRight value = IsRight(either); + That(value, Is.EqualTo(expectedValue)); + return value; + } + + /// + /// Assert that the either is in the Left state and return the value it holds. + /// + internal static TLeft IsLeft(Either either) + { + if (!either.IsLeft) + { + Fail($"Expected Left, but was {Describe(either)}."); + } + + return either.LeftOrDefault(default!)!; + } + + /// + /// Assert that the either is in the Left state and holds , then + /// return the value it holds. + /// + internal static TLeft IsLeft(Either either, TLeft expectedValue) + { + TLeft value = IsLeft(either); + That(value, Is.EqualTo(expectedValue)); + return value; + } + + /// + /// Assert that the either is in the Neither state. + /// + internal static void IsNeither(Either either) + { + if (!either.IsNeither) + { + Fail($"Expected Neither, but was {Describe(either)}."); + } + } + + /// + /// Await the task, then assert that the maybe is in the Some state and return the value it holds. + /// + internal static async Task IsSomeAsync(Task> maybeTask) + { + return IsSome(await maybeTask); + } + + /// + /// Await the task, then assert that the maybe is in the Some state and holds + /// , then return the value it holds. + /// + internal static async Task IsSomeAsync(Task> maybeTask, T expectedValue) + { + return IsSome(await maybeTask, expectedValue); + } + + /// + /// Await the task, then assert that the maybe is in the None state. + /// + internal static async Task IsNoneAsync(Task> maybeTask) + { + IsNone(await maybeTask); + } + + /// + /// Await the task, then assert that the either is in the Right state and return the value it holds. + /// + internal static async Task IsRightAsync(Task> eitherTask) + { + return IsRight(await eitherTask); + } + + /// + /// Await the task, then assert that the either is in the Right state and holds + /// , then return the value it holds. + /// + internal static async Task IsRightAsync(Task> eitherTask, TRight expectedValue) + { + return IsRight(await eitherTask, expectedValue); + } + + /// + /// Await the task, then assert that the either is in the Left state and return the value it holds. + /// + internal static async Task IsLeftAsync(Task> eitherTask) + { + return IsLeft(await eitherTask); + } + + /// + /// Await the task, then assert that the either is in the Left state and holds + /// , then return the value it holds. + /// + internal static async Task IsLeftAsync(Task> eitherTask, TLeft expectedValue) + { + return IsLeft(await eitherTask, expectedValue); + } + + /// + /// Await the task, then assert that the either is in the Neither state. + /// + internal static async Task IsNeitherAsync(Task> eitherTask) + { + IsNeither(await eitherTask); + } + + private static string Describe(Maybe maybe) + { + return maybe.IsSome + ? $"Some({maybe.SomeOrDefault()})" + : "None"; + } + + private static string Describe(Either either) + { + if (either.IsRight) + { + return $"Right({either.RightOrDefault(default!)})"; + } + + return either.IsLeft + ? $"Left({either.LeftOrDefault(default!)})" + : "Neither"; + } +} diff --git a/Crypter.Test/Common_Tests/Assert_Tests.cs b/Crypter.Test/Common_Tests/Assert_Tests.cs new file mode 100644 index 00000000..21adbd96 --- /dev/null +++ b/Crypter.Test/Common_Tests/Assert_Tests.cs @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System.Threading.Tasks; +using EasyMonads; +using NUnit.Framework; + +namespace Crypter.Test.Common_Tests; + +/// +/// Every monad assertion is exercised twice: once against the state it expects, and once against a +/// state it must reject. A helper that never fails would silently pass every test that depends on it. +/// +/// +/// The rejection tests wrap the assertion in Assert.Throws rather than a try/catch. NUnit +/// records a failed assertion against the running test as soon as it happens, and only +/// Assert.Throws clears that record. A caught still fails the +/// test. +/// +[TestFixture] +internal class Assert_Tests +{ + private const string SomeValue = "Frodo"; + private const string OtherValue = "Bilbo"; + private const int LeftValue = 42; + private const int OtherLeftValue = 24; + + private static Maybe Some => Maybe.From(SomeValue); + private static Maybe None => Maybe.None; + + private static Either Right => Either.FromRight(SomeValue); + private static Either Left => Either.FromLeft(LeftValue); + private static Either Neither => Either.Neither; + + [Test] + public void Is_Some_Returns_The_Value() + { + string value = Assert.IsSome(Some); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Some_Fails_For_None() + { + AssertionException? exception = Assert.Throws(() => Assert.IsSome(None)); + Assert.That(exception!.Message, Is.EqualTo("Expected Some, but was None.")); + } + + [Test] + public void Is_Some_With_An_Expected_Value_Returns_The_Value() + { + string value = Assert.IsSome(Some, SomeValue); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Some_With_An_Expected_Value_Fails_For_A_Different_Value() + { + Assert.Throws(() => Assert.IsSome(Some, OtherValue)); + } + + [Test] + public void Is_None_Passes_For_None() + { + Assert.IsNone(None); + } + + [Test] + public void Is_None_Fails_For_Some() + { + AssertionException? exception = Assert.Throws(() => Assert.IsNone(Some)); + Assert.That(exception!.Message, Is.EqualTo($"Expected None, but was Some({SomeValue}).")); + } + + [Test] + public void Is_Right_Returns_The_Value() + { + string value = Assert.IsRight(Right); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Right_Fails_For_Left() + { + AssertionException? exception = Assert.Throws(() => Assert.IsRight(Left)); + Assert.That(exception!.Message, Is.EqualTo($"Expected Right, but was Left({LeftValue}).")); + } + + [Test] + public void Is_Right_With_An_Expected_Value_Returns_The_Value() + { + string value = Assert.IsRight(Right, SomeValue); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Right_With_An_Expected_Value_Fails_For_A_Different_Value() + { + Assert.Throws(() => Assert.IsRight(Right, OtherValue)); + } + + [Test] + public void Is_Left_Returns_The_Value() + { + int value = Assert.IsLeft(Left); + Assert.That(value, Is.EqualTo(LeftValue)); + } + + [Test] + public void Is_Left_Fails_For_Right() + { + AssertionException? exception = Assert.Throws(() => Assert.IsLeft(Right)); + Assert.That(exception!.Message, Is.EqualTo($"Expected Left, but was Right({SomeValue}).")); + } + + [Test] + public void Is_Left_With_An_Expected_Value_Returns_The_Value() + { + int value = Assert.IsLeft(Left, LeftValue); + Assert.That(value, Is.EqualTo(LeftValue)); + } + + [Test] + public void Is_Left_With_An_Expected_Value_Fails_For_A_Different_Value() + { + Assert.Throws(() => Assert.IsLeft(Left, OtherLeftValue)); + } + + [Test] + public void Is_Neither_Passes_For_Neither() + { + Assert.IsNeither(Neither); + } + + [Test] + public void Is_Neither_Fails_For_Right() + { + AssertionException? exception = Assert.Throws(() => Assert.IsNeither(Right)); + Assert.That(exception!.Message, Is.EqualTo($"Expected Neither, but was Right({SomeValue}).")); + } + + [Test] + public async Task Is_Some_Async_Returns_The_Value_Async() + { + string value = await Assert.IsSomeAsync(Task.FromResult(Some)); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Some_Async_Fails_For_None() + { + AssertionException? exception = + Assert.ThrowsAsync(() => Assert.IsSomeAsync(Task.FromResult(None))); + Assert.That(exception!.Message, Is.EqualTo("Expected Some, but was None.")); + } + + [Test] + public async Task Is_Some_Async_With_An_Expected_Value_Returns_The_Value_Async() + { + string value = await Assert.IsSomeAsync(Task.FromResult(Some), SomeValue); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Some_Async_With_An_Expected_Value_Fails_For_A_Different_Value() + { + Assert.ThrowsAsync(() => Assert.IsSomeAsync(Task.FromResult(Some), OtherValue)); + } + + [Test] + public async Task Is_None_Async_Passes_For_None_Async() + { + await Assert.IsNoneAsync(Task.FromResult(None)); + } + + [Test] + public void Is_None_Async_Fails_For_Some() + { + AssertionException? exception = + Assert.ThrowsAsync(() => Assert.IsNoneAsync(Task.FromResult(Some))); + Assert.That(exception!.Message, Is.EqualTo($"Expected None, but was Some({SomeValue}).")); + } + + [Test] + public async Task Is_Right_Async_Returns_The_Value_Async() + { + string value = await Assert.IsRightAsync(Task.FromResult(Right)); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Right_Async_Fails_For_Left() + { + AssertionException? exception = + Assert.ThrowsAsync(() => Assert.IsRightAsync(Task.FromResult(Left))); + Assert.That(exception!.Message, Is.EqualTo($"Expected Right, but was Left({LeftValue}).")); + } + + [Test] + public async Task Is_Right_Async_With_An_Expected_Value_Returns_The_Value_Async() + { + string value = await Assert.IsRightAsync(Task.FromResult(Right), SomeValue); + Assert.That(value, Is.EqualTo(SomeValue)); + } + + [Test] + public void Is_Right_Async_With_An_Expected_Value_Fails_For_A_Different_Value() + { + Assert.ThrowsAsync(() => Assert.IsRightAsync(Task.FromResult(Right), OtherValue)); + } + + [Test] + public async Task Is_Left_Async_Returns_The_Value_Async() + { + int value = await Assert.IsLeftAsync(Task.FromResult(Left)); + Assert.That(value, Is.EqualTo(LeftValue)); + } + + [Test] + public void Is_Left_Async_Fails_For_Right() + { + AssertionException? exception = + Assert.ThrowsAsync(() => Assert.IsLeftAsync(Task.FromResult(Right))); + Assert.That(exception!.Message, Is.EqualTo($"Expected Left, but was Right({SomeValue}).")); + } + + [Test] + public async Task Is_Left_Async_With_An_Expected_Value_Returns_The_Value_Async() + { + int value = await Assert.IsLeftAsync(Task.FromResult(Left), LeftValue); + Assert.That(value, Is.EqualTo(LeftValue)); + } + + [Test] + public void Is_Left_Async_With_An_Expected_Value_Fails_For_A_Different_Value() + { + Assert.ThrowsAsync(() => Assert.IsLeftAsync(Task.FromResult(Left), OtherLeftValue)); + } + + [Test] + public async Task Is_Neither_Async_Passes_For_Neither_Async() + { + await Assert.IsNeitherAsync(Task.FromResult(Neither)); + } + + [Test] + public void Is_Neither_Async_Fails_For_Left() + { + AssertionException? exception = + Assert.ThrowsAsync(() => Assert.IsNeitherAsync(Task.FromResult(Left))); + Assert.That(exception!.Message, Is.EqualTo($"Expected Neither, but was Left({LeftValue}).")); + } +} From a8d331efe951a5b0fabc4d51ccdc8e03d30fdb8a Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Fri, 31 Jul 2026 22:03:27 -0500 Subject: [PATCH 2/2] Throw from the monad assertions that return a value Inside an Assert.Multiple scope, Fail records the failure and returns rather than throwing. The assertions that return a value went on to hand back a default, so a test would bind null and report a NullReferenceException in place of the failure message, and the remaining assertions in the scope never ran. There is no value to return once the state is wrong, so these now throw. IsNone and IsNeither still call Fail, so a multiple scope collects them. Adds coverage for the value-returning assertions inside a multiple scope, and for the Neither state appearing in an IsRight or IsLeft failure message. Co-Authored-By: Claude Opus 5 --- Crypter.Test/Assert.cs | 6 +-- Crypter.Test/Common_Tests/Assert_Tests.cs | 56 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/Crypter.Test/Assert.cs b/Crypter.Test/Assert.cs index e07a2633..dd2e52e8 100644 --- a/Crypter.Test/Assert.cs +++ b/Crypter.Test/Assert.cs @@ -48,7 +48,7 @@ internal static T IsSome(Maybe maybe) { if (maybe.IsNone) { - Fail($"Expected Some, but was {Describe(maybe)}."); + throw new AssertionException($"Expected Some, but was {Describe(maybe)}."); } return maybe.SomeOrDefault()!; @@ -83,7 +83,7 @@ internal static TRight IsRight(Either either) { if (!either.IsRight) { - Fail($"Expected Right, but was {Describe(either)}."); + throw new AssertionException($"Expected Right, but was {Describe(either)}."); } return either.RightOrDefault(default!)!; @@ -107,7 +107,7 @@ internal static TLeft IsLeft(Either either) { if (!either.IsLeft) { - Fail($"Expected Left, but was {Describe(either)}."); + throw new AssertionException($"Expected Left, but was {Describe(either)}."); } return either.LeftOrDefault(default!)!; diff --git a/Crypter.Test/Common_Tests/Assert_Tests.cs b/Crypter.Test/Common_Tests/Assert_Tests.cs index 21adbd96..fba26ee1 100644 --- a/Crypter.Test/Common_Tests/Assert_Tests.cs +++ b/Crypter.Test/Common_Tests/Assert_Tests.cs @@ -109,6 +109,13 @@ public void Is_Right_Fails_For_Left() Assert.That(exception!.Message, Is.EqualTo($"Expected Right, but was Left({LeftValue}).")); } + [Test] + public void Is_Right_Fails_For_Neither() + { + AssertionException? exception = Assert.Throws(() => Assert.IsRight(Neither)); + Assert.That(exception!.Message, Is.EqualTo("Expected Right, but was Neither.")); + } + [Test] public void Is_Right_With_An_Expected_Value_Returns_The_Value() { @@ -136,6 +143,13 @@ public void Is_Left_Fails_For_Right() Assert.That(exception!.Message, Is.EqualTo($"Expected Left, but was Right({SomeValue}).")); } + [Test] + public void Is_Left_Fails_For_Neither() + { + AssertionException? exception = Assert.Throws(() => Assert.IsLeft(Neither)); + Assert.That(exception!.Message, Is.EqualTo("Expected Left, but was Neither.")); + } + [Test] public void Is_Left_With_An_Expected_Value_Returns_The_Value() { @@ -162,6 +176,48 @@ public void Is_Neither_Fails_For_Right() Assert.That(exception!.Message, Is.EqualTo($"Expected Neither, but was Right({SomeValue}).")); } + [Test] + public void Is_Some_Inside_A_Multiple_Scope_Does_Not_Return_A_Value() + { + bool returned = false; + + Assert.Throws(() => Assert.Multiple(() => + { + _ = Assert.IsSome(None); + returned = true; + })); + + Assert.That(returned, Is.False); + } + + [Test] + public void Is_Right_Inside_A_Multiple_Scope_Does_Not_Return_A_Value() + { + bool returned = false; + + Assert.Throws(() => Assert.Multiple(() => + { + _ = Assert.IsRight(Left); + returned = true; + })); + + Assert.That(returned, Is.False); + } + + [Test] + public void Is_Left_Inside_A_Multiple_Scope_Does_Not_Return_A_Value() + { + bool returned = false; + + Assert.Throws(() => Assert.Multiple(() => + { + _ = Assert.IsLeft(Right); + returned = true; + })); + + Assert.That(returned, Is.False); + } + [Test] public async Task Is_Some_Async_Returns_The_Value_Async() {