Skip to content

Commit

Permalink
Merge pull request #12 from polyadic/simplify-generator
Browse files Browse the repository at this point in the history
Improve the generation of arbitraries and tests
* Fix distribution stratgey for negative amounts of money
  • Loading branch information
FreeApophis authored Jan 7, 2021
2 parents 5b69a7f + 38cc72f commit 1bf9576
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 77 deletions.
14 changes: 13 additions & 1 deletion Funcky.Money.SourceGenerator/Funcky.Money.SourceGenerator.csproj
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk; Microsoft.Build.CentralPackageVersions">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net5.0</TargetFrameworks>
<TargetFrameworks>netstandard2.0</TargetFrameworks>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Funcky" GeneratePathProperty="true" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker Include="$(PkgFuncky)\lib\netstandard2.0\Funcky.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
</Project>
37 changes: 28 additions & 9 deletions Funcky.Money.SourceGenerator/Iso4217RecordGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System.Linq;
using System.Text;
using System.Xml;
using Funcky.Extensions;
using Funcky.Monads;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using static System.Environment;
Expand Down Expand Up @@ -131,7 +133,7 @@ private static IEnumerable<Iso4217Record> ReadIso4217RecordsFromAdditionalFiles(
.Where(f => f is not null)
.SelectMany(text => CreateXmlDocumentFromString(text!.ToString())
.SelectNodesAsEnumerable("//CcyNtry/Ccy/..")
.Select(ReadIso4217RecordFromNode))
.WhereSelect(ReadIso4217RecordFromNode))
.ToImmutableDictionary(r => r.AlphabeticCurrencyCode)
.Select(r => r.Value);

Expand All @@ -142,13 +144,30 @@ private static XmlDocument CreateXmlDocumentFromString(string xml)
return document;
}

private static Iso4217Record ReadIso4217RecordFromNode(XmlNode node)
{
var currencyName = node.SelectSingleNode(CurrencyNameNode)?.InnerText;
var alphabeticCurrencyName = node.SelectSingleNode(AlphabeticCurrencyCodeNode)?.InnerText;
var numericCurrencyCode = int.Parse(node.SelectSingleNode(NumericCurrencyCodeNode)?.InnerText);
var minorUnit = int.TryParse(node.SelectSingleNode(MinorUnitNode)?.InnerText, out var minorUnitTemp) ? (int?)minorUnitTemp : null;
return new Iso4217Record(currencyName, alphabeticCurrencyName, numericCurrencyCode, minorUnit);
}
private static Option<Iso4217Record> ReadIso4217RecordFromNode(XmlNode node)
=> from currencyName in CurrencyName(node)
from alphabeticCurrencyName in AlphabeticCurrencyName(node)
from numericCurrencyCode in NumericCurrencyCode(node)
select new Iso4217Record(currencyName, alphabeticCurrencyName, numericCurrencyCode, MinorUnit(node));

private static Option<string> CurrencyName(XmlNode node)
=> GetInnerText(node, CurrencyNameNode);

private static Option<string> AlphabeticCurrencyName(XmlNode node)
=> GetInnerText(node, AlphabeticCurrencyCodeNode);

private static Option<int> NumericCurrencyCode(XmlNode node)
=> GetInnerText(node, NumericCurrencyCodeNode)
.AndThen(s => s.TryParseInt());

private static int MinorUnit(XmlNode node)
=> GetInnerText(node, MinorUnitNode)
.AndThen(s => s.TryParseInt())
.GetOrElse(0);

private static Option<string> GetInnerText(XmlNode node, string nodeName)
=> Option
.FromNullable(node.SelectSingleNode(nodeName))
.AndThen(n => n.InnerText);
}
}
14 changes: 0 additions & 14 deletions Funcky.Money.Test/CurrencyGenerator.cs

This file was deleted.

4 changes: 0 additions & 4 deletions Funcky.Money.Test/Funcky.Money.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,8 @@
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />

<IsPackable>false</IsPackable>

<RootNamespace>Funcky.Test</RootNamespace>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
Expand Down
25 changes: 25 additions & 0 deletions Funcky.Money.Test/MoneyArbitraries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using FsCheck;

namespace Funcky.Test
{
internal class MoneyArbitraries
{
public static Arbitrary<Currency> ArbitraryCurrency()
=> Arb.From(Gen.Elements<Currency>(Currency.AllCurrencies));

public static Arbitrary<Money> ArbitraryMoney()
=> GenerateMoney().ToArbitrary();

public static Arbitrary<SwissMoney> ArbitrarySwissMoney()
=> GenerateSwissFranc().ToArbitrary();

private static Gen<Money> GenerateMoney()
=> from currency in Arb.Generate<Currency>()
from amount in Arb.Generate<int>()
select new Money(Power.OfATenth(currency.MinorUnitDigits) * amount, currency);

private static Gen<SwissMoney> GenerateSwissFranc()
=> from amount in Arb.Generate<int>()
select new SwissMoney(Money.CHF(SwissMoney.SmallestCoin * amount));
}
}
99 changes: 56 additions & 43 deletions Funcky.Money.Test/MoneyTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,28 @@ namespace Funcky.Test
{
public sealed class MoneyTest
{
private const decimal SmallestCoin = 0.05m;

public MoneyTest() =>
Arb
.Register<CurrencyGenerator>();
.Register<MoneyArbitraries>();

private static MoneyEvaluationContext SwissRounding
=> MoneyEvaluationContext
.Builder
.Default
.WithTargetCurrency(Currency.CHF)
.WithSmallestDistributionUnit(SmallestCoin)
.WithSmallestDistributionUnit(SwissMoney.SmallestCoin)
.Build();

[Property]
public Property EvaluatingAMoneyInTheSameCurrencyDoesReturnTheSameAmount(decimal amount, Currency currency)
public Property EvaluatingAMoneyInTheSameCurrencyDoesReturnTheSameAmount(Money money)
{
var money = new Money(ValidAmount(amount, currency), currency);

return (money.Amount == money.Evaluate().Amount).ToProperty();
}

[Property]
public Property TheSumOfTwoMoneysIsCommutative(decimal amount1, decimal amount2, Currency currency)
public Property TheSumOfTwoMoneysIsCommutative(Money money1, Money money2)
{
var money1 = new Money(ValidAmount(amount1, currency));
var money2 = new Money(ValidAmount(amount2, currency));
money2 = new Money(money2.Amount, money1.Currency);

return (money1.Add(money2).Evaluate().Amount == money2.Add(money1).Evaluate().Amount).ToProperty();
}
Expand All @@ -56,34 +51,35 @@ public void WeCanBuildTheSumOfTwoMoneysWithDifferentCurrenciesButOnEvaluationYou
[Property]
public Property DollarsAreNotFrancs(decimal amount)
{
var francs = Money.CHF(ValidAmount(amount, Currency.CHF));
var dollars = Money.USD(ValidAmount(amount, Currency.USD));
var francs = Money.CHF(amount);
var dollars = Money.USD(amount);

return (francs != dollars).ToProperty();
}

[Property]
public Property MoneyCanBeMultipliedByConstantsFactors(decimal amount, decimal multiplier)
public Property MoneyCanBeMultipliedByConstantsFactors(Money someMoney, decimal multiplier)
{
var someMoney = Money.CHF(amount);
var result = decimal.Round(amount * multiplier, someMoney.Currency.MinorUnitDigits)
var result = decimal.Round(someMoney.Amount * multiplier, someMoney.Currency.MinorUnitDigits)
== someMoney.Multiply(multiplier).Evaluate().Amount;

return result.ToProperty();
}

[Property]
public Property DistributeMoneyEqually(decimal amount, PositiveInt numberOfParts)
{
var validAmount = SwissRounding.RoundingStrategy.Round(amount);
var someMoney = new Money(validAmount, SwissRounding);
var distributed = someMoney.Distribute(numberOfParts.Get).Select(e => e.Evaluate(SwissRounding).Amount).ToList();
var first = distributed.First();
public Property DistributeMoneyConservesTheTotal(SwissMoney someMoney, PositiveInt numberOfParts)
=> TheSumOfThePartsIsEqualToTheTotal(someMoney.Get.Amount, Distributed(someMoney, numberOfParts.Get))
.ToProperty();

return (TheSumOfThePartsIsEqualToTheTotal(distributed, validAmount)
&& TheNumberOfPartsIsCorrect(numberOfParts, distributed)
&& TheIndividualPartsAreAtMostOneUnitApart(distributed, first)).ToProperty();
}
[Property]
public Property DistributeMoneyHasNumberOfParts(SwissMoney someMoney, PositiveInt numberOfParts)
=> TheNumberOfPartsIsCorrect(numberOfParts.Get, Distributed(someMoney, numberOfParts.Get))
.ToProperty();

[Property]
public Property DistributeMoneyHasMinimalDifference(SwissMoney someMoney, PositiveInt numberOfParts)
=> TheIndividualPartsAreAtMostOneUnitApart(Distributed(someMoney, numberOfParts.Get))
.ToProperty();

[Theory]
[MemberData(nameof(ProportionalDistributionData))]
Expand All @@ -102,6 +98,23 @@ public void DistributeMoneyProportionally(int first, int second, decimal expecte
item => Assert.Equal(expected2, item));
}

[Theory]
[MemberData(nameof(ProportionalDistributionData))]
public void DistributeNegativeMoneyProportionally(int first, int second, decimal expected1, decimal expected2)
{
var fiftyCents = Money.EUR(-0.5m);
var sum = fiftyCents.Add(fiftyCents);
var distribution = sum.Distribute(new[] { first, second });

var distributed = distribution.Select(e => e.Evaluate().Amount).ToList();
Assert.Equal(sum.Evaluate().Amount, distributed.Sum());

Assert.Collection(
distributed,
item => Assert.Equal(expected1 * -1, item),
item => Assert.Equal(expected2 * -1, item));
}

public static TheoryData<int, int, decimal, decimal> ProportionalDistributionData()
=> new()
{
Expand Down Expand Up @@ -163,10 +176,8 @@ public void WeCanDefineMoneyExpressionsWithOperators()
}

[Property]
public Property TheMoneyNeutralElementWorksWithAnyCurrency(decimal amount, Currency currency)
public Property TheMoneyNeutralElementWorksWithAnyCurrency(Money money)
{
var money = new Money(ValidAmount(amount, currency), currency);

return (money == (money + Money.Zero).Evaluate()
&& (money == (Money.Zero + money).Evaluate())).ToProperty().When(!money.IsZero);
}
Expand Down Expand Up @@ -217,11 +228,9 @@ public void CurrenciesWithoutFormatProviders()
}

[Property]
public Property WeCanParseTheStringsWeGenerate(decimal amount, Currency currency)
public Property WeCanParseTheStringsWeGenerate(Money money)
{
var money = new Money(decimal.Round(amount, currency.MinorUnitDigits), currency);

return Money.ParseOrNone(money.ToString(), currency).Match(false, m => m == money).ToProperty();
return Money.ParseOrNone(money.ToString(), money.Currency).Match(false, m => m == money).ToProperty();
}

[Fact]
Expand Down Expand Up @@ -258,10 +267,10 @@ public void TheRoundingStrategyIsCorrectlyPassedThrough()
.Default
.WithTargetCurrency(Currency.CHF);

var precision05 = new Money(1m, commonContext.WithRounding(RoundingStrategy.BankersRounding(SmallestCoin)).Build());
var precision05 = new Money(1m, commonContext.WithRounding(RoundingStrategy.BankersRounding(SwissMoney.SmallestCoin)).Build());
var precision002 = new Money(1m, commonContext.WithRounding(RoundingStrategy.BankersRounding(0.002m)).Build());

Assert.Equal(precision05.RoundingStrategy, precision05.Distribute(3, SmallestCoin).First().Evaluate().RoundingStrategy);
Assert.Equal(precision05.RoundingStrategy, precision05.Distribute(3, SwissMoney.SmallestCoin).First().Evaluate().RoundingStrategy);
Assert.Equal(precision002.RoundingStrategy, precision002.Distribute(3, 0.002m).First().Evaluate().RoundingStrategy);
}

Expand Down Expand Up @@ -463,19 +472,23 @@ public void RoundingStrategiesMustBeInitializedWithAValidPrecision()
Assert.Throws<InvalidPrecisionException>(() => _ = RoundingStrategy.RoundWithAwayFromZero(0.0m));
}

private static decimal ValidAmount(decimal amount, Currency currency)
=> decimal.Round(amount, currency.MinorUnitDigits);
private static List<decimal> Distributed(SwissMoney someMoney, int numberOfParts)
=> someMoney
.Get
.Distribute(numberOfParts)
.Select(e => e.Evaluate(SwissRounding).Amount)
.ToList();

private static bool TheIndividualPartsAreAtMostOneUnitApart(IEnumerable<decimal> distributed, decimal first)
=> distributed.All(AtMostOneUnitLess(first, SmallestCoin));
private static bool TheIndividualPartsAreAtMostOneUnitApart(IEnumerable<decimal> distributed)
=> distributed.All(AtMostOneDistributionUnitLess(distributed.First()));

private static Func<decimal, bool> AtMostOneUnitLess(decimal reference, decimal unit)
=> amount => amount == reference || amount == reference - unit;
private static Func<decimal, bool> AtMostOneDistributionUnitLess(decimal reference)
=> amount => Math.Abs(amount - reference) <= SwissMoney.SmallestCoin;

private static bool TheNumberOfPartsIsCorrect(PositiveInt numberOfParts, ICollection distributed)
=> distributed.Count == numberOfParts.Get;
private static bool TheNumberOfPartsIsCorrect(int numberOfParts, ICollection distributed)
=> distributed.Count == numberOfParts;

private static bool TheSumOfThePartsIsEqualToTheTotal(IEnumerable<decimal> distributed, decimal validAmount)
private static bool TheSumOfThePartsIsEqualToTheTotal(decimal validAmount, IEnumerable<decimal> distributed)
=> distributed.Sum() == validAmount;

private static IMoneyExpression ComplexExpression()
Expand Down
7 changes: 7 additions & 0 deletions Funcky.Money.Test/SwissMoney.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Funcky.Test
{
public record SwissMoney(Money Get)
{
public const decimal SmallestCoin = 0.05m;
}
}
21 changes: 17 additions & 4 deletions Funcky.Money/Distribution/DefaultDistributionStrategy.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Linq;
using Funcky.Extensions;
using Funcky.Monads;
Expand All @@ -23,15 +24,24 @@ private bool IsDistributable(MoneyDistributionPart part, Money money)
&& RoundingStrategy(money).IsSameAfterRounding(ToDistribute(part, money));

private decimal SliceAmount(MoneyDistributionPart part, Money money)
=> Slice(part.Distribution, part.Index, money) + DistributeRest(part, money);

private decimal DistributeRest(MoneyDistributionPart part, Money money)
=> part.Index switch
{
_ when Precision(part.Distribution, money) * (part.Index + 1) < ToDistribute(part, money) => Slice(part.Distribution, part.Index, money) + Precision(part.Distribution, money),
_ when Precision(part.Distribution, money) * part.Index < ToDistribute(part, money) => Slice(part.Distribution, part.Index, money) + ToDistribute(part, money) - AlreadyDistributed(part, money),
_ => Slice(part.Distribution, part.Index, money),
_ when AtLeastOneDistributionUnitLeft(part, money) => SignedPrecision(part.Distribution, money),
_ when BetweenZeroToOneDistributionUnitLeft(part, money) => ToDistribute(part, money) - AlreadyDistributed(part, money),
_ => 0.0m,
};

private bool AtLeastOneDistributionUnitLeft(MoneyDistributionPart part, Money money)
=> Precision(part.Distribution, money) * (part.Index + 1) < Math.Abs(ToDistribute(part, money));

private bool BetweenZeroToOneDistributionUnitLeft(MoneyDistributionPart part, Money money)
=> Precision(part.Distribution, money) * part.Index < Math.Abs(ToDistribute(part, money));

private decimal AlreadyDistributed(MoneyDistributionPart part, Money money)
=> Precision(part.Distribution, money) * part.Index;
=> SignedPrecision(part.Distribution, money) * part.Index;

private IRoundingStrategy RoundingStrategy(Money money)
=> _context.Match(
Expand All @@ -54,6 +64,9 @@ private decimal Slice(MoneyDistribution distribution, int index, Money money)
private static decimal ExactSlice(MoneyDistribution distribution, int index, Money money)
=> money.Amount / DistributionTotal(distribution) * distribution.Factors[index];

private decimal SignedPrecision(MoneyDistribution distribution, Money money)
=> Precision(distribution, money).CopySign(money.Amount);

// Order of evaluation: Distribution > Context Distribution > Context Currency > Money Currency
private decimal Precision(MoneyDistribution distribution, Money money)
=> distribution
Expand Down
12 changes: 12 additions & 0 deletions Funcky.Money/Extensions/SignExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Funcky.Extensions
{
internal static class SignExtension
{
public static decimal CopySign(this decimal positiveNumber, decimal signSource)
=> signSource switch
{
< 0 => -positiveNumber,
>= 0 => positiveNumber,
};
}
}
3 changes: 1 addition & 2 deletions Funcky.Money/Funcky.Money.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />
<RootNamespace>Funcky</RootNamespace>
<Product>Funcky.Money</Product>
<Description>Funcky.Money is the Money-Class you want to have.</Description>
<PackageTags>Functional Money</PackageTags>
<Version>1.0.0</Version>
<Version>1.0.1</Version>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
Expand Down

0 comments on commit 1bf9576

Please sign in to comment.