feat(offline): bundle local assets and add safe UI error recovery (U16)

This commit is contained in:
2026-11-02 19:49:31 +01:00
parent f284b85771
commit 71eed68e21
27 changed files with 275 additions and 24 deletions
+14
View File
@@ -93,3 +93,17 @@ Hilfe steht in der Navigation und wird als Teil der Anwendung offline ausgeliefe
## U15 – Suchen und Sortieren ## U15 – Suchen und Sortieren
Suche über Beschreibung, Nummer, Datum, Gegenkonto und deutsche Betragsdarstellung; mehrere Suchwörter müssen gemeinsam passen. Auswahl für Datum, vorzeichenrichtigen Betrag und Beschreibung. Trefferzahl und eigene wirksame Treffermengensumme erklären ausdrücklich den unveränderten Kontostand des gewählten Zeitraums. Zurücksetzen zeigt dessen vollständige aktive Liste; Historie bleibt gesondert. Test sucht mit Umlaut und 12,50 aus 400 Einträgen, prüft Nummer, Leerzustand, Reset und unveränderte Originalbeträge. Suche über Beschreibung, Nummer, Datum, Gegenkonto und deutsche Betragsdarstellung; mehrere Suchwörter müssen gemeinsam passen. Auswahl für Datum, vorzeichenrichtigen Betrag und Beschreibung. Trefferzahl und eigene wirksame Treffermengensumme erklären ausdrücklich den unveränderten Kontostand des gewählten Zeitraums. Zurücksetzen zeigt dessen vollständige aktive Liste; Historie bleibt gesondert. Test sucht mit Umlaut und 12,50 aus 400 Einträgen, prüft Nummer, Leerzustand, Reset und unveränderte Originalbeträge.
## U16 – Offline und Fehlerauswege
Bootstrap 5.3.3 sowie Bootstrap Icons 1.11.3 einschließlich Schriftdateien und MIT-Lizenzen werden lokal ausgeliefert; Startseite lädt keine CDN-Dateien mehr. Ressourcenprüfung prüft CSS-/JS-/Schriftdateien. Fehlergrenze bietet sicheren Wiederholungs-/Übersichtsweg, behauptet keine Speicherung und gibt nur Referenz/Fehlertyp statt potenzieller Buchungsdaten aus; Angaben sind kopierbar. Nicht gefunden bietet Rückweg. Erwartbare Speicherfehler sind weiterhin mit erhaltenen Eingaben als Toast behandelt. Paket- und Offline-Layoutprüfung folgt im Abschluss.
## Abschließende Prüfungen U08–U16
Debug-Suite nach jeder Maßnahme bestanden: U08 44, U09 45, U10 46, U11 49, U12 51, U13 51, U14 52, U15 54, U16 57 Tests. Desktop jeweils ohne Warnungen/Fehler gebaut; abschließende Release-Suite 57/57 bestanden.
Zusätzliche Headless-Chromium-Prüfung der tatsächlich gerenderten Konto-/Buchungs-Komponenten mit lokalen Produktionsressourcen: 16 Kombinationen bei 768 × 480 CSS-Pixeln, hell/dunkel und 100/125/150/200 % Textgröße. Browsernetz blockiert, keine externen Requests, lokale Symbole sichtbar. Kein horizontaler Seitenüberlauf; Dialog im Fenster, Speichern per Scrollen erreichbar. PNGs wurden exemplarisch kontrolliert. Dies prüft Textvergrößerung und Browserlayout, nicht sämtliche realen Windows-DPI-/Screenreader-Kombinationen.
Computer Use wurde nach der Unterbrechung fortgesetzt. U08: semantische Kontolinks, sichtbarer Eingabefokus, modaler Accessibility-Baum ohne Hintergrund und Escape-Schließen geprüft. Nach behobener Fensterzuordnung: finale Anwendung mit lokalen Bootstrap-Ressourcen gestartet; beschriftetes Zeilenmenü geöffnet und empfangene Umbuchung mit korrekter Von→Nach-Richtung, festem/änderbarem Konto sowie Minus/Plus-Vorschau gelesen. Sämtliche Tätigkeiten fanden im künstlichen Debug-Datenordner statt. Kein tatsächlicher Bankvorgang und keine Änderung am Vereinsbestand. Native vollständige DPI-/Hilfstechnologie-/Zielgruppenabnahme bleibt offen.
Abschlusskorrekturen aus der nativen Prüfung: Betragswert im Korrekturdialog und erklärter Leerzustand werden als Werte statt als Parametertexte gebunden; Regressionen prüfen beide Ausgangsanzeigen. Rückgängig aktualisiert auch den sichtbaren Löschstatus und verwendet nach Navigation weiterhin das ursprüngliche Konto. Eine zweite Escape-Anforderung im Verlustschutz rendert den Wechsel zurück zur Bearbeitung. Finale Prüfung: **58/58 Debug- und 58/58 Release-Tests**, Desktop Debug/Release ohne Warnungen/Fehler; alle 16 Offline-Layoutkombinationen erneut bestanden. Die native Prüfung der Bestandskorrektur bestätigte Konto/Jahr, Formel, Vorher/Nachher und Warnung für alle Jahre; es wurde keine Korrektur gespeichert. Der anschließend korrigierte Zahlenwert ist zusätzlich automatisiert geprüft.
@@ -2,7 +2,7 @@
<td data-label="Nummer">@Entry.DisplayId</td> <td data-label="Nummer">@Entry.DisplayId</td>
<td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td> <td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
<td data-label="Beschreibung" class="entry-description"> <td data-label="Beschreibung" class="entry-description">
@Entry.Title <span class="entry-title">@Entry.Title</span>
@if (Entry.IsDeleted) @if (Entry.IsDeleted)
{ {
<p class="deleted-status">Gelöscht – zählt nicht zum Kontostand</p> <p class="deleted-status">Gelöscht – zählt nicht zum Kontostand</p>
@@ -0,0 +1,37 @@
@inherits ErrorBoundary
@inject IJSRuntime JS
@inject ToastService Toasts
@if (CurrentException == null)
{
@ChildContent
}
else
{
<section class="error-recovery" role="alert">
<h1>Diese Ansicht konnte nicht angezeigt werden</h1>
<p>Ein unerwarteter Fehler ist aufgetreten. Eine Speicherung ist dadurch nicht bestätigt. Bitte vor einer erneuten Buchung zuerst die Liste prüfen.</p>
<p>Ungespeicherte Eingaben dieser Ansicht können verloren sein. Bei erwartbaren Speicherfehlern im Formular bleiben sie erhalten.</p>
<button type="button" class="btn btn-primary" @onclick="Recover">Ansicht erneut versuchen</button>
<a class="btn btn-outline-secondary" href="/">Zur Kontenübersicht</a>
<details><summary>Technische Angaben zur Fehlermeldung</summary>
<p>@diagnostic</p><button type="button" class="btn btn-outline-secondary" @onclick="Copy">Angaben kopieren</button>
</details>
</section>
}
@code {
private string diagnostic = string.Empty;
protected override Task OnErrorAsync(Exception exception)
{
diagnostic = $"Dümpelkas; Fehlerreferenz {Guid.NewGuid():N}; Typ {exception.GetType().Name}";
// Deliberately exclude exception messages/stack traces that may contain booking data.
System.Diagnostics.Trace.TraceError(diagnostic);
return Task.CompletedTask;
}
private async Task Copy()
{
try { await JS.InvokeVoidAsync("navigator.clipboard.writeText", diagnostic); Toasts.Show("Technische Angaben kopiert.", ToastKind.Success); }
catch (JSException) { Toasts.Show("Kopieren ist nicht möglich. Bitte die technischen Angaben markieren und manuell kopieren.", ToastKind.Error); }
}
}
@@ -5,7 +5,7 @@
<p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p> <p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p>
<p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p> <p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p>
<label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label> <label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label>
<AmountInput Id="carryover" Value="amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" /> <AmountInput Id="carryover" Value="@amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" />
<div class="correction-preview"> <div class="correction-preview">
<p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p> <p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p>
<p>Gemeinsamer Anfangsbestand: @Money(InitialBalance) → @Money(InitialBalance + Delta)</p> <p>Gemeinsamer Anfangsbestand: @Money(InitialBalance) → @Money(InitialBalance + Delta)</p>
@@ -55,7 +55,11 @@
focusPrompt = true; focusPrompt = true;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
else if (discardPrompt) await ContinueEditing(); else if (discardPrompt)
{
await ContinueEditing();
await InvokeAsync(StateHasChanged);
}
else await OnCancel.InvokeAsync(); else await OnCancel.InvokeAsync();
} }
@@ -1,3 +1,4 @@
@inject NavigationManager Navigation
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject IJSRuntime JS @inject IJSRuntime JS
@@ -19,11 +20,11 @@
</nav> </nav>
<main class="flex-grow-1 overflow-auto p-3"> <main class="flex-grow-1 overflow-auto p-3">
@Body <AppErrorBoundary @key="Navigation.Uri">@Body</AppErrorBoundary>
</main> </main>
<footer class="app-footer d-flex align-items-center justify-content-center gap-2"> <footer class="app-footer d-flex align-items-center justify-content-center gap-2">
<span>Dümpelkas &copy; @DateTime.Now.Year &middot; Version @AppVersion</span> &middot;<a class="app-footer-about" @onclick="OpenAboutDialog">Über</a> <span>Dümpelkas &copy; @DateTime.Now.Year &middot; Version @AppVersion</span> &middot;<button type="button" class="app-footer-about btn btn-link" @onclick="OpenAboutDialog">Über</button>
</footer> </footer>
</div> </div>
+2 -1
View File
@@ -4,7 +4,8 @@
</Found> </Found>
<NotFound> <NotFound>
<LayoutView Layout="@typeof(MainLayout)"> <LayoutView Layout="@typeof(MainLayout)">
<p>Seite nicht gefunden.</p> <h1>Diese Seite wurde nicht gefunden</h1>
<a class="btn btn-primary" href="/">Zur Kontenübersicht</a>
</LayoutView> </LayoutView>
</NotFound> </NotFound>
</Router> </Router>
@@ -1,4 +1,5 @@
@inject ToastService Toasts @inject ToastService Toasts
@implements IDisposable
@inject ReportingPeriod Period @inject ReportingPeriod Period
@page "/accounts/{AccountId:int}" @page "/accounts/{AccountId:int}"
@inject IAccountService AccountService @inject IAccountService AccountService
@@ -96,7 +97,7 @@
@if (entries != null) @if (entries != null)
{ {
<div class="entries-scroll-wrapper"> <div class="entries-scroll-wrapper">
<EntryTable HighlightedEntryId="lastSaved?.EntryId" Entries="entries" EmptyMessage="emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" /> <EntryTable HighlightedEntryId="lastSaved?.EntryId" Entries="entries" EmptyMessage="@emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
</div> </div>
} }
} }
@@ -6,6 +6,8 @@ namespace Duempelkas.App.Pages.Accounts;
public partial class AccountDetail public partial class AccountDetail
{ {
private bool disposed;
public void Dispose() => disposed = true;
#region Parameters #region Parameters
[Parameter] [Parameter]
@@ -164,7 +166,7 @@ public partial class AccountDetail
await LoadAll(); await LoadAll();
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value); if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
undoToastId = Toasts.Show("Buchung gelöscht.", ToastKind.Success, undoToastId = Toasts.Show("Buchung gelöscht.", ToastKind.Success,
actionLabel: "Rückgängig", action: () => UndoEntryChange(id, true)); actionLabel: "Rückgängig", action: CreateUndoAction(AccountId, id, true));
} }
catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); } catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
finally { entryActionBusy = false; } finally { entryActionBusy = false; }
@@ -195,19 +197,21 @@ public partial class AccountDetail
await LoadAll(); await LoadAll();
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value); if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
undoToastId = Toasts.Show("Buchung wiederhergestellt.", ToastKind.Success, undoToastId = Toasts.Show("Buchung wiederhergestellt.", ToastKind.Success,
actionLabel: "Rückgängig", action: () => UndoEntryChange(id, false)); actionLabel: "Rückgängig", action: CreateUndoAction(AccountId, id, false));
} }
catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); } catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
finally { entryActionBusy = false; } finally { entryActionBusy = false; }
} }
private async Task UndoEntryChange(int id, bool restore) private Func<Task> CreateUndoAction(int accountId, int id, bool restore) => () => UndoEntryChange(accountId, id, restore);
private async Task UndoEntryChange(int accountId, int id, bool restore)
{ {
if (entryActionBusy) return; if (entryActionBusy) return;
entryActionBusy = true; entryActionBusy = true;
try try
{ {
var current = (await EntryService.GetEntriesAsync(AccountId, (int?)null)).FirstOrDefault(e => e.Id == id); var current = (await EntryService.GetEntriesAsync(accountId, (int?)null)).FirstOrDefault(e => e.Id == id);
if (current == null || current.IsDeleted != restore) if (current == null || current.IsDeleted != restore)
{ {
Toasts.Show("Die Buchung wurde inzwischen geändert. Bitte die aktuelle Liste prüfen."); Toasts.Show("Die Buchung wurde inzwischen geändert. Bitte die aktuelle Liste prüfen.");
@@ -215,7 +219,12 @@ public partial class AccountDetail
} }
if (restore) await EntryService.RestoreEntryAsync(id); else await EntryService.DeleteEntryAsync(id); if (restore) await EntryService.RestoreEntryAsync(id); else await EntryService.DeleteEntryAsync(id);
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value); if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
await LoadAll(); if (!disposed)
{
await LoadAll();
await InvokeAsync(StateHasChanged);
}
else Navigation.NavigateTo($"/accounts/{accountId}");
Toasts.Show("Änderung rückgängig gemacht.", ToastKind.Success); Toasts.Show("Änderung rückgängig gemacht.", ToastKind.Success);
} }
finally { entryActionBusy = false; } finally { entryActionBusy = false; }
@@ -56,7 +56,7 @@ else
@code { @code {
private int step; private int step;
private bool loading = true, busy, showAccount; private bool loading = true, busy, showAccount, advancing;
private string clubName = string.Empty; private string clubName = string.Empty;
private List<AccountSummaryDto> accounts = []; private List<AccountSummaryDto> accounts = [];
private FileOperationResult? backupResult; private FileOperationResult? backupResult;
@@ -67,7 +67,14 @@ else
accounts = await Accounts.GetAllAccountsAsync(); accounts = await Accounts.GetAllAccountsAsync();
loading = false; loading = false;
} }
private async Task Next() { step = Math.Min(step + 1, 4); await Settings.SetSetupStepAsync(step); } private async Task Next()
{
if (advancing) return;
advancing = true;
try { var next = Math.Min(step + 1, 4); await Settings.SetSetupStepAsync(next); step = next; }
catch (Exception ex) { Toasts.Show("Der Fortschritt konnte nicht gespeichert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
finally { advancing = false; }
}
private async Task SaveClub() private async Task SaveClub()
{ {
if (busy || string.IsNullOrWhiteSpace(clubName)) return; if (busy || string.IsNullOrWhiteSpace(clubName)) return;
+1 -1
View File
@@ -120,7 +120,7 @@ class Program
AppDomain.CurrentDomain.UnhandledException += (sender, error) => AppDomain.CurrentDomain.UnhandledException += (sender, error) =>
{ {
app.MainWindow.ShowMessage("Schwerwiegender Fehler", error.ExceptionObject.ToString()); app.MainWindow.ShowMessage("Dümpelkas konnte nicht fortgesetzt werden", "Bitte das Programm schließen und neu öffnen. Eine Speicherung ist nicht bestätigt; vor erneutem Buchen die Liste prüfen. Fehlerart: " + error.ExceptionObject.GetType().Name);
}; };
app.Run(); app.Run();
@@ -702,3 +702,9 @@ button,summary { touch-action: manipulation; }
.entry-filters > div { flex: 1 1 14rem; min-width: 0; } .entry-filters > div { flex: 1 1 14rem; min-width: 0; }
.entry-filters input,.entry-filters select { width: 100%; box-sizing: border-box; padding: .5rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); } .entry-filters input,.entry-filters select { width: 100%; box-sizing: border-box; padding: .5rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); }
.search-summary { padding: .6rem; border-left: 3px solid var(--color-accent); } .search-summary { padding: .6rem; border-left: 3px solid var(--color-accent); }
.text-danger { color: var(--color-expense) !important; }
.text-success { color: var(--color-income) !important; }
.entry-deleted td { text-decoration: none; }
.entry-deleted .entry-title, .entry-deleted td[data-label="Betrag"] { text-decoration: line-through; }
+5 -9
View File
@@ -109,21 +109,17 @@
})(); })();
</script> </script>
<title>Dümpelkas – Kassenbuch</title> <title>Dümpelkas – Kassenbuch</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" <link href="vendor/bootstrap/bootstrap.min.css" rel="stylesheet" />
rel="stylesheet" <link href="vendor/bootstrap-icons/bootstrap-icons.min.css" rel="stylesheet" />
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1nDaGm7AT/+aIi56blBBJoSpcBx5Hn"
crossorigin="anonymous" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" /> <link href="css/app.css" rel="stylesheet" />
</head> </head>
<body> <body>
<app>Laden...</app> <app>Laden...</app>
<div id="blazor-error-ui"> <div id="blazor-error-ui">
Ein unbehandelter Fehler ist aufgetreten. Die Ansicht konnte wegen eines unerwarteten Fehlers nicht fortgesetzt werden. Eine Speicherung ist nicht bestätigt; bitte vor erneutem Buchen die Liste prüfen.
<a href="" class="reload">Neu laden</a> <a href="" class="reload">Ansicht neu laden</a>
<a class="dismiss">×</a> <button type="button" class="dismiss" aria-label="Fehlerhinweis schließen">×</button>
</div> </div>
<script src="js/app.js"></script> <script src="js/app.js"></script>
+8
View File
@@ -0,0 +1,8 @@
Bootstrap 5.3.3 and Bootstrap Icons 1.11.3, MIT-licensed. Vendored from the corresponding npm releases via cdn.jsdelivr.net; original licenses are in each directory. Font query hashes were removed for local WebView paths.
bootstrap\bootstrap.min.css SHA256 3c8f27e6009ccfd710a905e6dcf12d0ee3c6f2ac7da05b0572d3e0d12e736fc8
bootstrap\LICENSE SHA256 8c14611ae41ac6fd543c13349f22188eb12c69b3e59105c5eca3925a8e4eca3e
bootstrap-icons\bootstrap-icons.min.css SHA256 ea5f11e3455e7ec8cdfec5a46a6f0905308979d60cb2777c9246bc04cd37cff7
bootstrap-icons\LICENSE SHA256 0fb3e11bd57e896c5a512afd64864d28a37de45d19835016c87ca1ad19ead969
bootstrap-icons\fonts\bootstrap-icons.woff SHA256 bb1de989b83970f6f4e54de1cd974c5cba55b73582da5e1b225a6d0edf029483
bootstrap-icons\fonts\bootstrap-icons.woff2 SHA256 476adf42b40325098fcfa8b36ab3e769186bb4f6ce6a249753e2e1a9c22bf99e
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019-2024 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2011-2024 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -37,7 +37,9 @@ public class CarryoverCorrectionTests
var dialog = f.Context.Render<EditCarryoverDialog>(p => p.Add(x => x.CurrentAmount, 110m) 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.InitialBalance, 100m).Add(x => x.Year, 2026).Add(x => x.AccountName, "Barkasse")
.Add(x => x.OnSave, value => saved = value)); .Add(x => x.OnSave, value => saved = value));
Assert.Equal("110,00", dialog.Find("#carryover").GetAttribute("value"));
dialog.Find("#carryover").Input("120,00"); 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("frühere und spätere Jahre", dialog.Markup);
Assert.Contains("110,00", dialog.Find(".correction-preview").TextContent); Assert.Contains("110,00", dialog.Find(".correction-preview").TextContent);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
@@ -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")));
}
}
@@ -27,6 +27,7 @@ public class EntryLifecycleUiTests
Assert.Contains("zählt nicht zum Kontostand", page.Markup); Assert.Contains("zählt nicht zum Kontostand", page.Markup);
page.Find(".toast-success .btn").Click(); page.Find(".toast-success .btn").Click();
page.WaitForAssertion(() => Assert.Single(page.FindAll("tbody tr"))); 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)); Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.False(e.IsDeleted));
} }
} }
@@ -8,6 +8,15 @@ namespace Duempelkas.Tests;
public class EntryPresentationTests 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] [Fact]
public void Actions_HaveVisibleTextAndBookingSpecificNames() public void Actions_HaveVisibleTextAndBookingSpecificNames()
{ {
@@ -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,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>");
}
}
@@ -23,6 +23,9 @@ public class ModalDialogTests
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync()); await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.Equal(1, cancelled); Assert.Equal(1, cancelled);
Assert.Contains("Ungespeicherte Eingaben", modal.Markup); 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(); modal.Find("[data-discard-continue]").Click();
Assert.Empty(modal.FindAll("[role=alertdialog]")); Assert.Empty(modal.FindAll("[role=alertdialog]"));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync()); await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());