AccountingNext/Schaad.Accounting.Tests/ChartServiceTestShould.cs
Claudio Schaad 1dcccf89b1 Add booking-text charts to the transactions page
Adds two charts to the per-account transactions view: a stacked bar
chart of monthly totals and a pie chart of overall totals, both
grouped by booking text, so recurring bookings (rent, subscriptions,
etc.) are visible at a glance. Chart components are keyed on the
account id so switching accounts remounts them instead of reusing a
stale Plotly instance.
2026-07-22 13:57:28 +02:00

248 lines
10 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 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 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();
}
}