Every repository, service, Blazor page/dialog, and test now uses
async/await. Single atomic diff; the codebase does not compile in
intermediate states.
- BaseRepository: LoadListAsync/LoadAsync/SaveAsync return Task<T>;
per-file locks use SemaphoreSlim so waiters can await; Save
serialises to a MemoryStream sync (XmlSerializer has no async
form), then File.WriteAllBytesAsync + sync File.Move.
- RepositoryCache.GetOrLoadAsync takes a Func<Task<List<T>>>.
- All 7 repository interfaces + implementations async.
- All service interfaces + implementations async (except vendor
IFxService and stateless IFxConverter / SettingsService).
- Every Blazor OnInitializedAsync switches to await base.
- Test suite fully async, 42 tests pass.
AccountRepository.EnsureAccountsFile keeps two .GetAwaiter().GetResult()
bridges because it runs from the constructor.
Null-render guard follow-up (folded in):
Blazor now renders the component once with fields at their initial
values while OnInitializedAsync awaits — so fields declared `= null!`
are actually null on that first render and things like
`accounts.GroupBy(...)` throw ArgumentNullException. Fixed across
Transactions, BalanceReport, BalanceSheetReport, ProfitLossReport,
DetailReport, Assets, Spendings, SpendingsOverTime, TransactionDialog,
TransactionSplitDialog, and BookingRuleDialog:
- Collection fields initialise to [] so first-render loops are empty.
- Single-object data fields become nullable; the razor wraps
consumption in `@if (field is null) { <p>Lädt…</p> return; }`.
- <PlotlyChart> guarded behind a null check on config/layout/data so
Plotly.Blazor's @bind doesn't see nulls.
- Header/footer strings initialise to "" instead of null!.
Architectural hygiene on a single-user local Blazor Server app: the
observed win is one File.ReadAllBytesAsync and one
File.WriteAllBytesAsync per Load/Save, and after PR D each file is
loaded at most once per SignalR circuit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
132 lines
4.8 KiB
C#
132 lines
4.8 KiB
C#
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), AsDataset(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");
|
|
series.ShouldContain(s => s.Id == "43");
|
|
}
|
|
|
|
[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) => new()
|
|
{
|
|
Id = id,
|
|
Number = number,
|
|
Name = id,
|
|
Currency = "CHF"
|
|
};
|
|
|
|
private static AccountDataset AsDataset(Account a) =>
|
|
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: "");
|
|
|
|
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate) =>
|
|
new(
|
|
new Transaction
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
OriginAccountId = origin.Id,
|
|
TargetAccountId = target.Id,
|
|
Value = value,
|
|
ValueDate = valueDate,
|
|
BookingDate = valueDate
|
|
},
|
|
origin, target);
|
|
}
|