AccountingNext/Schaad.Accounting.Tests/ChartServiceTestShould.cs
Claudio Schaad 5d6d49ed8b Group booking-text charts by first word instead of full text
Bookings like "Narbonne Car" and "Narbonne Food" should roll up into
one series/slice instead of staying separate, so grouping now uses
the text up to the first space via TransactionDataset.GetTextGroup().
2026-08-07 18:23:22 +02:00

317 lines
13 KiB
C#

using System.Linq;
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class ChartServiceTestShould
{
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
private readonly IViewService viewService = Substitute.For<IViewService>();
public ChartServiceTestShould()
{
settingsService.GetYear().Returns(2026);
}
private ChartService BuildService() => new(settingsService, viewService);
[Fact]
public async Task HonourSelectedYearWhenBuildingMonthlySeries()
{
settingsService.GetYear().Returns(2023);
var groceries = MakeAccount("groc", 4200);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(1);
series[0].X.ShouldAllBe(x => ((DateOnly)x).Year == 2023);
settingsService.DidNotReceive().SetYear(Arg.Any<int>());
settingsService.DidNotReceive().TrySetYear(Arg.Any<int>());
}
[Fact]
public async Task GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
{
var mannenbach = MakeAccount("m1", 4000);
var mannenbach2 = MakeAccount("m2", 4001);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, mannenbach, value: 10m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, mannenbach2, value: 20m, valueDate: new DateTime(2026, 1, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "m1");
series.ShouldContain(s => s.Id == "m2");
}
[Fact]
public async Task GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
{
var groceries = MakeAccount("groc", 4200);
var utilities = MakeAccount("util", 4300);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(groceries, subClassName: "Groceries"), AsDataset(utilities, subClassName: "Utilities"), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "42" && s.Name == "Groceries");
series.ShouldContain(s => s.Id == "43" && s.Name == "Utilities");
}
[Fact]
public async Task SkipAccountsWithoutTransactionsWhenBuildingSeries()
{
var groceries = MakeAccount("groc", 4000);
var utilities = MakeAccount("util", 4001);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(1);
series[0].Id.ShouldBe("groc");
}
private static Account MakeAccount(string id, int number, string currency = "CHF") => new()
{
Id = id,
Number = number,
Name = id,
Currency = currency
};
private static AccountDataset AsDataset(Account a, string subClassName = "") =>
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName);
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null, string text = "") =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
FxRate = fxRate,
Text = text,
ValueDate = valueDate,
BookingDate = valueDate
},
origin, target);
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingAccountSeries()
{
var usSubscription = MakeAccount("us", 4000, currency: "USD");
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(usSubscription), AsDataset(checking) });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m)
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Single().Y.Single().ShouldBe(200m);
}
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingSubClassSeries()
{
var usSubscription = MakeAccount("us", 4200, currency: "USD");
var utilities = MakeAccount("util", 4300);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(usSubscription), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m),
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Single(s => s.Id == "42").Y[0].ShouldBe(200m);
}
[Fact]
public async Task GroupBookingTextExpensesByTextWhenBuildingMonthlySeriesForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 3), text: "Coop")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Count.ShouldBe(2);
var migros = series.Single(s => s.Name == "Migros");
migros.Y[0].ShouldBe(-50m);
migros.Y[1].ShouldBe(0m);
var coop = series.Single(s => s.Name == "Coop");
coop.Y[0].ShouldBe(0m);
coop.Y[1].ShouldBe(-20m);
}
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingMonthlySeriesForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
var narbonne = series.Single(s => s.Name == "Narbonne");
narbonne.Y[0].ShouldBe(-70m);
}
[Fact]
public async Task ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction()
{
var checking = MakeAccount("chk", 1010);
var salary = MakeAccount("sal", 3000);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(salary, checking, value: 5000m, valueDate: new DateTime(2026, 1, 5), text: "Lohn")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(5000m);
}
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingBookingTextSeries()
{
var usChecking = MakeAccount("chk", 1010, currency: "USD");
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(usChecking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(usChecking, groceries, value: 100m, valueDate: new DateTime(2026, 1, 5), fxRate: 0.5m, text: "Migros")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(-200m);
}
[Fact]
public async Task SumBookingTextTotalsAcrossMonthsWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: 30m, valueDate: new DateTime(2026, 2, 3), text: "Migros"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 4), text: "Coop")
});
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
totals.Count.ShouldBe(2);
totals.ShouldContain(t => t.Name == "Migros" && t.Value == -80m);
totals.ShouldContain(t => t.Name == "Coop" && t.Value == -20m);
}
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Name.ShouldBe("Narbonne");
totals.Single().Value.ShouldBe(70m);
}
[Fact]
public async Task NetOutgoingAndIncomingBookingsWithTheSameTextWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(groceries, checking, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
totals.Single().Value.ShouldBe(-30m);
}
[Fact]
public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
totals.ShouldBeEmpty();
}
}