Add Schaad.Accounting.Tests (xUnit + NSubstitute + Shouldly, net9.0) wired into Accounting.sln with a direct Reference to Schaad.Finance.Api.dll so it flows into the test binary. Naming convention: file/class is <Subject>TestShould, each test method is DoThisWhenThat. Reads as a sentence: "ViewService test should sum activa and passiva totals separately when getting balance view". All assertions use Shouldly (.ShouldBe, .ShouldBeNull, .ShouldContain, ...) rather than xUnit Assert.*. 38 tests across seven files: - ViewServiceTestShould: balance math (activa/passiva totals, per- account balance from start balance + credits - debits, FX conversion to CHF) and bank-transaction auto-matching (booking-rule text, value-matching preference, same-accounts-last-month fallback, open-transaction filter). - TransactionRepositoryTestShould: FX round-trip against a temp XML directory, unknown-id -> null, and the mutation-on-read regression from PR A (a second Get on the same FX transaction used to divide by FxRate again). - FormattingTestShould: Swiss thousands separator, two-decimal rounding, culture independence. - RepositoryCacheTestShould: loader called once, per-key isolation, invalidation forces reload, case-insensitive keys. - AccountRepositoryTestShould: id assignment, in-place update, currency defaulting, delete, bank-account lookup, bank-balance update, and constructor re-run against an existing file. - ChartServiceTestShould: honours settingsService.GetYear() without mutating it (locks in PR E), account vs sub-class grouping heuristic, skips empty accounts. - FileServiceTestShould: CSV header + running balance for debit and credit lines, ordering by BookingDate/ValueDate/Value, no in-place value mutation (locks in PR A). CLAUDE.md gains a `dotnet test` line. IMPROVEMENT_PLAN.md notes that this landed as three PRs (G, H, I) and was squashed on request. Also add `*.DotSettings.user` to .gitignore so Rider's per-user solution settings don't get accidentally staged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
132 lines
4.9 KiB
C#
132 lines
4.9 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 void HonourSelectedYearWhenBuildingMonthlySeries()
|
|
{
|
|
settingsService.GetYear().Returns(2023);
|
|
var groceries = MakeAccount("groc", 4200);
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewList().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
|
|
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
|
|
});
|
|
|
|
var series = BuildService().GetExpensesPerMonth();
|
|
|
|
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 void GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
|
|
{
|
|
var mannenbach = MakeAccount("m1", 4000); // SubClass 40
|
|
var mannenbach2 = MakeAccount("m2", 4001); // SubClass 40 (same)
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
|
{
|
|
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
|
|
});
|
|
viewService.GetTransactionViewList().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 = BuildService().GetExpensesPerMonth();
|
|
|
|
series.Count.ShouldBe(2);
|
|
series.ShouldContain(s => s.Id == "m1");
|
|
series.ShouldContain(s => s.Id == "m2");
|
|
}
|
|
|
|
[Fact]
|
|
public void GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
|
|
{
|
|
var groceries = MakeAccount("groc", 4200); // SubClass 42
|
|
var utilities = MakeAccount("util", 4300); // SubClass 43
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
|
{
|
|
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
|
});
|
|
viewService.GetTransactionViewList().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 = BuildService().GetExpensesPerMonth();
|
|
|
|
series.Count.ShouldBe(2);
|
|
series.ShouldContain(s => s.Id == "42"); // sub-class number as id
|
|
series.ShouldContain(s => s.Id == "43");
|
|
}
|
|
|
|
[Fact]
|
|
public void SkipAccountsWithoutTransactionsWhenBuildingSeries()
|
|
{
|
|
var groceries = MakeAccount("groc", 4000); // has activity
|
|
var utilities = MakeAccount("util", 4001); // same subclass, no activity
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
|
{
|
|
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
|
});
|
|
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
|
|
});
|
|
|
|
var series = BuildService().GetExpensesPerMonth();
|
|
|
|
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);
|
|
}
|