feat(usability): preserve reporting year and locate outside-period bookings (U04)

This commit is contained in:
2026-11-02 06:28:36 +01:00
parent 122d4de809
commit 9c41856b5b
16 changed files with 169 additions and 26 deletions
+4
View File
@@ -27,3 +27,7 @@ Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen
## U03 – Gleiche Bedeutung in PDF und Oberfläche ## U03 – Gleiche Bedeutung in PDF und Oberfläche
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft. Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
## U04 – Jahreskontext
Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück erhalten, auch „Alle Jahre“. Auswahl enthält aktuelles und gewähltes Jahr auch ohne Buchungen. Liste und Summen benennen den Zeitraum; Leerzustände unterscheiden fehlende Buchungen und leeren Zeitraum. Abweichendes Datum bleibt unverändert, erfordert eine gezielte Bestätigung im Formular und bietet nach Erfolg „Buchung im Jahr … anzeigen“. Prüfung: 21/21 Debug-Tests, einschließlich gerenderter Navigation 2025 → Konto → Zurück, Buchung 2026 bei Filter 2025 und anschließendem Aufruf des gespeicherten Jahres. Dabei bestehende doppelte DbContext-Registrierung korrigiert, da die Singleton-Factory sonst scoped Optionen konsumiert.
@@ -20,6 +20,6 @@
@if (!Entries.Any()) @if (!Entries.Any())
{ {
<div class="text-center py-3 text-muted">Keine Buchungen vorhanden.</div> <div class="text-center py-3 text-muted">@EmptyMessage</div>
} }
@@ -5,6 +5,7 @@ namespace Duempelkas.App.Components.Accounts;
public partial class EntryTable public partial class EntryTable
{ {
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
#region Parameters #region Parameters
[Parameter] [Parameter]
@@ -24,9 +24,17 @@
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" /> <input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
</div> </div>
@if (OutsideYear)
{
<div class="alert alert-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
</div>
}
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" <button class="btn btn-success" @onclick="Save"
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)"> disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0 || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))">
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen") <i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
</button> </button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
@@ -15,13 +15,18 @@ public partial class AddEntryDialog
public EntryDto? EditEntry { get; set; } public EntryDto? EditEntry { get; set; }
[Parameter] [Parameter]
public EventCallback OnSave { get; set; } public EventCallback<DateTime> OnSave { get; set; }
[Parameter] [Parameter]
public EventCallback OnCancel { get; set; } public EventCallback OnCancel { get; set; }
#endregion #endregion
[Parameter] public int? ContextYear { get; set; }
private bool confirmDifferentYear;
private int? confirmedYear;
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
#region Fields #region Fields
private EntryType entryType = EntryType.Income; private EntryType entryType = EntryType.Income;
@@ -50,6 +55,7 @@ public partial class AddEntryDialog
private async Task Save() private async Task Save()
{ {
if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
if (EditEntry != null) if (EditEntry != null)
{ {
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount); await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
@@ -59,7 +65,7 @@ public partial class AddEntryDialog
await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount); await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount);
} }
await OnSave.InvokeAsync(); await OnSave.InvokeAsync(date);
} }
private async Task Cancel() => await OnCancel.InvokeAsync(); private async Task Cancel() => await OnCancel.InvokeAsync();
@@ -25,9 +25,17 @@
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" /> <input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
</div> </div>
@if (OutsideYear)
{
<div class="alert alert-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
</div>
}
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" <button class="btn btn-success" @onclick="Save"
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button> disabled="@(!CanSave || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</div> </div>
@@ -14,13 +14,18 @@ public partial class AddTransferDialog
public EntryDto? EditEntry { get; set; } public EntryDto? EditEntry { get; set; }
[Parameter] [Parameter]
public EventCallback OnSave { get; set; } public EventCallback<DateTime> OnSave { get; set; }
[Parameter] [Parameter]
public EventCallback OnCancel { get; set; } public EventCallback OnCancel { get; set; }
#endregion #endregion
[Parameter] public int? ContextYear { get; set; }
private bool confirmDifferentYear;
private int? confirmedYear;
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
#region Fields #region Fields
private List<AccountSummaryDto> accounts = new(); private List<AccountSummaryDto> accounts = new();
@@ -61,6 +66,7 @@ public partial class AddTransferDialog
private async Task Save() private async Task Save()
{ {
if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
if (EditEntry != null) if (EditEntry != null)
{ {
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount); await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
@@ -70,7 +76,7 @@ public partial class AddTransferDialog
await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount); await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
} }
await OnSave.InvokeAsync(); await OnSave.InvokeAsync(date);
} }
private async Task Cancel() => await OnCancel.InvokeAsync(); private async Task Cancel() => await OnCancel.InvokeAsync();
@@ -1,3 +1,4 @@
@inject ReportingPeriod Period
@page "/accounts/{AccountId:int}" @page "/accounts/{AccountId:int}"
@inject IAccountService AccountService @inject IAccountService AccountService
@inject IEntryService EntryService @inject IEntryService EntryService
@@ -10,6 +11,13 @@
<OperationNotice Result="fileResult" /> <OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> } @if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
@if (savedOutsideYear.HasValue)
{
<div class="alert alert-info" role="status">
Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
<button class="btn btn-outline-primary" @onclick="ShowSavedYear">Buchung im Jahr @savedOutsideYear anzeigen</button>
</div>
}
@if (account == null) @if (account == null)
{ {
<div class="text-center py-5"> <div class="text-center py-5">
@@ -43,10 +51,11 @@
</button> </button>
| |
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog"> <button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel() <i class="bi bi-funnel"></i> @GetFilterLabel()
</button> </button>
</div> </div>
<p class="text-muted">Zeitraum für Liste und Summen: @Period.Label</p>
@if (balance != null) @if (balance != null)
{ {
<div class="summary-section"> <div class="summary-section">
@@ -96,7 +105,7 @@
@if (entries != null) @if (entries != null)
{ {
<div class="entries-scroll-wrapper"> <div class="entries-scroll-wrapper">
<EntryTable Entries="entries" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" /> <EntryTable Entries="entries" EmptyMessage="emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
</div> </div>
} }
} }
@@ -104,14 +113,14 @@
@if (showAddEntry) @if (showAddEntry)
{ {
<AddEntryDialog AccountId="AccountId" <AddEntryDialog AccountId="AccountId" ContextYear="selectedYear"
OnSave="HandleEntryCreated" OnSave="HandleEntryCreated"
OnCancel="() => showAddEntry = false" /> OnCancel="() => showAddEntry = false" />
} }
@if (showAddTransfer) @if (showAddTransfer)
{ {
<AddTransferDialog SourceAccountId="AccountId" <AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
OnSave="HandleTransferCreated" OnSave="HandleTransferCreated"
OnCancel="() => showAddTransfer = false" /> OnCancel="() => showAddTransfer = false" />
} }
@@ -168,7 +177,7 @@
@if (editingEntry != null) @if (editingEntry != null)
{ {
<AddEntryDialog AccountId="AccountId" <AddEntryDialog AccountId="AccountId" ContextYear="selectedYear"
EditEntry="editingEntry" EditEntry="editingEntry"
OnSave="HandleEntryEdited" OnSave="HandleEntryEdited"
OnCancel="() => editingEntry = null" /> OnCancel="() => editingEntry = null" />
@@ -176,7 +185,7 @@
@if (editingTransferEntry != null) @if (editingTransferEntry != null)
{ {
<AddTransferDialog SourceAccountId="AccountId" <AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
EditEntry="editingTransferEntry" EditEntry="editingTransferEntry"
OnSave="HandleEntryEdited" OnSave="HandleEntryEdited"
OnCancel="() => editingTransferEntry = null" /> OnCancel="() => editingTransferEntry = null" />
@@ -22,7 +22,7 @@ public partial class AccountDetail
private bool showEditName; private bool showEditName;
private bool showEditCarryover; private bool showEditCarryover;
private bool showYearFilterDialog; private bool showYearFilterDialog;
private int? selectedYear = DateTime.Now.Year; private int? selectedYear { get => Period.Year; set => Period.Year = value; }
private List<int> availableYears = []; private List<int> availableYears = [];
private int? confirmDeleteEntryId; private int? confirmDeleteEntryId;
@@ -35,6 +35,8 @@ public partial class AccountDetail
private FileOperationResult? fileResult; private FileOperationResult? fileResult;
private bool fileBusy; private bool fileBusy;
private int? savedOutsideYear;
private string emptyEntriesMessage = "Noch keine Buchungen vorhanden.";
private EntryDto? editingEntry; private EntryDto? editingEntry;
private EntryDto? editingTransferEntry; private EntryDto? editingTransferEntry;
@@ -62,7 +64,9 @@ public partial class AccountDetail
account = await AccountService.GetAccountAsync(AccountId); account = await AccountService.GetAccountAsync(AccountId);
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear); balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
entries = await EntryService.GetEntriesAsync(AccountId, selectedYear); entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
availableYears = await EntryService.GetEntryYearsAsync(AccountId); var entryYears = await EntryService.GetEntryYearsAsync(AccountId);
availableYears = Period.Choices(entryYears);
emptyEntriesMessage = entryYears.Count == 0 ? "Noch keine Buchungen vorhanden." : "Keine Buchungen in diesem Zeitraum. Wähle ein anderes Jahr oder Alle Jahre.";
} }
private void OpenYearFilterDialog() private void OpenYearFilterDialog()
@@ -78,6 +82,7 @@ public partial class AccountDetail
private async Task SelectFilterYear(int? year) private async Task SelectFilterYear(int? year)
{ {
selectedYear = year; selectedYear = year;
savedOutsideYear = null;
showYearFilterDialog = false; showYearFilterDialog = false;
await LoadAll(); await LoadAll();
} }
@@ -100,18 +105,22 @@ public partial class AccountDetail
await LoadAll(); await LoadAll();
} }
private async Task HandleEntryCreated() private async Task HandleEntryCreated(DateTime savedDate)
{ {
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
showAddEntry = false; showAddEntry = false;
await LoadAll(); await LoadAll();
} }
private async Task HandleTransferCreated() private async Task HandleTransferCreated(DateTime savedDate)
{ {
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
showAddTransfer = false; showAddTransfer = false;
await LoadAll(); await LoadAll();
} }
private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
private void RequestDeleteEntry(int entryId) private void RequestDeleteEntry(int entryId)
{ {
confirmDeleteEntryId = entryId; confirmDeleteEntryId = entryId;
@@ -171,8 +180,9 @@ public partial class AccountDetail
} }
} }
private async Task HandleEntryEdited() private async Task HandleEntryEdited(DateTime savedDate)
{ {
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
editingEntry = null; editingEntry = null;
editingTransferEntry = null; editingTransferEntry = null;
await LoadAll(); await LoadAll();
@@ -226,7 +236,7 @@ public partial class AccountDetail
private static string FormatAmount(decimal amount) => $"{amount:N2} €"; private static string FormatAmount(decimal amount) => $"{amount:N2} €";
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle"; private string GetFilterLabel() => Period.Label;
#endregion #endregion
} }
+3 -2
View File
@@ -1,3 +1,4 @@
@inject ReportingPeriod Period
@page "/" @page "/"
@inject IAccountService AccountService @inject IAccountService AccountService
@inject IBackupService BackupService @inject IBackupService BackupService
@@ -40,7 +41,7 @@
</button> </button>
| |
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog"> <button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel() <i class="bi bi-funnel"></i> @GetFilterLabel()
</button> </button>
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln"> <button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel <i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
@@ -50,7 +51,7 @@
@if (accounts != null && accounts.Any()) @if (accounts != null && accounts.Any())
{ {
<div class="alert alert-secondary py-2 px-3 mb-3" role="status"> <div class="alert alert-secondary py-2 px-3 mb-3" role="status">
<strong>Summe aller Konten:</strong> <strong>Summe aller Konten – @Period.Label:</strong>
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")"> <span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
@FormatCurrency(TotalClubBalance) @FormatCurrency(TotalClubBalance)
</span> </span>
+3 -3
View File
@@ -14,7 +14,7 @@ public partial class Dashboard
private bool showEditClubName; private bool showEditClubName;
private bool showRestoreConfirm; private bool showRestoreConfirm;
private bool showYearFilterDialog; private bool showYearFilterDialog;
private int? selectedYear = DateTime.Now.Year; private int? selectedYear { get => Period.Year; set => Period.Year = value; }
private List<int> availableYears = []; private List<int> availableYears = [];
private string clubName = string.Empty; private string clubName = string.Empty;
private string? savedPdfPath; private string? savedPdfPath;
@@ -67,7 +67,7 @@ public partial class Dashboard
private async Task LoadAll() private async Task LoadAll()
{ {
await LoadAccounts(); await LoadAccounts();
availableYears = await EntryService.GetAllEntryYearsAsync(); availableYears = Period.Choices(await EntryService.GetAllEntryYearsAsync());
} }
private async Task LoadAccounts() private async Task LoadAccounts()
@@ -207,7 +207,7 @@ public partial class Dashboard
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €"; return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
} }
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle"; private string GetFilterLabel() => Period.Label;
private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel"; private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel";
@@ -0,0 +1,11 @@
namespace Duempelkas.App.Services;
/// <summary>The user's explicit selection survives navigation, including All years.</summary>
public sealed class ReportingPeriod
{
public int? Year { get; set; } = DateTime.Today.Year;
public string Label => Year.HasValue ? $"Jahr: {Year}" : "Alle Jahre";
public List<int> Choices(IEnumerable<int> entryYears) => entryYears
.Append(DateTime.Today.Year).Concat(Year.HasValue ? [Year.Value] : Array.Empty<int>())
.Distinct().OrderDescending().ToList();
}
@@ -11,8 +11,8 @@ public static class DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString) public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{ {
services.AddSingleton(new DatabaseLocation(connectionString)); services.AddSingleton(new DatabaseLocation(connectionString));
services.AddDbContext<FinanceDbContext>(options => // The factory also registers FinanceDbContext as scoped. Registering
options.UseSqlite(connectionString)); // AddDbContext first would give its singleton factory scoped options.
services.AddDbContextFactory<FinanceDbContext>(options => services.AddDbContextFactory<FinanceDbContext>(options =>
options.UseSqlite(connectionString)); options.UseSqlite(connectionString));
@@ -24,6 +24,7 @@ public static class DependencyInjection
services.AddScoped<IFileSaveService, FileSaveService>(); services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>(); services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>(); services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<ReportingPeriod>();
return services; return services;
} }
@@ -8,6 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="bunit" Version="2.13.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="PdfPig" Version="0.1.16" /> <PackageReference Include="PdfPig" Version="0.1.16" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
@@ -0,0 +1,19 @@
using Duempelkas.App.Services;
using Xunit;
namespace Duempelkas.Tests;
public class ReportingPeriodTests
{
[Fact]
public void EmptyYearList_PreservesSelectedYear_AndOffersCurrentYear()
{
var period = new ReportingPeriod { Year = 2025 };
Assert.Contains(2025, period.Choices([]));
Assert.Contains(DateTime.Today.Year, period.Choices([]));
Assert.Equal("Jahr: 2025", period.Label);
period.Year = null;
Assert.Equal("Alle Jahre", period.Label);
Assert.Null(period.Year);
}
}
@@ -0,0 +1,58 @@
using Bunit;
using Duempelkas.App.Components;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure;
using Duempelkas.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class YearNavigationTests
{
[Fact]
public async Task Navigation_PreservesYear_AndOutsideBookingOffersReturn()
{
using var context = new BunitContext();
var connectionString = $"Data Source=year-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
using var anchor = new SqliteConnection(connectionString);
anchor.Open();
context.Services.AddInfrastructure(connectionString);
context.JSInterop.Mode = JSRuntimeMode.Loose;
using (var db = context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext())
{
db.Database.EnsureCreated();
db.Accounts.Add(new() { Name = "Testkasse" });
db.SaveChanges();
await context.Services.GetRequiredService<IEntryService>().CreateEntryAsync(db.Accounts.Single().Id,
EntryType.Income, new(2025, 1, 1), "Vorjahr", 10m);
}
context.Services.GetRequiredService<ReportingPeriod>().Year = 2025;
var page = context.Render<Routes>();
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
page.Find(".account-card").Click();
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
page.Find(".account-card").Click();
page.WaitForElement(".summary-section");
page.FindAll("button").Single(b => b.TextContent.Trim() == "Buchung").Click();
var dialog = page.FindComponent<AddEntryDialog>();
dialog.Find("input[type=date]").Change("2026-01-02");
dialog.Find("input[type=text]").Change("Außerhalb");
dialog.Find("input[type=number]").Change("12.50");
Assert.Contains("außerhalb", dialog.Markup);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("input[type=checkbox]").Change(true);
dialog.Find("button.btn-success").Click();
page.WaitForAssertion(() => Assert.Contains("Buchung im Jahr 2026 anzeigen", page.Markup));
Assert.DoesNotContain("Außerhalb", page.Find("table").TextContent);
page.FindAll("button").Single(b => b.TextContent.Contains("Buchung im Jahr 2026 anzeigen")).Click();
page.WaitForAssertion(() => Assert.Contains("Außerhalb", page.Find("table").TextContent));
Assert.Equal(2026, context.Services.GetRequiredService<ReportingPeriod>().Year);
}
}