From 6062ca83684ac34499a49b1eacf5822eaefbe9e0 Mon Sep 17 00:00:00 2001 From: Leo Mengesha Date: Fri, 22 May 2026 16:04:03 +0100 Subject: [PATCH 1/5] improvements improvements to thread safety improved safety when calling ValueOr (Option/Result) separated internal vs public api added better documentation on types for clarity and better intellisense usage --- ZeroNull/.github/copilot-instructions.md | 3 + .../Types/Either/EitherTests.cs | 36 +-- .../Types/Either/Rule/RuleValidatorTests.cs | 62 ----- .../Types/Result/ResultTests.cs | 4 +- ZeroNull/ZeroNull/Types/Either/Either.cs | 184 ++++++++++---- ZeroNull/ZeroNull/Types/Either/IEither.cs | 13 +- .../ZeroNull/Types/Either/Root/IRootEither.cs | 11 - .../ZeroNull/Types/Either/Root/RootEither.cs | 102 -------- .../Types/Either/Rule/IRuleValidator.cs | 22 -- .../Types/Either/Rule/RuleValidator.cs | 156 ------------ ZeroNull/ZeroNull/Types/Option/Option.cs | 232 ++++++++++++------ ZeroNull/ZeroNull/Types/Result/Result.cs | 146 ++++++----- 12 files changed, 410 insertions(+), 561 deletions(-) create mode 100644 ZeroNull/.github/copilot-instructions.md delete mode 100644 ZeroNull/ZeroNull.Tests/Types/Either/Rule/RuleValidatorTests.cs delete mode 100644 ZeroNull/ZeroNull/Types/Either/Root/IRootEither.cs delete mode 100644 ZeroNull/ZeroNull/Types/Either/Root/RootEither.cs delete mode 100644 ZeroNull/ZeroNull/Types/Either/Rule/IRuleValidator.cs delete mode 100644 ZeroNull/ZeroNull/Types/Either/Rule/RuleValidator.cs diff --git a/ZeroNull/.github/copilot-instructions.md b/ZeroNull/.github/copilot-instructions.md new file mode 100644 index 0000000..e40201c --- /dev/null +++ b/ZeroNull/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. +- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. +- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. diff --git a/ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs b/ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs index 92c7f90..3434fc5 100644 --- a/ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs +++ b/ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs @@ -8,6 +8,7 @@ public class EitherTests public void Of_WithLeftValue_CreatesLeftEither() { var either = Either.Of("error"); + Assert.True(either.IsLeft); Assert.False(!either.IsLeft); // IsRight Assert.Equal("error", either.Left); @@ -17,6 +18,7 @@ public void Of_WithLeftValue_CreatesLeftEither() public void Of_WithRightValue_CreatesRightEither() { var either = Either.Of(42); + Assert.False(either.IsLeft); Assert.True(!either.IsLeft); // IsRight Assert.Equal(42, either.Right); @@ -26,6 +28,7 @@ public void Of_WithRightValue_CreatesRightEither() public void ImplicitOperator_FromLeft_CreatesLeftEither() { Either either = "error"; + Assert.True(either.IsLeft); Assert.Equal("error", either.Left); } @@ -34,6 +37,7 @@ public void ImplicitOperator_FromLeft_CreatesLeftEither() public void ImplicitOperator_FromRight_CreatesRightEither() { Either either = 42; + Assert.False(either.IsLeft); Assert.Equal(42, either.Right); } @@ -42,6 +46,7 @@ public void ImplicitOperator_FromRight_CreatesRightEither() public void Left_WhenLeft_ReturnsValue() { var either = Either.Of("test"); + Assert.Equal("test", either.Left); } @@ -56,6 +61,7 @@ public void Left_WhenRight_ThrowsInvalidOperationException() public void Right_WhenRight_ReturnsValue() { var either = Either.Of(99); + Assert.Equal(99, either.Right); } @@ -63,6 +69,7 @@ public void Right_WhenRight_ReturnsValue() public void Right_WhenLeft_ThrowsInvalidOperationException() { var either = Either.Of("error"); + Assert.Throws(() => either.Right); } @@ -70,7 +77,9 @@ public void Right_WhenLeft_ThrowsInvalidOperationException() public void GetValue_WithMatchingType_WhenLeft_ReturnsLeftValue() { var either = Either.Of("hello"); + var value = either.GetValue(); + Assert.Equal("hello", value); } @@ -78,7 +87,9 @@ public void GetValue_WithMatchingType_WhenLeft_ReturnsLeftValue() public void GetValue_WithMatchingType_WhenRight_ReturnsRightValue() { var either = Either.Of(123); + var value = either.GetValue(); + Assert.Equal(123, value); } @@ -86,6 +97,7 @@ public void GetValue_WithMatchingType_WhenRight_ReturnsRightValue() public void GetValue_WithNonMatchingType_ThrowsInvalidCastException() { var either = Either.Of("test"); + Assert.Throws(() => either.GetValue()); } @@ -93,6 +105,7 @@ public void GetValue_WithNonMatchingType_ThrowsInvalidCastException() public void GetValue_WithWrongSideType_ThrowsInvalidCastException() { var either = Either.Of(42); + Assert.Throws(() => either.GetValue()); } @@ -100,7 +113,9 @@ public void GetValue_WithWrongSideType_ThrowsInvalidCastException() public void ExplicitCast_ToLeftType_WhenLeft_ReturnsValue() { var either = Either.Of("cast"); + string value = (string)either; + Assert.Equal("cast", value); } @@ -116,6 +131,7 @@ public void ExplicitCast_ToRightType_WhenRight_ReturnsValue() { var either = Either.Of(100); int value = (int)either; + Assert.Equal(100, value); } @@ -123,6 +139,7 @@ public void ExplicitCast_ToRightType_WhenRight_ReturnsValue() public void ExplicitCast_ToRightType_WhenLeft_ThrowsInvalidCastException() { var either = Either.Of("error"); + Assert.Throws(() => (int)either); } @@ -144,29 +161,12 @@ public void Of_WithNullReferenceRight_AllowsNull() Assert.Null(either.RightRawValue); } - [Fact] - public void Dispose_DoesNotThrow() - { - var either = Either.Of("test"); - var exception = Record.Exception(() => either.Dispose()); - - Assert.Null(exception); - } - - [Fact] - public void Dispose_CanBeCalledMultipleTimes() - { - var either = Either.Of(42); - - either.Dispose(); - either.Dispose(); // Should not throw - } - [Fact] public void Either_WithDifferentTypes_Works() { var eitherInt = Either.Of(10); var eitherBool = Either.Of(true); + Assert.Equal(10, eitherInt.Right); Assert.True(eitherBool.Right); } diff --git a/ZeroNull/ZeroNull.Tests/Types/Either/Rule/RuleValidatorTests.cs b/ZeroNull/ZeroNull.Tests/Types/Either/Rule/RuleValidatorTests.cs deleted file mode 100644 index 84e31a2..0000000 --- a/ZeroNull/ZeroNull.Tests/Types/Either/Rule/RuleValidatorTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ZeroNull.Types.Either.Rule; - -namespace ZeroNull.Tests.Types.Either.Rule -{ - public class RuleValidatorTests - { - private RuleValidator _ruleValidator; - - [Fact(Skip = "class needs to be re-created, tests disabled for now")] - public void AddRule_RuleNameForLeftIsNull_ThrowsArgumentException() - { - Assert.Throws(() => - { - _ruleValidator.AddRule(null, value => value > 0); - }); - } - - [Fact(Skip = "class needs to be re-created, tests disabled for now")] - public void AddRule_RuleNameForRightIsNull_ThrowsArgumentException() - { - Assert.Throws(() => - { - _ruleValidator.AddRule(null, value => !string.IsNullOrEmpty(value)); - }); - } - - [Fact(Skip = "class needs to be re-created, tests disabled for now")] - public void AddRule_RuleProvidedForLeft_RuleExists() - { - var ruleName = "greater than 0"; - _ruleValidator.AddRule(ruleName, value => value > 0); - - Assert.True(_ruleValidator.RuleCount == 1); - Assert.True(_ruleValidator.ContainsRule(ruleName)); - } - - [Fact(Skip = "class needs to be re-created, tests disabled for now")] - public void AddRule_RuleProvidedForRight_RuleExists() - { - var ruleName = "not null or empty"; - _ruleValidator.AddRule(ruleName, value => !string.IsNullOrEmpty(value)); - - Assert.True(_ruleValidator.RuleCount == 1); - Assert.True(_ruleValidator.ContainsRule(ruleName)); - } - - [Fact(Skip = "class needs to be re-created, tests disabled for now")] - public void AddRule_RuleProvidedForLeftAndRight_RulesExists() - { - var ruleNameForRight = "not null or empty"; - var ruleNameForLeft = "greater than 0"; - - _ruleValidator - .AddRule(ruleNameForLeft, value => !string.IsNullOrEmpty(value)) - .AddRule(ruleNameForRight, value => value > 0); - - Assert.True(_ruleValidator.RuleCount == 2); - Assert.True(_ruleValidator.ContainsRule(ruleNameForLeft)); - Assert.True(_ruleValidator.ContainsRule(ruleNameForRight)); - } - } -} diff --git a/ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs b/ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs index c8d5846..128a7b3 100644 --- a/ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs +++ b/ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs @@ -1,6 +1,4 @@ -using ZeroNull.Types.Result; - -namespace ZeroNull.Tests.Types.Result +namespace ZeroNull.Tests.Types.Result { public class ResultTests { diff --git a/ZeroNull/ZeroNull/Types/Either/Either.cs b/ZeroNull/ZeroNull/Types/Either/Either.cs index d2db8b0..2d7da10 100644 --- a/ZeroNull/ZeroNull/Types/Either/Either.cs +++ b/ZeroNull/ZeroNull/Types/Either/Either.cs @@ -1,84 +1,172 @@ -using ZeroNull.Types.Either.Root; - -namespace ZeroNull.Types.Either +namespace ZeroNull.Types.Either { public class Either : IEither { - private RootEither _root; - private Type _currentType; - private bool _disposed; + //============= Internal Implementation =============== + + //===================================================== + // getters + //===================================================== - public bool IsLeft => _root.LeftPresent; + internal Func? _leftValidator; + internal Func? _rightValidator; - public TLeft Left => IsLeft ? _root.Left! : throw new InvalidOperationException("Either is Right, no Left value."); - public TRight Right => !IsLeft ? _root.Right! : throw new InvalidOperationException("Either is Left, no Right value."); + internal bool _validateLeftOnAssignment = false; + internal bool _validateRightOnAssignment = false; + + internal TLeft? LeftRawValue => _left; + internal TRight? RightRawValue => _right; + + internal bool _isLeft => _left is not null; + internal bool _isRight => _right is not null; - internal TLeft LeftRawValue => _root.RawLeft; - internal TRight RightRawValue => _root.RawRight; + internal readonly TLeft? _left; + internal readonly TRight? _right; + internal Type _currentType; - private Either(TLeft left) + internal Either(TLeft? left, Func? validator = null, bool validateOnAssignment = false) { - _root = new RootEither(left); + RegisterValidator(validator, validateOnAssignment); - _currentType = typeof(TLeft); + if (validateOnAssignment && left is null) throw new ArgumentNullException(nameof(left)); + + _left = left; + _right = default; + + _currentType = typeof(TLeft); } - private Either(TRight right) + internal Either(TRight right, Func? validator = null, bool validateOnAssignment = false) { - _root = new RootEither(right); + RegisterValidator(null, false, validator, validateOnAssignment); + + if (validateOnAssignment && right is null) throw new ArgumentNullException(nameof(right)); + + _right = right; + _left = default; _currentType = typeof(TRight); } - ~Either() => Dispose(false); + //===================================================== + // Internal methods + //===================================================== + internal void RegisterValidator(Func? leftValidator = null, bool validateLeftOnAssignment = false, Func? rightValidator = null, bool validateRightOnAssignment = false) + { + if (rightValidator is not null) + { + _validateRightOnAssignment = validateRightOnAssignment; + _validateLeftOnAssignment = false; + + _leftValidator = null; + _rightValidator = rightValidator; + + if (validateLeftOnAssignment) Validate(); - public T GetValue() + return; + } + + if (leftValidator is not null) + { + _validateLeftOnAssignment = validateLeftOnAssignment; + _validateRightOnAssignment = false; + + _rightValidator = null; + _leftValidator = leftValidator; + + if (validateLeftOnAssignment) Validate(); + + return; + } + } + + internal TValue GetAssignedValue() { - var type = typeof(T); + var type = typeof(TValue); if (_currentType == type) { - if (_root.LeftPresent) - return (T)Convert.ChangeType(_root.Left, type); - - if (_root.RightPresent) - return (T)Convert.ChangeType(_root.Right, type); + if (_isLeft) + return (TValue)Convert.ChangeType(_left!, type); + + if (_isRight) + return (TValue)Convert.ChangeType(_right!, type); } - throw new InvalidCastException($"Either {typeof(TLeft)} nor {typeof(TRight)} match type: {typeof(T)}"); + throw new InvalidCastException($"Either {typeof(TLeft).Name} nor {typeof(TRight).Name} match type: {type.Name}"); } - - public static Either Of(TLeft value) => new Either(value); - public static Either Of(TRight value) => new Either(value); - // Assignment & Cast Operators + internal bool ValidateValues(bool throwWhenFail = true) + { + if (_leftValidator is not null && _validateLeftOnAssignment && _isLeft) + { + var pass = _leftValidator.Invoke(_left!); + if (!pass && throwWhenFail) + throw new ArgumentException($"Left value failed validation by the given validator."); + + return pass; + } - public static implicit operator Either(TRight right) => new Either(right); - public static implicit operator Either(TLeft left) => new Either(left); + if (_rightValidator is not null && _validateLeftOnAssignment && _isRight) + { + var pass = _rightValidator.Invoke(_right!); + if (!pass && throwWhenFail) + throw new ArgumentException($"Right value failed validation by the given validator."); - public static explicit operator TLeft(Either either) => either.GetValue(); - public static explicit operator TRight(Either either) => either.GetValue(); - - // IDisposable implementation + return pass; + } + + return false; + } + + //==================== Public API ===================== + + //===================================================== + // Getters + //===================================================== + + public bool IsLeft => _isLeft; + public bool IsRight => _isRight; + public TLeft Left => IsLeft ? Left! : throw new InvalidOperationException("Either is Right, no Left value."); + public TRight Right => !IsLeft ? Right! : throw new InvalidOperationException("Either is Left, no Right value."); + + //===================================================== + // methods + //===================================================== - public void Dispose() + public T GetValue() => GetAssignedValue(); + + public void RegisterLeftValidator(Func validator, bool validateOnAssignment = false) { - Dispose(true); - GC.SuppressFinalize(this); + if (validator is null) + throw new ArgumentException($"Given validator is null."); + + RegisterValidator(validator, validateOnAssignment); } - protected virtual void Dispose(bool disposing) + public void RegisterRightValidator(Func validator, bool validateOnAssignment = false) { - if(!_disposed) - { - if (disposing) - { - //_ruleValidator.Dispose(); - _root.Dispose(); - } + if (validator is null) + throw new ArgumentException($"Given validator is null."); - _disposed = true; - } + RegisterValidator(null, false, validator, validateOnAssignment); } + + public void ValidateAndThrow() => ValidateValues(); + + public bool Validate() => ValidateValues(false); + + public static Either Of(TLeft value, Func? validator = null, bool validateOnAssignment = false) => new Either(value, validator, validateOnAssignment); + public static Either Of(TRight value, Func? validator = null, bool validateOnAssignment = false) => new Either(value, validator, validateOnAssignment); + + //===================================================== + // Assignment & Cast Operators + //===================================================== + + public static implicit operator Either(TRight right) => new Either(right, null, true); + public static implicit operator Either(TLeft left) => new Either(left, null, true); + + public static explicit operator TLeft(Either either) => either.GetValue(); + public static explicit operator TRight(Either either) => either.GetValue(); } } diff --git a/ZeroNull/ZeroNull/Types/Either/IEither.cs b/ZeroNull/ZeroNull/Types/Either/IEither.cs index 686202d..54c86ef 100644 --- a/ZeroNull/ZeroNull/Types/Either/IEither.cs +++ b/ZeroNull/ZeroNull/Types/Either/IEither.cs @@ -1,13 +1,14 @@ -using ZeroNull.Types.Either.Rule; - -namespace ZeroNull.Types.Either +namespace ZeroNull.Types.Either { - public interface IEither: IDisposable + public interface IEither { - TLeft Left { get; } - TRight Right { get; } bool IsLeft { get; } + bool IsRight { get; } + void RegisterLeftValidator(Func validator, bool validateOnAssignment = false); + void RegisterRightValidator(Func validator, bool validateOnAssignment = false); + void ValidateAndThrow(); + bool Validate(); T GetValue(); } } diff --git a/ZeroNull/ZeroNull/Types/Either/Root/IRootEither.cs b/ZeroNull/ZeroNull/Types/Either/Root/IRootEither.cs deleted file mode 100644 index 4ed315b..0000000 --- a/ZeroNull/ZeroNull/Types/Either/Root/IRootEither.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ZeroNull.Types.Either.Root -{ - internal interface IRootEither - { - TRight Right { get; } - TLeft Left { get; } - - RootEither Of(TLeft left); - RootEither Of(TRight right); - } -} diff --git a/ZeroNull/ZeroNull/Types/Either/Root/RootEither.cs b/ZeroNull/ZeroNull/Types/Either/Root/RootEither.cs deleted file mode 100644 index 062a9e1..0000000 --- a/ZeroNull/ZeroNull/Types/Either/Root/RootEither.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; - -namespace ZeroNull.Types.Either.Root -{ - internal class RootEither : IRootEither, IDisposable - { - public bool LeftPresent { get; } - public bool RightPresent { get; } - - private TLeft _left; - private TRight _right; - private bool _disposed = false; - - public TLeft Left - { - get - { - if (LeftPresent && !RightPresent) - { - return _left; - } - - throw new FieldAccessException("Value is not present"); - } - } - - public TRight Right - { - get - { - if (RightPresent && !LeftPresent) - { - return _right; - } - - throw new FieldAccessException("Value is not present"); - } - } - - public TLeft RawLeft => _left; - public TRight RawRight => _right; - - public Type LeftType => typeof(TLeft); - public Type RightType => typeof(TRight); - - public RootEither(TLeft left) - { - _left = left; - _right = default; - - LeftPresent = true; - RightPresent = false; - } - - public RootEither(TRight right) - { - _right = right; - _left = default; - - LeftPresent = false; - RightPresent = true; - } - - ~RootEither() => Dispose(false); - - public RootEither Of(TLeft left) => new RootEither(left); - public RootEither Of(TRight right) => new RootEither(right); - - // assignment operators - - public static implicit operator RootEither(TLeft left) => new RootEither(left); - public static implicit operator RootEither(TRight right) => new RootEither(right); - - - public static explicit operator TLeft(RootEither value) => value.Left; - public static explicit operator TRight(RootEither value) => value.Right; - - // GC - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if(!_disposed) - { - if (disposing) - { - // managed resources - - _left = default(TLeft); - _right = default(TRight); - } - - _disposed = true; - } - } - } -} diff --git a/ZeroNull/ZeroNull/Types/Either/Rule/IRuleValidator.cs b/ZeroNull/ZeroNull/Types/Either/Rule/IRuleValidator.cs deleted file mode 100644 index 3c0540b..0000000 --- a/ZeroNull/ZeroNull/Types/Either/Rule/IRuleValidator.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace ZeroNull.Types.Either.Rule -{ - internal interface IRuleValidator : IDisposable - { - bool TerminateOnFail { get; set; } - bool IsLeftValue { get; set; } - int FailedCount { get; } - int RuleCount { get; } - - IRuleValidator AddRule(string ruleName, Func rule); - IRuleValidator AddRule(string ruleName, Func rule); - - IRuleValidator Replace(string ruleName, Func replacement); - IRuleValidator Replace(string ruleName, Func replacement); - - bool ValidateRuleFor(TLeft left); - bool ValidateRuleFor(TRight right); - void ResetRulesForLeftValue(); - void ResetRulesForRightValue(); - bool GetRuleValidationResult(string ruleName); - } -} \ No newline at end of file diff --git a/ZeroNull/ZeroNull/Types/Either/Rule/RuleValidator.cs b/ZeroNull/ZeroNull/Types/Either/Rule/RuleValidator.cs deleted file mode 100644 index fff7e81..0000000 --- a/ZeroNull/ZeroNull/Types/Either/Rule/RuleValidator.cs +++ /dev/null @@ -1,156 +0,0 @@ -namespace ZeroNull.Types.Either.Rule -{ - internal class RuleValidator : IRuleValidator - { - private IDictionary, bool)> _rulesForLeft; - private IDictionary, bool)> _rulesForRight; - private bool _initialized; - private bool _disposed = false; - private int _capacity = 10; - - public IList FailedValidationMessages { get; private set; } - public bool TerminateOnFail { get; set; } - public bool IsLeftValue { get; set; } - public int FailedCount { get; private set; } - public int RuleCount { get; private set; } - - public RuleValidator() => Init(); - - // dispose destructor - ~RuleValidator() => Dispose(false); - - public IRuleValidator AddRule(string ruleName, Func rule) - { - AddRule(ruleName, rule, _rulesForLeft); - return this; - } - - public IRuleValidator AddRule(string ruleName, Func rule) - { - AddRule(ruleName, rule, _rulesForRight); - return this; - } - - public IRuleValidator Replace(string ruleName, Func replacement) - { - _rulesForLeft[ruleName] = (replacement, false); - return this; - } - public IRuleValidator Replace(string ruleName, Func replacement) - { - _rulesForRight[ruleName] = (replacement, false); - return this; - } - - public bool ValidateRuleFor(TLeft value) => ValidateRuleFor(value, _rulesForLeft); - public bool ValidateRuleFor(TRight value) => ValidateRuleFor(value, _rulesForRight); - - public void ResetRulesForLeftValue() => _rulesForLeft.Clear(); - public void ResetRulesForRightValue() => _rulesForRight.Clear(); - - public bool ContainsRule(string ruleName) => _rulesForLeft.ContainsKey(ruleName) || _rulesForRight.ContainsKey(ruleName); - - public bool GetRuleValidationResult(string ruleName) - { - if (_rulesForLeft.TryGetValue(ruleName, out var leftRuleDetails)) - { - return leftRuleDetails.Item2; - } - - if (_rulesForRight.TryGetValue(ruleName, out var rightRuleDetails)) - { - return rightRuleDetails.Item2; - } - - throw new KeyNotFoundException("Rule not found"); - } - - // Private Methods - - private void Init() - { - if (!_initialized) - { - _rulesForLeft = new Dictionary, bool)>(_capacity); - _rulesForRight = new Dictionary, bool)>(_capacity); - FailedValidationMessages = new List(_capacity); - TerminateOnFail = false; - - _initialized = true; - } - } - - private void AddRule(string ruleName, Func rule, IDictionary, bool)> ruleContainer) - { - if(string.IsNullOrWhiteSpace(ruleName)) - { - throw new ArgumentException("Rule must have a name"); - } - - RuleCount++; - - ruleContainer.Add(ruleName, (rule, false) ); - } - - private bool ValidateRuleFor(T value, IDictionary, bool)> ruleContainer) - { - if(value == null) - { - throw new ArgumentException("Value is null"); - } - - if(ruleContainer.Count == 0) - { - return true; - } - - for(int index = 0; index < ruleContainer.Count; ++index) - { - var ruleName = ruleContainer.Keys.ElementAt(index); - var rule = ruleContainer[ruleName].Item1; - - if (TerminateOnFail && !rule.Invoke(value)) - { - FailedCount++; - return false; - } - - if (!TerminateOnFail && !rule.Invoke(value)) - { - FailedValidationMessages.Add($"Value failed rule {ruleName} on validation"); - FailedCount++; - continue; - } - - ruleContainer[ruleName] = (ruleContainer[ruleName].Item1, true); - } - - return !TerminateOnFail && FailedValidationMessages.Count > 0 || true; - } - - // GC - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - // managed resources - - _rulesForLeft = null; - _rulesForRight = null; - FailedValidationMessages = null; - } - - _disposed = true; - } - } - } -} \ No newline at end of file diff --git a/ZeroNull/ZeroNull/Types/Option/Option.cs b/ZeroNull/ZeroNull/Types/Option/Option.cs index e05da5d..4a0a088 100644 --- a/ZeroNull/ZeroNull/Types/Option/Option.cs +++ b/ZeroNull/ZeroNull/Types/Option/Option.cs @@ -1,77 +1,165 @@ -namespace ZeroNull.Types.Option +using ZeroNull.Types.Option; + +public readonly struct Option : IOption { - using System; + // Internal state – the actual implementation details + internal readonly T? _value; + internal readonly bool _hasValue; - /// - /// Represents an optional value that can be either Some (has a value) or None (missing). - /// - public readonly struct Option: IOption + internal Option(T? value, bool hasValue) + { + _value = value; + _hasValue = hasValue; + } + + // ========== INTERNAL IMPLEMENTATION (the real logic) ========== + internal static Option NoneImpl() + { + // Note: default! is still used here, but it's internal. + // Public callers only see None(). + return new Option(default!, false); + } + + internal static Option SomeImpl(T value) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + return new Option(value, true); + } + + internal bool IsSomeImpl => _hasValue; + internal bool IsNoneImpl => !_hasValue; + + internal T ValueImpl => + IsSomeImpl ? _value! : throw new InvalidOperationException("Option has no value."); + + internal Option MapImpl(Func mapper) + { + if (mapper == null) throw new ArgumentNullException(nameof(mapper)); + return IsSomeImpl ? Option.SomeImpl(mapper(_value!)) : Option.NoneImpl(); + } + + internal Option BindImpl(Func> binder) { - private readonly T _value; - private readonly bool _hasValue; - - private Option(T value, bool hasValue) - { - _value = value; - _hasValue = hasValue; - } - - public static Option Some(T value) - { - if (value == null) - throw new ArgumentNullException(nameof(value)); - return new Option(value, true); - } - - public static Option None() => new Option(default!, false); - - public bool IsSome => _hasValue; - public bool IsNone => !_hasValue; - - public T Value => - IsSome ? _value! : throw new InvalidOperationException("Option has no value."); - - // Core functional methods - public Option Map(Func mapper) - { - if (mapper == null) throw new ArgumentNullException(nameof(mapper)); - return IsSome ? Option.Some(mapper(_value!)) : Option.None(); - } - - public Option Bind(Func> binder) - { - if (binder == null) throw new ArgumentNullException(nameof(binder)); - return IsSome ? binder(_value!) : Option.None(); - } - - public T ValueOr(T fallback) { - if (fallback == null) - throw new ArgumentNullException(nameof(fallback)); - - return IsSome? _value! : fallback; - } - - public T ValueOr(Func fallbackFactory) - { - if (fallbackFactory == null) throw new ArgumentNullException(nameof(fallbackFactory)); - - return IsSome ? _value! : fallbackFactory(); - } - - // Exhaustive matching - public TResult Match(Func some, Func none) - { - if (some == null) throw new ArgumentNullException(nameof(some)); - if (none == null) throw new ArgumentNullException(nameof(none)); - return IsSome ? some(_value!) : none(); - } - - // LINQ support (optional but nice) - public Option Select(Func mapper) => Map(mapper); - - public Option SelectMany(Func> bind, Func project) - { - return Bind(x => bind(x).Map(y => project(x, y))); - } + if (binder == null) throw new ArgumentNullException(nameof(binder)); + return IsSomeImpl ? binder(_value!) : Option.NoneImpl(); } + + internal T ValueOrImpl(T? fallback) + { + if (fallback == null) + throw new ArgumentNullException(nameof(fallback)); + return IsSomeImpl ? _value! : fallback; + } + + internal T ValueOrImpl(Func fallbackFactory) + { + if (fallbackFactory == null) throw new ArgumentNullException(nameof(fallbackFactory)); + return IsSomeImpl ? _value! : fallbackFactory(); + } + + internal TResult MatchImpl(Func some, Func none) + { + if (some == null) throw new ArgumentNullException(nameof(some)); + if (none == null) throw new ArgumentNullException(nameof(none)); + return IsSomeImpl ? some(_value!) : none(); + } + + internal Option SelectImpl(Func mapper) => MapImpl(mapper); + + internal Option SelectManyImpl(Func> bind, Func project) + { + return BindImpl(x => bind(x).MapImpl(y => project(x, y))); + } + + // ========== PUBLIC API (thin wrappers over internal methods) ========== + + /// + /// Creates an option that contains the specified value. + /// + /// The value to wrap in an option. + /// An option containing the specified value. + public static Option Some(T value) => SomeImpl(value); + + /// + /// Creates an option that contains no value. + /// + /// An option representing the absence of a value. + public static Option None() => NoneImpl(); + + /// + /// Indicates whether the option contains a value. + /// + public bool IsSome => IsSomeImpl; + + /// + /// Indicates whether the option does not contains a value. + /// + public bool IsNone => IsNoneImpl; + + /// + /// Generic type value provided. + /// + public T Value => ValueImpl; + + /// + /// Maps the type onto a different type than the given one or the same. + /// + /// Result type. + /// Mapper function. + /// The result generic type produced by the mapping fucntion. + public Option Map(Func mapper) => MapImpl(mapper); + + /// + /// Projects the value of the current option into a new option using the specified binding function. + /// + /// The type of the value contained in the resulting option. + /// A function that maps the contained value to an option of type U. + /// An option containing the result of the binding function, or an empty option if the current option is empty. + public Option Bind(Func> binder) => BindImpl(binder); + + /// + /// Returns the current value if present; otherwise, returns the fallback value provided. + /// + /// fallback value when current value is not available. + /// the current value or fallback value provided. + public T ValueOr(T? fallback) => ValueOrImpl(fallback); + + /// + /// Returns the current value if present; otherwise, invokes the specified factory function to provide a fallback + /// value. + /// + /// A function that produces a fallback value when the current value is not available. + /// The current value or the value produced by the fallback factory. + public T ValueOr(Func fallbackFactory) => ValueOrImpl(fallbackFactory); + + /// + /// Invokes a function based on whether a value is present or absent. + /// + /// The type of the result returned by the invoked function. + /// A function to execute if a value is present. + /// A function to execute if no value is present. + /// The result of the executed function. + public TResult Match(Func some, Func none) => MatchImpl(some, none); + + /// + /// Projects the value of the current option into a new form using the specified mapping function. + /// + /// The type of the value returned by the mapping function. + /// A function to transform the value of the current option. + /// An option containing the result of the mapping function if the current option has a value; otherwise, an empty + /// option. + public Option Select(Func mapper) => SelectImpl(mapper); + + /// + /// Projects each value of the option into an option and flattens the resulting options into a single option. + /// + /// The type of the intermediate value returned by the bind function. + /// The type of the result value returned by the project function. + /// A function to transform the option's value into an intermediate option. + /// A function to combine the original value and the intermediate value into a result value. + /// An option containing the projected value if both the original and intermediate options have values; otherwise, + /// an empty option. + public Option SelectMany(Func> bind, Func project) + => SelectManyImpl(bind, project); } \ No newline at end of file diff --git a/ZeroNull/ZeroNull/Types/Result/Result.cs b/ZeroNull/ZeroNull/Types/Result/Result.cs index 75d825b..efd02df 100644 --- a/ZeroNull/ZeroNull/Types/Result/Result.cs +++ b/ZeroNull/ZeroNull/Types/Result/Result.cs @@ -1,82 +1,106 @@ - +using System; -namespace ZeroNull.Types.Result +public readonly struct Result { - /// - /// Represents a computation that can either succeed with a value (Ok) or fail with an error (Error). - /// - public readonly struct Result - { - private readonly T? _value; - private readonly TError? _error; - private readonly bool _isOk; + // Internal state + private readonly T? _value; + private readonly TError? _error; + private readonly bool _isOk; - private Result(T? value, TError? error, bool isOk) - { - _value = value; - _error = error; - _isOk = isOk; - } + private Result(T? value, TError? error, bool isOk) + { + _value = value; + _error = error; + _isOk = isOk; + } - public static Result Ok(T value) => - new Result(value, default, true); + // ========== INTERNAL IMPLEMENTATION ========== + internal static Result OkImpl(T value) + { + // Original doesn't check for null here; we keep same behavior. + return new Result(value, default, true); + } - public static Result Error(TError error) => - new Result(default, error, false); + internal static Result ErrorImpl(TError error) + { + return new Result(default, error, false); + } - public bool IsOk => _isOk; - public bool IsError => !_isOk; + internal bool IsOkImpl => _isOk; + internal bool IsErrorImpl => !_isOk; - public T Value => - IsOk ? _value! : throw new InvalidOperationException("Cannot access value of an error result."); + internal T ValueImpl => + IsOkImpl ? _value! : throw new InvalidOperationException("Cannot access value of an error result."); - public TError ErrorValue => - IsError ? _error! : throw new InvalidOperationException("Cannot access error of a success result."); + internal TError ErrorValueImpl => + IsErrorImpl ? _error! : throw new InvalidOperationException("Cannot access error of a success result."); - // Core functional methods - public Result Map(Func mapper) - { - if (mapper == null) throw new ArgumentNullException(nameof(mapper)); + internal Result MapImpl(Func mapper) + { + if (mapper == null) throw new ArgumentNullException(nameof(mapper)); - return IsOk ? Result.Ok(mapper(_value!)) : Result.Error(_error!); - } + return IsOkImpl + ? Result.OkImpl(mapper(_value!)) + : Result.ErrorImpl(_error!); + } - public Result Bind(Func> binder) - { - if (binder == null) throw new ArgumentNullException(nameof(binder)); + internal Result BindImpl(Func> binder) + { + if (binder == null) throw new ArgumentNullException(nameof(binder)); - return IsOk ? binder(_value!) : Result.Error(_error!); - } + return IsOkImpl ? binder(_value!) : Result.ErrorImpl(_error!); + } - public Result MapError(Func mapper) - { - if (mapper == null) throw new ArgumentNullException(nameof(mapper)); + internal Result MapErrorImpl(Func mapper) + { + if (mapper == null) throw new ArgumentNullException(nameof(mapper)); - return IsError ? Result.Error(mapper(_error!)) : Result.Ok(_value!); - } + return IsErrorImpl + ? Result.ErrorImpl(mapper(_error!)) + : Result.OkImpl(_value!); + } - public T ValueOr(T fallback) => IsOk ? _value! : fallback; + internal T ValueOrImpl(T fallback) => IsOkImpl ? _value! : fallback; - // Exhaustive matching - public TResult Match(Func ok, Func error) - { - if (ok == null) throw new ArgumentNullException(nameof(ok)); - if (error == null) throw new ArgumentNullException(nameof(error)); + internal TResult MatchImpl(Func ok, Func error) + { + if (ok == null) throw new ArgumentNullException(nameof(ok)); + if (error == null) throw new ArgumentNullException(nameof(error)); - return IsOk ? ok(_value!) : error(_error!); - } + return IsOkImpl ? ok(_value!) : error(_error!); + } - // LINQ support - public Result Select(Func mapper) => Map(mapper); + internal Result SelectImpl(Func mapper) => MapImpl(mapper); - public Result SelectMany( - Func> bind, - Func project) - { - if (bind == null) throw new ArgumentNullException(nameof(bind)); - if (project == null) throw new ArgumentNullException(nameof(project)); + internal Result SelectManyImpl( + Func> bind, + Func project) + { + if (bind == null) throw new ArgumentNullException(nameof(bind)); + if (project == null) throw new ArgumentNullException(nameof(project)); - return Bind(x => bind(x).Map(y => project(x, y))); - } + return BindImpl(x => bind(x).MapImpl(y => project(x, y))); } -} + + // ========== PUBLIC API ========== + public static Result Ok(T value) => OkImpl(value); + public static Result Error(TError error) => ErrorImpl(error); + + public bool IsOk => IsOkImpl; + public bool IsError => IsErrorImpl; + + public T Value => ValueImpl; + public TError ErrorValue => ErrorValueImpl; + + public Result Map(Func mapper) => MapImpl(mapper); + public Result Bind(Func> binder) => BindImpl(binder); + public Result MapError(Func mapper) => MapErrorImpl(mapper); + public T ValueOr(T fallback) => ValueOrImpl(fallback); + public TResult Match(Func ok, Func error) => MatchImpl(ok, error); + + // LINQ support + public Result Select(Func mapper) => SelectImpl(mapper); + public Result SelectMany( + Func> bind, + Func project) => SelectManyImpl(bind, project); +} \ No newline at end of file From 52ea9cb10bb4a1af9a478f6c5393c3632d9491c6 Mon Sep 17 00:00:00 2001 From: Leo Mengesha Date: Fri, 22 May 2026 17:08:03 +0100 Subject: [PATCH 2/5] added Validation monad skeleton & tests for TDD approach --- .../Types/Validation/ValidationTests.cs | 307 ++++++++++++++++++ ZeroNull/ZeroNull/Types/Result/Result.cs | 4 +- .../ZeroNull/Types/Validation/IValidation.cs | 45 +++ .../ZeroNull/Types/Validation/Validation.cs | 82 +++++ 4 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs create mode 100644 ZeroNull/ZeroNull/Types/Validation/IValidation.cs create mode 100644 ZeroNull/ZeroNull/Types/Validation/Validation.cs diff --git a/ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs b/ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs new file mode 100644 index 0000000..62397f7 --- /dev/null +++ b/ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs @@ -0,0 +1,307 @@ +using System.Collections.Immutable; +using ZeroNull.Types.Validation; + +namespace ZeroNull.Tests.Types.Validation +{ + public class ValidationTests + { + // ---------- Factory methods and state (implemented) ---------- + [Fact] + public void Valid_ShouldCreateValidInstance() + { + var valid = Validation.Valid(42); + Assert.True(valid.IsValid); + Assert.False(valid.IsInvalid); + Assert.Equal(42, valid.Value); + Assert.Equal(ImmutableArray.Empty, valid.Errors); + } + + [Fact] + public void Invalid_SingleError_ShouldCreateInvalidInstance() + { + var invalid = Validation.Invalid("Too big"); + Assert.True(invalid.IsInvalid); + Assert.False(invalid.IsValid); + Assert.Throws(() => invalid.Value); + Assert.Equal(new[] { "Too big" }, invalid.Errors); + } + + [Fact] + public void Invalid_MultipleErrors_ShouldCreateInvalidInstance() + { + var invalid = Validation.Invalid("Too big", "Negative"); + Assert.True(invalid.IsInvalid); + Assert.Equal(new[] { "Too big", "Negative" }, invalid.Errors); + } + + [Fact] + public void Valid_WithNullValue_ShouldBeAllowedForReferenceTypes() + { + var valid = Validation.Valid(null); + Assert.True(valid.IsValid); + Assert.Null(valid.Value); + } + + [Fact] + public void Invalid_WithNullError_ShouldBeAllowed() + { + var invalid = Validation.Invalid((string?)null); + Assert.True(invalid.IsInvalid); + Assert.Equal(new string?[] { null }, invalid.Errors); + } + + // ---------- Map (not implemented) ---------- + [Fact(Skip = "Map method not implemented")] + public void Map_OnValid_ShouldTransformValue() + { + var valid = Validation.Valid(5); + var result = valid.Map(x => x * 2); + Assert.True(result.IsValid); + Assert.Equal(10, result.Value); + } + + [Fact(Skip = "Map method not implemented")] + public void Map_OnInvalid_ShouldPropagateErrors() + { + var invalid = Validation.Invalid("Error"); + var result = invalid.Map(x => x * 2); + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error" }, result.Errors); + } + + // ---------- Bind (not implemented) ---------- + [Fact(Skip = "Bind method not implemented")] + public void Bind_OnValid_ShouldApplyBinder() + { + var valid = Validation.Valid(10); + var result = valid.Bind(x => Validation.Valid($"Value: {x}")); + Assert.True(result.IsValid); + Assert.Equal("Value: 10", result.Value); + } + + [Fact(Skip = "Bind method not implemented")] + public void Bind_OnInvalid_ShouldPropagateErrors() + { + var invalid = Validation.Invalid("Original error"); + var result = invalid.Bind(x => Validation.Valid("ignored")); + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Original error" }, result.Errors); + } + + // ---------- Apply (corrected tests) ---------- + + [Fact(Skip = "Apply method not implemented")] + public void Apply_WithBothValid_AppliesFunction() + { + var validValue = Validation.Valid(5); + var validFunc = Validation>.Valid(x => $"Number {x}"); + var result = validValue.Apply(validFunc); + Assert.True(result.IsValid); + Assert.Equal("Number 5", result.Value); + } + + [Fact(Skip = "Apply method not implemented")] + public void Apply_WithLeftInvalid_PropagatesErrors() + { + var left = Validation.Invalid("Left error"); + var right = Validation>.Valid(x => x.ToString()); + + var result = left.Apply(right); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Left error" }, result.Errors); + } + + [Fact(Skip = "Apply method not implemented")] + public void Apply_WithRightInvalid_PropagatesErrors() + { + var left = Validation.Valid(3); + var right = Validation>.Invalid("Right error"); + + var result = left.Apply(right); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Right error" }, result.Errors); + } + + [Fact(Skip = "Apply method not implemented")] + public void Apply_WithBothInvalid_AccumulatesAllErrors() + { + var left = Validation.Invalid("Left error"); + var right = Validation>.Invalid("Right error"); + + var result = left.Apply(right); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Left error", "Right error" }, result.Errors); + } + + // ---------- Combine (not implemented) ---------- + [Fact(Skip = "Combine method not implemented")] + public void Combine_WithBothValid_ShouldCombineValues() + { + var a = Validation.Valid(5); + var b = Validation.Valid(3); + + var result = a.Combine(b, (x, y) => x + y); + + Assert.True(result.IsValid); + Assert.Equal(8, result.Value); + } + + [Fact(Skip = "Combine method not implemented")] + public void Combine_WithLeftInvalid_ShouldReturnLeftErrors() + { + var a = Validation.Invalid("Left error"); + var b = Validation.Valid(3); + + var result = a.Combine(b, (x, y) => x + y); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Left error" }, result.Errors); + } + + [Fact(Skip = "Combine method not implemented")] + public void Combine_WithBothInvalid_ShouldAccumulateBothErrors() + { + var a = Validation.Invalid("Error A"); + var b = Validation.Invalid("Error B"); + + var result = a.Combine(b, (x, y) => x + y); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error A", "Error B" }, result.Errors); + } + + // ---------- ValueOr (not implemented) ---------- + [Fact(Skip = "ValueOr method not implemented")] + public void ValueOr_OnValid_ReturnsValue() + { + var valid = Validation.Valid(42); + + Assert.Equal(42, valid.ValueOr(100)); + Assert.Equal(42, valid.ValueOr(() => 100)); + } + + [Fact(Skip = "ValueOr method not implemented")] + public void ValueOr_OnInvalid_ReturnsFallback() + { + var invalid = Validation.Invalid("Error"); + + Assert.Equal(100, invalid.ValueOr(100)); + Assert.Equal(200, invalid.ValueOr(() => 200)); + } + + [Fact(Skip = "ValueOr method not implemented")] + public void ValueOr_FallbackFactory_ShouldBeCalledOnlyWhenInvalid() + { + int callCount = 0; + var valid = Validation.Valid(5); + + var result = valid.ValueOr(() => { callCount++; return 99; }); + + Assert.Equal(5, result); + Assert.Equal(0, callCount); + + var invalid = Validation.Invalid("err"); + + result = invalid.ValueOr(() => { callCount++; return 99; }); + + Assert.Equal(99, result); + Assert.Equal(1, callCount); + } + + // ---------- Match (not implemented) ---------- + [Fact(Skip = "Match method not implemented")] + public void Match_OnValid_CallsValidBranch() + { + var valid = Validation.Valid(10); + + var result = valid.Match( + valid: v => $"Value: {v}", + invalid: e => $"Errors: {string.Join(",", e)}" + ); + + Assert.Equal("Value: 10", result); + } + + [Fact(Skip = "Match method not implemented")] + public void Match_OnInvalid_CallsInvalidBranch() + { + var invalid = Validation.Invalid("Bad", "Worse"); + var result = invalid.Match( + valid: v => $"Value: {v}", + invalid: e => $"Errors: {string.Join(",", e)}" + ); + Assert.Equal("Errors: Bad,Worse", result); + } + + // ---------- Select (LINQ) - not implemented ---------- + [Fact(Skip = "Select method not implemented")] + public void Select_OnValid_Transforms() + { + var valid = Validation.Valid(3); + var result = from x in valid select x * 2; + Assert.True(result.IsValid); + Assert.Equal(6, result.Value); + } + + [Fact(Skip = "Select method not implemented")] + public void Select_OnInvalid_PropagatesErrors() + { + var invalid = Validation.Invalid("Error"); + var result = from x in invalid select x * 2; + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error" }, result.Errors); + } + + // ---------- SelectMany (LINQ) - not implemented ---------- + [Fact(Skip = "SelectMany method not implemented")] + public void SelectMany_WithBothValid_Projects() + { + var v1 = Validation.Valid(2); + var v2 = Validation.Valid(3); + var result = from a in v1 + from b in v2 + select a + b; + Assert.True(result.IsValid); + Assert.Equal(5, result.Value); + } + + [Fact(Skip = "SelectMany method not implemented")] + public void SelectMany_WithFirstInvalid_ShortCircuits() + { + var v1 = Validation.Invalid("First error"); + var v2 = Validation.Valid(3); + var result = from a in v1 + from b in v2 + select a + b; + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "First error" }, result.Errors); + } + + [Fact(Skip = "SelectMany method not implemented")] + public void SelectMany_WithSecondInvalid_AccumulatesErrors() + { + var v1 = Validation.Valid(2); + var v2 = Validation.Invalid("Second error"); + var result = from a in v1 + from b in v2 + select a + b; + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Second error" }, result.Errors); + } + + [Fact(Skip = "SelectMany method not implemented")] + public void SelectMany_WithBothInvalid_AccumulatesAllErrors() + { + var v1 = Validation.Invalid("First error"); + var v2 = Validation.Invalid("Second error"); + var result = from a in v1 + from b in v2 + select a + b; + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "First error", "Second error" }, result.Errors); + } + } +} diff --git a/ZeroNull/ZeroNull/Types/Result/Result.cs b/ZeroNull/ZeroNull/Types/Result/Result.cs index efd02df..3a355df 100644 --- a/ZeroNull/ZeroNull/Types/Result/Result.cs +++ b/ZeroNull/ZeroNull/Types/Result/Result.cs @@ -1,6 +1,4 @@ -using System; - -public readonly struct Result +public readonly struct Result { // Internal state private readonly T? _value; diff --git a/ZeroNull/ZeroNull/Types/Validation/IValidation.cs b/ZeroNull/ZeroNull/Types/Validation/IValidation.cs new file mode 100644 index 0000000..8b2e339 --- /dev/null +++ b/ZeroNull/ZeroNull/Types/Validation/IValidation.cs @@ -0,0 +1,45 @@ +using System.Collections.Immutable; + +namespace ZeroNull.Types.Validation +{ + /// + /// Represents a validation result that is either valid (with a value) + /// or invalid (with one or more errors). + /// + /// The type of validation errors (e.g., string, custom error record). + /// The type of the valid value. + public interface IValidation + { + // State checks + bool IsValid { get; } + bool IsInvalid { get; } + + // Accessors (throw if accessed in wrong state) + TValue Value { get; } + ImmutableArray Errors { get; } + + // Core monadic operations + IValidation Map(Func mapper); + IValidation Bind(Func> binder); + + /// + /// Applicative apply: combines two validations, accumulating errors from both. + /// + IValidation Apply(IValidation> validator); + + // Error accumulation – combine with another validation + IValidation Combine(IValidation other, Func combineValues); + + // Fallback / matching + TValue ValueOr(TValue fallback); + TValue ValueOr(Func fallbackFactory); + TResult Match(Func valid, Func, TResult> invalid); + + // LINQ support (query comprehension) + IValidation Select(Func selector); + IValidation SelectMany( + Func> bind, + Func project); + } + +} diff --git a/ZeroNull/ZeroNull/Types/Validation/Validation.cs b/ZeroNull/ZeroNull/Types/Validation/Validation.cs new file mode 100644 index 0000000..6d14472 --- /dev/null +++ b/ZeroNull/ZeroNull/Types/Validation/Validation.cs @@ -0,0 +1,82 @@ +using System.Collections.Immutable; + +namespace ZeroNull.Types.Validation +{ + public readonly struct Validation : IValidation + { + private readonly TValue? _value; + private readonly ImmutableArray _errors; + private readonly bool _isValid; + + private Validation(TValue? value, ImmutableArray errors, bool isValid) + { + _value = value; + _errors = errors; + _isValid = isValid; + } + + // Internal constructors + internal static Validation ValidImpl(TValue value) => new(value, default, true); + internal static Validation InvalidImpl(ImmutableArray errors) => new(default, errors, false); + internal static Validation InvalidImpl(TError error) => new(default, [error], false); + + // Public factory methods + public static Validation Valid(TValue value) => ValidImpl(value); + public static Validation Invalid(TError error) => InvalidImpl(error); + public static Validation Invalid(params TError[] errors) => InvalidImpl(errors.ToImmutableArray()); + + public IValidation Map(Func mapper) + { + throw new NotImplementedException(); + } + + public IValidation Bind(Func> binder) + { + throw new NotImplementedException(); + } + + public IValidation Apply(IValidation> validator) + { + throw new NotImplementedException(); + } + + public IValidation Combine(IValidation other, Func combineValues) + { + throw new NotImplementedException(); + } + + public TValue ValueOr(TValue fallback) + { + throw new NotImplementedException(); + } + + public TValue ValueOr(Func fallbackFactory) + { + throw new NotImplementedException(); + } + + public TResult Match(Func valid, Func, TResult> invalid) + { + throw new NotImplementedException(); + } + + public IValidation Select(Func selector) + { + throw new NotImplementedException(); + } + + public IValidation SelectMany(Func> bind, Func project) + { + throw new NotImplementedException(); + } + + // State checks + public bool IsValid => _isValid; + public bool IsInvalid => !_isValid; + public TValue Value => IsValid ? _value! : throw new InvalidOperationException("Value provided is not valid"); + public ImmutableArray Errors => IsInvalid ? _errors : ImmutableArray.Empty; + + // Map, Bind, Apply, Combine, Match, ValueOr, Select, SelectMany... + // (all delegate to internal implementation methods) + } +} From 123c4b0da27e77bdd39b32a1b5c6dd195ac817c6 Mon Sep 17 00:00:00 2001 From: Leo Mengesha Date: Wed, 27 May 2026 18:10:06 +0100 Subject: [PATCH 3/5] improvements & updates - removed interfaces - added Equals & GetHashCode to all types - ensure types are thread safe & immutable (readonly structs) - added more functionality to each type - added more tests to cover more edge-cases --- .../Extensions/EitherExtensionsTests.cs | 334 ------- .../Types/Either/EitherTests.cs | 174 ---- ZeroNull/ZeroNull.Tests/Types/EitherTests.cs | 911 ++++++++++++++++++ .../Types/Option/OptionTests.cs | 233 ----- ZeroNull/ZeroNull.Tests/Types/OptionTests.cs | 576 +++++++++++ .../Types/Result/ResultTests.cs | 312 ------ ZeroNull/ZeroNull.Tests/Types/ResultTests.cs | 640 ++++++++++++ .../Types/Validation/ValidationTests.cs | 307 ------ .../ZeroNull.Tests/Types/ValidationTests.cs | 793 +++++++++++++++ ZeroNull/ZeroNull/Enums/EitherState.cs | 9 + ZeroNull/ZeroNull/Enums/OptionState.cs | 9 + ZeroNull/ZeroNull/Enums/ResultState.cs | 9 + ZeroNull/ZeroNull/Enums/ValidationState.cs | 9 + .../ZeroNull/Extensions/EitherExtensions.cs | 133 --- ZeroNull/ZeroNull/Types/Either.cs | 362 +++++++ ZeroNull/ZeroNull/Types/Either/Either.cs | 172 ---- ZeroNull/ZeroNull/Types/Either/IEither.cs | 14 - .../ZeroNull/Types/{Option => }/Option.cs | 55 +- ZeroNull/ZeroNull/Types/Option/IOption.cs | 18 - .../ZeroNull/Types/{Result => }/Result.cs | 83 +- ZeroNull/ZeroNull/Types/Validation.cs | 308 ++++++ .../ZeroNull/Types/Validation/IValidation.cs | 45 - .../ZeroNull/Types/Validation/Validation.cs | 82 -- 23 files changed, 3737 insertions(+), 1851 deletions(-) delete mode 100644 ZeroNull/ZeroNull.Tests/Extensions/EitherExtensionsTests.cs delete mode 100644 ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs create mode 100644 ZeroNull/ZeroNull.Tests/Types/EitherTests.cs delete mode 100644 ZeroNull/ZeroNull.Tests/Types/Option/OptionTests.cs create mode 100644 ZeroNull/ZeroNull.Tests/Types/OptionTests.cs delete mode 100644 ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs create mode 100644 ZeroNull/ZeroNull.Tests/Types/ResultTests.cs delete mode 100644 ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs create mode 100644 ZeroNull/ZeroNull.Tests/Types/ValidationTests.cs create mode 100644 ZeroNull/ZeroNull/Enums/EitherState.cs create mode 100644 ZeroNull/ZeroNull/Enums/OptionState.cs create mode 100644 ZeroNull/ZeroNull/Enums/ResultState.cs create mode 100644 ZeroNull/ZeroNull/Enums/ValidationState.cs delete mode 100644 ZeroNull/ZeroNull/Extensions/EitherExtensions.cs create mode 100644 ZeroNull/ZeroNull/Types/Either.cs delete mode 100644 ZeroNull/ZeroNull/Types/Either/Either.cs delete mode 100644 ZeroNull/ZeroNull/Types/Either/IEither.cs rename ZeroNull/ZeroNull/Types/{Option => }/Option.cs (80%) delete mode 100644 ZeroNull/ZeroNull/Types/Option/IOption.cs rename ZeroNull/ZeroNull/Types/{Result => }/Result.cs (59%) create mode 100644 ZeroNull/ZeroNull/Types/Validation.cs delete mode 100644 ZeroNull/ZeroNull/Types/Validation/IValidation.cs delete mode 100644 ZeroNull/ZeroNull/Types/Validation/Validation.cs diff --git a/ZeroNull/ZeroNull.Tests/Extensions/EitherExtensionsTests.cs b/ZeroNull/ZeroNull.Tests/Extensions/EitherExtensionsTests.cs deleted file mode 100644 index c418d15..0000000 --- a/ZeroNull/ZeroNull.Tests/Extensions/EitherExtensionsTests.cs +++ /dev/null @@ -1,334 +0,0 @@ -using System; -using Xunit; -using ZeroNull.Extensions; -using ZeroNull.Types.Either; - -namespace ZeroNull.Tests.Extensions -{ - public class EitherExtensionsTests - { - private static Either Left(TLeft value) => Either.Of(value); - private static Either Right(TRight value) => Either.Of(value); - - - [Fact] - public void Select_OnRight_ProjectsValue() - { - var either = Right(5); - var result = either.Select(x => x * 2); - - Assert.False(result.IsLeft); - Assert.Equal(10, result.Right); - } - - [Fact] - public void Select_OnLeft_PropagatesLeft() - { - var either = Left("error"); - var result = either.Select(x => x * 2); - - Assert.True(result.IsLeft); - Assert.Equal("error", result.Left); - } - - [Fact] - public void Select_WithNullSelector_Throws() - { - var either = Right(5); - - Assert.Throws(() => either.Select(null!)); - } - - [Fact] - public void SelectMany_OnRightRight_Combines() - { - var either = Right(2); - - var result = either.SelectMany( - x => Right(x * 3), - (a, b) => a + b - ); - - Assert.False(result.IsLeft); - Assert.Equal(8, result.Right); - } - - [Fact] - public void SelectMany_FirstIsLeft_PropagatesLeft() - { - var either = Left("first error"); - - var result = either.SelectMany( - x => Right(x * 3), - (a, b) => a + b - ); - - Assert.True(result.IsLeft); - Assert.Equal("first error", result.Left); - } - - [Fact] - public void SelectMany_SecondIsLeft_PropagatesLeft() - { - var either = Right(2); - - var result = either.SelectMany( - x => Left("second error"), - (a, b) => a + b - ); - - Assert.True(result.IsLeft); - Assert.Equal("second error", result.Left); - } - - [Fact] - public void SelectMany_WithNullCollectionSelector_Throws() - { - var either = Right(2); - - Assert.Throws(() => - either.SelectMany(null!, (a, b) => a + b)); - } - - [Fact] - public void SelectMany_WithNullResultSelector_Throws() - { - var either = Right(2); - - Assert.Throws(() => - either.SelectMany(x => Right(x), null!)); - } - - [Fact] - public void Where_RightPredicate_True_ReturnsSameRight() - { - var either = Right(10); - - var result = either.Where(x => x > 5, "too small"); - - Assert.False(result.IsLeft); - Assert.Equal(10, result.Right); - } - - [Fact] - public void Where_RightPredicate_False_ReturnsLeftWithError() - { - var either = Right(3); - - var result = either.Where(x => x > 5, "too small"); - - Assert.True(result.IsLeft); - Assert.Equal("too small", result.Left); - } - - [Fact] - public void Where_RightPredicate_OnLeft_PropagatesLeft() - { - var either = Left("original"); - - var result = either.Where(x => x > 5, "too small"); - - Assert.True(result.IsLeft); - Assert.Equal("original", result.Left); - } - - [Fact] - public void Where_RightPredicate_WithNullPredicate_Throws() - { - var either = Right(5); - - Assert.Throws(() => either.Where((Func)null!, "error")); - } - - // ========== Where (Left predicate) ========== - - [Fact] - public void Where_LeftPredicate_OnLeft_True_ReturnsSameLeft() - { - var either = Left("keep"); - - var result = either.Where((string s) => s == "keep", "fallback"); - - Assert.True(result.IsLeft); - Assert.Equal("keep", result.Left); - } - - [Fact] - public void Where_LeftPredicate_OnLeft_False_ReturnsLeftWithFallback() - { - var either = Left("old"); - - var result = either.Where((string s) => s == "keep", "fallback"); - - Assert.True(result.IsLeft); - Assert.Equal("fallback", result.Left); - } - - [Fact] - public void Where_LeftPredicate_OnRight_PropagatesRight() - { - var either = Right(42); - - var result = either.Where((string s) => s == "keep", "fallback"); - - Assert.False(result.IsLeft); - Assert.Equal(42, result.Right); - } - - [Fact] - public void Where_LeftPredicate_WithNullPredicate_Throws() - { - var either = Left("test"); - - Assert.Throws(() => either.Where((Func)null!, "fallback")); - } - - [Fact] - public void MapLeft_OnLeft_TransformsValue() - { - var either = Left("hello"); - - var result = either.MapLeft(s => s.Length); - - Assert.True(result.IsLeft); - Assert.Equal(5, result.Left); - } - - [Fact] - public void MapLeft_OnRight_PreservesRight() - { - var either = Right(99); - - var result = either.MapLeft(s => s.Length); - - Assert.False(result.IsLeft); - Assert.Equal(99, result.Right); - } - - [Fact] - public void MapLeft_WithNullMapper_Throws() - { - var either = Left("test"); - - Assert.Throws(() => either.MapLeft(null!)); - } - - [Fact] - public void MapRight_OnRight_TransformsValue() - { - var either = Right(5); - - var result = either.MapRight(x => x * 3); - - Assert.False(result.IsLeft); - Assert.Equal(15, result.Right); - } - - [Fact] - public void MapRight_OnLeft_PreservesLeft() - { - var either = Left("error"); - - var result = either.MapRight(x => x * 3); - - Assert.True(result.IsLeft); - Assert.Equal("error", result.Left); - } - - [Fact] - public void MapRight_WithNullMapper_Throws() - { - var either = Right(5); - - Assert.Throws(() => either.MapRight(null!)); - } - - // ========== BindLeft ========== - - [Fact] - public void BindLeft_OnLeft_AppliesBinder() - { - var either = Left("start"); - - var result = either.BindLeft(s => Left(s.Length)); - - Assert.True(result.IsLeft); - Assert.Equal(5, result.Left); - } - - [Fact] - public void BindLeft_OnRight_PreservesRight() - { - var either = Right(42); - - var result = either.BindLeft(s => Left(s.Length)); - - Assert.False(result.IsLeft); - Assert.Equal(42, result.Right); - } - - [Fact] - public void BindLeft_WithNullBinder_Throws() - { - var either = Left("x"); - - Assert.Throws(() => either.BindLeft(null!)); - } - - [Fact] - public void BindRight_OnRight_AppliesBinder() - { - var either = Right(10); - - var result = either.BindRight(x => Right(x * 2.5)); - - Assert.False(result.IsLeft); - Assert.Equal(25.0, result.Right); - } - - [Fact] - public void BindRight_OnLeft_PreservesLeft() - { - var either = Left("error"); - - var result = either.BindRight(x => Right(x * 2.5)); - - Assert.True(result.IsLeft); - Assert.Equal("error", result.Left); - } - - [Fact] - public void BindRight_WithNullBinder_Throws() - { - var either = Right(5); - - Assert.Throws(() => either.BindRight(null!)); - } - - - [Fact] - public void Chaining_MapRightThenWhere_Works() - { - var result = Right(4) - .MapRight(x => x * 2) - .Where(x => x > 10, "too low") - .MapLeft(err => $"Error: {err}"); - - Assert.True(result.IsLeft); - Assert.Equal("Error: too low", result.Left); - } - - [Fact] - public void Chaining_SelectManyWithMapRight() - { - var final = from a in Right(3) - from b in Right(5) - select a + b; - - var mapped = final.MapRight(x => x * 2); - - Assert.False(mapped.IsLeft); - Assert.Equal(16, mapped.Right); - } - } -} \ No newline at end of file diff --git a/ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs b/ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs deleted file mode 100644 index 3434fc5..0000000 --- a/ZeroNull/ZeroNull.Tests/Types/Either/EitherTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -using ZeroNull.Types.Either; - -namespace ZeroNull.Tests.Types.Either -{ - public class EitherTests - { - [Fact] - public void Of_WithLeftValue_CreatesLeftEither() - { - var either = Either.Of("error"); - - Assert.True(either.IsLeft); - Assert.False(!either.IsLeft); // IsRight - Assert.Equal("error", either.Left); - } - - [Fact] - public void Of_WithRightValue_CreatesRightEither() - { - var either = Either.Of(42); - - Assert.False(either.IsLeft); - Assert.True(!either.IsLeft); // IsRight - Assert.Equal(42, either.Right); - } - - [Fact] - public void ImplicitOperator_FromLeft_CreatesLeftEither() - { - Either either = "error"; - - Assert.True(either.IsLeft); - Assert.Equal("error", either.Left); - } - - [Fact] - public void ImplicitOperator_FromRight_CreatesRightEither() - { - Either either = 42; - - Assert.False(either.IsLeft); - Assert.Equal(42, either.Right); - } - - [Fact] - public void Left_WhenLeft_ReturnsValue() - { - var either = Either.Of("test"); - - Assert.Equal("test", either.Left); - } - - [Fact] - public void Left_WhenRight_ThrowsInvalidOperationException() - { - var either = Either.Of(42); - Assert.Throws(() => either.Left); - } - - [Fact] - public void Right_WhenRight_ReturnsValue() - { - var either = Either.Of(99); - - Assert.Equal(99, either.Right); - } - - [Fact] - public void Right_WhenLeft_ThrowsInvalidOperationException() - { - var either = Either.Of("error"); - - Assert.Throws(() => either.Right); - } - - [Fact] - public void GetValue_WithMatchingType_WhenLeft_ReturnsLeftValue() - { - var either = Either.Of("hello"); - - var value = either.GetValue(); - - Assert.Equal("hello", value); - } - - [Fact] - public void GetValue_WithMatchingType_WhenRight_ReturnsRightValue() - { - var either = Either.Of(123); - - var value = either.GetValue(); - - Assert.Equal(123, value); - } - - [Fact] - public void GetValue_WithNonMatchingType_ThrowsInvalidCastException() - { - var either = Either.Of("test"); - - Assert.Throws(() => either.GetValue()); - } - - [Fact] - public void GetValue_WithWrongSideType_ThrowsInvalidCastException() - { - var either = Either.Of(42); - - Assert.Throws(() => either.GetValue()); - } - - [Fact] - public void ExplicitCast_ToLeftType_WhenLeft_ReturnsValue() - { - var either = Either.Of("cast"); - - string value = (string)either; - - Assert.Equal("cast", value); - } - - [Fact] - public void ExplicitCast_ToLeftType_WhenRight_ThrowsInvalidCastException() - { - var either = Either.Of(5); - Assert.Throws(() => (string)either); - } - - [Fact] - public void ExplicitCast_ToRightType_WhenRight_ReturnsValue() - { - var either = Either.Of(100); - int value = (int)either; - - Assert.Equal(100, value); - } - - [Fact] - public void ExplicitCast_ToRightType_WhenLeft_ThrowsInvalidCastException() - { - var either = Either.Of("error"); - - Assert.Throws(() => (int)either); - } - - [Fact] - public void Of_WithNullReferenceLeft_AllowsNull() - { - var either = Either.Of(0); - - Assert.True(!either.IsLeft); // is right - Assert.Null(either.LeftRawValue); - } - - [Fact] - public void Of_WithNullReferenceRight_AllowsNull() - { - var either = Either.Of(""); // right needs to be a nullable, primitive int defaults to 0 - - Assert.True(either.IsLeft); // is left - Assert.Null(either.RightRawValue); - } - - [Fact] - public void Either_WithDifferentTypes_Works() - { - var eitherInt = Either.Of(10); - var eitherBool = Either.Of(true); - - Assert.Equal(10, eitherInt.Right); - Assert.True(eitherBool.Right); - } - } -} \ No newline at end of file diff --git a/ZeroNull/ZeroNull.Tests/Types/EitherTests.cs b/ZeroNull/ZeroNull.Tests/Types/EitherTests.cs new file mode 100644 index 0000000..9dc74b5 --- /dev/null +++ b/ZeroNull/ZeroNull.Tests/Types/EitherTests.cs @@ -0,0 +1,911 @@ +using ZeroNull.Types; + +namespace ZeroNull.Tests.Types +{ + // ========================================================================= + // Helpers shared across all test classes + // ========================================================================= + + internal static class E + { + // Convenient named factories so test intent reads clearly + public static Either Left(string v) => Either.Of(v); + public static Either Right(int v) => Either.Of(v); + } + + public class EitherTests + { + // ----- Of(TLeft) ----- + + [Fact] + public void Of_Left_SetsIsLeft() + { + var e = Either.Of("error"); + Assert.True(e.IsLeft); + Assert.False(e.IsRight); + } + + [Fact] + public void Of_Right_SetsIsRight() + { + var e = Either.Of(42); + Assert.False(e.IsLeft); + Assert.True(e.IsRight); + } + + [Fact] + public void Of_Left_StoresValue() + { + var e = Either.Of("error"); + Assert.Equal("error", e.Left); + } + + [Fact] + public void Of_Right_StoresValue() + { + var e = Either.Of(42); + Assert.Equal(42, e.Right); + } + + // ----- Of with validator ----- + + [Fact] + public void Of_Left_WithPassingValidator_DoesNotThrow() + { + var e = Either.Of("ok", v => v.Length > 0); + Assert.Equal("ok", e.Left); + } + + [Fact] + public void Of_Left_WithFailingValidator_ThrowsOnAssignment() + { + Assert.Throws(() => + Either.Of("", v => v.Length > 0)); + } + + [Fact] + public void Of_Right_WithPassingValidator_DoesNotThrow() + { + var e = Either.Of(10, v => v > 0); + Assert.Equal(10, e.Right); + } + + [Fact] + public void Of_Right_WithFailingValidator_ThrowsOnAssignment() + { + Assert.Throws(() => + Either.Of(-1, v => v > 0)); + } + + // ----- Implicit operators ----- + + [Fact] + public void ImplicitFromLeft_CreatesLeftEither() + { + Either e = "implicit-left"; + Assert.True(e.IsLeft); + Assert.Equal("implicit-left", e.Left); + } + + [Fact] + public void ImplicitFromRight_CreatesRightEither() + { + Either e = 99; + Assert.True(e.IsRight); + Assert.Equal(99, e.Right); + } + + [Fact] + public void Left_OnLeftEither_ReturnsValue() + { + var e = E.Left("err"); + Assert.Equal("err", e.Left); + } + + [Fact] + public void Left_OnRightEither_ThrowsInvalidOperationException() + { + var e = E.Right(1); + Assert.Throws(() => e.Left); + } + + [Fact] + public void Right_OnRightEither_ReturnsValue() + { + var e = E.Right(7); + Assert.Equal(7, e.Right); + } + + [Fact] + public void Right_OnLeftEither_ThrowsInvalidOperationException() + { + var e = E.Left("err"); + Assert.Throws(() => e.Right); + } + + [Fact] + public void IsLeft_And_IsRight_AreExclusive() + { + var left = E.Left("x"); + var right = E.Right(1); + + Assert.True(left.IsLeft && !left.IsRight); + Assert.True(right.IsRight && !right.IsLeft); + } + + // Edge: reference-type left can be the empty string (not null) — should still be Left + [Fact] + public void EmptyString_IsValidLeftValue() + { + var e = E.Left(string.Empty); + + Assert.True(e.IsLeft); + Assert.Equal(string.Empty, e.Left); + } + + // Edge: zero is a valid Right value + [Fact] + public void Zero_IsValidRightValue() + { + var e = E.Right(0); + + Assert.True(e.IsRight); + Assert.Equal(0, e.Right); + } + + [Fact] + public void ExplicitCastToTLeft_OnLeftEither_ReturnsValue() + { + var e = E.Left("cast-me"); + + var v = (string)e; + + Assert.Equal("cast-me", v); + } + + [Fact] + public void ExplicitCastToTRight_OnRightEither_ReturnsValue() + { + var e = E.Right(55); + + var v = (int)e; + + Assert.Equal(55, v); + } + + [Fact] + public void ExplicitCastToTLeft_OnRightEither_ThrowsInvalidCastException() + { + var e = E.Right(1); + + Assert.Throws(() => (string)e); + } + + [Fact] + public void ExplicitCastToTRight_OnLeftEither_ThrowsInvalidCastException() + { + var e = E.Left("err"); + + Assert.Throws(() => (int)e); + } + + [Fact] + public void GetValue_Left_ReturnsLeftValue() + { + var e = E.Left("hello"); + + Assert.Equal("hello", e.GetValue()); + } + + [Fact] + public void GetValue_Right_ReturnsRightValue() + { + var e = E.Right(123); + + Assert.Equal(123, e.GetValue()); + } + + [Fact] + public void GetValue_WrongType_ThrowsInvalidCastException() + { + var e = E.Right(5); + + Assert.Throws(() => e.GetValue()); + } + + [Fact] + public void GetValue_Left_WrongTypeRequest_ThrowsInvalidCastException() + { + var e = E.Left("oops"); + + Assert.Throws(() => e.GetValue()); + } + + [Fact] + public void Select_OnRight_ProjectsValue() + { + var result = E.Right(10).Select(x => x * 2); + + Assert.True(result.IsRight); + Assert.Equal(20, result.Right); + } + + [Fact] + public void Select_OnLeft_PropagatesLeft() + { + var result = E.Left("err").Select(x => x * 2); + + Assert.True(result.IsLeft); + Assert.Equal("err", result.Left); + } + + [Fact] + public void Select_CanChangeRightType() + { + var result = E.Right(42).Select(x => (double)x); + + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public void Select_NullSelector_ThrowsArgumentNullException() + { + Assert.Throws(() => E.Right(1).Select(null!)); + } + + [Fact] + public void Select_SelectorNotCalledWhenLeft() + { + var called = false; + E.Left("err").Select(x => { called = true; return x; }); + Assert.False(called); + } + + [Fact] + public void Select_ChainedTwice_BothProjectionsApply() + { + var result = E.Right(3) + .Select(x => x + 1) // 4 + .Select(x => x * 10); // 40 + Assert.Equal(40, result.Right); + } + + [Fact] + public void Select_ChainedAfterLeft_StillLeft() + { + var result = E.Left("fail") + .Select(x => x + 1) + .Select(x => x * 10); + Assert.True(result.IsLeft); + Assert.Equal("fail", result.Left); + } + + private Either Safe_Divide(int numerator, int denominator) => + denominator == 0 + ? Either.Of("division by zero") + : Either.Of(numerator / denominator); + + [Fact] + public void SelectMany_BothRight_CombinesValues() + { + var result = E.Right(10).SelectMany( + x => Safe_Divide(x, 2), + (x, y) => x + y); // 10 + 5 = 15 + + Assert.True(result.IsRight); + Assert.Equal(15, result.Right); + } + + [Fact] + public void SelectMany_FirstIsLeft_PropagatesLeft() + { + var result = E.Left("initial-err").SelectMany( + x => Safe_Divide(x, 2), + (x, y) => x + y); + + Assert.True(result.IsLeft); + Assert.Equal("initial-err", result.Left); + } + + [Fact] + public void SelectMany_SecondIsLeft_PropagatesSecondLeft() + { + var result = E.Right(10).SelectMany( + x => Safe_Divide(x, 0), // produces Left "division by zero" + (x, y) => x + y); + + Assert.True(result.IsLeft); + Assert.Equal("division by zero", result.Left); + } + + [Fact] + public void SelectMany_NullCollectionSelector_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Right(1).SelectMany(null!, (a, b) => a + b)); + } + + [Fact] + public void SelectMany_NullResultSelector_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Right(1).SelectMany(x => E.Right(x), null!)); + } + + [Fact] + public void SelectMany_CollectionSelectorNotCalledWhenLeft() + { + var called = false; + E.Left("err").SelectMany( + x => { called = true; return E.Right(x); }, + (a, b) => a + b); + Assert.False(called); + } + + // LINQ query-expression sugar relies on SelectMany — verify it compiles and runs + [Fact] + public void SelectMany_CanBeUsedInLinqQueryExpression() + { + var result = + from x in E.Right(6) + from y in Safe_Divide(x, 2) + select x + y; // 6 + 3 = 9 + + Assert.True(result.IsRight); + Assert.Equal(9, result.Right); + } + + [Fact] + public void SelectMany_LinqQueryExpression_PropagatesLeftCorrectly() + { + var result = + from x in E.Left("oops") + from y in Safe_Divide(x, 2) + select x + y; + + Assert.True(result.IsLeft); + Assert.Equal("oops", result.Left); + } + + [Fact] + public void WhereRight_PredicateTrue_ReturnsSameRight() + { + var e = E.Right(10); + var result = e.WhereRight(x => x > 0, "not positive"); + Assert.True(result.IsRight); + Assert.Equal(10, result.Right); + } + + [Fact] + public void WhereRight_PredicateFalse_ReturnsLeftOnFalse() + { + var result = E.Right(-1).WhereRight(x => x > 0, "not positive"); + Assert.True(result.IsLeft); + Assert.Equal("not positive", result.Left); + } + + [Fact] + public void WhereRight_OnLeft_PassesThroughUnchanged() + { + var result = E.Left("original").WhereRight(x => x > 0, "fallback"); + Assert.True(result.IsLeft); + Assert.Equal("original", result.Left); + } + + [Fact] + public void WhereRight_NullPredicate_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Right(1).WhereRight(null!, "fallback")); + } + + [Fact] + public void WhereRight_PredicateNotCalledWhenLeft() + { + var called = false; + E.Left("err").WhereRight(x => { called = true; return true; }, "unused"); + Assert.False(called); + } + + [Fact] + public void WhereLeft_PredicateTrue_ReturnsSameLeft() + { + var result = E.Left("short").WhereLeft(x => x.Length < 10, "too-long-fallback"); + Assert.True(result.IsLeft); + Assert.Equal("short", result.Left); + } + + [Fact] + public void WhereLeft_PredicateFalse_ReturnsLeftOnFalse() + { + var result = E.Left("this-is-long").WhereLeft(x => x.Length < 5, "replaced"); + + Assert.True(result.IsLeft); + Assert.Equal("replaced", result.Left); + } + + [Fact] + public void WhereLeft_OnRight_PassesThroughUnchanged() + { + var result = E.Right(7).WhereLeft(x => true, "fallback"); + Assert.True(result.IsRight); + Assert.Equal(7, result.Right); + } + + [Fact] + public void WhereLeft_NullPredicate_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Left("err").WhereLeft(null!, "fallback")); + } + + [Fact] + public void WhereLeft_PredicateNotCalledWhenRight() + { + var called = false; + E.Right(1).WhereLeft(x => { called = true; return true; }, "unused"); + Assert.False(called); + } + + [Fact] + public void MapLeft_OnLeft_TransformsValue() + { + var result = E.Left("error").MapLeft(e => e.ToUpper()); + + Assert.True(result.IsLeft); + Assert.Equal("ERROR", result.Left); + } + + [Fact] + public void MapLeft_OnRight_LeavesRightUnchanged() + { + var result = E.Right(5).MapLeft(e => e.ToUpper()); + Assert.True(result.IsRight); + Assert.Equal(5, result.Right); + } + + [Fact] + public void MapLeft_CanChangeLeftType() + { + var result = E.Left("error").MapLeft(e => (double)e.Length); + + Assert.True(result.IsLeft); + Assert.Equal(5, result.Left); // "error".Length == 5 + } + + [Fact] + public void MapLeft_NullMapper_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Left("err").MapLeft(null!)); + } + + [Fact] + public void MapLeft_MapperNotCalledWhenRight() + { + var called = false; + E.Right(1).MapLeft(e => { called = true; return e; }); + Assert.False(called); + } + + [Fact] + public void MapRight_OnRight_TransformsValue() + { + var result = E.Right(4).MapRight(x => x * x); + Assert.True(result.IsRight); + Assert.Equal(16, result.Right); + } + + [Fact] + public void MapRight_OnLeft_LeavesLeftUnchanged() + { + var result = E.Left("err").MapRight(x => x * x); + + Assert.True(result.IsLeft); + Assert.Equal("err", result.Left); + } + + [Fact] + public void MapRight_CanChangeRightType() + { + var result = E.Right(42).MapRight(x => (double)x); + + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public void MapRight_NullMapper_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Right(1).MapRight(null!)); + } + + [Fact] + public void MapRight_MapperNotCalledWhenLeft() + { + var called = false; + E.Left("err").MapRight(x => { called = true; return x; }); + Assert.False(called); + } + + [Fact] + public void BindLeft_OnLeft_AppliesBinder() + { + var result = E.Left("error").BindLeft(e => E.Left(e + "-recovered")); + Assert.True(result.IsLeft); + Assert.Equal("error-recovered", result.Left); + } + + [Fact] + public void BindLeft_OnLeft_BinderCanReturnRight() + { + var result = E.Left("try-again").BindLeft(e => E.Right(99)); + Assert.True(result.IsRight); + Assert.Equal(99, result.Right); + } + + [Fact] + public void BindLeft_OnRight_ShortCircuits() + { + var called = false; + var result = E.Right(10).BindLeft(e => { called = true; return E.Left(e); }); + Assert.False(called); + Assert.True(result.IsRight); + Assert.Equal(10, result.Right); + } + + [Fact] + public void BindLeft_NullBinder_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Left("err").BindLeft(null!)); + } + + [Fact] + public void BindRight_OnRight_AppliesBinder() + { + var result = E.Right(10).BindRight(x => E.Right(x * 3)); + Assert.True(result.IsRight); + Assert.Equal(30, result.Right); + } + + [Fact] + public void BindRight_OnRight_BinderCanReturnLeft() + { + var result = E.Right(-1).BindRight(x => E.Left("negative not allowed")); + Assert.True(result.IsLeft); + Assert.Equal("negative not allowed", result.Left); + } + + [Fact] + public void BindRight_OnLeft_ShortCircuits() + { + var called = false; + var result = E.Left("err").BindRight(x => { called = true; return E.Right(x); }); + Assert.False(called); + Assert.True(result.IsLeft); + Assert.Equal("err", result.Left); + } + + [Fact] + public void BindRight_NullBinder_ThrowsArgumentNullException() + { + Assert.Throws(() => + E.Right(1).BindRight(null!)); + } + + [Fact] + public void BindRight_ChainedTwice_BothBindersApply() + { + var result = E.Right(2) + .BindRight(x => E.Right(x + 3)) // 5 + .BindRight(x => E.Right(x * 10)); // 50 + + Assert.Equal(50, result.Right); + } + + [Fact] + public void BindRight_ChainedAfterLeft_StillLeft() + { + var result = E.Right(2) + .BindRight(x => E.Left("bail")) + .BindRight(x => E.Right(x * 10)); + + Assert.True(result.IsLeft); + Assert.Equal("bail", result.Left); + } + + // Verifies the struct is assignable to the interface without boxing weirdness + [Fact] + public void Either_IsAssignableToInterface() + { + var e = E.Right(5); + + Assert.True(e.IsRight); + Assert.Equal(5, e.Right); + } + + [Fact] + public void Interface_LeftEither_ExposesCorrectMembers() + { + var e = E.Left("via-interface"); + + Assert.True(e.IsLeft); + Assert.Equal("via-interface", e.Left); + } + + [Fact] + public void Interface_Select_Works() + { + var e = E.Right(3); + + var result = e.Select(x => x * 4); + + Assert.Equal(12, result.Right); + } + + [Fact] + public void Interface_BindRight_Works() + { + var e = E.Right(7); + + var result = e.BindRight(x => E.Right(x + 1)); + + Assert.Equal(8, result.Right); + } + + // Represents a mini parsing + validation pipeline + private Either Parse(string raw) => + int.TryParse(raw, out var n) ? Either.Of(n) : Either.Of("parse failed"); + + private Either EnsurePositive(int n) => + n > 0 ? Either.Of(n) : Either.Of("must be positive"); + + [Fact] + public void Pipeline_HappyPath_ProducesRight() + { + var result = Parse("42") + .BindRight(EnsurePositive) + .MapRight(x => x * 2); + + Assert.True(result.IsRight); + Assert.Equal(84, result.Right); + } + + [Fact] + public void Pipeline_ParseFails_ProducesLeft() + { + var result = Parse("abc") + .BindRight(EnsurePositive) + .MapRight(x => x * 2); + + Assert.True(result.IsLeft); + Assert.Equal("parse failed", result.Left); + } + + [Fact] + public void Pipeline_ValidationFails_ProducesLeft() + { + var result = Parse("-5") + .BindRight(EnsurePositive) + .MapRight(x => x * 2); + + Assert.True(result.IsLeft); + Assert.Equal("must be positive", result.Left); + } + + [Fact] + public void Pipeline_MapLeftTransformsErrorMessage() + { + var result = Parse("abc") + .BindRight(EnsurePositive) + .MapLeft(e => $"[ERR] {e}"); + + Assert.Equal("[ERR] parse failed", result.Left); + } + + [Fact] + public void Pipeline_WhereRight_FiltersSuccessfulResult() + { + var result = Parse("3") + .BindRight(EnsurePositive) + .WhereRight(x => x > 10, "too small"); + + Assert.True(result.IsLeft); + Assert.Equal("too small", result.Left); + } + + [Fact] + public void Pipeline_LinqComprehension_AllRight() + { + var result = + from x in Parse("10") + from y in EnsurePositive(x) + select x + y; // 10 + 10 = 20 + + Assert.True(result.IsRight); + Assert.Equal(20, result.Right); + } + + [Fact] + public void Pipeline_LinqComprehension_FirstStepFails() + { + var result = + from x in Parse("bad") + from y in EnsurePositive(x) + select x + y; + + Assert.True(result.IsLeft); + Assert.Equal("parse failed", result.Left); + } + + [Fact] + public void Equals_Should_Return_True_For_Equal_Left_Values() + { + var left1 = Either.Of("error"); + var left2 = Either.Of("error"); + + Assert.True(left1.Equals(left2)); + } + + [Fact] + public void Equals_Should_Return_False_For_Different_Left_Values() + { + var left1 = Either.Of("error"); + var left2 = Either.Of("failure"); + + Assert.False(left1.Equals(left2)); + } + + // ========================================================= + // Equals - Right + // ========================================================= + + [Fact] + public void Equals_Should_Return_True_For_Equal_Right_Values() + { + var right1 = Either.Of(42); + var right2 = Either.Of(42); + + Assert.True(right1.Equals(right2)); + } + + [Fact] + public void Equals_Should_Return_False_For_Different_Right_Values() + { + var right1 = Either.Of(42); + var right2 = Either.Of(99); + + Assert.False(right1.Equals(right2)); + } + + // ========================================================= + // Equals - State differences + // ========================================================= + + [Fact] + public void Equals_Should_Return_False_For_Left_And_Right() + { + Either left = Either.Of("42"); + Either right = Either.Of(42); + + Assert.False(left.Equals(right)); + Assert.False(right.Equals(left)); + } + + [Fact] + public void Equals_Should_Return_False_When_Compared_With_Null() + { + var either = Either.Of(42); + + Assert.False(either.Equals(null)); + } + + [Fact] + public void Equals_Should_Return_False_When_Compared_With_Different_Type() + { + var either = Either.Of(42); + + Assert.False(either.Equals("42")); + } + + // ========================================================= + // Equals - Validator behavior + // ========================================================= + + [Fact] + public void Equals_Should_Return_True_When_Validators_Are_Same_Instance() + { + Func validator = x => x > 0; + + var left = Either.Of(42, validator); + var right = Either.Of(42, validator); + + Assert.True(left.Equals(right)); + } + + [Fact] + public void Equals_Should_Return_False_When_Validators_Are_Different_Instances() + { + Func validator1 = x => x > 0; + Func validator2 = x => x > 0; + + var left = Either.Of(42, validator1); + var right = Either.Of(42, validator2); + + Assert.False(left.Equals(right)); + } + + // ========================================================= + // Equals - Consistency / symmetry + // ========================================================= + + [Fact] + public void Equals_Should_Be_Symmetric() + { + var left = Either.Of(123); + var right = Either.Of(123); + + Assert.True(left.Equals(right)); + Assert.True(right.Equals(left)); + } + + [Fact] + public void Equals_Should_Be_Consistent() + { + var left = Either.Of(123); + var right = Either.Of(123); + + Assert.True(left.Equals(right)); + Assert.True(left.Equals(right)); + Assert.True(left.Equals(right)); + } + + // ========================================================= + // GetHashCode + // ========================================================= + + [Fact] + public void GetHashCode_Should_Return_Same_Value_For_Equal_Lefts() + { + var left1 = Either.Of("error"); + var left2 = Either.Of("error"); + + Assert.Equal(left1.GetHashCode(), left2.GetHashCode()); + } + + [Fact] + public void GetHashCode_Should_Return_Same_Value_For_Equal_Rights() + { + var right1 = Either.Of(42); + var right2 = Either.Of(42); + + Assert.Equal(right1.GetHashCode(), right2.GetHashCode()); + } + + [Fact] + public void GetHashCode_Should_Be_Stable() + { + var either = Either.Of(42); + + var hash1 = either.GetHashCode(); + var hash2 = either.GetHashCode(); + var hash3 = either.GetHashCode(); + + Assert.Equal(hash1, hash2); + Assert.Equal(hash2, hash3); + } + + [Fact] + public void Equal_Objects_Should_Have_Equal_HashCodes() + { + var left1 = Either.Of("same"); + var left2 = Either.Of("same"); + + Assert.True(left1.Equals(left2)); + Assert.Equal(left1.GetHashCode(), left2.GetHashCode()); + } + } +} diff --git a/ZeroNull/ZeroNull.Tests/Types/Option/OptionTests.cs b/ZeroNull/ZeroNull.Tests/Types/Option/OptionTests.cs deleted file mode 100644 index 4915fd8..0000000 --- a/ZeroNull/ZeroNull.Tests/Types/Option/OptionTests.cs +++ /dev/null @@ -1,233 +0,0 @@ -using ZeroNull.Types.Option; - -namespace ZeroNull.Tests.Types.Option -{ - public class OptionTests - { - [Fact] - public void Some_CreatesOptionWithValue() - { - var option = Option.Some("test"); - Assert.True(option.IsSome); - Assert.False(option.IsNone); - Assert.Equal("test", option.Value); - } - - [Fact] - public void Some_ThrowsWhenValueIsNull() - { - Assert.Throws(() => Option.Some(null!)); - } - - [Fact] - public void None_CreatesOptionWithNoValue() - { - var option = Option.None(); - Assert.True(option.IsNone); - Assert.False(option.IsSome); - Assert.Throws(() => option.Value); - } - - [Fact] - public void ValueOr_ReturnsValueWhenSome() - { - var option = Option.Some(42); - Assert.Equal(42, option.ValueOr(100)); - } - - [Fact] - public void ValueOr_ReturnsFallbackWhenNone() - { - var option = Option.None(); - Assert.Equal(100, option.ValueOr(100)); - } - - [Fact] - public void ValueOr_Factory_ReturnsValueWhenSome() - { - var option = Option.Some("hello"); - Assert.Equal("hello", option.ValueOr(() => "fallback")); - } - - [Fact] - public void ValueOr_Factory_ReturnsFactoryResultWhenNone() - { - var option = Option.None(); - Assert.Equal("fallback", option.ValueOr(() => "fallback")); - } - - [Fact] - public void Map_TransformsSomeValue() - { - var option = Option.Some(5); - var result = option.Map(x => x * 2); - Assert.True(result.IsSome); - Assert.Equal(10, result.Value); - } - - [Fact] - public void Map_ReturnsNoneWhenNone() - { - var option = Option.None(); - var result = option.Map(x => x * 2); - Assert.True(result.IsNone); - } - - [Fact] - public void Bind_ReturnsSomeWhenSomeAndBinderReturnsSome() - { - var option = Option.Some(5); - var result = option.Bind(x => Option.Some(x * 2)); - Assert.True(result.IsSome); - Assert.Equal(10, result.Value); - } - - [Fact] - public void Bind_ReturnsNoneWhenSomeButBinderReturnsNone() - { - var option = Option.Some(5); - var result = option.Bind(x => Option.None()); - Assert.True(result.IsNone); - } - - [Fact] - public void Bind_ReturnsNoneWhenNone() - { - var option = Option.None(); - var result = option.Bind(x => Option.Some(x * 2)); - Assert.True(result.IsNone); - } - - [Fact] - public void Match_CallsSomeDelegateWhenSome() - { - var option = Option.Some(10); - var result = option.Match( - some: x => $"Value: {x}", - none: () => "No value" - ); - Assert.Equal("Value: 10", result); - } - - [Fact] - public void Match_CallsNoneDelegateWhenNone() - { - var option = Option.None(); - var result = option.Match( - some: x => $"Value: {x}", - none: () => "No value" - ); - Assert.Equal("No value", result); - } - - [Fact] - public void Linq_Select_ProjectsValue() - { - var result = from x in Option.Some(5) - select x * 3; - Assert.True(result.IsSome); - Assert.Equal(15, result.Value); - } - - [Fact] - public void Linq_Select_ReturnsNoneWhenNone() - { - var result = from x in Option.None() - select x * 3; - Assert.True(result.IsNone); - } - - [Fact] - public void Linq_SelectMany_CombinesTwoOptions() - { - var result = from a in Option.Some(2) - from b in Option.Some(3) - select a + b; - Assert.True(result.IsSome); - Assert.Equal(5, result.Value); - } - - [Fact] - public void Linq_SelectMany_ShortCircuitsOnFirstNone() - { - var result = from a in Option.Some(2) - from b in Option.None() - select a + b; - Assert.True(result.IsNone); - } - - [Fact] - public void Linq_SelectMany_WithProjection() - { - var result = from a in Option.Some("Hello") - from b in Option.Some("World") - select $"{a} {b}"; - Assert.Equal("Hello World", result.Value); - } - - [Fact] - public void Some_AllowsValueTypesEvenWithDefault() - { - var option = Option.Some(0); - Assert.True(option.IsSome); - Assert.Equal(0, option.Value); - } - - [Fact] - public void None_WithReferenceType_ThrowsOnValueAccess() - { - var option = Option.None(); - Assert.Throws(() => option.Value); - } - - [Fact] - public void Map_WithNullMapper_Throws() - { - var option = Option.Some(5); - Assert.Throws(() => option.Map(null!)); - } - - [Fact] - public void Bind_WithNullBinder_Throws() - { - var option = Option.Some(5); - Assert.Throws(() => option.Bind(null!)); - } - - [Fact] - public void Match_WithNullSomeDelegate_Throws() - { - var option = Option.Some(5); - Assert.Throws(() => option.Match(null!, () => 0)); - } - - [Fact] - public void Match_WithNullNoneDelegate_Throws() - { - var option = Option.Some(5); - Assert.Throws(() => option.Match(x => x, null!)); - } - - [Fact] - public void Chain_MapThenBindThenMatch() - { - var result = Option.Some(4) - .Map(x => x * 2) // 8 - .Bind(x => x > 10 ? Option.None() : Option.Some(x)) - .Match( - some: x => $"Result: {x}", - none: () => "Too large" - ); - Assert.Equal("Result: 8", result); - } - - [Fact] - public void Chain_WithFallback() - { - var final = Option.None() - .Map(s => s.ToUpper()) - .ValueOr("default"); - Assert.Equal("default", final); - } - } -} diff --git a/ZeroNull/ZeroNull.Tests/Types/OptionTests.cs b/ZeroNull/ZeroNull.Tests/Types/OptionTests.cs new file mode 100644 index 0000000..4021d88 --- /dev/null +++ b/ZeroNull/ZeroNull.Tests/Types/OptionTests.cs @@ -0,0 +1,576 @@ +using ZeroNull.Types; + +namespace ZeroNull.Tests.Types; + +public sealed class OptionTests +{ + // ========================================================= + // Some / None + // ========================================================= + + [Fact] + public void Some_Should_Create_Option_With_Value() + { + var option = Option.Some(42); + + Assert.True(option.IsSome); + Assert.False(option.IsNone); + Assert.Equal(42, option.Value); + } + + [Fact] + public void None_Should_Create_Empty_Option() + { + var option = Option.None(); + + Assert.False(option.IsSome); + Assert.True(option.IsNone); + } + + [Fact] + public void Some_Should_Throw_When_Value_Is_Null() + { + Assert.Throws(() => + Option.Some(null!)); + } + + // ========================================================= + // Value + // ========================================================= + + [Fact] + public void Value_Should_Return_Contained_Value() + { + var option = Option.Some("hello"); + + Assert.Equal("hello", option.Value); + } + + [Fact] + public void Value_Should_Throw_When_None() + { + var option = Option.None(); + + var ex = Assert.Throws(() => + { + _ = option.Value; + }); + + Assert.Equal("Option has no value.", ex.Message); + } + + // ========================================================= + // Map + // ========================================================= + + [Fact] + public void Map_Should_Transform_Value_When_Some() + { + var option = Option.Some(10); + + var mapped = option.Map(x => x * 2); + + Assert.True(mapped.IsSome); + Assert.Equal(20, mapped.Value); + } + + [Fact] + public void Map_Should_Return_None_When_None() + { + var option = Option.None(); + + var mapped = option.Map(x => x * 2); + + Assert.True(mapped.IsNone); + } + + [Fact] + public void Map_Should_Not_Invoke_Mapper_When_None() + { + var option = Option.None(); + + var invoked = false; + + _ = option.Map(x => + { + invoked = true; + return x * 2; + }); + + Assert.False(invoked); + } + + [Fact] + public void Map_Should_Throw_When_Mapper_Is_Null() + { + var option = Option.Some(1); + + Assert.Throws(() => + option.Map(null!)); + } + + // ========================================================= + // Bind + // ========================================================= + + [Fact] + public void Bind_Should_Project_Value_When_Some() + { + var option = Option.Some(5); + + var result = option.Bind(x => Option.Some($"Value:{x}")); + + Assert.True(result.IsSome); + Assert.Equal("Value:5", result.Value); + } + + [Fact] + public void Bind_Should_Return_None_When_None() + { + var option = Option.None(); + + var result = option.Bind(x => Option.Some(x.ToString())); + + Assert.True(result.IsNone); + } + + [Fact] + public void Bind_Should_Not_Invoke_Binder_When_None() + { + var option = Option.None(); + + var invoked = false; + + _ = option.Bind(x => + { + invoked = true; + return Option.Some(x); + }); + + Assert.False(invoked); + } + + [Fact] + public void Bind_Should_Throw_When_Binder_Is_Null() + { + var option = Option.Some(1); + + Assert.Throws(() => + option.Bind(null!)); + } + + // ========================================================= + // ValueOr (direct value) + // ========================================================= + + [Fact] + public void ValueOr_Should_Return_Value_When_Some() + { + var option = Option.Some("actual"); + + var result = option.ValueOr("fallback"); + + Assert.Equal("actual", result); + } + + [Fact] + public void ValueOr_Should_Return_Fallback_When_None() + { + var option = Option.None(); + + var result = option.ValueOr("fallback"); + + Assert.Equal("fallback", result); + } + + [Fact] + public void ValueOr_Should_Throw_When_Fallback_Is_Null() + { + var option = Option.None(); + + Assert.Throws(() => + option.ValueOr((string)null!)); + } + + // ========================================================= + // ValueOr (factory) + // ========================================================= + + [Fact] + public void ValueOrFactory_Should_Return_Value_When_Some() + { + var option = Option.Some(100); + + var invoked = false; + + var result = option.ValueOr(() => + { + invoked = true; + return 50; + }); + + Assert.Equal(100, result); + Assert.False(invoked); + } + + [Fact] + public void ValueOrFactory_Should_Invoke_Factory_When_None() + { + var option = Option.None(); + + var invoked = false; + + var result = option.ValueOr(() => + { + invoked = true; + return 50; + }); + + Assert.True(invoked); + Assert.Equal(50, result); + } + + [Fact] + public void ValueOrFactory_Should_Throw_When_Factory_Is_Null() + { + var option = Option.None(); + + Assert.Throws(() => + option.ValueOr((Func)null!)); + } + + // ========================================================= + // Match + // ========================================================= + + [Fact] + public void Match_Should_Invoke_Some_Function_When_Some() + { + var option = Option.Some(5); + + var result = option.Match( + some: x => $"Value:{x}", + none: () => "None"); + + Assert.Equal("Value:5", result); + } + + [Fact] + public void Match_Should_Invoke_None_Function_When_None() + { + var option = Option.None(); + + var result = option.Match( + some: x => $"Value:{x}", + none: () => "None"); + + Assert.Equal("None", result); + } + + [Fact] + public void Match_Should_Throw_When_Some_Function_Is_Null() + { + var option = Option.Some(1); + + Assert.Throws(() => + option.Match(null!, () => "none")); + } + + [Fact] + public void Match_Should_Throw_When_None_Function_Is_Null() + { + var option = Option.Some(1); + + Assert.Throws(() => + option.Match( + x => x.ToString(), + null!)); + } + + // ========================================================= + // Select + // ========================================================= + + [Fact] + public void Select_Should_Behave_The_Same_As_Map() + { + var option = Option.Some(2); + + var result = option.Select(x => x * 3); + + Assert.True(result.IsSome); + Assert.Equal(6, result.Value); + } + + // ========================================================= + // SelectMany + // ========================================================= + + [Fact] + public void SelectMany_Should_Compose_Options() + { + var option = Option.Some(5); + + var result = option.SelectMany( + x => Option.Some(x * 2), + (x, y) => x + y); + + Assert.True(result.IsSome); + Assert.Equal(15, result.Value); + } + + [Fact] + public void SelectMany_Should_Return_None_When_Source_Is_None() + { + var option = Option.None(); + + var result = option.SelectMany( + x => Option.Some(x * 2), + (x, y) => x + y); + + Assert.True(result.IsNone); + } + + [Fact] + public void SelectMany_Should_Return_None_When_Bind_Returns_None() + { + var option = Option.Some(5); + + var result = option.SelectMany( + _ => Option.None(), + (x, y) => x + y); + + Assert.True(result.IsNone); + } + + // ========================================================= + // LINQ Query Syntax + // ========================================================= + + [Fact] + public void Query_Syntax_Should_Work() + { + var option = + from x in Option.Some(10) + from y in Option.Some(20) + select x + y; + + Assert.True(option.IsSome); + Assert.Equal(30, option.Value); + } + + [Fact] + public void Query_Syntax_Should_Short_Circuit_On_None() + { + var option = + from x in Option.Some(10) + from y in Option.None() + select x + y; + + Assert.True(option.IsNone); + } + + // ========================================================= + // Struct Semantics + // ========================================================= + + [Fact] + public void Default_Option_Should_Behave_As_None() + { + Option option = default; + + Assert.True(option.IsNone); + Assert.False(option.IsSome); + } + + // ========================================================= + // Nested Mapping + // ========================================================= + + [Fact] + public void Multiple_Maps_Should_Compose() + { + var result = Option + .Some(2) + .Map(x => x + 1) + .Map(x => x * 10); + + Assert.True(result.IsSome); + Assert.Equal(30, result.Value); + } + + [Fact] + public void Multiple_Binds_Should_Compose() + { + var result = Option + .Some(2) + .Bind(x => Option.Some(x + 3)) + .Bind(x => Option.Some(x * 2)); + + Assert.True(result.IsSome); + Assert.Equal(10, result.Value); + } + + // ========================================================= + // Equals + // ========================================================= + + [Fact] + public void Equals_Should_Return_True_For_Equal_Some_Values() + { + var left = Option.Some(42); + var right = Option.Some(42); + + Assert.True(left.Equals(right)); + } + + [Fact] + public void Equals_Should_Return_False_For_Different_Some_Values() + { + var left = Option.Some(42); + var right = Option.Some(99); + + Assert.False(left.Equals(right)); + } + + [Fact] + public void Equals_Should_Return_True_For_Two_None_Options() + { + var left = Option.None(); + var right = Option.None(); + + Assert.True(left.Equals(right)); + } + + [Fact] + public void Equals_Should_Return_False_For_Some_And_None() + { + var some = Option.Some(42); + var none = Option.None(); + + Assert.False(some.Equals(none)); + Assert.False(none.Equals(some)); + } + + [Fact] + public void Equals_Should_Return_False_When_Compared_With_Null() + { + var option = Option.Some(42); + + Assert.False(option.Equals(null)); + } + + [Fact] + public void Equals_Should_Return_False_When_Compared_With_Different_Type() + { + var option = Option.Some(42); + + Assert.False(option.Equals("42")); + } + + [Fact] + public void Equals_Should_Be_Symmetric() + { + var left = Option.Some("abc"); + var right = Option.Some("abc"); + + Assert.True(left.Equals(right)); + Assert.True(right.Equals(left)); + } + + [Fact] + public void Equals_Should_Be_Consistent() + { + var left = Option.Some(7); + var right = Option.Some(7); + + Assert.True(left.Equals(right)); + Assert.True(left.Equals(right)); + Assert.True(left.Equals(right)); + } + + // ========================================================= + // GetHashCode + // ========================================================= + + [Fact] + public void GetHashCode_Should_Return_Same_Value_For_Equal_Options() + { + var left = Option.Some(123); + var right = Option.Some(123); + + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } + + [Fact] + public void GetHashCode_Should_Be_Stable() + { + var option = Option.Some("stable"); + + var first = option.GetHashCode(); + var second = option.GetHashCode(); + var third = option.GetHashCode(); + + Assert.Equal(first, second); + Assert.Equal(second, third); + } + + [Fact] + public void GetHashCode_Should_Differentiate_Some_And_None() + { + var some = Option.Some(0); + var none = Option.None(); + + Assert.NotEqual(some.GetHashCode(), none.GetHashCode()); + } + + [Fact] + public void Equal_Options_Should_Have_Equal_HashCodes() + { + var left = Option.Some("hello"); + var right = Option.Some("hello"); + + Assert.True(left.Equals(right)); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } + + [Fact] + public void None_Options_Should_Have_Equal_HashCodes() + { + var left = Option.None(); + var right = Option.None(); + + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } + + // ========================================================= + // Regression Tests + // ========================================================= + + [Fact] + public void Some_Zero_Should_Not_Equal_None() + { + var some = Option.Some(0); + var none = Option.None(); + + Assert.False(some.Equals(none)); + } + + [Fact] + public void Some_False_Should_Not_Equal_None() + { + var some = Option.Some(false); + var none = Option.None(); + + Assert.False(some.Equals(none)); + } + + [Fact] + public void Default_Option_Should_Not_Equal_None() + { + Option left = default; + var right = Option.None(); + + Assert.False(left.Equals(right)); + } +} \ No newline at end of file diff --git a/ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs b/ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs deleted file mode 100644 index 128a7b3..0000000 --- a/ZeroNull/ZeroNull.Tests/Types/Result/ResultTests.cs +++ /dev/null @@ -1,312 +0,0 @@ -namespace ZeroNull.Tests.Types.Result -{ - public class ResultTests - { - // ========== Creation & Properties ========== - - [Fact] - public void Ok_CreatesResultWithValue() - { - var result = Result.Ok(42); - Assert.True(result.IsOk); - Assert.False(result.IsError); - Assert.Equal(42, result.Value); - Assert.Throws(() => result.ErrorValue); - } - - [Fact] - public void Error_CreatesResultWithError() - { - var result = Result.Error("Something went wrong"); - Assert.True(result.IsError); - Assert.False(result.IsOk); - Assert.Equal("Something went wrong", result.ErrorValue); - Assert.Throws(() => result.Value); - } - - [Fact] - public void Ok_AllowsNullForReferenceTypeValue_IfTIsNullableReference() - { - // For reference types, null is a valid value (though maybe not desirable) - var result = Result.Ok(null!); - Assert.True(result.IsOk); - Assert.Null(result.Value); - } - - [Fact] - public void Error_AllowsNullForErrorType() - { - var result = Result.Error(null!); - Assert.True(result.IsError); - Assert.Null(result.ErrorValue); - } - - // ========== ValueOr ========== - - [Fact] - public void ValueOr_ReturnsValueWhenOk() - { - var result = Result.Ok(42); - Assert.Equal(42, result.ValueOr(100)); - } - - [Fact] - public void ValueOr_ReturnsFallbackWhenError() - { - var result = Result.Error("fail"); - Assert.Equal(100, result.ValueOr(100)); - } - - // ========== Map ========== - - [Fact] - public void Map_TransformsOkValue() - { - var result = Result.Ok(5); - var mapped = result.Map(x => x * 2); - Assert.True(mapped.IsOk); - Assert.Equal(10, mapped.Value); - } - - [Fact] - public void Map_PropagatesErrorWhenError() - { - var result = Result.Error("original error"); - var mapped = result.Map(x => x * 2); - Assert.True(mapped.IsError); - Assert.Equal("original error", mapped.ErrorValue); - } - - [Fact] - public void Map_WithNullMapper_Throws() - { - var result = Result.Ok(5); - Assert.Throws(() => result.Map(null!)); - } - - // ========== Bind ========== - - [Fact] - public void Bind_TransformsOkToNewResult() - { - var result = Result.Ok(5); - var bound = result.Bind(x => Result.Ok(x * 2)); - Assert.True(bound.IsOk); - Assert.Equal(10, bound.Value); - } - - [Fact] - public void Bind_TransformsOkToError() - { - var result = Result.Ok(5); - var bound = result.Bind(x => Result.Error("converted to error")); - Assert.True(bound.IsError); - Assert.Equal("converted to error", bound.ErrorValue); - } - - [Fact] - public void Bind_PropagatesErrorWhenError() - { - var result = Result.Error("persistent error"); - var bound = result.Bind(x => Result.Ok(x * 2)); - Assert.True(bound.IsError); - Assert.Equal("persistent error", bound.ErrorValue); - } - - [Fact] - public void Bind_WithNullBinder_Throws() - { - var result = Result.Ok(5); - Assert.Throws(() => result.Bind(null!)); - } - - // ========== MapError ========== - - [Fact] - public void MapError_TransformsErrorWhenError() - { - var result = Result.Error("error"); - var mapped = result.MapError(err => $"Prefix: {err}"); - Assert.True(mapped.IsError); - Assert.Equal("Prefix: error", mapped.ErrorValue); - } - - [Fact] - public void MapError_PropagatesOkWhenOk() - { - var result = Result.Ok(42); - var mapped = result.MapError(err => $"Prefix: {err}"); - Assert.True(mapped.IsOk); - Assert.Equal(42, mapped.Value); - } - - [Fact] - public void MapError_WithNullMapper_Throws() - { - var result = Result.Error("error"); - Assert.Throws(() => result.MapError(null!)); - } - - // ========== Match ========== - - [Fact] - public void Match_CallsOkDelegateWhenOk() - { - var result = Result.Ok(10); - var output = result.Match( - ok: x => $"Value: {x}", - error: e => $"Error: {e}" - ); - Assert.Equal("Value: 10", output); - } - - [Fact] - public void Match_CallsErrorDelegateWhenError() - { - var result = Result.Error("failure"); - var output = result.Match( - ok: x => $"Value: {x}", - error: e => $"Error: {e}" - ); - Assert.Equal("Error: failure", output); - } - - [Fact] - public void Match_WithNullOkDelegate_Throws() - { - var result = Result.Ok(5); - Assert.Throws(() => result.Match(null!, e => 0)); - } - - [Fact] - public void Match_WithNullErrorDelegate_Throws() - { - var result = Result.Ok(5); - Assert.Throws(() => result.Match(x => x, null!)); - } - - // ========== LINQ Query Syntax ========== - - [Fact] - public void Linq_Select_ProjectsOkValue() - { - var result = from x in Result.Ok(5) - select x * 3; - Assert.True(result.IsOk); - Assert.Equal(15, result.Value); - } - - [Fact] - public void Linq_Select_PropagatesError() - { - var result = from x in Result.Error("fail") - select x * 3; - Assert.True(result.IsError); - Assert.Equal("fail", result.ErrorValue); - } - - [Fact] - public void Linq_SelectMany_CombinesTwoResults() - { - var result = from a in Result.Ok(2) - from b in Result.Ok(3) - select a + b; - Assert.True(result.IsOk); - Assert.Equal(5, result.Value); - } - - [Fact] - public void Linq_SelectMany_ShortCircuitsOnFirstError() - { - var result = from a in Result.Ok(2) - from b in Result.Error("b error") - select a + b; - Assert.True(result.IsError); - Assert.Equal("b error", result.ErrorValue); - } - - [Fact] - public void Linq_SelectMany_WithComplexProjection() - { - var result = from name in Result.Ok("Alice") - from age in Result.Ok(30) - select $"{name} is {age} years old"; - Assert.Equal("Alice is 30 years old", result.Value); - } - - // ========== Chaining / Realistic Scenarios ========== - - [Fact] - public void Chain_MapBindMatch_OkPath() - { - var final = Result.Ok(4) - .Map(x => x * 2) // 8 - .Bind(x => x > 10 ? Result.Error("too large") : Result.Ok(x)) - .Match( - ok: x => $"Result: {x}", - error: e => $"Error: {e}" - ); - Assert.Equal("Result: 8", final); - } - - [Fact] - public void Chain_MapBindMatch_ErrorPath() - { - var final = Result.Ok(6) - .Map(x => x * 2) // 12 - .Bind(x => x > 10 ? Result.Error("too large") : Result.Ok(x)) - .Match( - ok: x => $"Result: {x}", - error: e => $"Error: {e}" - ); - Assert.Equal("Error: too large", final); - } - - [Fact] - public void ValueOr_AfterChain() - { - var value = Result.Error("missing") - .Map(x => x * 2) - .ValueOr(99); - Assert.Equal(99, value); - } - - // ========== Value Types & Nullability ========== - - [Fact] - public void Ok_WithValueTypeDefault_IsValid() - { - var result = Result.Ok(0); - Assert.True(result.IsOk); - Assert.Equal(0, result.Value); - } - - [Fact] - public void Error_WithReferenceTypeError_CanHoldNull() - { - var result = Result.Error(null!); - Assert.True(result.IsError); - Assert.Null(result.ErrorValue); - } - - [Fact] - public void MapError_WithDifferentErrorType() - { - var result = Result.Error("42"); - var mapped = result.MapError(err => int.Parse(err)); - Assert.True(mapped.IsError); - Assert.Equal(42, mapped.ErrorValue); - } - - [Fact] - public void Bind_ChangingErrorType() - { - var result = Result.Ok(10); - var bound = result.Bind(x => x > 5 - ? Result.Ok(x) - : Result.Error("value too small")); - Assert.True(bound.IsOk); - Assert.Equal(10, bound.Value); - } - } -} diff --git a/ZeroNull/ZeroNull.Tests/Types/ResultTests.cs b/ZeroNull/ZeroNull.Tests/Types/ResultTests.cs new file mode 100644 index 0000000..bcc3f5e --- /dev/null +++ b/ZeroNull/ZeroNull.Tests/Types/ResultTests.cs @@ -0,0 +1,640 @@ +namespace ZeroNull.Tests.Types +{ + public class ResultTests + { + private static Result Ok(T value) => Result.Ok(value); + private static Result Error(TError error) => Result.Error(error); + + // ========== Creation & Properties ========== + + [Fact] + public void Ok_CreatesResultWithValue() + { + var result = Result.Ok(42); + Assert.True(result.IsOk); + Assert.False(result.IsError); + Assert.Equal(42, result.Value); + Assert.Throws(() => result.ErrorValue); + } + + [Fact] + public void Error_CreatesResultWithError() + { + var result = Result.Error("Something went wrong"); + Assert.True(result.IsError); + Assert.False(result.IsOk); + Assert.Equal("Something went wrong", result.ErrorValue); + Assert.Throws(() => result.Value); + } + + [Fact] + public void Ok_AllowsNullForReferenceTypeValue_IfTIsNullableReference() + { + // For reference types, null is a valid value (though maybe not desirable) + var result = Result.Ok(null!); + Assert.True(result.IsOk); + Assert.Null(result.Value); + } + + [Fact] + public void Error_AllowsNullForErrorType() + { + var result = Result.Error(null!); + Assert.True(result.IsError); + Assert.Null(result.ErrorValue); + } + + // ========== ValueOr ========== + + [Fact] + public void ValueOr_ReturnsValueWhenOk() + { + var result = Result.Ok(42); + Assert.Equal(42, result.ValueOr(100)); + } + + [Fact] + public void ValueOr_ReturnsFallbackWhenError() + { + var result = Result.Error("fail"); + Assert.Equal(100, result.ValueOr(100)); + } + + // ========== Map ========== + + [Fact] + public void Map_TransformsOkValue() + { + var result = Result.Ok(5); + var mapped = result.Map(x => x * 2); + Assert.True(mapped.IsOk); + Assert.Equal(10, mapped.Value); + } + + [Fact] + public void Map_PropagatesErrorWhenError() + { + var result = Result.Error("original error"); + var mapped = result.Map(x => x * 2); + Assert.True(mapped.IsError); + Assert.Equal("original error", mapped.ErrorValue); + } + + [Fact] + public void Map_WithNullMapper_Throws() + { + var result = Result.Ok(5); + Assert.Throws(() => result.Map(null!)); + } + + // ========== Bind ========== + + [Fact] + public void Bind_TransformsOkToNewResult() + { + var result = Result.Ok(5); + var bound = result.Bind(x => Result.Ok(x * 2)); + Assert.True(bound.IsOk); + Assert.Equal(10, bound.Value); + } + + [Fact] + public void Bind_TransformsOkToError() + { + var result = Result.Ok(5); + var bound = result.Bind(x => Result.Error("converted to error")); + Assert.True(bound.IsError); + Assert.Equal("converted to error", bound.ErrorValue); + } + + [Fact] + public void Bind_PropagatesErrorWhenError() + { + var result = Result.Error("persistent error"); + var bound = result.Bind(x => Result.Ok(x * 2)); + Assert.True(bound.IsError); + Assert.Equal("persistent error", bound.ErrorValue); + } + + [Fact] + public void Bind_WithNullBinder_Throws() + { + var result = Result.Ok(5); + Assert.Throws(() => result.Bind(null!)); + } + + // ========== MapError ========== + + [Fact] + public void MapError_TransformsErrorWhenError() + { + var result = Result.Error("error"); + var mapped = result.MapError(err => $"Prefix: {err}"); + Assert.True(mapped.IsError); + Assert.Equal("Prefix: error", mapped.ErrorValue); + } + + [Fact] + public void MapError_PropagatesOkWhenOk() + { + var result = Result.Ok(42); + var mapped = result.MapError(err => $"Prefix: {err}"); + Assert.True(mapped.IsOk); + Assert.Equal(42, mapped.Value); + } + + [Fact] + public void MapError_WithNullMapper_Throws() + { + var result = Result.Error("error"); + Assert.Throws(() => result.MapError(null!)); + } + + // ========== Match ========== + + [Fact] + public void Match_CallsOkDelegateWhenOk() + { + var result = Result.Ok(10); + var output = result.Match( + ok: x => $"Value: {x}", + error: e => $"Error: {e}" + ); + Assert.Equal("Value: 10", output); + } + + [Fact] + public void Match_CallsErrorDelegateWhenError() + { + var result = Result.Error("failure"); + var output = result.Match( + ok: x => $"Value: {x}", + error: e => $"Error: {e}" + ); + Assert.Equal("Error: failure", output); + } + + [Fact] + public void Match_WithNullOkDelegate_Throws() + { + var result = Result.Ok(5); + Assert.Throws(() => result.Match(null!, e => 0)); + } + + [Fact] + public void Match_WithNullErrorDelegate_Throws() + { + var result = Result.Ok(5); + Assert.Throws(() => result.Match(x => x, null!)); + } + + // ========== LINQ Query Syntax ========== + + [Fact] + public void Linq_Select_ProjectsOkValue() + { + var result = from x in Result.Ok(5) + select x * 3; + Assert.True(result.IsOk); + Assert.Equal(15, result.Value); + } + + [Fact] + public void Linq_Select_PropagatesError() + { + var result = from x in Result.Error("fail") + select x * 3; + Assert.True(result.IsError); + Assert.Equal("fail", result.ErrorValue); + } + + [Fact] + public void Linq_SelectMany_CombinesTwoResults() + { + var result = from a in Result.Ok(2) + from b in Result.Ok(3) + select a + b; + Assert.True(result.IsOk); + Assert.Equal(5, result.Value); + } + + [Fact] + public void Linq_SelectMany_ShortCircuitsOnFirstError() + { + var result = from a in Result.Ok(2) + from b in Result.Error("b error") + select a + b; + Assert.True(result.IsError); + Assert.Equal("b error", result.ErrorValue); + } + + [Fact] + public void Linq_SelectMany_WithComplexProjection() + { + var result = from name in Result.Ok("Alice") + from age in Result.Ok(30) + select $"{name} is {age} years old"; + Assert.Equal("Alice is 30 years old", result.Value); + } + + // ========== Chaining / Realistic Scenarios ========== + + [Fact] + public void Chain_MapBindMatch_OkPath() + { + var final = Result.Ok(4) + .Map(x => x * 2) // 8 + .Bind(x => x > 10 ? Result.Error("too large") : Result.Ok(x)) + .Match( + ok: x => $"Result: {x}", + error: e => $"Error: {e}" + ); + Assert.Equal("Result: 8", final); + } + + [Fact] + public void Chain_MapBindMatch_ErrorPath() + { + var final = Result.Ok(6) + .Map(x => x * 2) // 12 + .Bind(x => x > 10 ? Result.Error("too large") : Result.Ok(x)) + .Match( + ok: x => $"Result: {x}", + error: e => $"Error: {e}" + ); + Assert.Equal("Error: too large", final); + } + + [Fact] + public void ValueOr_AfterChain() + { + var value = Result.Error("missing") + .Map(x => x * 2) + .ValueOr(99); + Assert.Equal(99, value); + } + + // ========== Value Types & Nullability ========== + + [Fact] + public void Ok_WithValueTypeDefault_IsValid() + { + var result = Result.Ok(0); + Assert.True(result.IsOk); + Assert.Equal(0, result.Value); + } + + [Fact] + public void Error_WithReferenceTypeError_CanHoldNull() + { + var result = Result.Error(null!); + Assert.True(result.IsError); + Assert.Null(result.ErrorValue); + } + + [Fact] + public void MapError_WithDifferentErrorType() + { + var result = Result.Error("42"); + var mapped = result.MapError(err => int.Parse(err)); + Assert.True(mapped.IsError); + Assert.Equal(42, mapped.ErrorValue); + } + + [Fact] + public void Bind_ChangingErrorType() + { + var result = Result.Ok(10); + var bound = result.Bind(x => x > 5 + ? Result.Ok(x) + : Result.Error("value too small")); + Assert.True(bound.IsOk); + Assert.Equal(10, bound.Value); + } + + // ========== EQUALS – BASIC CONTRACT ========== + [Fact] + public void Equals_NullObject_ReturnsFalse() + { + var result = Ok(42); + Assert.False(result.Equals(null)); + } + + [Fact] + public void Equals_DifferentType_ReturnsFalse() + { + var result = Ok(42); + Assert.False(result.Equals("not a result")); + } + + [Fact] + public void Equals_SameBoxedResult_ReturnsTrue() + { + var result = Ok(42); + object boxed = result; + Assert.True(result.Equals(boxed)); + } + + // ========== EQUALS – OK vs OK ========== + [Fact] + public void Equals_Ok_EqualValues_ValueType_ReturnsTrue() + { + var a = Ok(42); + var b = Ok(42); + Assert.True(a.Equals(b)); + Assert.True(b.Equals(a)); + } + + [Fact] + public void Equals_Ok_EqualValues_ReferenceType_ReturnsTrue() + { + var obj = new object(); + var a = Ok(obj); + var b = Ok(obj); + Assert.True(a.Equals(b)); + } + + [Fact] + public void Equals_Ok_DifferentValues_ValueType_ReturnsFalse() + { + var a = Ok(42); + var b = Ok(99); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_Ok_EqualStrings_ReturnsTrue() + { + var a = Ok("hello"); + var b = Ok("hello"); + Assert.True(a.Equals(b)); + } + + [Fact] + public void Equals_Ok_NullReferenceValue_ReturnsTrue() + { + var a = Ok(null); + var b = Ok(null); + Assert.True(a.Equals(b)); + } + + // ========== EQUALS – ERROR vs ERROR ========== + [Fact] + public void Equals_Error_EqualErrors_ValueType_ReturnsTrue() + { + var a = Error("disk full"); + var b = Error("disk full"); + Assert.True(a.Equals(b)); + Assert.True(b.Equals(a)); + } + + [Fact] + public void Equals_Error_EqualErrors_ReferenceType_ReturnsTrue() + { + var ex = new InvalidOperationException(); + var a = Error(ex); + var b = Error(ex); + Assert.True(a.Equals(b)); + } + + [Fact] + public void Equals_Error_DifferentErrors_ValueType_ReturnsFalse() + { + var a = Error("disk full"); + var b = Error("network error"); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_Error_DifferentErrors_ReferenceType_ReturnsFalse() + { + var a = Error(new InvalidOperationException()); + var b = Error(new ArgumentNullException()); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_Error_NullErrorValue_ReturnsTrue() + { + var a = Error(null); + var b = Error(null); + Assert.True(a.Equals(b)); + } + + // ========== EQUALS – OK vs ERROR (cross‑state) ========== + // With state check, cross‑state equality is ALWAYS false + [Fact] + public void Equals_OkAndError_AlwaysFalse_EvenWhenValuesMatch() + { + var ok = Ok(0); + var err = Error("any error"); + Assert.False(ok.Equals(err)); + Assert.False(err.Equals(ok)); + } + + [Fact] + public void Equals_OkWithNull_AndErrorWithNullError_ReturnsFalse() + { + var ok = Ok(null); + var err = Error(null); + Assert.False(ok.Equals(err)); + Assert.False(err.Equals(ok)); + } + + // ========== EQUALS – Custom Equality for T or TError ========== + public class Person : IEquatable + { + public string Name { get; } + public Person(string name) => Name = name; + public bool Equals(Person? other) => other != null && Name == other.Name; + public override bool Equals(object? obj) => Equals(obj as Person); + public override int GetHashCode() => Name?.GetHashCode() ?? 0; + } + + [Fact] + public void Equals_UsesCustomEqualityForT_InOk() + { + var a = Ok(new Person("Alice")); + var b = Ok(new Person("Alice")); + Assert.True(a.Equals(b)); + } + + [Fact] + public void Equals_UsesCustomEqualityForTError_InError() + { + var err1 = Error(new Person("Bob")); + var err2 = Error(new Person("Bob")); + Assert.True(err1.Equals(err2)); + } + + // ========== GETHASHCODE ========== + [Fact] + public void GetHashCode_ConsistentForSameInstance() + { + var result = Ok(42); + var hash1 = result.GetHashCode(); + var hash2 = result.GetHashCode(); + Assert.Equal(hash1, hash2); + } + + [Fact] + public void GetHashCode_EqualOkResults_HaveSameHash() + { + var a = Ok(42); + var b = Ok(42); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_DifferentOkResults_HaveDifferentHash_Usually() + { + var a = Ok(42); + var b = Ok(99); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_EqualErrorResults_HaveSameHash() + { + var a = Error("disk full"); + var b = Error("disk full"); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_DifferentErrorResults_HaveDifferentHash() + { + var a = Error("disk full"); + var b = Error("network error"); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_OkWithNullReference_ReturnsZero() + { + var result = Ok(null); + Assert.Equal(0, result.GetHashCode()); + } + + [Fact] + public void GetHashCode_ErrorWithNullError_ReturnsZero() + { + var result = Error(null); + Assert.Equal(0, result.GetHashCode()); + } + + [Fact] + public void GetHashCode_UsesUnderlyingHashOfT_ForOk() + { + var obj = new object(); + var result = Ok(obj); + Assert.Equal(obj.GetHashCode(), result.GetHashCode()); + } + + [Fact] + public void GetHashCode_UsesUnderlyingHashOfTError_ForError() + { + var ex = new InvalidOperationException(); + var result = Error(ex); + Assert.Equal(ex.GetHashCode(), result.GetHashCode()); + } + + // ========== EQUALS + GETHASHCODE CONSISTENCY ========== + [Fact] + public void Equals_And_GetHashCode_AreConsistent_ForSameState() + { + var a = Ok(42); + var b = Ok(42); + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + + var e1 = Error("err"); + var e2 = Error("err"); + Assert.True(e1.Equals(e2)); + Assert.Equal(e1.GetHashCode(), e2.GetHashCode()); + } + + [Fact] + public void Equals_And_GetHashCode_NoLongerViolated_CrossState() + { + var ok = Ok(0); + var err = Error("some error"); + Assert.False(ok.Equals(err)); // not equal, so hash codes don't have to match + // The previous violation (Equal objects with different hash codes) no longer exists. + } + + // ========== EDGE: NULLABLE VALUE TYPES ========== + [Fact] + public void Equals_WithNullableValueType_Ok_Works() + { + var a = Ok(null); + var b = Ok(null); + Assert.True(a.Equals(b)); + + var c = Ok(5); + var d = Ok(5); + Assert.True(c.Equals(d)); + + var e = Ok(5); + var f = Ok(null); + Assert.False(e.Equals(f)); + } + + [Fact] + public void GetHashCode_WithNullableValueType_Ok_RespectsNull() + { + var nullResult = Ok(null); + Assert.Equal(0, nullResult.GetHashCode()); + + var fiveResult = Ok(5); + Assert.Equal(5.GetHashCode(), fiveResult.GetHashCode()); + } + + // ========== SYMMETRY & TRANSITIVITY ========== + [Fact] + public void Equals_IsSymmetric_ForSameState() + { + var a = Ok(42); + var b = Ok(42); + + Assert.True(a.Equals(b) && b.Equals(a)); + + var e1 = Error("err"); + var e2 = Error("err"); + + Assert.True(e1.Equals(e2) && e2.Equals(e1)); + } + + [Fact] + public void Equals_IsSymmetric_CrossState_BothFalse() + { + var ok = Ok(0); + var err = Error("error"); + + Assert.False(ok.Equals(err)); + Assert.False(err.Equals(ok)); + } + + [Fact] + public void Equals_Transitivity_HoldsForOk() + { + var a = Ok(42); + var b = Ok(42); + var c = Ok(42); + + Assert.True(a.Equals(b) && b.Equals(c)); + Assert.True(a.Equals(c)); + } + + [Fact] + public void Equals_Transitivity_HoldsForError() + { + var a = Error("err"); + var b = Error("err"); + var c = Error("err"); + + Assert.True(a.Equals(b) && b.Equals(c)); + Assert.True(a.Equals(c)); + } + } +} diff --git a/ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs b/ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs deleted file mode 100644 index 62397f7..0000000 --- a/ZeroNull/ZeroNull.Tests/Types/Validation/ValidationTests.cs +++ /dev/null @@ -1,307 +0,0 @@ -using System.Collections.Immutable; -using ZeroNull.Types.Validation; - -namespace ZeroNull.Tests.Types.Validation -{ - public class ValidationTests - { - // ---------- Factory methods and state (implemented) ---------- - [Fact] - public void Valid_ShouldCreateValidInstance() - { - var valid = Validation.Valid(42); - Assert.True(valid.IsValid); - Assert.False(valid.IsInvalid); - Assert.Equal(42, valid.Value); - Assert.Equal(ImmutableArray.Empty, valid.Errors); - } - - [Fact] - public void Invalid_SingleError_ShouldCreateInvalidInstance() - { - var invalid = Validation.Invalid("Too big"); - Assert.True(invalid.IsInvalid); - Assert.False(invalid.IsValid); - Assert.Throws(() => invalid.Value); - Assert.Equal(new[] { "Too big" }, invalid.Errors); - } - - [Fact] - public void Invalid_MultipleErrors_ShouldCreateInvalidInstance() - { - var invalid = Validation.Invalid("Too big", "Negative"); - Assert.True(invalid.IsInvalid); - Assert.Equal(new[] { "Too big", "Negative" }, invalid.Errors); - } - - [Fact] - public void Valid_WithNullValue_ShouldBeAllowedForReferenceTypes() - { - var valid = Validation.Valid(null); - Assert.True(valid.IsValid); - Assert.Null(valid.Value); - } - - [Fact] - public void Invalid_WithNullError_ShouldBeAllowed() - { - var invalid = Validation.Invalid((string?)null); - Assert.True(invalid.IsInvalid); - Assert.Equal(new string?[] { null }, invalid.Errors); - } - - // ---------- Map (not implemented) ---------- - [Fact(Skip = "Map method not implemented")] - public void Map_OnValid_ShouldTransformValue() - { - var valid = Validation.Valid(5); - var result = valid.Map(x => x * 2); - Assert.True(result.IsValid); - Assert.Equal(10, result.Value); - } - - [Fact(Skip = "Map method not implemented")] - public void Map_OnInvalid_ShouldPropagateErrors() - { - var invalid = Validation.Invalid("Error"); - var result = invalid.Map(x => x * 2); - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Error" }, result.Errors); - } - - // ---------- Bind (not implemented) ---------- - [Fact(Skip = "Bind method not implemented")] - public void Bind_OnValid_ShouldApplyBinder() - { - var valid = Validation.Valid(10); - var result = valid.Bind(x => Validation.Valid($"Value: {x}")); - Assert.True(result.IsValid); - Assert.Equal("Value: 10", result.Value); - } - - [Fact(Skip = "Bind method not implemented")] - public void Bind_OnInvalid_ShouldPropagateErrors() - { - var invalid = Validation.Invalid("Original error"); - var result = invalid.Bind(x => Validation.Valid("ignored")); - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Original error" }, result.Errors); - } - - // ---------- Apply (corrected tests) ---------- - - [Fact(Skip = "Apply method not implemented")] - public void Apply_WithBothValid_AppliesFunction() - { - var validValue = Validation.Valid(5); - var validFunc = Validation>.Valid(x => $"Number {x}"); - var result = validValue.Apply(validFunc); - Assert.True(result.IsValid); - Assert.Equal("Number 5", result.Value); - } - - [Fact(Skip = "Apply method not implemented")] - public void Apply_WithLeftInvalid_PropagatesErrors() - { - var left = Validation.Invalid("Left error"); - var right = Validation>.Valid(x => x.ToString()); - - var result = left.Apply(right); - - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Left error" }, result.Errors); - } - - [Fact(Skip = "Apply method not implemented")] - public void Apply_WithRightInvalid_PropagatesErrors() - { - var left = Validation.Valid(3); - var right = Validation>.Invalid("Right error"); - - var result = left.Apply(right); - - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Right error" }, result.Errors); - } - - [Fact(Skip = "Apply method not implemented")] - public void Apply_WithBothInvalid_AccumulatesAllErrors() - { - var left = Validation.Invalid("Left error"); - var right = Validation>.Invalid("Right error"); - - var result = left.Apply(right); - - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Left error", "Right error" }, result.Errors); - } - - // ---------- Combine (not implemented) ---------- - [Fact(Skip = "Combine method not implemented")] - public void Combine_WithBothValid_ShouldCombineValues() - { - var a = Validation.Valid(5); - var b = Validation.Valid(3); - - var result = a.Combine(b, (x, y) => x + y); - - Assert.True(result.IsValid); - Assert.Equal(8, result.Value); - } - - [Fact(Skip = "Combine method not implemented")] - public void Combine_WithLeftInvalid_ShouldReturnLeftErrors() - { - var a = Validation.Invalid("Left error"); - var b = Validation.Valid(3); - - var result = a.Combine(b, (x, y) => x + y); - - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Left error" }, result.Errors); - } - - [Fact(Skip = "Combine method not implemented")] - public void Combine_WithBothInvalid_ShouldAccumulateBothErrors() - { - var a = Validation.Invalid("Error A"); - var b = Validation.Invalid("Error B"); - - var result = a.Combine(b, (x, y) => x + y); - - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Error A", "Error B" }, result.Errors); - } - - // ---------- ValueOr (not implemented) ---------- - [Fact(Skip = "ValueOr method not implemented")] - public void ValueOr_OnValid_ReturnsValue() - { - var valid = Validation.Valid(42); - - Assert.Equal(42, valid.ValueOr(100)); - Assert.Equal(42, valid.ValueOr(() => 100)); - } - - [Fact(Skip = "ValueOr method not implemented")] - public void ValueOr_OnInvalid_ReturnsFallback() - { - var invalid = Validation.Invalid("Error"); - - Assert.Equal(100, invalid.ValueOr(100)); - Assert.Equal(200, invalid.ValueOr(() => 200)); - } - - [Fact(Skip = "ValueOr method not implemented")] - public void ValueOr_FallbackFactory_ShouldBeCalledOnlyWhenInvalid() - { - int callCount = 0; - var valid = Validation.Valid(5); - - var result = valid.ValueOr(() => { callCount++; return 99; }); - - Assert.Equal(5, result); - Assert.Equal(0, callCount); - - var invalid = Validation.Invalid("err"); - - result = invalid.ValueOr(() => { callCount++; return 99; }); - - Assert.Equal(99, result); - Assert.Equal(1, callCount); - } - - // ---------- Match (not implemented) ---------- - [Fact(Skip = "Match method not implemented")] - public void Match_OnValid_CallsValidBranch() - { - var valid = Validation.Valid(10); - - var result = valid.Match( - valid: v => $"Value: {v}", - invalid: e => $"Errors: {string.Join(",", e)}" - ); - - Assert.Equal("Value: 10", result); - } - - [Fact(Skip = "Match method not implemented")] - public void Match_OnInvalid_CallsInvalidBranch() - { - var invalid = Validation.Invalid("Bad", "Worse"); - var result = invalid.Match( - valid: v => $"Value: {v}", - invalid: e => $"Errors: {string.Join(",", e)}" - ); - Assert.Equal("Errors: Bad,Worse", result); - } - - // ---------- Select (LINQ) - not implemented ---------- - [Fact(Skip = "Select method not implemented")] - public void Select_OnValid_Transforms() - { - var valid = Validation.Valid(3); - var result = from x in valid select x * 2; - Assert.True(result.IsValid); - Assert.Equal(6, result.Value); - } - - [Fact(Skip = "Select method not implemented")] - public void Select_OnInvalid_PropagatesErrors() - { - var invalid = Validation.Invalid("Error"); - var result = from x in invalid select x * 2; - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Error" }, result.Errors); - } - - // ---------- SelectMany (LINQ) - not implemented ---------- - [Fact(Skip = "SelectMany method not implemented")] - public void SelectMany_WithBothValid_Projects() - { - var v1 = Validation.Valid(2); - var v2 = Validation.Valid(3); - var result = from a in v1 - from b in v2 - select a + b; - Assert.True(result.IsValid); - Assert.Equal(5, result.Value); - } - - [Fact(Skip = "SelectMany method not implemented")] - public void SelectMany_WithFirstInvalid_ShortCircuits() - { - var v1 = Validation.Invalid("First error"); - var v2 = Validation.Valid(3); - var result = from a in v1 - from b in v2 - select a + b; - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "First error" }, result.Errors); - } - - [Fact(Skip = "SelectMany method not implemented")] - public void SelectMany_WithSecondInvalid_AccumulatesErrors() - { - var v1 = Validation.Valid(2); - var v2 = Validation.Invalid("Second error"); - var result = from a in v1 - from b in v2 - select a + b; - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "Second error" }, result.Errors); - } - - [Fact(Skip = "SelectMany method not implemented")] - public void SelectMany_WithBothInvalid_AccumulatesAllErrors() - { - var v1 = Validation.Invalid("First error"); - var v2 = Validation.Invalid("Second error"); - var result = from a in v1 - from b in v2 - select a + b; - Assert.True(result.IsInvalid); - Assert.Equal(new[] { "First error", "Second error" }, result.Errors); - } - } -} diff --git a/ZeroNull/ZeroNull.Tests/Types/ValidationTests.cs b/ZeroNull/ZeroNull.Tests/Types/ValidationTests.cs new file mode 100644 index 0000000..2491706 --- /dev/null +++ b/ZeroNull/ZeroNull.Tests/Types/ValidationTests.cs @@ -0,0 +1,793 @@ +using System.Collections.Immutable; +using ZeroNull.Types; + +namespace ZeroNull.Tests.Types +{ + public class ValidationTests + { + private static Validation Valid(TValue value) + => Validation.Valid(value); + + private static Validation Invalid(params TError[] errors) + => Validation.Invalid(errors.ToImmutableArray()); + + // ---------- Factory methods and state (implemented) ---------- + + [Fact] + public void Valid_ShouldCreateValidInstance() + { + var valid = Validation.Valid(42); + Assert.True(valid.IsValid); + Assert.False(valid.IsInvalid); + Assert.Equal(42, valid.Value); + Assert.Equal(ImmutableArray.Empty, valid.Errors); + } + + [Fact] + public void Invalid_SingleError_ShouldCreateInvalidInstance() + { + var invalid = Validation.Invalid("Too big"); + Assert.True(invalid.IsInvalid); + Assert.False(invalid.IsValid); + Assert.Throws(() => invalid.Value); + Assert.Equal(new[] { "Too big" }, invalid.Errors); + } + + [Fact] + public void Invalid_MultipleErrors_ShouldCreateInvalidInstance() + { + var invalid = Validation.Invalid("Too big", "Negative"); + Assert.True(invalid.IsInvalid); + Assert.Equal(new[] { "Too big", "Negative" }, invalid.Errors); + } + + [Fact] + public void Valid_WithNullValue_ShouldBeAllowedForReferenceTypes() + { + var valid = Validation.Valid(null); + Assert.True(valid.IsValid); + Assert.Null(valid.Value); + } + + [Fact] + public void Invalid_WithNullError_ShouldBeAllowed() + { + var invalid = Validation.Invalid((string?)null); + Assert.True(invalid.IsInvalid); + Assert.Equal(new string?[] { null }, invalid.Errors); + } + + // ---------- Map ---------- + [Fact] + public void Map_OnValid_ShouldTransformValue() + { + var valid = Validation.Valid(5); + + var result = valid.Map(x => x * 2); + + Assert.True(result.IsValid); + Assert.Equal(10, result.Value); + } + + [Fact] + public void Map_OnInvalid_ShouldPropagateErrors() + { + var invalid = Validation.Invalid("Error"); + + var result = invalid.Map(x => x * 2); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error" }, result.Errors); + } + + // ---------- Bind ---------- + [Fact] + public void Bind_OnValid_ShouldApplyBinder() + { + var valid = Validation.Valid(10); + + var result = valid.Bind(x => Validation.Valid($"Value: {x}")); + + Assert.True(result.IsValid); + Assert.Equal("Value: 10", result.Value); + } + + [Fact] + public void Bind_OnInvalid_ShouldPropagateErrors() + { + var invalid = Validation.Invalid("Original error"); + var result = invalid.Bind(x => Validation.Valid("ignored")); + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Original error" }, result.Errors); + } + + // ---------- Apply ---------- + + [Fact] + public void Apply_WithBothValid_AppliesFunction() + { + var validValue = Validation.Valid(5); + + var validFunc = Validation>.Valid(x => $"Number {x}"); + + var result = validValue.Apply(validFunc); + + Assert.True(result.IsValid); + Assert.Equal("Number 5", result.Value); + } + + [Fact] + public void Apply_WithLeftInvalid_PropagatesErrors() + { + var left = Validation.Invalid("Left error"); + var right = Validation>.Valid(x => x.ToString()); + + var result = left.Apply(right); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Left error" }, result.Errors); + } + + [Fact] + public void Apply_WithRightInvalid_PropagatesErrors() + { + var left = Validation.Valid(3); + var right = Validation>.Invalid("Right error"); + + var result = left.Apply(right); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Right error" }, result.Errors); + } + + [Fact] + public void Apply_WithBothInvalid_AccumulatesAllErrors() + { + var left = Validation.Invalid("Left error"); + var right = Validation>.Invalid("Right error"); + + var result = left.Apply(right); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Left error", "Right error" }, result.Errors); + } + + // ---------- Combine ---------- + + [Fact] + public void Combine_WithBothValid_ShouldCombineValues() + { + var a = Validation.Valid(5); + var b = Validation.Valid(3); + + var result = a.Combine(b, (x, y) => x + y); + + Assert.True(result.IsValid); + Assert.Equal(8, result.Value); + } + + [Fact] + public void Combine_WithLeftInvalid_ShouldReturnLeftErrors() + { + var a = Validation.Invalid("Left error"); + var b = Validation.Valid(3); + + var result = a.Combine(b, (x, y) => x + y); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Left error" }, result.Errors.ToArray()); + } + + [Fact] + public void Combine_WithBothInvalid_ShouldAccumulateBothErrors() + { + var a = Validation.Invalid("Error A"); + var b = Validation.Invalid("Error B"); + + var result = a.Combine(b, (x, y) => x + y); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error A", "Error B" }, result.Errors); + } + + // ---------- ValueOr (not implemented) ---------- + + [Fact] + public void ValueOr_OnValid_ReturnsValue() + { + var valid = Validation.Valid(42); + + Assert.Equal(42, valid.ValueOr(100)); + Assert.Equal(42, valid.ValueOr(() => 100)); + } + + [Fact] + public void ValueOr_OnInvalid_ReturnsFallback() + { + var invalid = Validation.Invalid("Error"); + + Assert.Equal(100, invalid.ValueOr(100)); + Assert.Equal(200, invalid.ValueOr(() => 200)); + } + + [Fact] + public void ValueOr_FallbackFactory_ShouldBeCalledOnlyWhenInvalid() + { + int callCount = 0; + var valid = Validation.Valid(5); + + var result = valid.ValueOr(() => { callCount++; return 99; }); + + Assert.Equal(5, result); + Assert.Equal(0, callCount); + + var invalid = Validation.Invalid("err"); + + result = invalid.ValueOr(() => { callCount++; return 99; }); + + Assert.Equal(99, result); + Assert.Equal(1, callCount); + } + + // ---------- Match (not implemented) ---------- + + [Fact] + public void Match_OnValid_CallsValidBranch() + { + var valid = Validation.Valid(10); + + var result = valid.Match( + valid: v => $"Value: {v}", + invalid: e => $"Errors: {string.Join(",", e)}" + ); + + Assert.Equal("Value: 10", result); + } + + [Fact] + public void Match_OnInvalid_CallsInvalidBranch() + { + var invalid = Validation.Invalid("Bad", "Worse"); + var result = invalid.Match( + valid: v => $"Value: {v}", + invalid: e => $"Errors: {string.Join(",", e)}" + ); + Assert.Equal("Errors: Bad,Worse", result); + } + + // ---------- Select (LINQ) ---------- + + [Fact] + public void Select_OnValid_Transforms() + { + var valid = Validation.Valid(3); + + var result = from x in valid select x * 2; + + Assert.True(result.IsValid); + Assert.Equal(6, result.Value); + } + + [Fact] + public void Select_OnInvalid_PropagatesErrors() + { + var invalid = Validation.Invalid("Error"); + + var result = from x in invalid select x * 2; + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error" }, result.Errors); + } + + // ---------- SelectMany (LINQ) - not implemented ---------- + + [Fact] + public void SelectMany_WithBothValid_ProjectsSuccessfully() + { + var v1 = Validation.Valid(2); + var v2 = Validation.Valid(3); + + var result = from a in v1 + from b in v2 + select a + b; + + Assert.True(result.IsValid); + Assert.Equal(5, result.Value); + } + + [Fact] + public void SelectMany_WithFirstInvalid_ShortCircuits_ReturnsFirstErrorOnly() + { + var v1 = Validation.Invalid("First error"); + var v2 = Validation.Valid(3); + + var result = from a in v1 + from b in v2 + select a + b; + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "First error" }, result.Errors); + } + + [Fact] + public void SelectMany_WithSecondInvalid_ReturnsSecondError() + { + var v1 = Validation.Valid(2); + var v2 = Validation.Invalid("Second error"); + + var result = from a in v1 + from b in v2 + select a + b; + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Second error" }, result.Errors); + } + + [Fact] + public void SelectMany_WithFirstInvalidAndSecondInvalid_OnlyFirstErrorIsReturned() + { + var v1 = Validation.Invalid("First error"); + var v2 = Validation.Invalid("Second error"); + + var result = from a in v1 + from b in v2 + select a + b; + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "First error" }, result.Errors); + } + + [Fact] + public void SelectMany_WhenBindReturnsInvalid_PropagatesThatErrorAndIgnoresProjector() + { + var valid = Validation.Valid(10); + + var result = valid.SelectMany( + x => Validation.Invalid("Bind error"), + (x, y) => $"{x} {y}" // projector should never be called + ); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Bind error" }, result.Errors); + } + + [Fact] + public void SelectMany_WhenCurrentIsValidAndBindReturnsValid_AppliesProjector() + { + var valid = Validation.Valid(5); + + var result = valid.SelectMany( + x => Validation.Valid($"Value: {x}"), + (x, y) => $"{x} -> {y}" + ); + + Assert.True(result.IsValid); + Assert.Equal("5 -> Value: 5", result.Value); + } + + [Fact] + public void SelectMany_ChainingThreeValidations_ProjectsCorrectly() + { + var v1 = Validation.Valid(1); + var v2 = Validation.Valid(2); + var v3 = Validation.Valid(3); + + var result = from a in v1 + from b in v2 + from c in v3 + select a + b + c; + + Assert.True(result.IsValid); + Assert.Equal(6, result.Value); + } + + [Fact] + public void SelectMany_ChainingWithFirstInvalid_ShortCircuits() + { + var v1 = Validation.Invalid("Error 1"); + var v2 = Validation.Valid(2); + var v3 = Validation.Valid(3); + + var result = from a in v1 + from b in v2 + from c in v3 + select a + b + c; + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error 1" }, result.Errors); + } + + // Bug 1: Map should NOT call mapper on invalid + [Fact] + public void Map_OnInvalid_ShouldNotInvokeMapper() + { + var invalid = Validation.Invalid("Error"); + int callCount = 0; + + invalid.Map(x => { callCount++; return x.ToUpper(); }); + + Assert.Equal(0, callCount); + } + + // Bug 2A: BindImpl null-throw for reference-type invalids + [Fact] + public void Bind_OnInvalid_WithReferenceTypeValue_ShouldPropagateErrors() + { + var invalid = Validation.Invalid("Error"); + + var result = invalid.Bind(x => Validation.Valid("ignored")); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Error" }, result.Errors); + } + + // Bug 2B: Binder should not be invoked on invalid + [Fact] + public void Bind_OnInvalid_ShouldNotInvokeBinder() + { + var invalid = Validation.Invalid("Error"); + int callCount = 0; + + invalid.Bind(x => { callCount++; return Validation.Valid("x"); }); + + Assert.Equal(0, callCount); + } + + // Bug 2C: Duplicate errors must not be deduplicated + [Fact] + public void Errors_DuplicateErrors_ShouldBePreserved() + { + var invalid = Validation.Invalid("Too big", "Too big"); + + Assert.Equal(new[] { "Too big", "Too big" }, invalid.Errors); + } + + // Bug 3: Apply on invalid reference-type should not throw + [Fact] + public void Apply_OnInvalidWithReferenceTypeValue_ShouldNotThrow() + { + var left = Validation.Invalid("Left error"); + var right = Validation>.Valid(x => x.ToUpper()); + + var result = left.Apply(right); + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Left error" }, result.Errors); + } + + // Bug 4: CombineImpl calls combiner with nulls + [Fact] + public void Combine_WithRightInvalid_ShouldReturnRightErrors() + { + var a = Validation.Valid("hello"); + var b = Validation.Invalid("Right error"); + + var result = a.Combine(b, (x, y) => x + y); // would NPE without fix + + Assert.True(result.IsInvalid); + Assert.Equal(new[] { "Right error" }, result.Errors); + } + + [Fact] + public void Combine_WithBothInvalid_ShouldNotInvokeCombiner() + { + var a = Validation.Invalid("Error A"); + var b = Validation.Invalid("Error B"); + int callCount = 0; + + a.Combine(b, (x, y) => { callCount++; return x + y; }); + + Assert.Equal(0, callCount); + } + + // Default struct state (uninitialized Validation<,>) + [Fact] + public void Default_Struct_ShouldBehavePredictably() + { + var defaultVal = default(Validation); + // Should be treated as invalid (IsValid is false by default) + Assert.False(defaultVal.IsValid); + Assert.True(defaultVal.IsInvalid); + } + + // Error order preservation + [Fact] + public void Apply_BothInvalid_ErrorOrder_ShouldBeLeftThenRight() + { + var left = Validation.Invalid("A", "B"); + var right = Validation>.Invalid("C", "D"); + + var result = left.Apply(right); + + Assert.Equal(new[] { "A", "B", "C", "D" }, result.Errors); + } + + // Match should only call one branch + [Fact] + public void Match_OnValid_ShouldNotCallInvalidBranch() + { + var valid = Validation.Valid(1); + int invalidCalls = 0; + + valid.Match(v => v.ToString(), e => { invalidCalls++; return ""; }); + + Assert.Equal(0, invalidCalls); + } + + [Fact] + public void Match_OnInvalid_ShouldNotCallValidBranch() + { + var invalid = Validation.Invalid("err"); + int validCalls = 0; + + invalid.Match(v => { validCalls++; return ""; }, e => "errors"); + + Assert.Equal(0, validCalls); + } + + // Null argument guards + [Fact] + public void Map_NullMapper_ShouldThrowArgumentNullException() + => Assert.Throws(() => + Validation.Valid(1).Map(null!)); + + [Fact] + public void Bind_NullBinder_ShouldThrowArgumentNullException() + => Assert.Throws(() => + Validation.Valid(1).Bind(null!)); + + [Fact] + public void ValueOr_NullFactory_ShouldThrowArgumentNullException() + => Assert.Throws(() => + Validation.Valid(1).ValueOr(null!)); + + // Select should not invoke selector when invalid + [Fact] + public void Select_OnInvalid_ShouldNotInvokeSelector() + { + var invalid = Validation.Invalid("Error"); + int callCount = 0; + + var _ = from x in invalid select (callCount++ + x); + + Assert.Equal(0, callCount); + } + + // ========== BASIC CONTRACT ========== + [Fact] + public void Equals_Null_ReturnsFalse() + { + var v = Valid(42); + Assert.False(v.Equals(null)); + } + + [Fact] + public void Equals_WrongType_ReturnsFalse() + { + var v = Valid(42); + Assert.False(v.Equals("not a validation")); + } + + [Fact] + public void Equals_SameBoxed_ReturnsTrue() + { + var v = Valid(42); + object boxed = v; + Assert.True(v.Equals(boxed)); + } + + // ========== VALID vs VALID ========== + [Fact] + public void Valid_EqualValues_ReturnsTrue() + { + var a = Valid(42); + var b = Valid(42); + Assert.True(a.Equals(b)); + } + + [Fact] + public void Valid_DifferentValues_ReturnsFalse() + { + var a = Valid(42); + var b = Valid(99); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Valid_NullValue_Equal() + { + var a = Valid(null); + var b = Valid(null); + Assert.True(a.Equals(b)); + } + + // ========== INVALID vs INVALID ========== + [Fact] + public void Invalid_EqualErrors_ReturnsTrue() + { + var a = Invalid(1, 2); + var b = Invalid(1, 2); + + Assert.True(a.Equals(b)); + } + + [Fact] + public void Invalid_DifferentErrors_ReturnsFalse() + { + var a = Invalid(1, 2); + var b = Invalid(1, 3); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Invalid_OrderMatters_ReturnsFalse() + { + var a = Invalid(1, 2); + var b = Invalid(2, 1); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Invalid_EmptyErrors_Equal() + { + var a = Invalid(); + var b = Invalid(); + Assert.True(a.Equals(b)); + } + + // ========== VALID vs INVALID (cross‑state) ========== + [Fact] + public void ValidAndInvalid_AlwaysFalse_EvenWhenValueMatchesDefaultAndErrorsEmpty() + { + var valid = Valid(0); + var invalid = Invalid(); + Assert.False(valid.Equals(invalid)); + Assert.False(invalid.Equals(valid)); + } + + [Fact] + public void ValidAndInvalid_AlwaysFalse_EvenWhenBothHaveNullValueAndEmptyErrors() + { + var valid = Valid(null); + var invalid = Invalid(); + Assert.False(valid.Equals(invalid)); + } + + // ========== CUSTOM EQUALITY ========== + public class Person : IEquatable + { + public string Name { get; } + public Person(string name) => Name = name; + public bool Equals(Person? other) => other != null && Name == other.Name; + public override bool Equals(object? obj) => Equals(obj as Person); + public override int GetHashCode() => Name?.GetHashCode() ?? 0; + } + + [Fact] + public void UsesCustomEquality_ForValue() + { + var a = Valid(new Person("Alice")); + var b = Valid(new Person("Alice")); + Assert.True(a.Equals(b)); + } + + [Fact] + public void UsesCustomEquality_ForErrors() + { + var e1 = new Person("Bob"); + var e2 = new Person("Bob"); + var a = Invalid(e1); + var b = Invalid(e2); + Assert.True(a.Equals(b)); + } + + // ========== GETHASHCODE ========== + [Fact] + public void GetHashCode_Consistent_ForSameInstance() + { + var v = Valid(42); + Assert.Equal(v.GetHashCode(), v.GetHashCode()); + } + + [Fact] + public void GetHashCode_EqualValid_ProducesSameHash() + { + var a = Valid(42); + var b = Valid(42); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_EqualInvalid_ProducesSameHash() + { + var a = Invalid(1, 2); + var b = Invalid(1, 2); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_DifferentValidValues_UsuallyDifferent() + { + var a = Valid(42); + var b = Valid(99); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_DifferentErrors_UsuallyDifferent() + { + var a = Invalid(1, 2); + var b = Invalid(1, 3); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_ValidWithNullValue_ReturnsSameAsOtherNull() + { + var a = Valid(null); + var b = Valid(null); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(0, a.GetHashCode()); // should not be zero because state contributes + } + + [Fact] + public void GetHashCode_InvalidEmptyErrors_IsStable() + { + var a = Invalid(); + var b = Invalid(); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_RespectsState_SoValidAndInvalidWithSameValueAndErrors_Differ() + { + var valid = Valid(0); + var invalid = Invalid(); + Assert.NotEqual(valid.GetHashCode(), invalid.GetHashCode()); + } + + // ========== CONSISTENCY BETWEEN EQUALS AND GETHASHCODE ========== + [Fact] + public void EqualObjects_AlwaysHaveSameHashCode() + { + var a = Valid(42); + var b = Valid(42); + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + + var i1 = Invalid(1, 2); + var i2 = Invalid(1, 2); + Assert.True(i1.Equals(i2)); + Assert.Equal(i1.GetHashCode(), i2.GetHashCode()); + } + + // ========== EDGE: NULLABLE VALUE TYPES ========== + [Fact] + public void NullableValueType_Valid_Works() + { + var a = Valid(null); + var b = Valid(null); + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + // ========== SYMMETRY & TRANSITIVITY ========== + [Fact] + public void Equals_IsSymmetric() + { + var a = Valid(42); + var b = Valid(42); + Assert.True(a.Equals(b) && b.Equals(a)); + } + + [Fact] + public void Equals_IsTransitive_ForValid() + { + var a = Valid(42); + var b = Valid(42); + var c = Valid(42); + Assert.True(a.Equals(b) && b.Equals(c)); + Assert.True(a.Equals(c)); + } + } +} diff --git a/ZeroNull/ZeroNull/Enums/EitherState.cs b/ZeroNull/ZeroNull/Enums/EitherState.cs new file mode 100644 index 0000000..73be7c5 --- /dev/null +++ b/ZeroNull/ZeroNull/Enums/EitherState.cs @@ -0,0 +1,9 @@ +namespace ZeroNull.Types +{ + internal enum EitherState + { + UnSet = 0, + Left = 1, + Right = 2 + } +} diff --git a/ZeroNull/ZeroNull/Enums/OptionState.cs b/ZeroNull/ZeroNull/Enums/OptionState.cs new file mode 100644 index 0000000..3692efa --- /dev/null +++ b/ZeroNull/ZeroNull/Enums/OptionState.cs @@ -0,0 +1,9 @@ +namespace ZeroNull.Types +{ + internal enum OptionState + { + UnSet = 0, + Some = 1, + None = 2 + } +} diff --git a/ZeroNull/ZeroNull/Enums/ResultState.cs b/ZeroNull/ZeroNull/Enums/ResultState.cs new file mode 100644 index 0000000..aae4e3b --- /dev/null +++ b/ZeroNull/ZeroNull/Enums/ResultState.cs @@ -0,0 +1,9 @@ +namespace ZeroNull.Types +{ + internal enum ResultState + { + UnSet = 0, + Success = 1, + Error = 2 + } +} diff --git a/ZeroNull/ZeroNull/Enums/ValidationState.cs b/ZeroNull/ZeroNull/Enums/ValidationState.cs new file mode 100644 index 0000000..21aa76c --- /dev/null +++ b/ZeroNull/ZeroNull/Enums/ValidationState.cs @@ -0,0 +1,9 @@ +namespace ZeroNull.Types +{ + internal enum ValidationState + { + UnSet = 0, + Valid = 1, + Invalid = 2 + } +} diff --git a/ZeroNull/ZeroNull/Extensions/EitherExtensions.cs b/ZeroNull/ZeroNull/Extensions/EitherExtensions.cs deleted file mode 100644 index c8afd62..0000000 --- a/ZeroNull/ZeroNull/Extensions/EitherExtensions.cs +++ /dev/null @@ -1,133 +0,0 @@ -using ZeroNull.Types.Either; - -namespace ZeroNull.Extensions -{ - public static class EitherExtensions - { - /// - /// Allows to select values on a collection based on a custom comparator - /// - public static Either Select( - this Either either, - Func selector) - { - if (selector == null) throw new ArgumentNullException(nameof(selector)); - - if (!either.IsLeft) - return Either.Of(selector(either.Right)); - - return Either.Of(either.Left); - } - - /// - /// Allows to select many values on different collections based on a custom comparator - /// - public static Either SelectMany( - this Either either, - Func> collectionSelector, - Func resultSelector) - { - if (collectionSelector == null) throw new ArgumentNullException(nameof(collectionSelector)); - if (resultSelector == null) throw new ArgumentNullException(nameof(resultSelector)); - - // Short-circuit: if this either is Left, propagate the error - if (either.IsLeft) - return Either.Of(either.Left); - - // Get the next monad in the chain - var next = collectionSelector(either.Right); - - // If the next monad is Left, propagate the error - if (next.IsLeft) - return Either.Of(next.Left); - - // Both are Right, combine the values - var result = resultSelector(either.Right, next.Right); - return Either.Of(result); - } - - /// - /// Allows to match with Right value based on a custom comparator and a fallback value in case is false. - /// - public static Either Where( - this Either either, - Func predicate, - TLeft leftOnFalse) - { - if (predicate == null) throw new ArgumentNullException(nameof(predicate)); - - if (either.IsLeft) - return either; - - return predicate(either.Right) ? either : Either.Of(leftOnFalse); - } - - /// - /// Allows to match with Left value based on a custom comparator and a fallback value in case is false. - /// - public static Either Where( - this Either either, - Func predicate, - TLeft rightOnFalse) - { - if (predicate == null) throw new ArgumentNullException(nameof(predicate)); - - if (!either.IsLeft) - return either; - - return predicate(either.Left) ? either : Either.Of(rightOnFalse); - } - - /// - /// Maps the Left value to a new type, leaving Right unchanged. - /// - public static Either MapLeft( - this Either either, - Func mapper) - { - if (mapper == null) throw new ArgumentNullException(nameof(mapper)); - - return either.IsLeft - ? Either.Of(mapper(either.Left)) - : Either.Of(either.Right); - } - - /// - /// Maps the Right value to a new type, leaving Left unchanged. - /// - public static Either MapRight( - this Either either, - Func mapper) - { - if (mapper == null) throw new ArgumentNullException(nameof(mapper)); - - return !either.IsLeft - ? Either.Of(mapper(either.Right)) - : Either.Of(either.Left); - } - - /// - /// Binds over the Left value, allowing transformation to a new Either (Left or Right). - /// - public static Either BindLeft( - this Either either, - Func> binder) - { - if (binder == null) throw new ArgumentNullException(nameof(binder)); - - return either.IsLeft ? binder(either.Left) : Either.Of(either.Right); - } - - /// - /// Binds over the Right value, allowing transformation to a new Either (Left or Right). - /// - public static Either BindRight( - this Either either, - Func> binder) - { - if (binder == null) throw new ArgumentNullException(nameof(binder)); - - return !either.IsLeft ? binder(either.Right) : Either.Of(either.Left); - } - } -} diff --git a/ZeroNull/ZeroNull/Types/Either.cs b/ZeroNull/ZeroNull/Types/Either.cs new file mode 100644 index 0000000..3fbd40d --- /dev/null +++ b/ZeroNull/ZeroNull/Types/Either.cs @@ -0,0 +1,362 @@ +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace ZeroNull.Types +{ + public readonly struct Either + { + // ===================================================== + // Internal state + // ===================================================== + + internal readonly Func? _leftValidator; + internal readonly Func? _rightValidator; + + internal readonly TLeft? _left; + internal readonly TRight? _right; + internal readonly Type _currentType; + + internal readonly EitherState _state = EitherState.UnSet; + internal readonly bool _isLeft => _state == EitherState.Left; + internal readonly bool _isRight => _state == EitherState.Right; + + // ===================================================== + // Constructors + // ===================================================== + + internal Either(TLeft? left, Func? validator = null) + { + var leftType = typeof(TLeft); + if (leftType == typeof(TRight)) throw new ArgumentException("TLeft and TRight cannot be the same type."); + + _leftValidator = validator; + _rightValidator = null; + _state = EitherState.Left; + _left = left; + + if (validator != null) ValidateValuesImpl(); + + _right = default; + _currentType = leftType; + } + + internal Either(TRight? right, Func? validator = null) + { + var rightType = typeof(TRight); + if (rightType == typeof(TLeft)) throw new ArgumentException("TLeft and TRight cannot be the same type."); + + _leftValidator = null; + _rightValidator = validator; + _state = EitherState.Right; + _right = right; + + if (validator is not null) ValidateValuesImpl(); + + _left = default; + _currentType = rightType; + } + + // ===================================================== + // Internal helpers + // ===================================================== + + internal TValue GetAssignedValueImpl() + { + var type = typeof(TValue); + if (_currentType != type) + throw new InvalidCastException( + $"Either {typeof(TLeft).Name} nor {typeof(TRight).Name} match type: {type.Name}"); + + + if (_state == EitherState.Left) return (TValue)(object)_left!; + + return (TValue)(object)_right!; + } + + /// + /// Core validation logic shared by and . + /// + internal bool ValidateValuesImpl(bool throwWhenFail = true) + { + if (_leftValidator is not null && _isLeft) + { + if (_left is null) throw new ArgumentNullException("left"); + + var pass = _leftValidator.Invoke(_left!); + if (!pass && throwWhenFail) + throw new ArgumentException("Left value failed validation by the given validator."); + return pass; + } + + if (_rightValidator is not null && _isRight) + { + if (_right is null) throw new ArgumentNullException("right"); + + var pass = _rightValidator.Invoke(_right!); + if (!pass && throwWhenFail) + throw new ArgumentException("Right value failed validation by the given validator."); + return pass; + } + + return false; + } + + internal Either SelectImpl(Func selector) + { + if (selector is null) throw new ArgumentNullException(nameof(selector)); + + return IsLeft + ? Either.Of(Left) + : Either.Of(selector(Right)); + } + + internal Either SelectManyImpl( + Func> collectionSelector, + Func resultSelector) + { + if (collectionSelector is null) throw new ArgumentNullException(nameof(collectionSelector)); + if (resultSelector is null) throw new ArgumentNullException(nameof(resultSelector)); + + if (IsLeft) return Either.Of(Left); + + var next = collectionSelector(Right); + if (next.IsLeft) return Either.Of(next.Left); + + return Either.Of(resultSelector(Right, next.Right)); + } + + internal Either WhereRightImpl( + Func predicate, + TLeft leftOnFalse) + { + if (predicate is null) throw new ArgumentNullException(nameof(predicate)); + + if (IsLeft) return this; + + return predicate(Right) ? this : Either.Of(leftOnFalse); + } + + internal Either WhereLeftImpl( + Func predicate, + TLeft leftOnFalse) + { + if (predicate is null) throw new ArgumentNullException(nameof(predicate)); + + if (IsRight) return this; + + return predicate(Left) ? this : Either.Of(leftOnFalse); + } + + internal Either MapLeftImpl( + Func mapper) + { + if (mapper is null) throw new ArgumentNullException(nameof(mapper)); + + return IsLeft + ? Either.Of(mapper(Left)) + : Either.Of(Right); + } + + internal Either MapRightImpl( + Func mapper) + { + if (mapper is null) throw new ArgumentNullException(nameof(mapper)); + + return IsRight + ? Either.Of(mapper(Right)) + : Either.Of(Left); + } + + internal bool EqualsImpl(object? obj) + { + if (obj is not Either other) + return false; + + if (_state != other._state) + return false; + + if (_isLeft) + { + return EqualityComparer.Default.Equals(_left, other._left) + && _leftValidator == other._leftValidator; + } + + if (_isRight) + { + return EqualityComparer.Default.Equals(_right, other._right) + && _rightValidator == other._rightValidator; + } + + return true; + } + + + internal int GetHashCodeImpl() + { + return _isLeft + ? HashCode.Combine( + _state, + EqualityComparer.Default.GetHashCode(_left!), + _leftValidator) + : HashCode.Combine( + _state, + EqualityComparer.Default.GetHashCode(_right!), + _rightValidator); + } + + internal TLeft GetLeftImpl() => IsLeft ? _left! : throw new InvalidOperationException("Either is Right, no Left value."); + internal TRight GetRightImpl() => IsRight ? _right! : throw new InvalidOperationException("Either is Right, no Left value."); + + internal bool GetIsLeftImpl() => _isLeft; + internal bool GetIsRightImpl() => _isRight; + + // ===================================================== + // Public API — State + // ===================================================== + + /// + /// Returns true if the Either is Left. + /// + public bool IsLeft => GetIsLeftImpl(); + + /// + /// Returns true if the Either is Right. + /// + public bool IsRight => GetIsRightImpl(); + + /// + /// Returns the Left value. + /// + public TLeft Left => GetLeftImpl(); + + /// + /// Returns the Right value. + /// + public TRight Right => GetRightImpl(); + + + public T GetValue() => GetAssignedValueImpl(); + + // ===================================================== + // Public API + // ===================================================== + + /// + /// Projects the Right value through , leaving Left unchanged. + /// Mirrors the LINQ Select pattern for use in query expressions. + /// + public Either Select( + Func selector) => SelectImpl(selector); + + /// + /// Chains two computations, combining their Right + /// values with . Propagates the first Left encountered. + /// Enables multi-from LINQ query expressions. + /// + public Either SelectMany( + Func> collectionSelector, + Func resultSelector) => SelectManyImpl(collectionSelector, resultSelector); + + /// + /// Returns this instance if it is Left, or if the Right value satisfies + /// ; otherwise returns a Left of . + /// + public Either WhereRight( + Func predicate, + TLeft leftOnFalse) => WhereRightImpl(predicate, leftOnFalse); + + /// + /// Returns this instance if it is Right, or if the Left value satisfies + /// ; otherwise returns a Left of . + /// + public Either WhereLeft( + Func predicate, + TLeft leftOnFalse) => WhereLeftImpl(predicate, leftOnFalse); + + /// + /// Transforms the Left value through , leaving Right unchanged. + /// + public Either MapLeft( + Func mapper) => MapLeftImpl(mapper); + + /// + /// Transforms the Right value through , leaving Left unchanged. + /// + public Either MapRight( + Func mapper) => MapRightImpl(mapper); + + /// + /// Applies to the Left value, allowing it to produce a new + /// . Short-circuits if this instance is Right. + /// + public Either BindLeft( + Func> binder) + { + if (binder is null) throw new ArgumentNullException(nameof(binder)); + + return IsLeft ? binder(Left) : this; + } + + /// + /// Applies to the Right value, allowing it to produce a new + /// . Short-circuits if this instance is Left. + /// + public Either BindRight( + Func> binder) + { + if (binder is null) throw new ArgumentNullException(nameof(binder)); + + return IsRight ? binder(Right) : this; + } + + // ===================================================== + // Factory methods + // ===================================================== + + /// + /// Creates a Left of type . + /// + /// Left value + /// Optional: validator to validate values on assignment + /// Optional: boolean specifies if to validate on assignment + /// + public static Either Of(TLeft value, Func? validator = null) + => new Either(value, validator); + + /// + /// Creates a Right of type . + /// + /// Right value + /// Optional: validator to validate values on assignment + /// Optional: boolean specifies if to validate on assignment + /// + public static Either Of(TRight value, Func? validator = null) + => new Either(value, validator); + + // ===================================================== + // Overloaded methods + // ===================================================== + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// + /// + public override bool Equals(object? obj) => EqualsImpl(obj); + + /// + /// Returns the hash code for this instance. + /// + /// + public override int GetHashCode() => GetHashCodeImpl(); + + // ===================================================== + // Implicit / explicit operators + // ===================================================== + + public static implicit operator Either(TLeft left) => new Either(left, null); + public static implicit operator Either(TRight right) => new Either(right, null); + + public static explicit operator TLeft(Either either) => either.GetValue(); + public static explicit operator TRight(Either either) => either.GetValue(); + } +} \ No newline at end of file diff --git a/ZeroNull/ZeroNull/Types/Either/Either.cs b/ZeroNull/ZeroNull/Types/Either/Either.cs deleted file mode 100644 index 2d7da10..0000000 --- a/ZeroNull/ZeroNull/Types/Either/Either.cs +++ /dev/null @@ -1,172 +0,0 @@ -namespace ZeroNull.Types.Either -{ - public class Either : IEither - { - //============= Internal Implementation =============== - - //===================================================== - // getters - //===================================================== - - internal Func? _leftValidator; - internal Func? _rightValidator; - - internal bool _validateLeftOnAssignment = false; - internal bool _validateRightOnAssignment = false; - - internal TLeft? LeftRawValue => _left; - internal TRight? RightRawValue => _right; - - internal bool _isLeft => _left is not null; - internal bool _isRight => _right is not null; - - internal readonly TLeft? _left; - internal readonly TRight? _right; - internal Type _currentType; - - internal Either(TLeft? left, Func? validator = null, bool validateOnAssignment = false) - { - RegisterValidator(validator, validateOnAssignment); - - if (validateOnAssignment && left is null) throw new ArgumentNullException(nameof(left)); - - _left = left; - _right = default; - - _currentType = typeof(TLeft); - } - - internal Either(TRight right, Func? validator = null, bool validateOnAssignment = false) - { - RegisterValidator(null, false, validator, validateOnAssignment); - - if (validateOnAssignment && right is null) throw new ArgumentNullException(nameof(right)); - - _right = right; - _left = default; - - _currentType = typeof(TRight); - } - - //===================================================== - // Internal methods - //===================================================== - internal void RegisterValidator(Func? leftValidator = null, bool validateLeftOnAssignment = false, Func? rightValidator = null, bool validateRightOnAssignment = false) - { - if (rightValidator is not null) - { - _validateRightOnAssignment = validateRightOnAssignment; - _validateLeftOnAssignment = false; - - _leftValidator = null; - _rightValidator = rightValidator; - - if (validateLeftOnAssignment) Validate(); - - return; - } - - if (leftValidator is not null) - { - _validateLeftOnAssignment = validateLeftOnAssignment; - _validateRightOnAssignment = false; - - _rightValidator = null; - _leftValidator = leftValidator; - - if (validateLeftOnAssignment) Validate(); - - return; - } - } - - internal TValue GetAssignedValue() - { - var type = typeof(TValue); - - if (_currentType == type) - { - if (_isLeft) - return (TValue)Convert.ChangeType(_left!, type); - - if (_isRight) - return (TValue)Convert.ChangeType(_right!, type); - } - - throw new InvalidCastException($"Either {typeof(TLeft).Name} nor {typeof(TRight).Name} match type: {type.Name}"); - } - - internal bool ValidateValues(bool throwWhenFail = true) - { - if (_leftValidator is not null && _validateLeftOnAssignment && _isLeft) - { - var pass = _leftValidator.Invoke(_left!); - if (!pass && throwWhenFail) - throw new ArgumentException($"Left value failed validation by the given validator."); - - return pass; - } - - if (_rightValidator is not null && _validateLeftOnAssignment && _isRight) - { - var pass = _rightValidator.Invoke(_right!); - if (!pass && throwWhenFail) - throw new ArgumentException($"Right value failed validation by the given validator."); - - return pass; - } - - return false; - } - - //==================== Public API ===================== - - //===================================================== - // Getters - //===================================================== - - public bool IsLeft => _isLeft; - public bool IsRight => _isRight; - public TLeft Left => IsLeft ? Left! : throw new InvalidOperationException("Either is Right, no Left value."); - public TRight Right => !IsLeft ? Right! : throw new InvalidOperationException("Either is Left, no Right value."); - - //===================================================== - // methods - //===================================================== - - public T GetValue() => GetAssignedValue(); - - public void RegisterLeftValidator(Func validator, bool validateOnAssignment = false) - { - if (validator is null) - throw new ArgumentException($"Given validator is null."); - - RegisterValidator(validator, validateOnAssignment); - } - - public void RegisterRightValidator(Func validator, bool validateOnAssignment = false) - { - if (validator is null) - throw new ArgumentException($"Given validator is null."); - - RegisterValidator(null, false, validator, validateOnAssignment); - } - - public void ValidateAndThrow() => ValidateValues(); - - public bool Validate() => ValidateValues(false); - - public static Either Of(TLeft value, Func? validator = null, bool validateOnAssignment = false) => new Either(value, validator, validateOnAssignment); - public static Either Of(TRight value, Func? validator = null, bool validateOnAssignment = false) => new Either(value, validator, validateOnAssignment); - - //===================================================== - // Assignment & Cast Operators - //===================================================== - - public static implicit operator Either(TRight right) => new Either(right, null, true); - public static implicit operator Either(TLeft left) => new Either(left, null, true); - - public static explicit operator TLeft(Either either) => either.GetValue(); - public static explicit operator TRight(Either either) => either.GetValue(); - } -} diff --git a/ZeroNull/ZeroNull/Types/Either/IEither.cs b/ZeroNull/ZeroNull/Types/Either/IEither.cs deleted file mode 100644 index 54c86ef..0000000 --- a/ZeroNull/ZeroNull/Types/Either/IEither.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ZeroNull.Types.Either -{ - public interface IEither - { - bool IsLeft { get; } - bool IsRight { get; } - - void RegisterLeftValidator(Func validator, bool validateOnAssignment = false); - void RegisterRightValidator(Func validator, bool validateOnAssignment = false); - void ValidateAndThrow(); - bool Validate(); - T GetValue(); - } -} diff --git a/ZeroNull/ZeroNull/Types/Option/Option.cs b/ZeroNull/ZeroNull/Types/Option.cs similarity index 80% rename from ZeroNull/ZeroNull/Types/Option/Option.cs rename to ZeroNull/ZeroNull/Types/Option.cs index 4a0a088..637719e 100644 --- a/ZeroNull/ZeroNull/Types/Option/Option.cs +++ b/ZeroNull/ZeroNull/Types/Option.cs @@ -1,15 +1,16 @@ -using ZeroNull.Types.Option; +namespace ZeroNull.Types; -public readonly struct Option : IOption -{ +public readonly struct Option +{ // Internal state – the actual implementation details internal readonly T? _value; - internal readonly bool _hasValue; + internal readonly bool _hasValue => _state == OptionState.Some; + internal readonly OptionState _state = OptionState.UnSet; - internal Option(T? value, bool hasValue) + internal Option(T? value, OptionState state) { _value = value; - _hasValue = hasValue; + _state = state; } // ========== INTERNAL IMPLEMENTATION (the real logic) ========== @@ -17,14 +18,14 @@ internal static Option NoneImpl() { // Note: default! is still used here, but it's internal. // Public callers only see None(). - return new Option(default!, false); + return new Option(default!, OptionState.None); } internal static Option SomeImpl(T value) { if (value == null) throw new ArgumentNullException(nameof(value)); - return new Option(value, true); + return new Option(value, OptionState.Some); } internal bool IsSomeImpl => _hasValue; @@ -72,7 +73,22 @@ internal Option SelectManyImpl(Func> bind, Func p return BindImpl(x => bind(x).MapImpl(y => project(x, y))); } - // ========== PUBLIC API (thin wrappers over internal methods) ========== + internal bool EqualsImpl(object? obj) + { + if (obj is not Option other) + return false; + + if (_state != other._state) + return false; + + return EqualityComparer.Default.Equals(_value, other._value); + } + + internal int GetHashCodeImpl() => HashCode.Combine(_state.GetHashCode(), EqualityComparer.Default.GetHashCode(_value!)); + + // ===================================================== + // Public API — State + // ===================================================== /// /// Creates an option that contains the specified value. @@ -102,6 +118,27 @@ internal Option SelectManyImpl(Func> bind, Func p /// public T Value => ValueImpl; + // ===================================================== + // Public API — overriden methods + // ===================================================== + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// + /// + public override bool Equals(object? obj) => EqualsImpl(obj); + + /// + /// Returns the hash code for this instance. + /// + /// + public override int GetHashCode() => GetHashCodeImpl(); + + // ===================================================== + // Public API — methods + // ===================================================== + /// /// Maps the type onto a different type than the given one or the same. /// diff --git a/ZeroNull/ZeroNull/Types/Option/IOption.cs b/ZeroNull/ZeroNull/Types/Option/IOption.cs deleted file mode 100644 index 84bb7de..0000000 --- a/ZeroNull/ZeroNull/Types/Option/IOption.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ZeroNull.Types.Option -{ - public interface IOption - { - bool IsSome { get; } - bool IsNone { get; } - - Option Map(Func mapper); - Option Bind(Func> binder); - - T ValueOr(T fallback); - T ValueOr(Func fallbackFactory); - - TResult Match(Func some, Func none); - Option Select(Func mapper); - Option SelectMany(Func> bind, Func project); - } -} diff --git a/ZeroNull/ZeroNull/Types/Result/Result.cs b/ZeroNull/ZeroNull/Types/Result.cs similarity index 59% rename from ZeroNull/ZeroNull/Types/Result/Result.cs rename to ZeroNull/ZeroNull/Types/Result.cs index 3a355df..5eb9b47 100644 --- a/ZeroNull/ZeroNull/Types/Result/Result.cs +++ b/ZeroNull/ZeroNull/Types/Result.cs @@ -1,31 +1,39 @@ -public readonly struct Result +using ZeroNull.Types; + +public readonly struct Result { // Internal state - private readonly T? _value; - private readonly TError? _error; - private readonly bool _isOk; + internal readonly T? _value; + internal readonly TError? _error; + internal readonly bool _isValid => _state == ResultState.Success; + + internal readonly ResultState _state = ResultState.UnSet; - private Result(T? value, TError? error, bool isOk) + internal Result(T? value) { _value = value; - _error = error; - _isOk = isOk; - } + _error = default; - // ========== INTERNAL IMPLEMENTATION ========== - internal static Result OkImpl(T value) - { - // Original doesn't check for null here; we keep same behavior. - return new Result(value, default, true); + _state = ResultState.Success; } - internal static Result ErrorImpl(TError error) + internal Result(TError? error) { - return new Result(default, error, false); + _value = default; + _error = error; + _state = ResultState.Error; + } - internal bool IsOkImpl => _isOk; - internal bool IsErrorImpl => !_isOk; + // ========== INTERNAL IMPLEMENTATION ========== + internal static Result OkImpl(T value) => new Result(value); + + + internal static Result ErrorImpl(TError error) => new Result(error); + + + internal bool IsOkImpl => _isValid; + internal bool IsErrorImpl => !_isValid; internal T ValueImpl => IsOkImpl ? _value! : throw new InvalidOperationException("Cannot access value of an error result."); @@ -80,7 +88,25 @@ internal Result SelectManyImpl( return BindImpl(x => bind(x).MapImpl(y => project(x, y))); } - // ========== PUBLIC API ========== + internal bool EqualsImpl(object? obj) + { + if (obj is not Result other) + return false; + + if (_state != other._state) + return false; + + if (_isValid) return EqualityComparer.Default.Equals(_value, other._value); + + return EqualityComparer.Default.Equals(_error, other._error); + } + + internal int GetHashCodeImpl() => _isValid ? EqualityComparer.Default.GetHashCode(_value!) : EqualityComparer.Default.GetHashCode(_error!); + + // ===================================================== + // Public API — State + // ===================================================== + public static Result Ok(T value) => OkImpl(value); public static Result Error(TError error) => ErrorImpl(error); @@ -90,6 +116,27 @@ internal Result SelectManyImpl( public T Value => ValueImpl; public TError ErrorValue => ErrorValueImpl; + // ===================================================== + // Public API — overriden methods + // ===================================================== + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// + /// + public override bool Equals(object? obj) => EqualsImpl(obj); + + /// + /// Returns the hash code for this instance. + /// + /// + public override int GetHashCode() => GetHashCodeImpl(); + + // ===================================================== + // Public API — methods + // ===================================================== + public Result Map(Func mapper) => MapImpl(mapper); public Result Bind(Func> binder) => BindImpl(binder); public Result MapError(Func mapper) => MapErrorImpl(mapper); diff --git a/ZeroNull/ZeroNull/Types/Validation.cs b/ZeroNull/ZeroNull/Types/Validation.cs new file mode 100644 index 0000000..4fb9682 --- /dev/null +++ b/ZeroNull/ZeroNull/Types/Validation.cs @@ -0,0 +1,308 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +namespace ZeroNull.Types +{ + public readonly struct Validation + { + // ===================================================== + // Internal Implementation — state + // ===================================================== + + internal readonly TValue? _value; + internal readonly ImmutableArray _errors; + internal readonly ValidationState _state = ValidationState.UnSet; + internal readonly bool _isValid => _state == ValidationState.Valid; + + private Validation(TValue? value, ImmutableArray? errors = null, ValidationState state = ValidationState.Invalid) + { + _value = value; + _errors = errors is null ? new ImmutableArray() : errors.Value; + _state = state; + } + + + // ===================================================== + // Internal Implementation — factory methods + // ===================================================== + + internal static Validation ValidImpl(TValue value) => new(value, [], ValidationState.Valid); + internal static Validation InvalidImpl(ImmutableArray errors) => new(default, errors); + internal static Validation InvalidImpl(TError error) => new(default, [error]); + + // ===================================================== + // Internal Implementation — methods + // ===================================================== + + internal Validation MapImpl(Func mapper) + { + if (mapper == null) + throw new ArgumentNullException(nameof(mapper)); + + if (IsInvalid) return Validation.InvalidImpl(_errors); + + var result = mapper.Invoke(_value!); // safe because valid instance ensures non-null + return new Validation(result, _errors, ValidationState.Valid); + } + + internal Validation BindImpl(Func> binder) + { + if (binder is null) + throw new ArgumentNullException(nameof(binder)); + + if (IsInvalid) return Validation.InvalidImpl(_errors); + + var result = binder.Invoke(_value!); + + if (IsValid && result.IsValid) + return Validation.ValidImpl(result.Value); + + var errors = _errors.ToImmutableArray(); + errors = errors.AddRange(result.Errors); + var distinctErrors = errors.ToImmutableArray(); + + return Validation.InvalidImpl(distinctErrors); + } + + internal Validation ApplyImpl(Validation> validator) + { + if (_isValid && validator.IsValid) + return BindImpl(x => validator.Map(mapperFunc => mapperFunc(x))); + + return Validation.InvalidImpl(_errors.AddRange(validator.Errors)); + } + + internal Validation CombineImpl(Validation other, Func combineValues) + { + if (combineValues is null) + throw new ArgumentNullException(nameof(combineValues)); + + var combinedValue = _isValid && other.IsValid + ? combineValues(_value!, other.Value) + : default; + + var state = IsValid && other.IsValid ? ValidationState.Valid : ValidationState.Invalid; + + return new Validation(combinedValue, _errors.AddRange(other.Errors), state); + } + + internal TValue ValueOrImpl(TValue fallback) => _isValid ? _value! : fallback; + + internal TValue ValueOrImpl(Func fallbackFactory) + { + if (fallbackFactory is null) + throw new ArgumentNullException(nameof(fallbackFactory)); + + return _isValid ? _value! : fallbackFactory(); + } + + internal TResult MatchImpl(Func valid, Func, TResult> invalid) + { + if (valid is null) + throw new ArgumentNullException(nameof(valid)); + + if (invalid is null) + throw new ArgumentNullException(nameof(invalid)); + + if (_isValid) + return valid.Invoke(_value!); + + return invalid.Invoke(_errors); + } + + internal Validation SelectImpl(Func selector) + { + if (selector is null) + throw new ArgumentNullException(nameof(selector)); + + return _isValid ? + Validation.ValidImpl(selector.Invoke(_value!)) + : Validation.InvalidImpl(Errors); + } + + internal Validation SelectManyImpl(Func> bind, Func project) + { + if (!_isValid) return Validation.InvalidImpl(_errors); + + var currentValue = _value!; + var next = bind(currentValue); + + if (next.IsInvalid) return Validation.InvalidImpl(_errors.AddRange(next.Errors)); + + var resultValue = project(currentValue, next.Value); + return Validation.ValidImpl(resultValue); + } + + internal bool EqualsImpl(object? obj) + { + if (obj is not Validation other) + return false; + + if (_state != other._state) return false; + + + if (_isValid) + return EqualityComparer.Default.Equals(_value, other._value); + + return _errors.SequenceEqual(other._errors); + } + + internal int GetHashCodeImpl() + { + if (_isValid) + return HashCode.Combine(_state.GetHashCode(), EqualityComparer.Default.GetHashCode(_value!)); + + return HashCode.Combine( + _state.GetHashCode(), + _errors.Aggregate(0, (acc, e) => HashCode.Combine(acc, e))); + } + + // ===================================================== + // Public API — State + // ===================================================== + + /// + /// Creates a new valid validation. + /// + /// + /// + public static Validation Valid(TValue value) => ValidImpl(value); + + /// + /// Creates a new invalid validation. + /// + /// + /// + public static Validation Invalid(TError error) => InvalidImpl(error); + + /// + /// Creates a new invalid validation. + /// + /// + /// + public static Validation Invalid(params TError[] errors) => InvalidImpl(errors.ToImmutableArray()); + + /// + /// Creates a new invalid validation. + /// + /// + /// + public static Validation Invalid(ImmutableArray errors) => InvalidImpl(errors); + + /// + /// Indicates whether the validation is valid. + /// + public bool IsValid => _isValid; + + /// + /// Indicates whether the validation is invalid. + /// + public bool IsInvalid => !_isValid; + + + /// + /// Returns the value of the validation. + /// + public TValue Value => IsValid ? _value! : throw new InvalidOperationException("Value provided is not valid"); + + /// + /// Returns the errors of the validation. + /// + public ImmutableArray Errors => IsInvalid ? _errors : ImmutableArray.Empty; + + // ===================================================== + // Public API — methods + // ===================================================== + + /// + /// Maps the value of the validation. + /// + /// Result of the mapping operation + /// mapping predicate + /// + public Validation Map(Func mapper) => MapImpl(mapper); + + /// + /// Maps the value of the validation. + /// + /// Result of the binding operation + /// binding predicate + /// + public Validation Bind(Func> binder) => BindImpl(binder); + + /// + /// Applies a validator to the value of the validation. + /// + /// + /// mapping predicate + /// + public Validation Apply(Validation> validator) => ApplyImpl(validator); + + + /// + /// Combines two validations. + /// + /// + /// + /// + public Validation Combine(Validation other, Func combineValues) => CombineImpl(other, combineValues); + + /// + /// Returns the value of the validation, or a fallback value if the validation is invalid. + /// + /// + /// + public TValue ValueOr(TValue fallback) => ValueOrImpl(fallback); + + /// + /// Returns the value of the validation, or a fallback value if the validation is invalid. + /// + /// + /// + public TValue ValueOr(Func fallbackFactory) => ValueOrImpl(fallbackFactory); + + /// + /// Returns the value of the validation, or a fallback value if the validation is invalid. + /// + /// + /// + /// + /// + public TResult Match(Func valid, Func, TResult> invalid) => MatchImpl(valid, invalid); + + /// + /// Returns the value of the validation, or a fallback value if the validation is invalid. + /// + /// + /// + /// + public Validation Select(Func selector) => SelectImpl(selector); + + /// + /// Returns the value of the validation, or a fallback value if the validation is invalid. + /// + /// + /// + /// + /// + /// + public Validation SelectMany(Func> bind, Func project) => SelectManyImpl(bind, project); + + // ===================================================== + // Public API — overriden methods + // ===================================================== + + /// + /// Returns a hash code for this instance. + /// + /// + public override int GetHashCode() => GetHashCodeImpl(); + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// + /// + public override bool Equals(object? obj) => EqualsImpl(obj); + } +} \ No newline at end of file diff --git a/ZeroNull/ZeroNull/Types/Validation/IValidation.cs b/ZeroNull/ZeroNull/Types/Validation/IValidation.cs deleted file mode 100644 index 8b2e339..0000000 --- a/ZeroNull/ZeroNull/Types/Validation/IValidation.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Collections.Immutable; - -namespace ZeroNull.Types.Validation -{ - /// - /// Represents a validation result that is either valid (with a value) - /// or invalid (with one or more errors). - /// - /// The type of validation errors (e.g., string, custom error record). - /// The type of the valid value. - public interface IValidation - { - // State checks - bool IsValid { get; } - bool IsInvalid { get; } - - // Accessors (throw if accessed in wrong state) - TValue Value { get; } - ImmutableArray Errors { get; } - - // Core monadic operations - IValidation Map(Func mapper); - IValidation Bind(Func> binder); - - /// - /// Applicative apply: combines two validations, accumulating errors from both. - /// - IValidation Apply(IValidation> validator); - - // Error accumulation – combine with another validation - IValidation Combine(IValidation other, Func combineValues); - - // Fallback / matching - TValue ValueOr(TValue fallback); - TValue ValueOr(Func fallbackFactory); - TResult Match(Func valid, Func, TResult> invalid); - - // LINQ support (query comprehension) - IValidation Select(Func selector); - IValidation SelectMany( - Func> bind, - Func project); - } - -} diff --git a/ZeroNull/ZeroNull/Types/Validation/Validation.cs b/ZeroNull/ZeroNull/Types/Validation/Validation.cs deleted file mode 100644 index 6d14472..0000000 --- a/ZeroNull/ZeroNull/Types/Validation/Validation.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections.Immutable; - -namespace ZeroNull.Types.Validation -{ - public readonly struct Validation : IValidation - { - private readonly TValue? _value; - private readonly ImmutableArray _errors; - private readonly bool _isValid; - - private Validation(TValue? value, ImmutableArray errors, bool isValid) - { - _value = value; - _errors = errors; - _isValid = isValid; - } - - // Internal constructors - internal static Validation ValidImpl(TValue value) => new(value, default, true); - internal static Validation InvalidImpl(ImmutableArray errors) => new(default, errors, false); - internal static Validation InvalidImpl(TError error) => new(default, [error], false); - - // Public factory methods - public static Validation Valid(TValue value) => ValidImpl(value); - public static Validation Invalid(TError error) => InvalidImpl(error); - public static Validation Invalid(params TError[] errors) => InvalidImpl(errors.ToImmutableArray()); - - public IValidation Map(Func mapper) - { - throw new NotImplementedException(); - } - - public IValidation Bind(Func> binder) - { - throw new NotImplementedException(); - } - - public IValidation Apply(IValidation> validator) - { - throw new NotImplementedException(); - } - - public IValidation Combine(IValidation other, Func combineValues) - { - throw new NotImplementedException(); - } - - public TValue ValueOr(TValue fallback) - { - throw new NotImplementedException(); - } - - public TValue ValueOr(Func fallbackFactory) - { - throw new NotImplementedException(); - } - - public TResult Match(Func valid, Func, TResult> invalid) - { - throw new NotImplementedException(); - } - - public IValidation Select(Func selector) - { - throw new NotImplementedException(); - } - - public IValidation SelectMany(Func> bind, Func project) - { - throw new NotImplementedException(); - } - - // State checks - public bool IsValid => _isValid; - public bool IsInvalid => !_isValid; - public TValue Value => IsValid ? _value! : throw new InvalidOperationException("Value provided is not valid"); - public ImmutableArray Errors => IsInvalid ? _errors : ImmutableArray.Empty; - - // Map, Bind, Apply, Combine, Match, ValueOr, Select, SelectMany... - // (all delegate to internal implementation methods) - } -} From 3f52499baa0ebeaedd7b2ffbc3f8a24101fdf9ec Mon Sep 17 00:00:00 2001 From: Leo Mengesha Date: Wed, 27 May 2026 18:32:15 +0100 Subject: [PATCH 4/5] updated readme and added changelog --- README.md | 50 +++++++++++-------- ZeroNull/ZeroNull/Changelog-1.0.1-to-1.0.2.md | 32 ++++++++++++ 2 files changed, 61 insertions(+), 21 deletions(-) create mode 100644 ZeroNull/ZeroNull/Changelog-1.0.1-to-1.0.2.md diff --git a/README.md b/README.md index c1242a9..90d2137 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ **Explicit, composable control flow for C#** +## Changelogs +[1.0.1 to 1.0.2](https://github.com/bargross/zero-null/blob/main/Changelog-1.0.1-to-1.0.2.md) + ## What is this library? ZeroNull brings **functional control-flow patterns** to C#. It provides lightweight, immutable structs like `Option`, `Result`, and `Either` that let you model absence, errors, and branching logic explicitly in your type signatures—without exceptions, nulls, or hidden side-effects. @@ -28,27 +31,17 @@ ZeroNull brings **functional control-flow patterns** to C#. It provides lightwei | Type | Purpose | Key methods | | :--- | :--- | :--- | | `Option` | A value that may be present (`Some`) or absent (`None`). | `Map`, `Bind`, `Match`, `ValueOr`, LINQ support (`Select`, `SelectMany`) | -| `Result` | A value that is either successful (`Ok`) or carries an error (`Error`). | `Map`, `Bind`, `MapError`, `Match`, LINQ support (`Select`, `SelectMany`) | -| `Either` | A value that can be one of two possible types. By convention, `Left` represents failure/alternative, `Right` represents success. | `IsLeft`, `IsRight`, `Left`, `Right`, `Match`

**Extension methods:**
• `Select` / `SelectMany` – LINQ query syntax support (works on the `Right` side)
• `Where` – filter `Right` (with custom `Left` on false) or filter `Left`
• `MapLeft` / `MapRight` – transform one side while keeping the other
• `BindLeft` / `BindRight` – bind over one side to produce a new `Either` | - -### Either‑specific extension method details +| `Result` | A value that is either successful (`Ok`) or carries an error (`Error`). | `Map`, `Bind`, `MapError`, `Match`, LINQ support | +| `Validation` | Accumulates multiple errors; can be `Valid` (with a value) or `Invalid` (with multiple errors). | `Map`, `Bind`, `Apply`, `Combine`, `Match`, LINQ support | +| `Either` | A value that can be one of two possible types (left or right). Typically used for two alternative success cases or a general union. | `MapLeft`, `MapRight`, `BindLeft`, `BindRight`, `Match`, `IsLeft`, `IsRight` | -| Method | Description | -| :--- | :--- | -| `Select(selector)` | Projects the `Right` value when the `Either` is in the `Right` state. Enables LINQ query syntax. | -| `SelectMany(binder, projector)` | Chains two `Either` operations that work on `Right` values. Short‑circuits on the first `Left`. | -| `Where(predicate, leftOnFalse)` | Filters the `Right` value; if the predicate fails, becomes `Left(leftOnFalse)`. | -| `Where(leftPredicate, leftOnFalse)` | Filters the `Left` value; if the predicate fails, becomes `Left(leftOnFalse)`. | -| `MapLeft(mapper)` | Applies `mapper` to the `Left` value if present; otherwise preserves the `Right`. | -| `MapRight(mapper)` | Applies `mapper` to the `Right` value if present; otherwise preserves the `Left`. | -| `BindLeft(binder)` | If `Left`, applies `binder` to produce a new `Either` (can change `Left` type, or even become `Right`). | -| `BindRight(binder)` | If `Right`, applies `binder` to produce a new `Either` (can change `Right` type, or even become `Left`). | **Core features:** -- Immutable, `readonly struct` – zero heap allocation in hot paths. -- LINQ query comprehension (`from...select...`). -- Exhaustive pattern matching with `Match`. -- Async extensions (optional – available in a separate package). +- **Immutable, `readonly struct`** – zero heap allocation in hot paths. +- **Full `Equals` / `GetHashCode` support** – all types properly consider their full internal state, making them safe to use as dictionary keys or in hash sets. +- **No interfaces** – clean, simple, and predictable API with no hidden dependencies. +- **LINQ query comprehension** (`from...select...`) – natural C# syntax for monadic operations. +- **Exhaustive pattern matching** with `Match`. ## Examples @@ -96,8 +89,8 @@ var outcome = Compute("42").Match( // A function returning an Either type. Here, Left is an error message, Right is a valid User. Either GetUser(int id) => _database.ContainsKey(id) - ? Either.Right(_database[id]) - : Either.Left($"User {id} not found."); + ? Either.Of(_database[id]) + : Either.Of($"User {id} not found."); var message = GetUser(42).Match( left: error => $"Error: {error}", @@ -163,6 +156,21 @@ ageResult.Match( // Output: Validation error: Age must be between 0 and 120 ``` +## What’s new in version 1.0.2 +This release introduces several important improvements: + +Full Equals / GetHashCode support – All types (Option, Result, Validation, Either) now properly implement equality and hash code methods. They take the complete internal state into account, making them safe to use in hash-based collections such as HashSet or as dictionary keys. + +Improved thread safety – All types are now implemented as readonly struct with immutable internal state, guaranteeing thread safety by design. + +Cleaner API surface – Interfaces have been removed in favor of a more straightforward, self-contained API. This reduces cognitive load and ensures predictable behavior. + +Better IntelliSense – XML documentation comments have been added for all public APIs, providing helpful guidance directly in the IDE. + +More robust ValueOr methods – The ValueOr method on both Option and Result now correctly validates arguments and handles edge cases safely. + +Comprehensive test suite – The library now includes extensive tests covering edge cases, ensuring reliability across all usage scenarios. + ## Roadmap / Future Features This library is actively evolving. Below are the planned types and capabilities for future releases. @@ -199,7 +207,7 @@ This library is actively evolving. Below are the planned types and capabilities ### Release Timeline -- **v2.0** – Phase 1 features (Validation, `NonEmptyList`, `Try`, `Unit`, async variants, interop). +- **v2.0** – Phase 1 features (`NonEmptyList`, `Try`, `Unit`, async variants, interop). - **v3.0** – Phase 2 monads (`Reader`, `Writer`, `State`, transformers). - **v3.x / v4.0** – Phase 3 interoperability and performance. diff --git a/ZeroNull/ZeroNull/Changelog-1.0.1-to-1.0.2.md b/ZeroNull/ZeroNull/Changelog-1.0.1-to-1.0.2.md new file mode 100644 index 0000000..7b94742 --- /dev/null +++ b/ZeroNull/ZeroNull/Changelog-1.0.1-to-1.0.2.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +### Added +- Full `Equals` / `GetHashCode` implementations for all core types (`Option`, `Result`, `Validation`, `Either`). + All types now consider their complete internal state (e.g., `Some`/`None` for `Option`, success/error for `Result`, state + errors for `Validation`, left/right for `Either`), making them safe to use as dictionary keys or in hash sets. +- XML documentation comments for all public APIs, improving IntelliSense and developer experience. +- Validation readonly struct for chain of validations. +- Documentation for each method/getter/setter detectable by intellisense in visual studio. +- More tests to cover more edge cases + +### Changed +- All types are now `readonly struct` – immutable and thread‑safe by design. +- Removed all external interfaces from the public API (no more `IOption`, `IResult`, etc.). The API is now simpler and more self‑contained. +- `ValueOr` methods on `Option` and `Result` now properly validate arguments (e.g., non‑null checks) and handle edge cases correctly. +- Internal state fields are now `internal` rather than `private` to enable efficient equality and hash code calculations without reflection. + +### Fixed +- Fixed Either type as it was a class and not a readonly struct, making it not a thread safe option. +- Fixed a bug where `Option.None` and `default(Option)` were considered equal – they are now correctly distinct because their internal states differ. +- Fixed a bug where `Validation` equality only compared the value and errors, but ignored the valid/invalid state. A valid `Validation(0)` no longer incorrectly equals an invalid `Validation` with the same default value. +- Fixed `GetHashCode` for `Validation` to produce a hash that respects both the state and the sequence of errors (order‑sensitive, matching `Equals` behaviour). +- Resolved potential `NullReferenceException` when calling `GetHashCode` on `None` options or invalid validations. + +### Removed +- Removed `IEquatable` constraints from generic parameters where they were unnecessary – the library now uses `EqualityComparer.Default` everywhere for consistent, non‑boxing comparisons. +- Removed interfaces as it was boxing the struct type, reducing performance and adding little value. + +## [Previous versions] + +No prior changelog entries – this is the first formal release with a documented changelog. \ No newline at end of file From 632b60b489199179fd9244e3aa93d3f0dfe98fae Mon Sep 17 00:00:00 2001 From: Leo Mengesha Date: Wed, 27 May 2026 18:32:48 +0100 Subject: [PATCH 5/5] removed redundant custom exception --- .../Exceptions/RuleValidationException.cs | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 ZeroNull/ZeroNull/Exceptions/RuleValidationException.cs diff --git a/ZeroNull/ZeroNull/Exceptions/RuleValidationException.cs b/ZeroNull/ZeroNull/Exceptions/RuleValidationException.cs deleted file mode 100644 index 985cc4c..0000000 --- a/ZeroNull/ZeroNull/Exceptions/RuleValidationException.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; - -namespace ZeroNull.Exceptions -{ - public class RuleValidationException : Exception - { - public RuleValidationException() - { - } - - public RuleValidationException(string message) - : base(message) - { - } - - public RuleValidationException(string message, Exception inner) - : base(message, inner) - { - } - } -} \ No newline at end of file