From 22e509d9827807105cbbf5d90d080bfa687e3f9a Mon Sep 17 00:00:00 2001 From: troogs Date: Mon, 2 Nov 2026 06:38:42 +0100 Subject: [PATCH] feat(usability): confirm saves and guard booking submissions against duplicates (U07) --- docs/usability-umsetzung.md | 10 ++ .../Components/Accounts/EntryRow.razor.cs | 3 + .../Components/Accounts/EntryTable.razor | 2 +- .../Components/Accounts/EntryTable.razor.cs | 1 + .../Components/Dialogs/AddEntryDialog.razor | 101 +++++++------ .../Dialogs/AddEntryDialog.razor.cs | 44 ++++-- .../Dialogs/AddTransferDialog.razor | 72 +++++---- .../Dialogs/AddTransferDialog.razor.cs | 44 ++++-- .../Pages/Accounts/AccountDetail.razor | 12 +- .../Pages/Accounts/AccountDetail.razor.cs | 30 ++-- src/Duempelkas.App/Services/IEntryService.cs | 2 +- .../Services/Models/BookingSaved.cs | 3 + src/Duempelkas.Desktop/Program.cs | 2 +- src/Duempelkas.Desktop/wwwroot/css/app.css | 10 ++ .../Services/EntryService.cs | 3 +- tests/Duempelkas.Tests/BookingSaveTests.cs | 143 ++++++++++++++++++ tests/Duempelkas.Tests/YearNavigationTests.cs | 9 +- 17 files changed, 373 insertions(+), 118 deletions(-) create mode 100644 src/Duempelkas.App/Services/Models/BookingSaved.cs create mode 100644 tests/Duempelkas.Tests/BookingSaveTests.cs diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index 8ffa232..2b3c154 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -39,3 +39,13 @@ Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung ## U06 – Verständliche Eingabe Konto im Dialogtitel, sichtbare Art beim Bearbeiten und erklärter Korrekturweg: abbrechen, falsche Buchung löschen, neu erfassen. Keine Änderung der Buchungsart bestehender Daten. Feldnahe, technisch zugeordnete Hinweise für Beschreibung, Datum und Betrag. Komma/Punkt sowie eingefügte einfache Beträge mit Rand-Leerzeichen werden akzeptiert; Minus, Tausendertrenner, Währungszeichen, Überlauf und mehr als zwei Nachkommastellen abgewiesen, ohne Rundung. Dienste prüfen dieselben fachlichen Mindestregeln. Nach erneutem Rendern bleiben geänderte Eingaben erhalten. Prüfung: 35/35 Debug-Tests, darunter Parsing-Grenzfälle und gerenderte Formularprüfung mit Korrektur ungültiger Eingaben. Längere Buchungsdialoge können innerhalb des Fensters scrollen. + +## U07 – Speichern und Mehrfachauslösung + +Beide Buchungsdialoge sperren Formular und Aktionen während des Speicherns und zeigen „Wird gespeichert …“. Submit (einschließlich Enter) verwendet denselben geschützten Ablauf. Nach Erfolg meldet die Kontoseite Art und Betrag und hebt die betroffene Zeile kurz hervor. Transfererfassung liefert die eindeutige Quellbuchungs-ID zurück. Fehler halten Eingaben im geöffneten Dialog; ein fehlgeschlagener Seitenrefresh nach bereits bestätigtem Schreiben kann ausschließlich den Refresh wiederholen. Eine bewusste weitere Erfassung bleibt möglich. Optionale Serienerfassung wurde nicht hinzugefügt. + +Prüfung: 40/40 Tests in Debug und Release. Verzögerte normale Buchung und Umbuchung mit mehrfachen Submit-Ereignissen erzeugen jeweils genau eine fachliche Buchung; zweite bewusste Erfassung funktioniert. Simulierte Fehler behalten Beschreibung und Dezimaltext, erzeugen keine falsche Erfolgsmeldung und erlauben Retry. Gesonderter Test prüft Refreshfehler nach Commit ohne erneutes Schreiben. Jahresnavigation einschließlich „Alle Jahre“ ist ebenfalls gerendert geprüft. + +## Abschluss und Grenzen + +Desktop-Projekte in Debug und Release gebaut, jeweils ohne Warnungen und Fehler. PDF-Inhalte und gerenderte Seiten sind geprüft. Die neue Windows-Debug-Anwendung wurde gestartet und ihr Accessibility-Baum mit den neuen Beschriftungen gelesen. **Die native visuelle/Interaktionsprüfung konnte nicht abgeschlossen werden: Windows war gesperrt; die Aufnahme zeigte den Sperrbildschirm.** Es wurden dort keine Buchungen angelegt oder Sicherungen zurückgespielt. Desktop-Sichtprüfung der umbrechenden Leisten, native Dateidialoge und reale Hilfstechnologien bleiben manuelle Abnahmepunkte. Die automatisierten Formular-/Navigationsprüfungen ersetzen keinen Test mit der Zielgruppe und keine vollständige U08-/U13-Abnahme. diff --git a/src/Duempelkas.App/Components/Accounts/EntryRow.razor.cs b/src/Duempelkas.App/Components/Accounts/EntryRow.razor.cs index a21d523..6c14805 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryRow.razor.cs +++ b/src/Duempelkas.App/Components/Accounts/EntryRow.razor.cs @@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts; public partial class EntryRow { + [Parameter] public bool Highlighted { get; set; } + #region Parameters [Parameter] @@ -26,6 +28,7 @@ public partial class EntryRow private string GetRowClass() { var classes = new List(); + if (Highlighted) classes.Add("entry-recent"); if (Entry.IsTransfer) { classes.Add("entry-row-transfer"); diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor b/src/Duempelkas.App/Components/Accounts/EntryTable.razor index ed26cd5..d8b71f0 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor +++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor @@ -12,7 +12,7 @@ @foreach (var entry in Entries) { - + } diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs index cd18e92..04503b2 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs +++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs @@ -5,6 +5,7 @@ namespace Duempelkas.App.Components.Accounts; public partial class EntryTable { + [Parameter] public int? HighlightedEntryId { get; set; } [Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden."; #region Parameters diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor index d6b7d19..576ea25 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor @@ -4,54 +4,65 @@ - - @if (OutsideYear) - { -
- Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt. - + + @if (isSaving) {

Wird gespeichert …

} + @if (saveError != null) + { + + } +
+ +
- } - -
- - -
+
diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs index dfb4989..81094d5 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs @@ -14,7 +14,7 @@ public partial class AddTransferDialog public EntryDto? EditEntry { get; set; } [Parameter] - public EventCallback OnSave { get; set; } + public EventCallback OnSave { get; set; } [Parameter] public EventCallback OnCancel { get; set; } @@ -26,6 +26,12 @@ public partial class AddTransferDialog private int? confirmedYear; private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value; + private bool isSaving; + private bool completed; + private BookingSaved? committedResult; + private string? saveError; + private string? saveDetails; + #region Fields private List accounts = new(); @@ -74,20 +80,40 @@ public partial class AddTransferDialog private async Task Save() { - if (!CanSave) return; - if (EditEntry != null) + if (isSaving || completed || (committedResult == null && !CanSave)) return; + isSaving = true; + saveError = null; + saveDetails = null; + try { - await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount); + if (committedResult == null) + { + var id = EditEntry?.Id ?? 0; + if (EditEntry != null) + await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount); + else + id = await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount); + committedResult = new(id, date, "Umbuchung", amount, EditEntry != null); + } + // If refreshing the page fails after commit, retry only notification, + // never create a second booking. + await OnSave.InvokeAsync(committedResult); + completed = true; } - else + catch (Exception ex) { - await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount); + saveError = committedResult == null + ? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen." + : "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt."; + saveDetails = ex.Message; } - - await OnSave.InvokeAsync(date); + finally { isSaving = false; } } - private async Task Cancel() => await OnCancel.InvokeAsync(); + private async Task Cancel() + { + if (!isSaving) await OnCancel.InvokeAsync(); + } #endregion } diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index 34bcd11..5bb215a 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -11,6 +11,12 @@ @if (fileBusy) {

Dateiaktion läuft … Bitte warten.

} + @if (lastSaved != null) + { +
+ @lastSaved.Kind über @FormatAmount(lastSaved.Amount) @(lastSaved.Edited ? "aktualisiert" : "gespeichert"). +
+ } @if (savedOutsideYear.HasValue) {
@@ -39,7 +45,7 @@ - -
@@ -103,7 +109,7 @@ @if (entries != null) {
- +
} } diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs index e28db1f..be18c7d 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs @@ -36,6 +36,7 @@ public partial class AccountDetail private bool fileBusy; private int? savedOutsideYear; + private BookingSaved? lastSaved; private string emptyEntriesMessage = "Noch keine Buchungen vorhanden."; private EntryDto? editingEntry; private EntryDto? editingTransferEntry; @@ -105,18 +106,19 @@ public partial class AccountDetail await LoadAll(); } - private async Task HandleEntryCreated(DateTime savedDate) - { - savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null; - showAddEntry = false; - await LoadAll(); - } + private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved); - private async Task HandleTransferCreated(DateTime savedDate) + private Task HandleTransferCreated(BookingSaved saved) => HandleBookingSaved(saved); + + private async Task HandleBookingSaved(BookingSaved saved) { - savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null; - showAddTransfer = false; await LoadAll(); + lastSaved = saved; + savedOutsideYear = selectedYear.HasValue && selectedYear.Value != saved.Date.Year ? saved.Date.Year : null; + showAddEntry = false; + showAddTransfer = false; + editingEntry = null; + editingTransferEntry = null; } private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear); @@ -180,13 +182,7 @@ public partial class AccountDetail } } - private async Task HandleEntryEdited(DateTime savedDate) - { - savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null; - editingEntry = null; - editingTransferEntry = null; - await LoadAll(); - } + private Task HandleEntryEdited(BookingSaved saved) => HandleBookingSaved(saved); private async Task HandleExport() { @@ -234,7 +230,7 @@ public partial class AccountDetail #region Helpers - private static string FormatAmount(decimal amount) => $"{amount:N2} €"; + private static string FormatAmount(decimal amount) => amount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €"; private string GetFilterLabel() => Period.Label; diff --git a/src/Duempelkas.App/Services/IEntryService.cs b/src/Duempelkas.App/Services/IEntryService.cs index ba34859..543191d 100644 --- a/src/Duempelkas.App/Services/IEntryService.cs +++ b/src/Duempelkas.App/Services/IEntryService.cs @@ -10,7 +10,7 @@ public interface IEntryService Task> GetEntryYearsAsync(int accountId); Task> GetAllEntryYearsAsync(); Task CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount); - Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount); + Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount); Task DeleteEntryAsync(int entryId); Task RestoreEntryAsync(int entryId); Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount); diff --git a/src/Duempelkas.App/Services/Models/BookingSaved.cs b/src/Duempelkas.App/Services/Models/BookingSaved.cs new file mode 100644 index 0000000..1793166 --- /dev/null +++ b/src/Duempelkas.App/Services/Models/BookingSaved.cs @@ -0,0 +1,3 @@ +namespace Duempelkas.App.Services.Models; + +public sealed record BookingSaved(int EntryId, DateTime Date, string Kind, decimal Amount, bool Edited); diff --git a/src/Duempelkas.Desktop/Program.cs b/src/Duempelkas.Desktop/Program.cs index 695f454..0b5559b 100644 --- a/src/Duempelkas.Desktop/Program.cs +++ b/src/Duempelkas.Desktop/Program.cs @@ -46,7 +46,7 @@ class Program } catch (Exception ex) { - throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht ?bernommen werden. Der Start wurde abgebrochen; die urspr?ngliche Datei bleibt erhalten.", ex); + throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht übernommen werden. Der Start wurde abgebrochen; die ursprüngliche Datei bleibt erhalten.", ex); } } diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css index caae055..e336c91 100644 --- a/src/Duempelkas.Desktop/wwwroot/css/app.css +++ b/src/Duempelkas.Desktop/wwwroot/css/app.css @@ -602,3 +602,13 @@ input[type="number"].no-spinner { .form-container p { margin: .35rem 0 0; font-size: .9rem; } .booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; } + +fieldset { min-width: 0; } +.entry-recent > td { animation: saved-booking 6s ease-out; } +@keyframes saved-booking { + from { background-color: var(--color-surface-hover); box-shadow: inset 0 2px var(--color-accent), inset 0 -2px var(--color-accent); } + to { background-color: transparent; box-shadow: none; } +} +@media (prefers-reduced-motion: reduce) { + .entry-recent > td { animation: none; border-bottom: 2px solid var(--color-accent); } +} diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs index 7c8eb6c..1b055bc 100644 --- a/src/Duempelkas.Infrastructure/Services/EntryService.cs +++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs @@ -121,7 +121,7 @@ public class EntryService : IEntryService return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null); } - public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount) + public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount) { BookingInput.Validate(date, title, amount); if (sourceAccountId == targetAccountId) @@ -173,6 +173,7 @@ public class EntryService : IEntryService await db.SaveChangesAsync(); await transaction.CommitAsync(); + return sourceEntry.Id; } public async Task DeleteEntryAsync(int entryId) diff --git a/tests/Duempelkas.Tests/BookingSaveTests.cs b/tests/Duempelkas.Tests/BookingSaveTests.cs new file mode 100644 index 0000000..fd802cc --- /dev/null +++ b/tests/Duempelkas.Tests/BookingSaveTests.cs @@ -0,0 +1,143 @@ +using Bunit; +using Duempelkas.App.Components; +using Duempelkas.App.Components.Dialogs; +using Duempelkas.App.Services; +using Duempelkas.App.Services.Models; +using Duempelkas.Domain.Enums; +using Duempelkas.Infrastructure; +using Duempelkas.Infrastructure.Persistence; +using Duempelkas.Infrastructure.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Duempelkas.Tests; + +public class BookingSaveTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RepeatedSubmission_StoresOnce_ThenAllowsDeliberateNewBooking(bool transfer) + { + using var fixture = new Fixture(); + var page = fixture.Context.Render(); + page.WaitForElement(".account-card").Click(); + page.WaitForElement(".summary-section"); + OpenAndFill(page, transfer); + var delayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Entries.BeforeWrite = () => delayed.Task; + var pending = page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + page.WaitForAssertion(() => Assert.Contains("Wird gespeichert", page.Markup)); + Assert.True(page.Find("button[type=submit]").HasAttribute("disabled")); + // Directly deliver repeated submit events, including ones a disabled UI would suppress. + await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + Assert.Equal(1, fixture.Entries.WriteCalls); + delayed.SetResult(); + await pending; + page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".alert-success").TextContent)); + Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null)); + Assert.Single(page.FindAll("tr.entry-recent")); + if (transfer) Assert.Single(await fixture.Entries.GetEntriesAsync(2, (int?)null)); + + fixture.Entries.BeforeWrite = () => Task.CompletedTask; + OpenAndFill(page, transfer); + await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + page.WaitForAssertion(() => Assert.Empty(page.FindAll("form"))); + Assert.Equal(2, (await fixture.Entries.GetEntriesAsync(1, (int?)null)).Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failure_PreservesInputs_AndRetryWorks(bool transfer) + { + using var fixture = new Fixture(); + var page = fixture.Context.Render(); + page.WaitForElement(".account-card").Click(); + page.WaitForElement(".summary-section"); + OpenAndFill(page, transfer); + fixture.Entries.BeforeWrite = () => Task.FromException(new IOException("Simulierter Speicherfehler")); + await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + Assert.Contains("Eingaben bleiben erhalten", page.Markup); + Assert.Empty(page.FindAll(".alert-success")); + Assert.Equal("Testbuchung", page.Find(transfer ? "#transfer-title" : "#entry-title").GetAttribute("value")); + Assert.Equal("12,50", page.Find(transfer ? "#transfer-amount" : "#entry-amount").GetAttribute("value")); + Assert.Empty(await fixture.Entries.GetEntriesAsync(1, (int?)null)); + fixture.Entries.BeforeWrite = () => Task.CompletedTask; + await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Markup)); + Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null)); + } + + [Fact] + public async Task NotificationFailureAfterCommit_RetriesRefreshWithoutWritingAgain() + { + using var fixture = new Fixture(); + var notifications = 0; + var dialog = fixture.Context.Render(p => p + .Add(x => x.AccountId, 1) + .Add(x => x.OnSave, (BookingSaved _) => + { + if (++notifications == 1) throw new IOException("Refresh fehlgeschlagen"); + })); + dialog.Find("#entry-title").Input("Testbuchung"); + dialog.Find("#entry-amount").Input("12,50"); + await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + Assert.Contains("Die Buchung ist gespeichert", dialog.Markup); + await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); + Assert.Equal(1, fixture.Entries.WriteCalls); + Assert.Equal(2, notifications); + Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null)); + } + + private static void OpenAndFill(IRenderedComponent page, bool transfer) + { + page.FindAll("button").Single(b => b.TextContent.Contains(transfer ? "Geld zwischen Konten" : "Einnahme oder Ausgabe")).Click(); + if (transfer) page.Find("#transfer-account").Change("2"); + page.Find(transfer ? "#transfer-title" : "#entry-title").Input("Testbuchung"); + page.Find(transfer ? "#transfer-amount" : "#entry-amount").Input("12,50"); + } + + private sealed class Fixture : IDisposable + { + public BunitContext Context { get; } = new(); + public ControlledEntries Entries { get; } + private readonly SqliteConnection anchor; + public Fixture() + { + var connection = $"Data Source=save-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + anchor = new SqliteConnection(connection); + anchor.Open(); + Context.Services.AddInfrastructure(connection); + Context.Services.AddSingleton(sp => new ControlledEntries(new EntryService(sp.GetRequiredService>()))); + Context.JSInterop.Mode = JSRuntimeMode.Loose; + using var db = Context.Services.GetRequiredService>().CreateDbContext(); + db.Database.EnsureCreated(); + db.Accounts.AddRange(new() { Name = "A-Testkasse" }, new() { Name = "B-Testbank" }); + db.SaveChanges(); + Entries = (ControlledEntries)Context.Services.GetRequiredService(); + } + public void Dispose() { Context.Dispose(); anchor.Dispose(); } + } + + private sealed class ControlledEntries(IEntryService inner) : IEntryService + { + public int WriteCalls; + public Func BeforeWrite = () => Task.CompletedTask; + public async Task CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount) + { WriteCalls++; await BeforeWrite(); return await inner.CreateEntryAsync(accountId, type, date, title, amount); } + public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount) + { WriteCalls++; await BeforeWrite(); return await inner.CreateTransferAsync(sourceAccountId, targetAccountId, date, title, amount); } + public Task> GetEntriesAsync(int accountId, int? year) => inner.GetEntriesAsync(accountId, year); + public Task> GetEntriesAsync(int accountId, bool currentYearOnly) => inner.GetEntriesAsync(accountId, currentYearOnly); + public Task> GetEntryYearsAsync(int accountId) => inner.GetEntryYearsAsync(accountId); + public Task> GetAllEntryYearsAsync() => inner.GetAllEntryYearsAsync(); + public Task DeleteEntryAsync(int entryId) => inner.DeleteEntryAsync(entryId); + public Task RestoreEntryAsync(int entryId) => inner.RestoreEntryAsync(entryId); + public Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) => inner.UpdateEntryAsync(entryId, date, title, amount); + public Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) => inner.UpdateTransferAsync(entryId, newLinkedAccountId, date, title, amount); + } +} diff --git a/tests/Duempelkas.Tests/YearNavigationTests.cs b/tests/Duempelkas.Tests/YearNavigationTests.cs index bea81f8..1f1df70 100644 --- a/tests/Duempelkas.Tests/YearNavigationTests.cs +++ b/tests/Duempelkas.Tests/YearNavigationTests.cs @@ -48,11 +48,18 @@ public class YearNavigationTests 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(); + dialog.Find("form").Submit(); 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().Year); + page.FindAll("button").Single(b => b.TextContent.Contains("Jahr: 2026")).Click(); + page.FindAll("button").Single(b => b.TextContent.Trim() == "Alle Buchungen").Click(); + page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click(); + page.WaitForAssertion(() => Assert.Contains("Alle Jahre", page.Markup)); + page.Find(".account-card").Click(); + page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup)); + Assert.Null(context.Services.GetRequiredService().Year); } }