improve result structure and handling
This commit is contained in:
+13
-18
@@ -1,4 +1,5 @@
|
|||||||
using CsharpEs.Library;
|
using CsharpEs.Library;
|
||||||
|
using static CsharpEs.Library.ResultModule;
|
||||||
|
|
||||||
namespace CsharpEs.Domain;
|
namespace CsharpEs.Domain;
|
||||||
|
|
||||||
@@ -47,31 +48,27 @@ public static class AccountDecider
|
|||||||
(state, command) switch
|
(state, command) switch
|
||||||
{
|
{
|
||||||
// if this is a new account, check for valid opening balance
|
// if this is a new account, check for valid opening balance
|
||||||
(null, AccountCommand.OpenAccount c) when c.OpeningBalance.Amount < 0m =>
|
(null, AccountCommand.OpenAccount c) when c.OpeningBalance.Amount < 0m => Fail(
|
||||||
Result<AccountEvent, AccountError>.Fail(
|
new AccountError.OpeningBalanceMustBeNonNegative()
|
||||||
new AccountError.OpeningBalanceMustBeNonNegative()
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// still a new account, now we can open it
|
// still a new account, now we can open it
|
||||||
(null, AccountCommand.OpenAccount c) => Result<AccountEvent, AccountError>.Ok(
|
(null, AccountCommand.OpenAccount c) => Ok(
|
||||||
new AccountEvent.AccountOpened(c.AccountId, c.OpeningBalance)
|
new AccountEvent.AccountOpened(c.AccountId, c.OpeningBalance)
|
||||||
),
|
),
|
||||||
|
|
||||||
// if we have an account already, you can't open it
|
// if we have an account already, you can't open it
|
||||||
(not null, AccountCommand.OpenAccount c) => Result<
|
(not null, AccountCommand.OpenAccount c) => Fail(
|
||||||
AccountEvent,
|
new AccountError.AccountOpenAlready()
|
||||||
AccountError
|
|
||||||
>.Fail(new AccountError.AccountOpenAlready()),
|
|
||||||
|
|
||||||
(null, _) => Result<AccountEvent, AccountError>.Fail(
|
|
||||||
new AccountError.AccountNotFound()
|
|
||||||
),
|
),
|
||||||
|
|
||||||
(_, AccountCommand.WithdrawMoney c) => Result<AccountEvent, AccountError>.Ok(
|
(null, _) => Fail(new AccountError.AccountNotFound()),
|
||||||
|
|
||||||
|
(_, AccountCommand.WithdrawMoney c) => Ok(
|
||||||
new AccountEvent.MoneyWithdrawn(c.AccountId, c.Amount)
|
new AccountEvent.MoneyWithdrawn(c.AccountId, c.Amount)
|
||||||
),
|
),
|
||||||
|
|
||||||
(_, AccountCommand.DepositMoney c) => Result<AccountEvent, AccountError>.Ok(
|
(_, AccountCommand.DepositMoney c) => Ok(
|
||||||
new AccountEvent.MoneyDeposited(c.AccountId, c.Amount)
|
new AccountEvent.MoneyDeposited(c.AccountId, c.Amount)
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -86,10 +83,8 @@ public static class AccountDecider
|
|||||||
) =>
|
) =>
|
||||||
(state, @event) switch
|
(state, @event) switch
|
||||||
{
|
{
|
||||||
(null, AccountEvent.AccountOpened e) => Result<AccountState?, AccountError>.Ok(
|
(null, AccountEvent.AccountOpened e) => Ok(new AccountState(e.AccountId)),
|
||||||
new AccountState(e.AccountId)
|
|
||||||
),
|
|
||||||
|
|
||||||
_ => Result<AccountState?, AccountError>.Ok(state),
|
_ => Ok(state),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using CsharpEs.Domain;
|
using CsharpEs.Domain;
|
||||||
using CsharpEs.Library;
|
using CsharpEs.Library;
|
||||||
|
using static CsharpEs.Library.ResultModule;
|
||||||
|
|
||||||
namespace CsharpEs.Infrastructure;
|
namespace CsharpEs.Infrastructure;
|
||||||
|
|
||||||
@@ -57,9 +58,7 @@ public static class AccountBalanceReadModelModule
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
return Result<AccountDetails, ReadModelError>.Fail(
|
return Fail(new ReadModelError.AccountDetailsNotFound());
|
||||||
new ReadModelError.AccountDetailsNotFound()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -78,9 +77,7 @@ public static class AccountBalanceReadModelModule
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
return Result<AccountDetails, ReadModelError>.Fail(
|
return Fail(new ReadModelError.AccountDetailsNotFound());
|
||||||
new ReadModelError.AccountDetailsNotFound()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -88,15 +85,13 @@ public static class AccountBalanceReadModelModule
|
|||||||
// Read model-side "project" may not normally return any data, but for demo
|
// Read model-side "project" may not normally return any data, but for demo
|
||||||
// purposes we return the affected data directly to save us modeling a separate
|
// purposes we return the affected data directly to save us modeling a separate
|
||||||
// query side.
|
// query side.
|
||||||
return Result<AccountDetails, ReadModelError>.Ok(modelData[@event.AccountId]);
|
return Ok(modelData[@event.AccountId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Result<KeyValuePair<Guid, AccountDetails>[], ReadModelError> Query() =>
|
Result<KeyValuePair<Guid, AccountDetails>[], ReadModelError> Query() =>
|
||||||
Result<KeyValuePair<Guid, AccountDetails>[], ReadModelError>.Ok(modelData.ToArray());
|
Ok(modelData.ToArray());
|
||||||
|
|
||||||
return Result<AccountBalanceReadModel, ReadModelError>.Ok(
|
return Ok(new AccountBalanceReadModel(Project, Query));
|
||||||
new AccountBalanceReadModel(Project, Query)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AccountFlags CalcAccountFlags(Money newBalance, AccountDetails oldDetails)
|
private static AccountFlags CalcAccountFlags(Money newBalance, AccountDetails oldDetails)
|
||||||
|
|||||||
+116
-117
@@ -1,138 +1,137 @@
|
|||||||
namespace CsharpEs.Library;
|
namespace CsharpEs.Library;
|
||||||
|
|
||||||
public readonly record struct Result<T, E>
|
// disable match exhaustion warnings - C# thinks we could have other
|
||||||
|
// derived instances of Result<T,E> but there's no way to say
|
||||||
|
// "don't allow creation of further derived types" while leaving
|
||||||
|
// the type publicly visible.
|
||||||
|
#pragma warning disable CS8509
|
||||||
|
|
||||||
|
public abstract record Result<T, E>
|
||||||
{
|
{
|
||||||
private readonly T _value;
|
public static implicit operator Result<T, E>(T value) => new ResultOk<T, E>(value);
|
||||||
|
|
||||||
private readonly E _error;
|
public static implicit operator Result<T, E>(E error) => new ResultFail<T, E>(error);
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsSuccess { get; }
|
public sealed record ResultOk<T, E>(T Value) : Result<T, E>;
|
||||||
|
|
||||||
public bool IsFailure => !IsSuccess;
|
public sealed record ResultFail<T, E>(E Error) : Result<T, E>;
|
||||||
|
|
||||||
public T Value =>
|
public static class ResultModule
|
||||||
IsSuccess
|
{
|
||||||
? _value
|
public static T Ok<T>(T v) => v;
|
||||||
: throw new InvalidOperationException("No value present for failed result.");
|
|
||||||
|
|
||||||
public E Error =>
|
public static Result<T?, E> OkNone<T, E>()
|
||||||
IsFailure
|
where T : class? => new ResultOk<T?, E>(null);
|
||||||
? _error
|
|
||||||
: throw new InvalidOperationException("No error present for successful result.");
|
|
||||||
|
|
||||||
private Result(T value)
|
public static E Fail<E>(E e) => e;
|
||||||
{
|
|
||||||
_value = value;
|
|
||||||
_error = default!;
|
|
||||||
IsSuccess = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result(E error)
|
|
||||||
{
|
|
||||||
_value = default!;
|
|
||||||
_error = error;
|
|
||||||
IsSuccess = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result<T, E> Ok(T value) => new(value);
|
|
||||||
|
|
||||||
public static Result<T, E> Fail(E error) => new(error);
|
|
||||||
|
|
||||||
public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<E, TResult> onFailure) =>
|
|
||||||
IsSuccess ? onSuccess(_value) : onFailure(_error);
|
|
||||||
|
|
||||||
public void Switch(Action<T> onSuccess, Action<E> onFailure)
|
|
||||||
{
|
|
||||||
if (IsSuccess)
|
|
||||||
onSuccess(_value);
|
|
||||||
else
|
|
||||||
onFailure(_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result<T, E> Catch(Func<T> f, Func<Exception, E> exceptionMapper)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = f();
|
|
||||||
return Result<T, E>.Ok(result);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
return Result<T, E>.Fail(exceptionMapper(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result<T, E> Catch(Func<Result<T, E>> f, Func<Exception, E> exceptionMapper)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return f();
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
return Result<T, E>.Fail(exceptionMapper(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ResultExtensions
|
public static class ResultExtensions
|
||||||
{
|
{
|
||||||
public static Result<TOut, E> Bind<TIn, TOut, E>(
|
extension<TIn, E>(Result<TIn, E> result)
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, Result<TOut, E>> binder
|
|
||||||
) => result.IsSuccess ? binder(result.Value) : Result<TOut, E>.Fail(result.Error);
|
|
||||||
|
|
||||||
public static Result<TOut, E> Map<TIn, TOut, E>(
|
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, TOut> mapper
|
|
||||||
) =>
|
|
||||||
result.IsSuccess
|
|
||||||
? Result<TOut, E>.Ok(mapper(result.Value))
|
|
||||||
: Result<TOut, E>.Fail(result.Error);
|
|
||||||
|
|
||||||
public static Result<T, E> Tap<T, E>(this Result<T, E> result, Action<T> action)
|
|
||||||
{
|
{
|
||||||
if (result.IsSuccess)
|
public bool IsSuccess => result is ResultOk<TIn, E>;
|
||||||
action(result.Value);
|
public bool IsFailure => !result.IsSuccess;
|
||||||
|
|
||||||
return result;
|
public Result<TOut, E> Bind<TOut>(Func<TIn, Result<TOut, E>> binder) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => binder(v),
|
||||||
|
ResultFail<TIn, E>(var e) => new ResultFail<TOut, E>(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Result<TOut, E> Map<TOut>(Func<TIn, TOut> mapper) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => new ResultOk<TOut, E>(mapper(v)),
|
||||||
|
ResultFail<TIn, E>(var e) => new ResultFail<TOut, E>(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Result<TIn, EOut> MapError<EOut>(Func<E, EOut> mapper) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => new ResultOk<TIn, EOut>(v),
|
||||||
|
ResultFail<TIn, E>(var e) => new ResultFail<TIn, EOut>(mapper(e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Result<TIn, E> Tap(Action<TIn> action)
|
||||||
|
{
|
||||||
|
if (result is ResultOk<TIn, E>(var v))
|
||||||
|
action(v);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<TIn, E> TapError(Action<E> action)
|
||||||
|
{
|
||||||
|
if (result is ResultFail<TIn, E>(var e))
|
||||||
|
action(e);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<TIn, E> Log(string src, string msg) =>
|
||||||
|
Tap(result, Logging.OutputDelegate<TIn>(src, msg))
|
||||||
|
.TapError(m => Logging.OutputError(src, m));
|
||||||
|
|
||||||
|
public Result<TIn, E> Log(string src, Func<TIn, string> renderText) =>
|
||||||
|
Tap(result, x => Logging.OutputDelegate<TIn>(src, renderText(x))(x))
|
||||||
|
.TapError(m => Logging.OutputError(src, m));
|
||||||
|
|
||||||
|
public Result<TOut, E> Select<TOut>(Func<TIn, TOut> mapper) => Map(result, mapper);
|
||||||
|
|
||||||
|
public Result<TOut, E> SelectMany<TIntermediate, TOut>(
|
||||||
|
Func<TIn, Result<TIntermediate, E>> binder,
|
||||||
|
Func<TIn, TIntermediate, TOut> projector
|
||||||
|
) => Bind(result, x => binder(x).Map(y => projector(x, y)));
|
||||||
|
|
||||||
|
public TResult Match<TResult>(Func<TIn, TResult> onSuccess, Func<E, TResult> onFailure) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => onSuccess(v),
|
||||||
|
ResultFail<TIn, E>(var e) => onFailure(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Switch(Action<TIn> onSuccess, Action<E> onFailure)
|
||||||
|
{
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case ResultOk<TIn, E>(var v):
|
||||||
|
onSuccess(v);
|
||||||
|
break;
|
||||||
|
case ResultFail<TIn, E>(var e):
|
||||||
|
onFailure(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<T, E> TapError<T, E>(this Result<T, E> result, Action<E> action)
|
extension<TIn, E>(Result<TIn, E>)
|
||||||
{
|
{
|
||||||
if (result.IsFailure)
|
public static Result<TIn, E> Catch(Func<TIn> f, Func<Exception, E> exceptionMapper)
|
||||||
action(result.Error);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new ResultOk<TIn, E>(f());
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return new ResultFail<TIn, E>(exceptionMapper(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
public static Result<TIn, E> Catch(
|
||||||
|
Func<Result<TIn, E>> f,
|
||||||
|
Func<Exception, E> exceptionMapper
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return f();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return new ResultFail<TIn, E>(exceptionMapper(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<T, EOut> MapError<T, EIn, EOut>(
|
|
||||||
this Result<T, EIn> result,
|
|
||||||
Func<EIn, EOut> map
|
|
||||||
) =>
|
|
||||||
result.IsSuccess
|
|
||||||
? Result<T, EOut>.Ok(result.Value)
|
|
||||||
: Result<T, EOut>.Fail(map(result.Error));
|
|
||||||
|
|
||||||
public static Result<T, E> Log<T, E>(this Result<T, E> result, string src, string msg) =>
|
|
||||||
Tap(result, Logging.OutputDelegate<T>(src, msg)).TapError(m => Logging.OutputError(src, m));
|
|
||||||
|
|
||||||
public static Result<T, E> Log<T, E>(
|
|
||||||
this Result<T, E> result,
|
|
||||||
string src,
|
|
||||||
Func<T, string> renderText
|
|
||||||
) =>
|
|
||||||
Tap(result, x => Logging.OutputDelegate<T>(src, renderText(x))(x))
|
|
||||||
.TapError(m => Logging.OutputError(src, m));
|
|
||||||
|
|
||||||
public static Result<TOut, E> Select<TIn, TOut, E>(
|
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, TOut> mapper
|
|
||||||
) => result.Map(mapper);
|
|
||||||
|
|
||||||
public static Result<TOut, E> SelectMany<TIn, TIntermediate, TOut, E>(
|
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, Result<TIntermediate, E>> binder,
|
|
||||||
Func<TIn, TIntermediate, TOut> projector
|
|
||||||
) => result.Bind(x => binder(x).Map(y => projector(x, y)));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using CsharpEs.Domain;
|
using CsharpEs.Domain;
|
||||||
using CsharpEs.Infrastructure;
|
using CsharpEs.Infrastructure;
|
||||||
using CsharpEs.Library;
|
using CsharpEs.Library;
|
||||||
|
using static CsharpEs.Library.ResultModule;
|
||||||
|
|
||||||
public abstract record DemoError
|
public abstract record DemoError
|
||||||
{
|
{
|
||||||
@@ -66,7 +67,7 @@ public class Program
|
|||||||
.Bind(readModel =>
|
.Bind(readModel =>
|
||||||
demoCommands
|
demoCommands
|
||||||
.Aggregate(
|
.Aggregate(
|
||||||
Result<AccountState?, DemoError>.Ok(null),
|
OkNone<AccountState?, DemoError>(),
|
||||||
(stateResult, command) =>
|
(stateResult, command) =>
|
||||||
stateResult
|
stateResult
|
||||||
.Bind(state =>
|
.Bind(state =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CsharpFp3.Domain;
|
using CsharpFp3.Domain;
|
||||||
|
using CsharpFp3.Library;
|
||||||
|
|
||||||
namespace CsharpFp3.Tests;
|
namespace CsharpFp3.Tests;
|
||||||
|
|
||||||
@@ -11,46 +12,52 @@ public class AccountTests
|
|||||||
{
|
{
|
||||||
var result = AccountDomain.Open(Guid.NewGuid(), new Money(-1m));
|
var result = AccountDomain.Open(Guid.NewGuid(), new Money(-1m));
|
||||||
|
|
||||||
Assert.IsType<AccountError.OpeningBalanceMustBeNonNegative>(result.Error);
|
Assert.True(result.Match(_ => false, e => e is AccountError.OpeningBalanceMustBeNonNegative));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Withdrawing_a_zero_amount_returns_AmountMustBePositive()
|
public void Withdrawing_a_zero_amount_returns_AmountMustBePositive()
|
||||||
{
|
{
|
||||||
var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value;
|
var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m));
|
||||||
|
var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
|
|
||||||
var result = AccountDomain.Withdraw(account, new Money(0m));
|
var result = AccountDomain.Withdraw(account, new Money(0m));
|
||||||
|
|
||||||
Assert.IsType<AccountError.AmountMustBePositive>(result.Error);
|
Assert.True(result.Match(_ => false, e => e is AccountError.AmountMustBePositive));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Withdrawing_a_negative_amount_returns_AmountMustBePositive()
|
public void Withdrawing_a_negative_amount_returns_AmountMustBePositive()
|
||||||
{
|
{
|
||||||
var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value;
|
var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m));
|
||||||
|
var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
|
|
||||||
var result = AccountDomain.Withdraw(account, new Money(-10m));
|
var result = AccountDomain.Withdraw(account, new Money(-10m));
|
||||||
|
|
||||||
Assert.IsType<AccountError.AmountMustBePositive>(result.Error);
|
Assert.True(result.Match(_ => false, e => e is AccountError.AmountMustBePositive));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Withdrawing_more_than_the_balance_returns_InsufficientBalance()
|
public void Withdrawing_more_than_the_balance_returns_InsufficientBalance()
|
||||||
{
|
{
|
||||||
var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value;
|
var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m));
|
||||||
|
var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
|
|
||||||
var result = AccountDomain.Withdraw(account, new Money(101m));
|
var result = AccountDomain.Withdraw(account, new Money(101m));
|
||||||
|
|
||||||
Assert.IsType<AccountError.InsufficientBalance>(result.Error);
|
Assert.True(result.Match(_ => false, e => e is AccountError.InsufficientBalance));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InsufficientBalance_error_reports_the_current_balance_and_attempted_amount()
|
public void InsufficientBalance_error_reports_the_current_balance_and_attempted_amount()
|
||||||
{
|
{
|
||||||
var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value;
|
var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m));
|
||||||
|
var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
|
|
||||||
var error = Assert.IsType<AccountError.InsufficientBalance>(
|
var result = AccountDomain.Withdraw(account, new Money(150m));
|
||||||
AccountDomain.Withdraw(account, new Money(150m)).Error
|
var error = result.Match(
|
||||||
|
_ => throw new InvalidOperationException("Expected failure"),
|
||||||
|
e => e is AccountError.InsufficientBalance ins ? ins : throw new InvalidOperationException("Expected InsufficientBalance")
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.Equal(100m, error.Balance.Amount);
|
Assert.Equal(100m, error.Balance.Amount);
|
||||||
@@ -60,13 +67,14 @@ public class AccountTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Successive_withdrawals_are_each_applied_to_the_running_balance()
|
public void Successive_withdrawals_are_each_applied_to_the_running_balance()
|
||||||
{
|
{
|
||||||
var account = AccountDomain.Open(Guid.NewGuid(), new Money(300m)).Value;
|
var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(300m));
|
||||||
|
var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
|
|
||||||
var result1 = AccountDomain.Withdraw(account, new Money(100m));
|
var result1 = AccountDomain.Withdraw(account, new Money(100m));
|
||||||
var updatedAccount1 = result1.Value;
|
var updatedAccount1 = result1.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
|
|
||||||
var result2 = AccountDomain.Withdraw(updatedAccount1, new Money(100m));
|
var result2 = AccountDomain.Withdraw(updatedAccount1, new Money(100m));
|
||||||
var updatedAccount2 = result2.Value;
|
var updatedAccount2 = result2.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
|
|
||||||
Assert.Equal(100m, updatedAccount2.Balance.Amount);
|
Assert.Equal(100m, updatedAccount2.Balance.Amount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CsharpFp3.Library;
|
using CsharpFp3.Library;
|
||||||
|
using static CsharpFp3.Library.ResultModule;
|
||||||
|
|
||||||
namespace CsharpFp3.Tests;
|
namespace CsharpFp3.Tests;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Ok_is_success()
|
public void Ok_is_success()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(42);
|
Result<int, string> result = new ResultOk<int, string>(42);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.False(result.IsFailure);
|
Assert.False(result.IsFailure);
|
||||||
@@ -19,42 +20,26 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Fail_is_failure()
|
public void Fail_is_failure()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Fail("oops");
|
Result<int, string> result = new ResultFail<int, string>("oops");
|
||||||
|
|
||||||
Assert.True(result.IsFailure);
|
Assert.True(result.IsFailure);
|
||||||
Assert.False(result.IsSuccess);
|
Assert.False(result.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ok_exposes_value()
|
public void Ok_exposes_value_via_Match()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(42);
|
Result<int, string> result = new ResultOk<int, string>(42);
|
||||||
|
|
||||||
Assert.Equal(42, result.Value);
|
Assert.Equal(42, result.Match(v => v, _ => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Fail_exposes_error()
|
public void Fail_exposes_error_via_Match()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Fail("oops");
|
Result<int, string> result = new ResultFail<int, string>("oops");
|
||||||
|
|
||||||
Assert.Equal("oops", result.Error);
|
Assert.Equal("oops", result.Match(_ => "", e => e));
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Accessing_Value_on_a_failed_result_throws()
|
|
||||||
{
|
|
||||||
var result = Result<int, string>.Fail("oops");
|
|
||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(() => result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Accessing_Error_on_a_successful_result_throws()
|
|
||||||
{
|
|
||||||
var result = Result<int, string>.Ok(42);
|
|
||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(() => result.Error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Match ---
|
// --- Match ---
|
||||||
@@ -62,7 +47,7 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Match_calls_onSuccess_for_Ok()
|
public void Match_calls_onSuccess_for_Ok()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(10);
|
Result<int, string> result = new ResultOk<int, string>(10);
|
||||||
|
|
||||||
var output = result.Match(v => $"value:{v}", e => $"error:{e}");
|
var output = result.Match(v => $"value:{v}", e => $"error:{e}");
|
||||||
|
|
||||||
@@ -72,7 +57,7 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Match_calls_onFailure_for_Fail()
|
public void Match_calls_onFailure_for_Fail()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Fail("bad");
|
Result<int, string> result = new ResultFail<int, string>("bad");
|
||||||
|
|
||||||
var output = result.Match(v => $"value:{v}", e => $"error:{e}");
|
var output = result.Match(v => $"value:{v}", e => $"error:{e}");
|
||||||
|
|
||||||
@@ -84,7 +69,7 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Switch_calls_onSuccess_for_Ok()
|
public void Switch_calls_onSuccess_for_Ok()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(7);
|
Result<int, string> result = new ResultOk<int, string>(7);
|
||||||
var called = false;
|
var called = false;
|
||||||
|
|
||||||
result.Switch(_ => called = true, _ => { });
|
result.Switch(_ => called = true, _ => { });
|
||||||
@@ -95,7 +80,7 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Switch_calls_onFailure_for_Fail()
|
public void Switch_calls_onFailure_for_Fail()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Fail("err");
|
Result<int, string> result = new ResultFail<int, string>("err");
|
||||||
var called = false;
|
var called = false;
|
||||||
|
|
||||||
result.Switch(_ => { }, _ => called = true);
|
result.Switch(_ => { }, _ => called = true);
|
||||||
@@ -111,7 +96,7 @@ public class ResultTests
|
|||||||
var result = Result<int, string>.Catch(() => 99, ex => ex.Message);
|
var result = Result<int, string>.Catch(() => 99, ex => ex.Message);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(99, result.Value);
|
Assert.Equal(99, result.Match(v => v, _ => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -123,7 +108,7 @@ public class ResultTests
|
|||||||
);
|
);
|
||||||
|
|
||||||
Assert.True(result.IsFailure);
|
Assert.True(result.IsFailure);
|
||||||
Assert.Equal("boom", result.Error);
|
Assert.Equal("boom", result.Match(_ => "", e => e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bind ---
|
// --- Bind ---
|
||||||
@@ -131,33 +116,34 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_chains_to_the_next_result_on_success()
|
public void Bind_chains_to_the_next_result_on_success()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(5).Bind(v => Result<string, string>.Ok($"got {v}"));
|
Result<int, string> result = new ResultOk<int, string>(5);
|
||||||
|
var chained = result.Bind(v => new ResultOk<string, string>($"got {v}"));
|
||||||
|
|
||||||
Assert.Equal("got 5", result.Value);
|
Assert.Equal("got 5", chained.Match(v => v, _ => ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_short_circuits_on_failure_without_calling_the_binder()
|
public void Bind_short_circuits_on_failure_without_calling_the_binder()
|
||||||
{
|
{
|
||||||
var binderCalled = false;
|
var binderCalled = false;
|
||||||
var result = Result<int, string>
|
Result<int, string> result = new ResultFail<int, string>("nope");
|
||||||
.Fail("nope")
|
var chained = result.Bind(v =>
|
||||||
.Bind(v =>
|
{
|
||||||
{
|
binderCalled = true;
|
||||||
binderCalled = true;
|
return new ResultOk<string, string>($"got {v}");
|
||||||
return Result<string, string>.Ok($"got {v}");
|
});
|
||||||
});
|
|
||||||
|
|
||||||
Assert.False(binderCalled);
|
Assert.False(binderCalled);
|
||||||
Assert.Equal("nope", result.Error);
|
Assert.Equal("nope", chained.Match(_ => "", e => e));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_propagates_the_inner_failure_when_the_binder_returns_Fail()
|
public void Bind_propagates_the_inner_failure_when_the_binder_returns_Fail()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(5).Bind(_ => Result<string, string>.Fail("inner fail"));
|
Result<int, string> result = new ResultOk<int, string>(5);
|
||||||
|
var chained = result.Bind(_ => new ResultFail<string, string>("inner fail"));
|
||||||
|
|
||||||
Assert.Equal("inner fail", result.Error);
|
Assert.Equal("inner fail", chained.Match(_ => "", e => e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Map ---
|
// --- Map ---
|
||||||
@@ -165,23 +151,25 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Map_transforms_the_value_on_success()
|
public void Map_transforms_the_value_on_success()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(3).Map(v => v * 2);
|
Result<int, string> result = new ResultOk<int, string>(3);
|
||||||
|
var mapped = result.Map(v => v * 2);
|
||||||
|
|
||||||
Assert.Equal(6, result.Value);
|
Assert.Equal(6, mapped.Match(v => v, _ => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Map_does_not_call_the_mapper_on_failure()
|
public void Map_does_not_call_the_mapper_on_failure()
|
||||||
{
|
{
|
||||||
var mapperCalled = false;
|
var mapperCalled = false;
|
||||||
var result = Result<int, string>.Fail("err").Map(v =>
|
Result<int, string> result = new ResultFail<int, string>("err");
|
||||||
|
var mapped = result.Map(v =>
|
||||||
{
|
{
|
||||||
mapperCalled = true;
|
mapperCalled = true;
|
||||||
return v * 2;
|
return v * 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.False(mapperCalled);
|
Assert.False(mapperCalled);
|
||||||
Assert.Equal("err", result.Error);
|
Assert.Equal("err", mapped.Match(_ => "", e => e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tap ---
|
// --- Tap ---
|
||||||
@@ -190,17 +178,19 @@ public class ResultTests
|
|||||||
public void Tap_calls_the_action_and_returns_the_original_result_on_success()
|
public void Tap_calls_the_action_and_returns_the_original_result_on_success()
|
||||||
{
|
{
|
||||||
var seen = -1;
|
var seen = -1;
|
||||||
var result = Result<int, string>.Ok(8).Tap(v => seen = v);
|
Result<int, string> result = new ResultOk<int, string>(8);
|
||||||
|
var tapped = result.Tap(v => seen = v);
|
||||||
|
|
||||||
Assert.Equal(8, seen);
|
Assert.Equal(8, seen);
|
||||||
Assert.Equal(8, result.Value);
|
Assert.Equal(8, tapped.Match(v => v, _ => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tap_does_not_call_the_action_on_failure()
|
public void Tap_does_not_call_the_action_on_failure()
|
||||||
{
|
{
|
||||||
var called = false;
|
var called = false;
|
||||||
Result<int, string>.Fail("err").Tap(_ => called = true);
|
Result<int, string> result = new ResultFail<int, string>("err");
|
||||||
|
result.Tap(_ => called = true);
|
||||||
|
|
||||||
Assert.False(called);
|
Assert.False(called);
|
||||||
}
|
}
|
||||||
@@ -211,17 +201,19 @@ public class ResultTests
|
|||||||
public void TapError_calls_the_action_and_returns_the_original_result_on_failure()
|
public void TapError_calls_the_action_and_returns_the_original_result_on_failure()
|
||||||
{
|
{
|
||||||
var seen = "";
|
var seen = "";
|
||||||
var result = Result<int, string>.Fail("bad").TapError(e => seen = e);
|
Result<int, string> result = new ResultFail<int, string>("bad");
|
||||||
|
var tapped = result.TapError(e => seen = e);
|
||||||
|
|
||||||
Assert.Equal("bad", seen);
|
Assert.Equal("bad", seen);
|
||||||
Assert.Equal("bad", result.Error);
|
Assert.Equal("bad", tapped.Match(_ => "", e => e));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TapError_does_not_call_the_action_on_success()
|
public void TapError_does_not_call_the_action_on_success()
|
||||||
{
|
{
|
||||||
var called = false;
|
var called = false;
|
||||||
Result<int, string>.Ok(1).TapError(_ => called = true);
|
Result<int, string> result = new ResultOk<int, string>(1);
|
||||||
|
result.TapError(_ => called = true);
|
||||||
|
|
||||||
Assert.False(called);
|
Assert.False(called);
|
||||||
}
|
}
|
||||||
@@ -231,23 +223,25 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void MapError_transforms_the_error_on_failure()
|
public void MapError_transforms_the_error_on_failure()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Fail("oops").MapError(e => e.Length);
|
Result<int, string> result = new ResultFail<int, string>("oops");
|
||||||
|
var mapped = result.MapError(e => e.Length);
|
||||||
|
|
||||||
Assert.Equal(4, result.Error);
|
Assert.Equal(4, mapped.Match(_ => 0, e => e));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MapError_does_not_call_the_mapper_on_success()
|
public void MapError_does_not_call_the_mapper_on_success()
|
||||||
{
|
{
|
||||||
var mapperCalled = false;
|
var mapperCalled = false;
|
||||||
var result = Result<int, string>.Ok(1).MapError(e =>
|
Result<int, string> result = new ResultOk<int, string>(1);
|
||||||
|
var mapped = result.MapError(e =>
|
||||||
{
|
{
|
||||||
mapperCalled = true;
|
mapperCalled = true;
|
||||||
return e.Length;
|
return e.Length;
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.False(mapperCalled);
|
Assert.False(mapperCalled);
|
||||||
Assert.Equal(1, result.Value);
|
Assert.Equal(1, mapped.Match(v => v, _ => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Select / SelectMany (LINQ support) ---
|
// --- Select / SelectMany (LINQ support) ---
|
||||||
@@ -255,41 +249,48 @@ public class ResultTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Select_is_equivalent_to_Map()
|
public void Select_is_equivalent_to_Map()
|
||||||
{
|
{
|
||||||
var result = Result<int, string>.Ok(4).Select(v => v + 1);
|
Result<int, string> result = new ResultOk<int, string>(4);
|
||||||
|
var selected = result.Select(v => v + 1);
|
||||||
|
|
||||||
Assert.Equal(5, result.Value);
|
Assert.Equal(5, selected.Match(v => v, _ => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectMany_sequences_two_successful_results()
|
public void SelectMany_sequences_two_successful_results()
|
||||||
{
|
{
|
||||||
|
Result<int, string> aResult = new ResultOk<int, string>(3);
|
||||||
|
Result<int, string> bResult = new ResultOk<int, string>(4);
|
||||||
var result =
|
var result =
|
||||||
from a in Result<int, string>.Ok(3)
|
from a in aResult
|
||||||
from b in Result<int, string>.Ok(4)
|
from b in bResult
|
||||||
select a + b;
|
select a + b;
|
||||||
|
|
||||||
Assert.Equal(7, result.Value);
|
Assert.Equal(7, result.Match(v => v, _ => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectMany_short_circuits_when_the_first_result_fails()
|
public void SelectMany_short_circuits_when_the_first_result_fails()
|
||||||
{
|
{
|
||||||
|
Result<int, string> aResult = new ResultFail<int, string>("first fail");
|
||||||
|
Result<int, string> bResult = new ResultOk<int, string>(4);
|
||||||
var result =
|
var result =
|
||||||
from a in Result<int, string>.Fail("first fail")
|
from a in aResult
|
||||||
from b in Result<int, string>.Ok(4)
|
from b in bResult
|
||||||
select a + b;
|
select a + b;
|
||||||
|
|
||||||
Assert.Equal("first fail", result.Error);
|
Assert.Equal("first fail", result.Match(_ => "", e => e));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectMany_short_circuits_when_the_second_result_fails()
|
public void SelectMany_short_circuits_when_the_second_result_fails()
|
||||||
{
|
{
|
||||||
|
Result<int, string> aResult = new ResultOk<int, string>(3);
|
||||||
|
Result<int, string> bResult = new ResultFail<int, string>("second fail");
|
||||||
var result =
|
var result =
|
||||||
from a in Result<int, string>.Ok(3)
|
from a in aResult
|
||||||
from b in Result<int, string>.Fail("second fail")
|
from b in bResult
|
||||||
select a + b;
|
select a + b;
|
||||||
|
|
||||||
Assert.Equal("second fail", result.Error);
|
Assert.Equal("second fail", result.Match(_ => "", e => e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using CsharpFp3.Application;
|
|||||||
using CsharpFp3.Domain;
|
using CsharpFp3.Domain;
|
||||||
using CsharpFp3.Infrastructure;
|
using CsharpFp3.Infrastructure;
|
||||||
using CsharpFp3.Library;
|
using CsharpFp3.Library;
|
||||||
|
using static CsharpFp3.Library.ResultModule;
|
||||||
|
|
||||||
namespace CsharpFp3.Tests;
|
namespace CsharpFp3.Tests;
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ public class WithdrawMoneyTests
|
|||||||
) BuildHandler()
|
) BuildHandler()
|
||||||
{
|
{
|
||||||
var repo = InMemoryAccountRepository.Create();
|
var repo = InMemoryAccountRepository.Create();
|
||||||
var withdraw = AccountApplication.CreateWithdrawMoney(repo).Value;
|
var result = AccountApplication.CreateWithdrawMoney(repo);
|
||||||
|
var withdraw = result.Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
return (withdraw, repo);
|
return (withdraw, repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +26,12 @@ public class WithdrawMoneyTests
|
|||||||
{
|
{
|
||||||
var (withdraw, repo) = BuildHandler();
|
var (withdraw, repo) = BuildHandler();
|
||||||
var accountId = Guid.NewGuid();
|
var accountId = Guid.NewGuid();
|
||||||
repo.SaveAccount(AccountDomain.Open(accountId, new Money(200m)).Value);
|
var accountResult = AccountDomain.Open(accountId, new Money(200m));
|
||||||
|
repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")));
|
||||||
|
|
||||||
withdraw(accountId, 75m);
|
withdraw(accountId, 75m);
|
||||||
|
|
||||||
var account = repo.LoadAccount(accountId).Value;
|
var account = repo.LoadAccount(accountId).Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
Assert.Equal(125m, account.Balance.Amount);
|
Assert.Equal(125m, account.Balance.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,11 +40,12 @@ public class WithdrawMoneyTests
|
|||||||
{
|
{
|
||||||
var (withdraw, repo) = BuildHandler();
|
var (withdraw, repo) = BuildHandler();
|
||||||
var accountId = Guid.NewGuid();
|
var accountId = Guid.NewGuid();
|
||||||
repo.SaveAccount(AccountDomain.Open(accountId, new Money(100m)).Value);
|
var accountResult = AccountDomain.Open(accountId, new Money(100m));
|
||||||
|
repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")));
|
||||||
|
|
||||||
withdraw(accountId, 100m);
|
withdraw(accountId, 100m);
|
||||||
|
|
||||||
var account = repo.LoadAccount(accountId).Value;
|
var account = repo.LoadAccount(accountId).Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
Assert.Equal(0m, account.Balance.Amount);
|
Assert.Equal(0m, account.Balance.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ public class WithdrawMoneyTests
|
|||||||
|
|
||||||
var result = withdraw(Guid.NewGuid(), 50m);
|
var result = withdraw(Guid.NewGuid(), 50m);
|
||||||
|
|
||||||
Assert.IsType<AccountError.AccountNotFound>(result.Error);
|
Assert.True(result.Match(_ => false, e => e is AccountError.AccountNotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -60,11 +64,12 @@ public class WithdrawMoneyTests
|
|||||||
{
|
{
|
||||||
var (withdraw, repo) = BuildHandler();
|
var (withdraw, repo) = BuildHandler();
|
||||||
var accountId = Guid.NewGuid();
|
var accountId = Guid.NewGuid();
|
||||||
repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value);
|
var accountResult = AccountDomain.Open(accountId, new Money(50m));
|
||||||
|
repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")));
|
||||||
|
|
||||||
var result = withdraw(accountId, 100m);
|
var result = withdraw(accountId, 100m);
|
||||||
|
|
||||||
Assert.IsType<AccountError.InsufficientBalance>(result.Error);
|
Assert.True(result.Match(_ => false, e => e is AccountError.InsufficientBalance));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -73,8 +78,9 @@ public class WithdrawMoneyTests
|
|||||||
var (withdraw, _) = BuildHandler();
|
var (withdraw, _) = BuildHandler();
|
||||||
var accountId = Guid.NewGuid();
|
var accountId = Guid.NewGuid();
|
||||||
|
|
||||||
var error = Assert.IsType<AccountError.AccountNotFound>(
|
var error = withdraw(accountId, 50m).Match(
|
||||||
withdraw(accountId, 50m).Error
|
_ => throw new InvalidOperationException("Expected failure"),
|
||||||
|
e => e is AccountError.AccountNotFound notFound ? notFound : throw new InvalidOperationException("Expected AccountNotFound")
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.Equal(accountId, error.AccountId);
|
Assert.Equal(accountId, error.AccountId);
|
||||||
@@ -85,10 +91,12 @@ public class WithdrawMoneyTests
|
|||||||
{
|
{
|
||||||
var (withdraw, repo) = BuildHandler();
|
var (withdraw, repo) = BuildHandler();
|
||||||
var accountId = Guid.NewGuid();
|
var accountId = Guid.NewGuid();
|
||||||
repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value);
|
var accountResult = AccountDomain.Open(accountId, new Money(50m));
|
||||||
|
repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")));
|
||||||
|
|
||||||
var error = Assert.IsType<AccountError.InsufficientBalance>(
|
var error = withdraw(accountId, 120m).Match(
|
||||||
withdraw(accountId, 120m).Error
|
_ => throw new InvalidOperationException("Expected failure"),
|
||||||
|
e => e is AccountError.InsufficientBalance ins ? ins : throw new InvalidOperationException("Expected InsufficientBalance")
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.Equal(50m, error.Balance.Amount);
|
Assert.Equal(50m, error.Balance.Amount);
|
||||||
@@ -100,11 +108,12 @@ public class WithdrawMoneyTests
|
|||||||
{
|
{
|
||||||
var (withdraw, repo) = BuildHandler();
|
var (withdraw, repo) = BuildHandler();
|
||||||
var accountId = Guid.NewGuid();
|
var accountId = Guid.NewGuid();
|
||||||
repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value);
|
var accountResult = AccountDomain.Open(accountId, new Money(50m));
|
||||||
|
repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")));
|
||||||
|
|
||||||
withdraw(accountId, 999m);
|
withdraw(accountId, 999m);
|
||||||
|
|
||||||
var account = repo.LoadAccount(accountId).Value;
|
var account = repo.LoadAccount(accountId).Match(v => v, _ => throw new InvalidOperationException("Expected success"));
|
||||||
Assert.Equal(50m, account.Balance.Amount);
|
Assert.Equal(50m, account.Balance.Amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using CsharpFp3.Domain;
|
using CsharpFp3.Domain;
|
||||||
using CsharpFp3.Infrastructure;
|
using CsharpFp3.Infrastructure;
|
||||||
using CsharpFp3.Library;
|
using CsharpFp3.Library;
|
||||||
|
using static CsharpFp3.Library.ResultModule;
|
||||||
|
|
||||||
namespace CsharpFp3.Application;
|
namespace CsharpFp3.Application;
|
||||||
|
|
||||||
@@ -16,8 +17,9 @@ public abstract record AppError
|
|||||||
public static class AccountApplication
|
public static class AccountApplication
|
||||||
{
|
{
|
||||||
public static CreateResultType CreateWithdrawMoney(Repository repo) =>
|
public static CreateResultType CreateWithdrawMoney(Repository repo) =>
|
||||||
CreateResultType.Ok(
|
Ok(
|
||||||
(accountId, amount) =>
|
// types needed in this lambda, otherwise c# doesn't love us
|
||||||
|
(Guid accountId, decimal amount) =>
|
||||||
repo.LoadAccount(accountId)
|
repo.LoadAccount(accountId)
|
||||||
.Log("App load", "Account loaded")
|
.Log("App load", "Account loaded")
|
||||||
.Log("App exec wdrwl", $"[App] Executing withdrawal of {amount:0.00}...")
|
.Log("App exec wdrwl", $"[App] Executing withdrawal of {amount:0.00}...")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CsharpFp3.Library;
|
using CsharpFp3.Library;
|
||||||
|
using static CsharpFp3.Library.ResultModule;
|
||||||
|
|
||||||
namespace CsharpFp3.Domain;
|
namespace CsharpFp3.Domain;
|
||||||
|
|
||||||
@@ -19,22 +20,19 @@ public static class AccountDomain
|
|||||||
{
|
{
|
||||||
public static Result<Account, AccountError> Open(Guid id, Money openingBalance) =>
|
public static Result<Account, AccountError> Open(Guid id, Money openingBalance) =>
|
||||||
openingBalance.Amount < 0m
|
openingBalance.Amount < 0m
|
||||||
? Result<Account, AccountError>.Fail(new AccountError.OpeningBalanceMustBeNonNegative())
|
? Fail(new AccountError.OpeningBalanceMustBeNonNegative())
|
||||||
: Result<Account, AccountError>.Ok(new Account(id, openingBalance));
|
: Ok(new Account(id, openingBalance));
|
||||||
|
|
||||||
public static Result<Account, AccountError> Withdraw(Account account, Money amount) =>
|
public static Result<Account, AccountError> Withdraw(Account account, Money amount) =>
|
||||||
(account.Balance.Amount, amount.Amount) switch
|
(account.Balance.Amount, amount.Amount) switch
|
||||||
{
|
{
|
||||||
(_, <= 0m) => Result<Account, AccountError>.Fail(
|
(_, <= 0m) => Fail(new AccountError.AmountMustBePositive()),
|
||||||
new AccountError.AmountMustBePositive()
|
|
||||||
|
var (balance, transaction) when balance < transaction => Fail(
|
||||||
|
new AccountError.InsufficientBalance(account.Balance, amount)
|
||||||
),
|
),
|
||||||
|
|
||||||
var (balance, transaction) when balance < transaction => Result<
|
var (balance, transaction) => Ok(
|
||||||
Account,
|
|
||||||
AccountError
|
|
||||||
>.Fail(new AccountError.InsufficientBalance(account.Balance, amount)),
|
|
||||||
|
|
||||||
var (balance, transaction) => Result<Account, AccountError>.Ok(
|
|
||||||
account with
|
account with
|
||||||
{
|
{
|
||||||
Balance = new Money(balance - transaction),
|
Balance = new Money(balance - transaction),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using CsharpFp3.Domain;
|
using CsharpFp3.Domain;
|
||||||
using CsharpFp3.Library;
|
using CsharpFp3.Library;
|
||||||
using static CsharpFp3.Library.Logging;
|
using static CsharpFp3.Library.Logging;
|
||||||
|
using static CsharpFp3.Library.ResultModule;
|
||||||
|
|
||||||
namespace CsharpFp3.Infrastructure;
|
namespace CsharpFp3.Infrastructure;
|
||||||
|
|
||||||
@@ -17,13 +18,10 @@ public static class InMemoryAccountRepository
|
|||||||
|
|
||||||
Result<Account, AccountError> GetById(Guid id) =>
|
Result<Account, AccountError> GetById(Guid id) =>
|
||||||
store.TryGetValue(id, out var account)
|
store.TryGetValue(id, out var account)
|
||||||
? LogReturn(
|
? LogReturn($"[Repo] Loaded account {id}", Ok(account))
|
||||||
$"[Repo] Loaded account {id}",
|
|
||||||
Result<Account, AccountError>.Ok(account)
|
|
||||||
)
|
|
||||||
: LogReturn(
|
: LogReturn(
|
||||||
$"[Repo] Account {id} not found",
|
$"[Repo] Account {id} not found",
|
||||||
Result<Account, AccountError>.Fail(new AccountError.AccountNotFound(id))
|
Fail(new AccountError.AccountNotFound(id))
|
||||||
);
|
);
|
||||||
|
|
||||||
Result<Account, AccountError> Save(Account account)
|
Result<Account, AccountError> Save(Account account)
|
||||||
@@ -33,7 +31,7 @@ public static class InMemoryAccountRepository
|
|||||||
// no failure modes here
|
// no failure modes here
|
||||||
return LogReturn(
|
return LogReturn(
|
||||||
$"[Repo] Saved account {account.Id} with balance {account.Balance.Amount:0.00}",
|
$"[Repo] Saved account {account.Id} with balance {account.Balance.Amount:0.00}",
|
||||||
Result<Account, AccountError>.Ok(account)
|
Ok(account)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,38 @@ public static class Logging
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Action<T> Output<T>(string src, string message) =>
|
static string Indent(string text, int indent = 4)
|
||||||
|
{
|
||||||
|
var prefix = new string(' ', indent);
|
||||||
|
|
||||||
|
return Environment.NewLine
|
||||||
|
+ string.Join(
|
||||||
|
Environment.NewLine,
|
||||||
|
text.Split(Environment.NewLine).Select(line => prefix + line)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Format(Object? o) =>
|
||||||
|
Indent(ObjectDumper.Dump(o, new DumpOptions() { DumpStyle = DumpStyle.CSharp }));
|
||||||
|
|
||||||
|
public static void Output(string src, string message, Object? x)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{src}] {message} | {Format(x)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Output(string src, string message)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{src}] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Action<T> OutputDelegate<T>(string src, string message) =>
|
||||||
x =>
|
x =>
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[{src}] {message} | {x}");
|
Output(src, message, x);
|
||||||
};
|
};
|
||||||
|
|
||||||
public static void OutputError<T>(string src, T error)
|
public static void OutputError<T>(string src, T error)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"\e[1;31m[{src} ERROR]\e[0m {error}");
|
Console.Error.WriteLine($"\e[1;31m[{src} ERROR]\e[0m {Format(error)}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-105
@@ -1,126 +1,137 @@
|
|||||||
namespace CsharpFp3.Library;
|
namespace CsharpFp3.Library;
|
||||||
|
|
||||||
public readonly record struct Result<T, E>
|
// disable match exhaustion warnings - C# thinks we could have other
|
||||||
|
// derived instances of Result<T,E> but there's no way to say
|
||||||
|
// "don't allow creation of further derived types" while leaving
|
||||||
|
// the type publicly visible.
|
||||||
|
#pragma warning disable CS8509
|
||||||
|
|
||||||
|
public abstract record Result<T, E>
|
||||||
{
|
{
|
||||||
private readonly T _value;
|
public static implicit operator Result<T, E>(T value) => new ResultOk<T, E>(value);
|
||||||
|
|
||||||
private readonly E _error;
|
public static implicit operator Result<T, E>(E error) => new ResultFail<T, E>(error);
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsSuccess { get; }
|
public sealed record ResultOk<T, E>(T Value) : Result<T, E>;
|
||||||
|
|
||||||
public bool IsFailure => !IsSuccess;
|
public sealed record ResultFail<T, E>(E Error) : Result<T, E>;
|
||||||
|
|
||||||
public T Value =>
|
public static class ResultModule
|
||||||
IsSuccess
|
{
|
||||||
? _value
|
public static T Ok<T>(T v) => v;
|
||||||
: throw new InvalidOperationException("No value present for failed result.");
|
|
||||||
|
|
||||||
public E Error =>
|
public static Result<T?, E> OkNone<T, E>()
|
||||||
IsFailure
|
where T : class? => new ResultOk<T?, E>(null);
|
||||||
? _error
|
|
||||||
: throw new InvalidOperationException("No error present for successful result.");
|
|
||||||
|
|
||||||
private Result(T value)
|
public static E Fail<E>(E e) => e;
|
||||||
{
|
|
||||||
_value = value;
|
|
||||||
_error = default!;
|
|
||||||
IsSuccess = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result(E error)
|
|
||||||
{
|
|
||||||
_value = default!;
|
|
||||||
_error = error;
|
|
||||||
IsSuccess = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result<T, E> Ok(T value) => new(value);
|
|
||||||
|
|
||||||
public static Result<T, E> Fail(E error) => new(error);
|
|
||||||
|
|
||||||
public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<E, TResult> onFailure) =>
|
|
||||||
IsSuccess ? onSuccess(_value) : onFailure(_error);
|
|
||||||
|
|
||||||
public void Switch(Action<T> onSuccess, Action<E> onFailure)
|
|
||||||
{
|
|
||||||
if (IsSuccess)
|
|
||||||
onSuccess(_value);
|
|
||||||
else
|
|
||||||
onFailure(_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result<T, E> Catch(Func<T> f, Func<Exception, E> exceptionMapper)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = f();
|
|
||||||
return Result<T, E>.Ok(result);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
return Result<T, E>.Fail(exceptionMapper(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ResultExtensions
|
public static class ResultExtensions
|
||||||
{
|
{
|
||||||
public static Result<TOut, E> Bind<TIn, TOut, E>(
|
extension<TIn, E>(Result<TIn, E> result)
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, Result<TOut, E>> binder
|
|
||||||
) => result.IsSuccess ? binder(result.Value) : Result<TOut, E>.Fail(result.Error);
|
|
||||||
|
|
||||||
public static Result<TOut, E> Map<TIn, TOut, E>(
|
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, TOut> mapper
|
|
||||||
) =>
|
|
||||||
result.IsSuccess
|
|
||||||
? Result<TOut, E>.Ok(mapper(result.Value))
|
|
||||||
: Result<TOut, E>.Fail(result.Error);
|
|
||||||
|
|
||||||
public static Result<T, E> Tap<T, E>(this Result<T, E> result, Action<T> action)
|
|
||||||
{
|
{
|
||||||
if (result.IsSuccess)
|
public bool IsSuccess => result is ResultOk<TIn, E>;
|
||||||
action(result.Value);
|
public bool IsFailure => !result.IsSuccess;
|
||||||
|
|
||||||
return result;
|
public Result<TOut, E> Bind<TOut>(Func<TIn, Result<TOut, E>> binder) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => binder(v),
|
||||||
|
ResultFail<TIn, E>(var e) => new ResultFail<TOut, E>(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Result<TOut, E> Map<TOut>(Func<TIn, TOut> mapper) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => new ResultOk<TOut, E>(mapper(v)),
|
||||||
|
ResultFail<TIn, E>(var e) => new ResultFail<TOut, E>(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Result<TIn, EOut> MapError<EOut>(Func<E, EOut> mapper) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => new ResultOk<TIn, EOut>(v),
|
||||||
|
ResultFail<TIn, E>(var e) => new ResultFail<TIn, EOut>(mapper(e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Result<TIn, E> Tap(Action<TIn> action)
|
||||||
|
{
|
||||||
|
if (result is ResultOk<TIn, E>(var v))
|
||||||
|
action(v);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<TIn, E> TapError(Action<E> action)
|
||||||
|
{
|
||||||
|
if (result is ResultFail<TIn, E>(var e))
|
||||||
|
action(e);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<TIn, E> Log(string src, string msg) =>
|
||||||
|
Tap(result, Logging.OutputDelegate<TIn>(src, msg))
|
||||||
|
.TapError(m => Logging.OutputError(src, m));
|
||||||
|
|
||||||
|
public Result<TIn, E> Log(string src, Func<TIn, string> renderText) =>
|
||||||
|
Tap(result, x => Logging.OutputDelegate<TIn>(src, renderText(x))(x))
|
||||||
|
.TapError(m => Logging.OutputError(src, m));
|
||||||
|
|
||||||
|
public Result<TOut, E> Select<TOut>(Func<TIn, TOut> mapper) => Map(result, mapper);
|
||||||
|
|
||||||
|
public Result<TOut, E> SelectMany<TIntermediate, TOut>(
|
||||||
|
Func<TIn, Result<TIntermediate, E>> binder,
|
||||||
|
Func<TIn, TIntermediate, TOut> projector
|
||||||
|
) => Bind(result, x => binder(x).Map(y => projector(x, y)));
|
||||||
|
|
||||||
|
public TResult Match<TResult>(Func<TIn, TResult> onSuccess, Func<E, TResult> onFailure) =>
|
||||||
|
result switch
|
||||||
|
{
|
||||||
|
ResultOk<TIn, E>(var v) => onSuccess(v),
|
||||||
|
ResultFail<TIn, E>(var e) => onFailure(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Switch(Action<TIn> onSuccess, Action<E> onFailure)
|
||||||
|
{
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case ResultOk<TIn, E>(var v):
|
||||||
|
onSuccess(v);
|
||||||
|
break;
|
||||||
|
case ResultFail<TIn, E>(var e):
|
||||||
|
onFailure(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<T, E> TapError<T, E>(this Result<T, E> result, Action<E> action)
|
extension<TIn, E>(Result<TIn, E>)
|
||||||
{
|
{
|
||||||
if (result.IsFailure)
|
public static Result<TIn, E> Catch(Func<TIn> f, Func<Exception, E> exceptionMapper)
|
||||||
action(result.Error);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new ResultOk<TIn, E>(f());
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return new ResultFail<TIn, E>(exceptionMapper(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
public static Result<TIn, E> Catch(
|
||||||
|
Func<Result<TIn, E>> f,
|
||||||
|
Func<Exception, E> exceptionMapper
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return f();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return new ResultFail<TIn, E>(exceptionMapper(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<T, EOut> MapError<T, EIn, EOut>(
|
|
||||||
this Result<T, EIn> result,
|
|
||||||
Func<EIn, EOut> map
|
|
||||||
) =>
|
|
||||||
result.IsSuccess
|
|
||||||
? Result<T, EOut>.Ok(result.Value)
|
|
||||||
: Result<T, EOut>.Fail(map(result.Error));
|
|
||||||
|
|
||||||
public static Result<T, E> Log<T, E>(this Result<T, E> result, string src, string msg) =>
|
|
||||||
Tap(result, Logging.Output<T>(src, msg)).TapError(m => Logging.OutputError(src, m));
|
|
||||||
|
|
||||||
public static Result<T, E> Log<T, E>(
|
|
||||||
this Result<T, E> result,
|
|
||||||
string src,
|
|
||||||
Func<T, string> renderText
|
|
||||||
) =>
|
|
||||||
Tap(result, x => Logging.Output<T>(src, renderText(x))(x))
|
|
||||||
.TapError(m => Logging.OutputError(src, m));
|
|
||||||
|
|
||||||
public static Result<TOut, E> Select<TIn, TOut, E>(
|
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, TOut> mapper
|
|
||||||
) => result.Map(mapper);
|
|
||||||
|
|
||||||
public static Result<TOut, E> SelectMany<TIn, TIntermediate, TOut, E>(
|
|
||||||
this Result<TIn, E> result,
|
|
||||||
Func<TIn, Result<TIntermediate, E>> binder,
|
|
||||||
Func<TIn, TIntermediate, TOut> projector
|
|
||||||
) => result.Bind(x => binder(x).Map(y => projector(x, y)));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ Result<Repository, AppError>
|
|||||||
"csharp-fp3 exec",
|
"csharp-fp3 exec",
|
||||||
$"Executing withdrawal {withdrawalAmount:0.00} from account {accountId}"
|
$"Executing withdrawal {withdrawalAmount:0.00} from account {accountId}"
|
||||||
)
|
)
|
||||||
.Bind<Account, Account, AccountError>(account =>
|
.Bind(account => withdrawMoney(account.Id, withdrawalAmount))
|
||||||
withdrawMoney(account.Id, withdrawalAmount)
|
|
||||||
)
|
|
||||||
.Log("csharp-fp3 new balance", account => $"New balance is {account.Balance}")
|
.Log("csharp-fp3 new balance", account => $"New balance is {account.Balance}")
|
||||||
.MapError(ae => (AppError)new AppError.InnerAccountError(ae))
|
.MapError(ae => (AppError)new AppError.InnerAccountError(ae))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,4 +6,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ObjectDumper.NET" Version="4.3.2" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user