Usability U01–U16: sichere Abläufe und Bedienkorrekturen (#1)
Dieser PR setzt die Usability-Maßnahmen U01–U16 und die Nacharbeit an Betragseingabe, Toasts und Aktionsleisten um. Analyse: `docs/usability-analyse.md`; Plan und fortlaufendes Prüfprotokoll: `docs/usability-umsetzung.md`.
- **U01–U03:** konsistente SQLite-Sicherung der aktiven Daten einschließlich WAL, geprüfte Wiederherstellung mit Rückfallsicherung; strukturierte Dateioperationen; PDF-Auszüge nur mit wirksamen Buchungen, erklärtem Zeitraum, Kontostand und internen Umbuchungen.
- **U04–U07:** gemeinsamer Jahreskontext einschließlich Alle Jahre; Hinweise und Rückweg für abweichendes Buchungsjahr; Alltagssprache; erkennbare Konto-/Buchungsart und Validierung; Schutz vor mehrfacher Speicherung einschließlich Retry nach bereits bestätigtem Schreiben.
- **Nacharbeit:** gemeinsames Betragscontrol weist zusätzliche Nachkommastellen und ungültige Einfügungen vollständig ab, ohne zu kürzen oder zu runden. Globale stapelbare Toasts erscheinen oben, laufen zeitgesteuert ab und pausieren bei Hover/Fokus. Laufende Vorgänge bleiben bis Abschluss sichtbar. Einheitliche Aktionsbuttons reservieren Symbol und zwei Textzeilen. Debug-Datenordner für isolierte Frontend-Tests.
- **U08:** native Modaldialoge mit Fokus, inaktivem Hintergrund, Escape, Rückkehr zum Auslöser und Verlustschutz; semantische Kontolinks; sichere Bestätigungen starten auf Abbrechen.
- **U09:** größere beschriftete Zeilenaktionen mit Buchungsbezug und Kartenansicht im schmalen Fenster.
- **U10:** gelöschte Buchungen standardmäßig ausgeblendet, gezählt erreichbar und ausdrücklich nicht mitgerechnet; Bestätigungen erklären beide Transferkonten, gezieltes Rückgängig mit Zustandsprüfung.
- **U11:** dieselbe Von→Nach-Richtung auf beiden Seiten, festes/änderbares Konto und Minus/Plus-Vorschau; Dienst verhindert auch beim Bearbeiten gleiche Konten.
- **U12:** Anfangsbestand bei Einrichtung getrennt von späterer Korrektur. Korrektur zeigt Jahr/Konto und vorher/nachher, warnt vor Auswirkungen auf frühere/spätere Jahre und benötigt Zustimmung. Transaktionale Prüfung weist veraltete Vorschau ab. Die gemeinsame Bestandssemantik bleibt erhalten; keine Migration oder neue Jahresspeicherung.
- **U13:** lesbare Grundschrift, lokal gespeicherte Textgröße bis 200 %, umbrechende Namen, responsive Formulare/Karten und im Fenster scrollbar bleibende Dialoge. Stichproben der Text-/Betragsrollen mindestens 5,91:1 (hell) bzw. 8,37:1 (dunkel); primäre Aktionsfarben mindestens 6,47:1.
- **U14:** sichtbare lokale Hilfe und überspringbare Einführung mit gespeichertem Fortschritt: Vereinsname, eigenes Konto/Anfangsbestand, tatsächliche erste Buchung und Sicherung. Keine Registrierung oder automatisch erzeugte Beispielbuchungen.
- **U15:** Suche über Beschreibung, Nummer, Datum, Gegenkonto und deutschen Betrag; Sortierung nach Datum, vorzeichenrichtigem Betrag oder Beschreibung. Trefferzahl und wirksame Treffermengensumme sind vom unveränderten Kontostand getrennt. Reset erhält den gewählten Jahreskontext.
- **U16:** Bootstrap 5.3.3 und Bootstrap Icons 1.11.3 samt lokalen Schriftdateien und MIT-Lizenzen werden ausgeliefert; keine CDN-Ressourcen beim Start. Unerwartete Fehler bieten Wiederholung/Übersicht und kopierbare Referenz/Fehlertyp ohne Buchungsdaten. Nicht gefunden bietet Rückweg. Erwartbare Speicherfehler erhalten Eingaben.
Validierung: **60/60 Tests in Debug und 60/60 in Release**, einschließlich SQLite-WAL/Restore, PDF-Textextraktion, mehrjähriger Bestandskorrektur, Transferänderungen von beiden Seiten, Löschung/Undo beider Transferbuchungen, Formular-/Navigationsabläufen, Einführungsfortsetzung, Suche aus 400 Einträgen und anonymisierter Fehleranzeige. Nach jeder Maßnahme Desktop gebaut und Debug-Tests ausgeführt; final auch Release-Desktop-Build. Keine Buildwarnungen, `git diff --check` sauber. PDF-Einzelkonto, Übersicht und mehrseitiger Auszug visuell geprüft.
Computer Use mit künstlichem Bestand: zusätzliche Nachkommastelle abgewiesen, gültige 12,50-€-Einnahme gespeichert, Kontostand und Toast kontrolliert. U08-Fokus im Eingabefeld, ausschließlich modaler Accessibility-Baum und Escape-Schließen beobachtet. Nach erneuter Fensterzuordnung finale Anwendung mit lokalen Ressourcen gestartet; beschriftetes Zeilenmenü und korrekte Umbuchungsrichtung von der empfangenen Seite kontrolliert. Bestandskorrektur zeigt Konto/Jahr, Formel, Vorher/Nachher und Warnung für alle Jahre, ohne zu speichern. Dabei gefundene Textbindung im Betragsfeld korrigiert und als Regression geprüft; ebenso erklärter Leerzustand, sichtbares Undo-Refresh und zweites Escape im Verlustschutz.
Offline-Layoutprüfung: tatsächliche gerenderte Komponenten mit Produktions-CSS/Schriften in Headless Chromium, Browsernetz blockiert, **0 externe Requests**. **16 Kombinationen** bei 768 × 480 CSS-Pixeln, hell/dunkel und 100/125/150/200 % Textgröße bestehen; keine horizontale Seitenüberschreitung, Dialog im Fenster, Speichern/Abbrechen durch Scrollen erreichbar. Repräsentative PNGs visuell geprüft.
Grenzen: Browser-Textvergrößerung ist kein vollständiger Test realer Windows-DPI-Einstellungen. Keine vollständige Screenreader-/Zielgruppenabnahme und kein Nutzertest mit unerfahrenen Personen behauptet. Native Fensterzuordnung war zeitweise instabil; deshalb sind automatisierte Komponentenprüfungen und Browser-Layoutnachweise ausdrücklich von den beschriebenen nativen Beobachtungen getrennt. Keine Vereinsdaten geändert oder zurückgespielt, keine Bankvorgänge ausgeführt. Keine neue Jahres-Speichersemantik; Rückfallsicherungen werden nicht automatisch gelöscht. Vereinsname, Einführungsfortschritt und Darstellung gehören nicht zum Datenbank-Backup.
U08–U12 wurden einzeln committed und als Paket gepusht; U13–U16 ebenfalls in eigenen sachlichen Commits. Nacharbeit an Betragseingabe/Toasts/Aktionsleisten liegt in `86a73b8`. Plan und Prüfprotokoll: `docs/usability-umsetzung.md`. Kein Merge; zur manuellen Analyse durch den Auftraggeber.
Weitere Bedienkorrekturen nach manueller Analyse:
- Betragscontrol blockiert zusätzliche Nachkommastellen und ungültige Einfügungen jetzt ohne Warnmeldung.
- Jahreswechselhinweis als Warnbox mit Symbol, Datum und beiden Jahren. Er erscheint beim Verlassen des Datumsfelds; der native Kalender bleibt beim Blättern über den Jahreswechsel geöffnet. Submit ohne vorherigen Fokuswechsel prüft den aktuellen Datumsentwurf; Verlustschutz berücksichtigt ihn sofort. Gilt auch für Umbuchungen.
- Historie als beschrifteter Switch mit vertikalem Abstand. Direkt sichtbare Stift-/Papierkorbbuttons mit mindestens 44 × 44 Pixeln, Title und buchungsbezogenem Accessibility-Namen ersetzen das Zeilenmenü.
- Windows-Anwendungssymbol: ungenutzte native Photino-Benachrichtigungsregistrierung deaktiviert, damit sie die explizite Windows-App-ID nicht überschreibt und keine dotnet-Host-Verknüpfungen erzeugt. Bestehendes Icon bleibt in EXE und nativem Fenster eingebunden.
Zusätzliche Validierung: 60/60 Debug- und 60/60 Release-Tests; beide Desktop-Builds und eigenständige Windows-Einzeldatei ohne Warnungen/Fehler. Alle 16 Offline-Layoutkombinationen erneut bestanden. Computer Use bestätigt Switch, direkten Bearbeiten-Button, stille Abweisung der dritten Nachkommastelle und geöffneten nativen Kalender beim Blättern von Dezember 2026 nach Januar 2027; Warnbox erscheint nach Wechsel zur Beschreibung. Kein Speichern in diesem Prüflauf. Das aus der veröffentlichten EXE extrahierte Icon und das Titelleistensymbol wurden visuell geprüft; Taskleistendarstellung nicht separat erfasst. UI-Nacharbeit in aa39281; Windows-Zuordnung in eigenem Commit. Kein Merge.
Weitere UI-Korrekturen in 038fbc0: Stift-/Papierkorbsymbole horizontal und vertikal zentriert, Tabellenkopf mit Designfarben statt festem hellem Hintergrund. Alle sechs allgemeinen „Dateiaktion läuft“-Meldungen durch konkrete Angaben zu Sicherung, Wiederherstellung, PDF-Übersicht, Kontoauszug bzw. PDF-Öffnen ersetzt. 60/60 Tests in Debug und Release, beide Desktop-Builds ohne Warnungen/Fehler. Zusätzliche Browserprüfung bei 100/200 % in hell/dunkel: Symbolzentrierung innerhalb 0,6 Pixeln, mindestens 44 × 44 Pixel große Buttons, Headerkontrast 17,85:1 (hell) bzw. 12,89:1 (dunkel); alle 16 bisherigen Offline-Layoutkombinationen bestanden und dunkler Tabellenkopf visuell geprüft.
Textgröße um „Klein (75 %)“ ergänzt, einschließlich Speicherung und Wiederherstellung beim Start. Debug-Suite 60/60 bestanden, Desktop-Build ohne Warnungen/Fehler. JavaScript-Prüfung bestätigt 12-Pixel-Grundschrift bei 75 % und korrektes Speichern/erneutes Laden der Auswahl.
Anzeige gelöschter Buchungen dauerhaft und kontenübergreifend: gespeicherte benutzerweite Einstellung wird bei jedem Konto geladen und bleibt über Neustarts sowie „Filter zurücksetzen“ bestehen, bis der Switch ausgeschaltet wird. Neue Regressionen prüfen Kontowechsel, Reset, Ausschalten, Neustart und Erhalt anderer Einstellungen. 62/62 Tests jeweils in Debug und Release; Debug-Build ohne Warnungen/Fehler. Vorhandenen Publish-Task erfolgreich ausgeführt: Publish/Duempelkas-v26.306.exe. Die bereits vorhandene lokale Kontokarten-Anpassung des Auftraggebers ist in der lokalen Veröffentlichung enthalten und bleibt außerhalb dieses Commits.
Über-Dialog zeigt neben der Version das Builddatum im Format TT.MM.JJJJ. Zeitstempel wird beim Desktop-Build als Assembly-Metadatum eingebettet und funktioniert dadurch auch bei der Einzeldatei-Veröffentlichung. Eingebettetes Datum aus erzeugter Desktop-Assembly geprüft; 62/62 Tests jeweils in Debug und Release, Debug-Build ohne Warnungen/Fehler. Publish-Task erneut erfolgreich ausgeführt und Publish/Duempelkas-v26.306.exe aktualisiert.
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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 sealed class BackupServiceTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-tests-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly ServiceProvider services;
|
||||
private readonly BackupService backup;
|
||||
private readonly EntryService entries;
|
||||
private readonly int accountId;
|
||||
|
||||
public BackupServiceTests()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
services = new ServiceCollection().AddInfrastructure($"Data Source={Path.Combine(folder, "active.db")};Pooling=False").BuildServiceProvider();
|
||||
using var db = services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||
db.Database.Migrate();
|
||||
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
|
||||
db.Accounts.Add(new() { Name = "Isolierte Testkasse" });
|
||||
db.SaveChanges();
|
||||
accountId = db.Accounts.Single().Id;
|
||||
backup = new BackupService(services.GetRequiredService<DatabaseLocation>());
|
||||
entries = (EntryService)services.GetRequiredService<Duempelkas.App.Services.IEntryService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WalSnapshot_RestoresExactState_AndKeepsFallback()
|
||||
{
|
||||
var first = await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Gesichert", 12.50m);
|
||||
var path = Path.Combine(folder, "snapshot.bak");
|
||||
await backup.CreateBackupToAsync(path);
|
||||
await entries.CreateEntryAsync(accountId, EntryType.Expense, new(2026, 1, 2), "Später", 4.20m);
|
||||
var fallback = await backup.RestoreFromAsync(path);
|
||||
var restored = await entries.GetEntriesAsync(accountId, (int?)null);
|
||||
Assert.Single(restored);
|
||||
Assert.Equal(first, restored[0]);
|
||||
using var old = new SqliteConnection($"Data Source={fallback};Mode=ReadOnly;Pooling=False");
|
||||
old.Open();
|
||||
using var command = old.CreateCommand();
|
||||
command.CommandText = "SELECT COUNT(*) FROM Entries";
|
||||
Assert.Equal(2L, command.ExecuteScalar());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task InvalidBackup_DoesNotChangeActiveData(bool sqlite)
|
||||
{
|
||||
await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Bleibt", 10m);
|
||||
var path = Path.Combine(folder, "invalid.bak");
|
||||
if (sqlite)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "CREATE TABLE Unrelated (Id INTEGER)";
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
else await File.WriteAllTextAsync(path, "not a database");
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(path));
|
||||
Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingSource_AndActiveDestination_AreRejected()
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(Path.Combine(folder, "missing.bak")));
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => backup.CreateBackupToAsync(Path.Combine(folder, "active.db")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
services.Dispose();
|
||||
Directory.Delete(folder, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingDateInputTests
|
||||
{
|
||||
[Fact]
|
||||
public void DateIsValidatedOnLeavingField_WithoutInterruptingNativeCalendar()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
var value = "2025-12-31";
|
||||
var field = context.Render<BookingDateInput>(p => p
|
||||
.Add(x => x.Value, value).Add(x => x.ValueChanged, next => value = next));
|
||||
field.Find("input").Input("2026-01-01");
|
||||
Assert.Equal("2025-12-31", value);
|
||||
field.Find("input").Blur();
|
||||
Assert.Equal("2026-01-01", value);
|
||||
field.Render(p => p.Add(x => x.Value, value));
|
||||
// The browser owns the edited value; avoid resetting its calendar by writing it back.
|
||||
Assert.Equal("2025-12-31", field.Find("input").GetAttribute("value"));
|
||||
field.Render(p => p.Add(x => x.Value, "2027-02-03"));
|
||||
Assert.Equal("2027-02-03", field.Find("input").GetAttribute("value"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingFormTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PendingDate_IsProtectedOnCancel_AndValidatedBeforeSubmit()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var dialog = f.Context.Render<AddEntryDialog>(p => p.Add(x => x.AccountId, 1).Add(x => x.ContextYear, DateTime.Today.Year));
|
||||
dialog.Find("#entry-date").Input($"{DateTime.Today.Year + 1}-01-02");
|
||||
Assert.Empty(dialog.FindAll(".booking-year-warning"));
|
||||
var modal = dialog.FindComponent<ModalDialog>();
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.NotEmpty(dialog.FindAll("[role=alertdialog]"));
|
||||
dialog.Find("[data-discard-continue]").Click();
|
||||
dialog.Find("#entry-title").Input("Pending date");
|
||||
dialog.Find("#entry-amount").Input("12,50");
|
||||
// A keyboard submit can happen without the date field losing focus first.
|
||||
dialog.Find("form").Submit();
|
||||
Assert.NotEmpty(dialog.FindAll(".booking-year-warning"));
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
Assert.Empty(await f.Service<IEntryService>().GetEntriesAsync(1, (int?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
|
||||
Assert.Contains("Barkasse", dialog.Find("h5").TextContent);
|
||||
Assert.Contains("Bitte beschreiben", dialog.Markup);
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-title").Input("Porto");
|
||||
dialog.Find("#entry-amount").Input("12,501");
|
||||
Assert.Equal("", dialog.Find("#entry-amount").GetAttribute("value"));
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-amount").Input("-12,50");
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-amount").Input("12,50");
|
||||
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-date").Input("");
|
||||
dialog.Find("#entry-date").Blur();
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
Assert.Contains("Bitte ein gültiges Datum", dialog.Markup);
|
||||
dialog.Find("#entry-date").Input("2026-01-02");
|
||||
dialog.Find("#entry-date").Blur();
|
||||
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
|
||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.EditEntry, entry));
|
||||
Assert.Contains("Ausgabe", dialog.Markup);
|
||||
Assert.Contains("mit der richtigen Art neu erfassen", dialog.Markup);
|
||||
dialog.Find("#entry-title").Input("Korrigierter Text");
|
||||
dialog.Render(p => p.Add(x => x.EditEntry, entry));
|
||||
Assert.Equal("Korrigierter Text", dialog.Find("#entry-title").GetAttribute("value"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingInputTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("12,50", true)]
|
||||
[InlineData("12.50", true)]
|
||||
[InlineData(" 12,50 ", true)]
|
||||
[InlineData("12,500", false)]
|
||||
[InlineData("-12,50", false)]
|
||||
[InlineData("1.234,56", false)]
|
||||
[InlineData("1,234.56", false)]
|
||||
[InlineData("12,50 €", false)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("9999999999999999999999999999999999999", false)]
|
||||
public void DecimalInput_HasExplicitRules(string text, bool valid)
|
||||
{
|
||||
Assert.Equal(valid, BookingInput.TryAmount(text, out var amount));
|
||||
if (valid) Assert.Equal(12.50m, amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceValidation_RejectsInvalidData()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(default, "Test", 1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, " ", 1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", -1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", 1.001m));
|
||||
Assert.Equal(default, BookingInput.Date("2026-02-30"));
|
||||
}
|
||||
}
|
||||
@@ -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<Routes>();
|
||||
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(".toast-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<Routes>();
|
||||
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(".toast-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<AddEntryDialog>(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(fixture.Context.Services.GetRequiredService<ToastService>().Messages, t => t.Message.Contains("Die Buchung ist gespeichert"));
|
||||
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<Routes> 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<IEntryService>(sp => new ControlledEntries(new EntryService(sp.GetRequiredService<IDbContextFactory<FinanceDbContext>>())));
|
||||
Context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
db.Accounts.AddRange(new() { Name = "A-Testkasse" }, new() { Name = "B-Testbank" });
|
||||
db.SaveChanges();
|
||||
Entries = (ControlledEntries)Context.Services.GetRequiredService<IEntryService>();
|
||||
}
|
||||
public void Dispose() { Context.Dispose(); anchor.Dispose(); }
|
||||
}
|
||||
|
||||
private sealed class ControlledEntries(IEntryService inner) : IEntryService
|
||||
{
|
||||
public int WriteCalls;
|
||||
public Func<Task> BeforeWrite = () => Task.CompletedTask;
|
||||
public async Task<EntryDto> 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<int> 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<List<EntryDto>> GetEntriesAsync(int accountId, int? year) => inner.GetEntriesAsync(accountId, year);
|
||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly) => inner.GetEntriesAsync(accountId, currentYearOnly);
|
||||
public Task<List<int>> GetEntryYearsAsync(int accountId) => inner.GetEntryYearsAsync(accountId);
|
||||
public Task<List<int>> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class CarryoverCorrectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Correction_ShiftsAllYearsByDelta_AndRejectsStaleState()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var accounts = f.Service<IAccountService>();
|
||||
var entries = f.Service<IEntryService>();
|
||||
var balances = f.Service<IBalanceQueryService>();
|
||||
await entries.CreateEntryAsync(1, EntryType.Income, new(2025, 1, 1), "Alt", 10m);
|
||||
await entries.CreateEntryAsync(1, EntryType.Expense, new(2026, 1, 1), "Neu", 5m);
|
||||
await accounts.CorrectCarryoverAsync(1, 120m, 2026, 110m, 100m);
|
||||
Assert.Equal(110m, (await accounts.GetAccountAsync(1)).CarryoverBalance);
|
||||
Assert.Equal(120m, (await balances.GetAccountBalanceAsync(1, 2025)).TotalBalance);
|
||||
Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2026)).TotalBalance);
|
||||
Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2027)).TotalBalance);
|
||||
Assert.Equal(2, (await entries.GetEntriesAsync(1, (int?)null)).Count);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => accounts.CorrectCarryoverAsync(1, 130m, 2026, 110m, 100m));
|
||||
var newAccount = await accounts.CreateAccountAsync("Neues Konto", -20m);
|
||||
Assert.Equal(-20m, newAccount.TotalBalance);
|
||||
Assert.Empty(await entries.GetEntriesAsync(newAccount.Id, (int?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrectionForm_ShowsBeforeAfter_AndRequiresAcknowledgement()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var saved = 0m;
|
||||
var dialog = f.Context.Render<EditCarryoverDialog>(p => p.Add(x => x.CurrentAmount, 110m)
|
||||
.Add(x => x.InitialBalance, 100m).Add(x => x.Year, 2026).Add(x => x.AccountName, "Barkasse")
|
||||
.Add(x => x.OnSave, value => saved = value));
|
||||
Assert.Equal("110,00", dialog.Find("#carryover").GetAttribute("value"));
|
||||
dialog.Find("#carryover").Input("120,00");
|
||||
Assert.Equal("120,00", dialog.Find("#carryover").GetAttribute("value"));
|
||||
Assert.Contains("frühere und spätere Jahre", dialog.Markup);
|
||||
Assert.Contains("110,00", dialog.Find(".correction-preview").TextContent);
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("input[type=checkbox]").Change(true);
|
||||
dialog.Find("button.btn-success").Click();
|
||||
Assert.Equal(120m, saved);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,62 @@ public class DashboardPdfStatementServiceTests : IDisposable
|
||||
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exports_ExcludeDeleted_ExplainTransfers_AndMatchBalances()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse", CarryoverBalance = 100m };
|
||||
var target = new Account { Name = "Girokonto" };
|
||||
_db.Accounts.AddRange(source, target);
|
||||
await _db.SaveChangesAsync();
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>().UseSqlite(_connectionString).Options;
|
||||
var factory = new TestDbContextFactory(options);
|
||||
var entries = new EntryService(factory);
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 1), "Mitgliedsbeitrag", 12.50m);
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Expense, new(2026, 1, 2), "Porto", 4.20m);
|
||||
await entries.CreateTransferAsync(source.Id, target.Id, new(2026, 1, 3), "Zur Bank", 50m);
|
||||
var deleted = await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 4), "GELOESCHTE_TESTBUCHUNG", 999m);
|
||||
await entries.DeleteEntryAsync(deleted.Id);
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2027, 1, 1), "ZUKUNFT_TESTBUCHUNG", 1m);
|
||||
var balance = await new BalanceQueryService(factory).GetAccountBalanceAsync(source.Id, 2026);
|
||||
Assert.Equal(58.30m, balance.TotalBalance);
|
||||
var single = await _pdfService.GenerateStatementAsync(source.Id, 2026);
|
||||
var combined = await _pdfService.GenerateDashboardStatementAsync(2026);
|
||||
foreach (var pdf in new[] { single, combined })
|
||||
{
|
||||
var text = Extract(pdf);
|
||||
Assert.Contains("Mitgliedsbeitrag", text);
|
||||
Assert.Contains("Porto", text);
|
||||
Assert.Contains("Zur Bank", text);
|
||||
Assert.DoesNotContain("GELOESCHTE_TESTBUCHUNG", text);
|
||||
Assert.DoesNotContain("ZUKUNFT_TESTBUCHUNG", text);
|
||||
Assert.Contains("58,30", text);
|
||||
Assert.Contains("31.12.2026", text);
|
||||
}
|
||||
Assert.Contains("108,30", Extract(combined));
|
||||
Assert.Contains("an Girokonto", Extract(single));
|
||||
var all = Extract(await _pdfService.GenerateStatementAsync(source.Id, (int?)null));
|
||||
Assert.Contains("Alle Jahre", all);
|
||||
Assert.Contains("Anfangsbestand", all);
|
||||
Assert.Contains("59,30", all);
|
||||
var evidence = Environment.GetEnvironmentVariable("DUEMPELKAS_PDF_EVIDENCE");
|
||||
if (evidence != null)
|
||||
{
|
||||
Directory.CreateDirectory(evidence);
|
||||
await File.WriteAllBytesAsync(Path.Combine(evidence, "single.pdf"), single);
|
||||
await File.WriteAllBytesAsync(Path.Combine(evidence, "combined.pdf"), combined);
|
||||
// Additional pages exercise repeated headers and the final summary.
|
||||
for (var i = 0; i < 90; i++)
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 2, 1), $"Lesbarkeitsprüfung Buchung {i + 1}", 1m);
|
||||
await File.WriteAllBytesAsync(Path.Combine(evidence, "multipage.pdf"), await _pdfService.GenerateStatementAsync(source.Id, 2026));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Extract(byte[] pdf)
|
||||
{
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
||||
return string.Join("\n", document.GetPages().Select(page => UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor.ContentOrderTextExtractor.GetText(page)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
@@ -118,6 +174,8 @@ public class DashboardPdfStatementServiceTests : IDisposable
|
||||
|
||||
private sealed class FixedSettingsService : ISettingsService
|
||||
{
|
||||
public Task<bool> GetShowDeletedEntriesAsync() => Task.FromResult(false);
|
||||
public Task SetShowDeletedEntriesAsync(bool show) => Task.CompletedTask;
|
||||
private readonly string? _clubName;
|
||||
|
||||
public FixedSettingsService(string? clubName)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class DesktopResourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartupStylesAndFonts_AreDeliveredLocally()
|
||||
{
|
||||
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent;
|
||||
Assert.NotNull(root);
|
||||
var web = Path.Combine(root.FullName, "src", "Duempelkas.Desktop", "wwwroot");
|
||||
var html = File.ReadAllText(Path.Combine(web, "index.html"));
|
||||
Assert.DoesNotContain("https://", html);
|
||||
foreach (Match match in Regex.Matches(html, "(?:href|src)=\"([^\"]+)\""))
|
||||
{
|
||||
var path = match.Groups[1].Value;
|
||||
if (path.Length == 0 || path == "/" || path.StartsWith("_framework")) continue;
|
||||
Assert.True(File.Exists(Path.Combine(web, path)), path);
|
||||
}
|
||||
var icons = Path.Combine(web, "vendor", "bootstrap-icons");
|
||||
var css = File.ReadAllText(Path.Combine(icons, "bootstrap-icons.min.css"));
|
||||
foreach (Match match in Regex.Matches(css, "url\\([\"']?([^\"')]+)"))
|
||||
Assert.True(File.Exists(Path.Combine(icons, match.Groups[1].Value.Split('?')[0])));
|
||||
Assert.True(File.Exists(Path.Combine(icons, "LICENSE")));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" Version="2.13.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.16" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntryLifecycleUiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DeleteTransfer_ExplainsBothAccounts_HidesRows_AndUndoRestoresBoth()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Testtransfer", 20m);
|
||||
var page = f.Context.Render<Routes>();
|
||||
page.WaitForElement(".account-card").Click();
|
||||
page.WaitForElement(".entry-action-buttons");
|
||||
page.Find("button.btn-outline-danger").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("beiden Konten", page.Markup));
|
||||
Assert.Contains("Barkasse", page.Find("dialog").TextContent);
|
||||
Assert.Contains("Girokonto", page.Find("dialog").TextContent);
|
||||
page.Find("dialog button.btn-danger").Click();
|
||||
page.WaitForAssertion(() => Assert.Empty(page.FindAll("tbody tr")));
|
||||
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.True(e.IsDeleted));
|
||||
page.Find(".history-toggle input").Change(true);
|
||||
Assert.Contains("zählt nicht zum Kontostand", page.Markup);
|
||||
page.Find(".toast-success .btn").Click();
|
||||
page.WaitForAssertion(() => Assert.Single(page.FindAll("tbody tr")));
|
||||
page.WaitForAssertion(() => Assert.Empty(page.FindAll(".deleted-status")));
|
||||
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.False(e.IsDeleted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Accounts;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntryPresentationTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyAccount_ShowsExplanationRatherThanParameterName()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
f.Service<Microsoft.AspNetCore.Components.NavigationManager>().NavigateTo("/accounts/2");
|
||||
var page = f.Context.Render<Duempelkas.App.Components.Routes>();
|
||||
page.WaitForAssertion(() => Assert.Contains("Noch keine Buchungen vorhanden.", page.Markup));
|
||||
Assert.DoesNotContain("emptyEntriesMessage", page.Markup);
|
||||
}
|
||||
[Fact]
|
||||
public void Actions_AreDirectIconButtonsWithTitlesAndBookingSpecificNames()
|
||||
{
|
||||
using var fixture = new LedgerUiFixture();
|
||||
var context = fixture.Context;
|
||||
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, DateTime.Today, "Porto", 4.20m, false, false, null, null, null);
|
||||
var edited = 0;
|
||||
var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, [entry]).Add(x => x.OnEditEntry, id => edited = id));
|
||||
Assert.Empty(table.FindAll("summary"));
|
||||
var edit = table.Find("button[title='Buchung bearbeiten']");
|
||||
Assert.Contains("2026-001 Porto", edit.GetAttribute("aria-label"));
|
||||
Assert.NotNull(edit.QuerySelector(".bi-pencil[aria-hidden=true]"));
|
||||
Assert.NotNull(table.Find("button.btn-outline-danger .bi-trash"));
|
||||
Assert.Contains("Porto", edit.GetAttribute("aria-label"));
|
||||
edit.Click();
|
||||
Assert.Equal(1, edited);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Accounts;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntrySearchTests
|
||||
{
|
||||
[Fact]
|
||||
public void Search_FindsKnownBookingInLargeList_AndResetsWithoutChangingAmounts()
|
||||
{
|
||||
using var fixture = new LedgerUiFixture();
|
||||
var context = fixture.Context;
|
||||
var entries = Enumerable.Range(1, 400).Select(i => new EntryDto(i, 1, $"2026-{i:D3}", EntryType.Income,
|
||||
new(2026, 1, 1), i == 321 ? "Älteres Jubiläum" : $"Beitrag {i}", i == 321 ? 12.50m : i, false, false, null, null, null)).ToList();
|
||||
var original = entries.Sum(e => e.Amount);
|
||||
var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, entries));
|
||||
table.Find("#entry-search").Input("Älteres 12,50");
|
||||
Assert.Single(table.FindAll("tbody tr"));
|
||||
Assert.Contains("12,50", table.Find(".search-summary").TextContent);
|
||||
Assert.Contains("unverändert", table.Find(".search-summary").TextContent);
|
||||
table.Find("#entry-search").Input("nicht vorhanden");
|
||||
Assert.Empty(table.FindAll("tbody tr"));
|
||||
Assert.Contains("Keine Treffer", table.Markup);
|
||||
table.Find(".filter-reset").Click();
|
||||
Assert.Equal(400, table.FindAll("tbody tr").Count);
|
||||
Assert.Equal(original, entries.Sum(e => e.Amount));
|
||||
table.Find("#entry-search").Input("2026-321");
|
||||
Assert.Single(table.FindAll("tbody tr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sort_UsesSignedAmounts_AndDeletedHistoryDoesNotAffectResultSum()
|
||||
{
|
||||
var entries = new List<EntryDto> {
|
||||
new(1,1,"2026-001",EntryType.Expense,DateTime.Today,"Porto",20m,false,false,null,null,null),
|
||||
new(2,1,"2026-002",EntryType.Income,DateTime.Today,"Beitrag",10m,false,false,null,null,null),
|
||||
new(3,1,"2026-003",EntryType.Income,DateTime.Today,"Gelöscht",99m,true,false,null,null,null) };
|
||||
Assert.Equal(new[] { 2, 1 }, EntryListQuery.Apply(entries, "", false, "amount-desc").Select(e => e.Id));
|
||||
Assert.Single(EntryListQuery.Apply(entries, "-20,00", false, "date-asc"));
|
||||
Assert.Equal(3, EntryListQuery.Apply(entries, "", true, "date-asc").Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ErrorRecoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnexpectedError_OffersRecoveryWithoutLeakingBookingData()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var boundary = context.Render<AppErrorBoundary>(p => p.AddChildContent<Throws>());
|
||||
boundary.WaitForAssertion(() => Assert.Contains("nicht bestätigt", boundary.Markup));
|
||||
Assert.Contains("Zur Kontenübersicht", boundary.Markup);
|
||||
Assert.Contains("InvalidOperationException", boundary.Markup);
|
||||
Assert.DoesNotContain("SECRET_BOOKING_DATA", boundary.Markup);
|
||||
}
|
||||
private sealed class Throws : ComponentBase
|
||||
{
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder) => throw new InvalidOperationException("SECRET_BOOKING_DATA");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class FileOperationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Cancel_IsNeutral()
|
||||
{
|
||||
var service = new FileSaveService(new Dialog(null));
|
||||
Assert.Equal(FileOperationStatus.Cancelled, (await service.SaveFileAsync([1], "test.pdf")).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAndFailure_AreDistinct_AndOpeningMissingFileIsError()
|
||||
{
|
||||
var folder = Path.Combine(Path.GetTempPath(), "duempelkas-files-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(folder);
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(folder, "Prüfung.pdf");
|
||||
var service = new FileSaveService(new Dialog(path));
|
||||
Assert.Equal(FileOperationStatus.Success, (await service.SaveFileAsync([1, 2, 3], "test.pdf")).Status);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
|
||||
// A directory cannot be overwritten as a file; the existing file is preserved.
|
||||
Assert.Equal(FileOperationStatus.Error, (await new FileSaveService(new Dialog(folder)).SaveFileAsync([4], "test.pdf")).Status);
|
||||
Assert.Equal(FileOperationStatus.Error, (await service.OpenFileAsync(Path.Combine(folder, "missing.pdf"))).Status);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
|
||||
}
|
||||
finally { Directory.Delete(folder, true); }
|
||||
}
|
||||
|
||||
private sealed class Dialog(string? path) : IFileDialogService
|
||||
{
|
||||
public Task<string?> SaveAsync(string suggestedFileName) => Task.FromResult(path);
|
||||
public Task<string?> OpenBackupAsync() => Task.FromResult(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class GettingStartedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Introduction_CanBeSkippedAndResumed_AndCreatesNoSampleBookings()
|
||||
{
|
||||
using var f = new LedgerUiFixture(false);
|
||||
var navigation = f.Service<NavigationManager>();
|
||||
navigation.NavigateTo("/einstieg");
|
||||
var page = f.Context.Render<Routes>();
|
||||
page.WaitForElement("#setup-club").Input("Mein Testverein");
|
||||
page.Find(".setup-next").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Dein erstes Konto", page.Markup));
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("später fortsetzen")).Click();
|
||||
navigation.NavigateTo("/einstieg");
|
||||
page.WaitForAssertion(() => Assert.Contains("Dein erstes Konto", page.Markup));
|
||||
page.FindAll("button").Single(b => b.TextContent.Trim() == "Konto anlegen").Click();
|
||||
page.Find("#account-name").Input("Barkasse");
|
||||
page.Find("#account-initial").Input("25,00");
|
||||
page.Find("dialog form").Submit();
|
||||
page.WaitForAssertion(() => Assert.Contains("Die erste Buchung", page.Markup));
|
||||
var account = Assert.Single(await f.Service<IAccountService>().GetAllAccountsAsync());
|
||||
Assert.Equal(25m, account.TotalBalance);
|
||||
Assert.Empty(await f.Service<IEntryService>().GetEntriesAsync(account.Id, (int?)null));
|
||||
Assert.Equal(2, await f.Service<ISettingsService>().GetSetupStepAsync());
|
||||
navigation.NavigateTo("/hilfe");
|
||||
page.WaitForAssertion(() => Assert.Contains("auch ohne Internet", page.Markup));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Accounts;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class HistoryPreferenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PreferenceSurvivesRestart_AndPreservesOtherSettings()
|
||||
{
|
||||
var folder = Path.Combine(Path.GetTempPath(), "duempelkas-history-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var settings = new SettingsService(folder);
|
||||
Assert.False(await settings.GetShowDeletedEntriesAsync());
|
||||
await settings.SetClubNameAsync("Testverein");
|
||||
await settings.SetShowDeletedEntriesAsync(true);
|
||||
var restarted = new SettingsService(folder);
|
||||
Assert.True(await restarted.GetShowDeletedEntriesAsync());
|
||||
Assert.Equal("Testverein", await restarted.GetClubNameAsync());
|
||||
await restarted.SetSetupStepAsync(2);
|
||||
Assert.True(await new SettingsService(folder).GetShowDeletedEntriesAsync());
|
||||
await restarted.SetShowDeletedEntriesAsync(false);
|
||||
Assert.False(await new SettingsService(folder).GetShowDeletedEntriesAsync());
|
||||
}
|
||||
finally { if (Directory.Exists(folder)) Directory.Delete(folder, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryStaysEnabledAcrossAccountsAndFilterReset_UntilSwitchedOff()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
List<EntryDto> Rows(int account) => [new(account, account, "2026-001", EntryType.Income,
|
||||
DateTime.Today, "Deleted test", 10m, true, false, null, null, null)];
|
||||
var first = f.Context.Render<EntryTable>(p => p.Add(x => x.Entries, Rows(1)));
|
||||
Assert.Empty(first.FindAll("tbody tr"));
|
||||
await first.Find("#show-deleted").ChangeAsync(new() { Value = true });
|
||||
Assert.True(await f.Service<ISettingsService>().GetShowDeletedEntriesAsync());
|
||||
first.Find(".filter-reset").Click();
|
||||
Assert.Single(first.FindAll("tbody tr"));
|
||||
var second = f.Context.Render<EntryTable>(p => p.Add(x => x.Entries, Rows(2)));
|
||||
second.WaitForAssertion(() => Assert.Single(second.FindAll("tbody tr")));
|
||||
await second.Find("#show-deleted").ChangeAsync(new() { Value = false });
|
||||
Assert.False(await f.Service<ISettingsService>().GetShowDeletedEntriesAsync());
|
||||
var returning = f.Context.Render<EntryTable>(p => p.Add(x => x.Entries, Rows(1)));
|
||||
returning.WaitForAssertion(() => Assert.Empty(returning.FindAll("tbody tr")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class LayoutControlTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TextScaleControl_ChangesPresentationWithoutChangingLedger()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
await entries.CreateEntryAsync(1, EntryType.Expense, DateTime.Today, "Porto mit sehr langer Beschreibung und Umlauten äöü", 4.20m);
|
||||
f.Service<NavigationManager>().NavigateTo("/accounts/1");
|
||||
var page = f.Context.Render<Routes>();
|
||||
page.WaitForElement("#text-scale").Change("200");
|
||||
Assert.Contains(f.Context.JSInterop.Invocations, i => i.Identifier == "duempelkasReading.set" && Equals(i.Arguments[0], 200));
|
||||
Assert.Equal(95.80m, (await f.Service<IBalanceQueryService>().GetAccountBalanceAsync(1)).TotalBalance);
|
||||
Capture("account", page.Markup);
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe")).Click();
|
||||
page.WaitForElement("#entry-title");
|
||||
Capture("booking", page.Markup);
|
||||
}
|
||||
|
||||
private static void Capture(string name, string markup)
|
||||
{
|
||||
var output = Environment.GetEnvironmentVariable("DUEMPELKAS_LAYOUT_EVIDENCE");
|
||||
if (output == null) return;
|
||||
Directory.CreateDirectory(output);
|
||||
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent;
|
||||
var web = new Uri(Path.Combine(root!.FullName, "src", "Duempelkas.Desktop", "wwwroot") + Path.DirectorySeparatorChar).AbsoluteUri;
|
||||
File.WriteAllText(Path.Combine(output, name + ".html"), "<!doctype html><html lang='de'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>" +
|
||||
$"<link rel='stylesheet' href='{web}vendor/bootstrap/bootstrap.min.css'><link rel='stylesheet' href='{web}vendor/bootstrap-icons/bootstrap-icons.min.css'><link rel='stylesheet' href='{web}css/app.css'>" +
|
||||
"</head><body>" + markup + $"<script src='{web}js/app.js'></script>" +
|
||||
"<script>document.querySelectorAll('dialog').forEach(d=>d.showModal());</script></body></html>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Bunit;
|
||||
using Duempelkas.Infrastructure;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
internal sealed class LedgerUiFixture : IDisposable
|
||||
{
|
||||
public BunitContext Context { get; } = new();
|
||||
private readonly SqliteConnection anchor;
|
||||
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-ui-" + Guid.NewGuid().ToString("N"));
|
||||
public LedgerUiFixture(bool accounts = true)
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
var connection = $"Data Source=ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
anchor = new SqliteConnection(connection);
|
||||
anchor.Open();
|
||||
Context.Services.AddInfrastructure(connection, folder);
|
||||
Context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
if (accounts)
|
||||
{
|
||||
db.Accounts.AddRange(new() { Name = "Barkasse", CarryoverBalance = 100m }, new() { Name = "Girokonto" });
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
public T Service<T>() where T : notnull => Context.Services.GetRequiredService<T>();
|
||||
public void Dispose() { Context.Dispose(); anchor.Dispose(); Directory.Delete(folder, true); }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ModalDialogTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EscapeRequest_ClosesCleanDialog_ButProtectsDirtyInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var cancelled = 0;
|
||||
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Title, "Testdialog").Add(x => x.OnCancel, () => cancelled++));
|
||||
Assert.NotNull(modal.Find("dialog").GetAttribute("aria-labelledby"));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.Equal(1, cancelled);
|
||||
modal.Render(p => p.Add(x => x.Dirty, true));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.Equal(1, cancelled);
|
||||
Assert.Contains("Ungespeicherte Eingaben", modal.Markup);
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.Empty(modal.FindAll("[role=alertdialog]"));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
modal.Find("[data-discard-continue]").Click();
|
||||
Assert.Empty(modal.FindAll("[role=alertdialog]"));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
modal.Find("button.btn-outline-danger").Click();
|
||||
Assert.Equal(2, cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BusyDialog_CannotClose()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var cancelled = false;
|
||||
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Busy, true).Add(x => x.OnCancel, () => cancelled = true));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.False(cancelled);
|
||||
}
|
||||
}
|
||||
@@ -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,43 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ToastTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ToastsStack_ExpireIndependently_AndPauseDuringInteraction()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var host = context.Render<ToastHost>();
|
||||
var service = context.Services.GetRequiredService<ToastService>();
|
||||
var first = service.Show("Erste Meldung", duration: TimeSpan.FromMilliseconds(100));
|
||||
service.Pause(first);
|
||||
service.Show("Zweite Meldung", ToastKind.Success, persistent: true);
|
||||
host.WaitForAssertion(() => Assert.Equal(2, host.FindAll(".app-toast").Count));
|
||||
await Task.Delay(200);
|
||||
Assert.Equal(2, service.Messages.Count);
|
||||
service.Resume(first);
|
||||
host.WaitForAssertion(() => Assert.Single(host.FindAll(".app-toast")));
|
||||
Assert.Contains("Zweite Meldung", host.Markup);
|
||||
host.Find(".toast-close").Click();
|
||||
Assert.Empty(service.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmountControl_RejectsWholePasteWithoutRounding()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
string amount = "12,50";
|
||||
var control = context.Render<AmountInput>(p => p.Add(x => x.Value, amount).Add(x => x.ValueChanged, value => amount = value));
|
||||
control.Find("input").Input("12,501");
|
||||
Assert.Equal("12,50", amount);
|
||||
Assert.Empty(control.FindAll("[role=status]"));
|
||||
control.Find("input").Input("12.25");
|
||||
Assert.Equal("12.25", amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class TransferDirectionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task BothSides_ShowSameDirection(int accountId)
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
|
||||
var entry = Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
|
||||
var dialog = f.Context.Render<AddTransferDialog>(p => p.Add(x => x.SourceAccountId, accountId).Add(x => x.EditEntry, entry));
|
||||
dialog.WaitForAssertion(() => Assert.Contains("Von: Barkasse → Nach: Girokonto", dialog.Find("#transfer-direction").TextContent));
|
||||
Assert.Contains("Gesamtsumme unverändert", dialog.Markup);
|
||||
Assert.Contains(accountId == 2 ? "Von Konto" : "Nach Konto", dialog.Find("label[for=transfer-account]").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncomingEdit_MovesSource_AndRejectsSameAccountWithoutChanges()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
var third = await f.Service<IAccountService>().CreateAccountAsync("Sparkonto");
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
|
||||
var incoming = Assert.Single(await entries.GetEntriesAsync(2, (int?)null));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => entries.UpdateTransferAsync(incoming.Id, 2, DateTime.Today, "Unzulässig", 99m));
|
||||
Assert.Equal(20m, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Amount);
|
||||
await entries.UpdateTransferAsync(incoming.Id, third.Id, DateTime.Today, "Andere Quelle", 25m);
|
||||
Assert.Empty(await entries.GetEntriesAsync(1, (int?)null));
|
||||
Assert.Equal(EntryType.Expense, Assert.Single(await entries.GetEntriesAsync(third.Id, (int?)null)).Type);
|
||||
Assert.Equal(EntryType.Income, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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.WaitForElement(".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.WaitForElement(".account-card").Click();
|
||||
page.WaitForElement(".summary-section");
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
|
||||
var dialog = page.FindComponent<AddEntryDialog>();
|
||||
dialog.Find("input[type=date]").Input("2026-01-02");
|
||||
dialog.Find("input[type=date]").Blur();
|
||||
dialog.Find("#entry-title").Input("Außerhalb");
|
||||
dialog.Find("#entry-amount").Input("12.50");
|
||||
Assert.Contains("außerhalb", dialog.Markup);
|
||||
Assert.NotNull(dialog.Find(".booking-year-warning[role=status] .bi-exclamation-triangle-fill"));
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("input[type=checkbox]").Change(true);
|
||||
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<ReportingPeriod>().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.WaitForElement(".account-card").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
|
||||
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user