Compare commits

...
4 Commits
25 changed files with 787 additions and 234 deletions
+183
View File
@@ -0,0 +1,183 @@
# Usability-Analyse: Dümpelkas für Menschen mit wenig Computererfahrung
Stand: 1. November 2026. Untersuchte Quellcodebasis: `9823945` auf `main`; laufende Windows-Debug-Anwendung: Version 26.305 aus `.artifacts/bin/Debug/net10.0`.
## Ziel und Ergebnis
Eine gelegentlich arbeitende Kassenführung soll ohne Fachwissen erkennen: Wo bin ich? Was muss ich eingeben? Hat das Speichern funktioniert? Wie komme ich nach einem Fehler zurück?
Dümpelkas hat dafür eine gute Grundlage: wenige Hauptansichten, große Schaltflächen für häufige Aufgaben, sichtbare Kontostände, Vorzeichen bei Beträgen und wiederherstellbare Buchungen. Die größten Verbesserungen liegen bei verständlichen Beschriftungen, verlässlichen Rückmeldungen, dem Jahreskontext und einer sicheren Datenrettung. Zusätzliche Rückfragen bei jeder gewöhnlichen Buchung würden den Alltag eher erschweren; Bestätigungen sollten auf folgenreiche Aktionen konzentriert bleiben.
**Empfohlener Start:** zuerst U01–U03 für verlässliche Daten und Ausgaben, danach U04–U08 für die tägliche Bedienung. U09–U16 ergänzen Orientierung, Lesbarkeit und Komfort. Die Priorität bezeichnet eine Empfehlung, keine bereits beschlossene Umsetzung.
## Vorgehen und Grenzen der Aussage
- Erneute Quellcodeprüfung von Startseite, Kontoansicht, Buchungs- und Umbuchungsdialog, Übertrag, Bestätigungen, Kontokarten, Buchungslisten, Layout, CSS, Datenpfad, Sicherung und PDF-Ausgabe.
- Erneute Live-Prüfung über den Computer-Use-Skill: Kontoansicht mit aktiven und gelöschten Zeilen, Öffnen des neuen Buchungsdialogs, Accessibility-Baum und Fokus, Escape-Verhalten und Abbrechen. Zusätzlich wurde der neue Umbuchungsdialog geöffnet, seine Felder geprüft und ohne Speichern geschlossen. Weitere Dialoge wurden in diesem Durchlauf anhand des Codes beurteilt.
- Die bestehende Testbuchung aus dem vorausgehenden Update ist vorhanden. Für diese Analyse werden keine neuen Buchungen angelegt und keine vorhandenen Daten geändert. Keine Sicherung wird zurückgespielt.
- Bestehende [Bedienungsanleitung](bedienungsanleitung.md) und [Programmstand](programmstand.md) dienen als Orientierung; die hier aufgeführten Codebefunde wurden erneut an den Quellen geprüft.
- Dies ist eine fachliche Oberflächenprüfung durch einen Reviewer, kein Nutzertest mit der Zielgruppe. Aussagen wie „kann verwirren“ sind begründete Hypothesen. Keine gemessenen Fehlerraten, keine behauptete Barrierefreiheitszertifizierung.
- Nicht in diesem Durchlauf geprüft: vollständiger Screenreader-Ablauf, Offline-Start, mehrere Bildschirm-Skalierungen, große Datenmengen, echte Wiederherstellung und neu erzeugte PDF-Dateien. Codebefunde dazu sind ausdrücklich von Live-Beobachtungen getrennt.
## Prioritäten und Aufwand
**P0:** vor einer verlässlichen Nutzung durch unerfahrene Personen beheben; Datenvertrauen oder Wiederherstellbarkeit betroffen. **P1:** hoher Nutzen für häufige Aufgaben und Fehlervermeidung. **P2:** Komfort und nachhaltige Einführung. Aufwand **S/M/L** ist eine relative Einschätzung für Implementierung einschließlich gezielter Prüfung, keine Zeit- oder Kostenzusage.
## Empfohlene Maßnahmen mit Umsetzungskriterien
### U01 · Sicherung und Wiederherstellung müssen dieselben aktiven Daten verwenden
**P0 · L · Codebefund.** [Programmstart](../src/Duempelkas.Desktop/Program.cs) öffnet `%APPDATA%/Duempelkas/duempelkas.db`; [BackupService](../src/Duempelkas.Infrastructure/Services/BackupService.cs) verwendet die Datei neben dem Prozessprogramm. Eine Person kann deshalb glauben, ihre Buchungen gesichert oder wiederhergestellt zu haben, obwohl eine andere Datei betroffen ist. Zusätzlich ist die rohe Dateikopie bei aktiver SQLite-Nutzung keine hinreichend geprüfte Sicherungsstrategie.
**Maßnahme:** eine gemeinsame Quelle für den aktiven Datenpfad; konsistente SQLite-Sicherung; Prüfung einer gewählten Sicherung vor dem Austausch; koordinierter Zugriff während der Wiederherstellung; automatische Rückfallsicherung der bisherigen aktiven Daten. Umfang verständlich erklären: derzeit enthält die Sicherung keine Vereinseinstellungen oder Darstellung. Eine automatische Sicherung beim Beenden erst ergänzen, wenn Fehlerbehandlung, Aufbewahrung und Wiederherstellbarkeit festgelegt sind. Eine Kopie auf derselben Festplatte nicht als Schutz gegen Geräteausfall darstellen.
**Abnahme:** Eine neue Testbuchung ist nach Sicherung, weiterer Änderung und Wiederherstellung in einer isolierten Testumgebung exakt im erwarteten Stand vorhanden. Beschädigte oder ungeeignete Dateien werden vor dem Austausch abgewiesen; die bisherigen Daten bleiben nutzbar. Auch bei SQLite-WAL-Nutzung gehen keine bestätigten Buchungen verloren. Dokumentieren, welche Einstellungen enthalten sind. Wiederherstellung niemals zur Abnahme auf dem echten Vereinsdatenbestand ausprobieren.
### U02 · Jede Dateioperation mit verständlichem Ergebnis abschließen
**P0 · M · Codebefund.** [Dashboard.HandleBackupAsync](../src/Duempelkas.App/Pages/Dashboard.razor.cs) zeigt nur Texte an, die „fehlgeschlagen“ enthalten. „Keine Datenbankdatei gefunden.“ und erfolgreiche Sicherungen bleiben damit unsichtbar. [FileSaveService](../src/Duempelkas.Infrastructure/Services/FileSaveService.cs) und PDF-Aufrufer bieten keinen durchgehenden nutzerfreundlichen Fehlerpfad.
**Maßnahme:** strukturierte Ergebnisse für Erfolg, Abbruch und Fehler; Meldung beispielsweise „Sicherung gespeichert: …“, mit „Ordner öffnen“. Abbruch neutral behandeln. Fehler nennen den nächsten sicheren Schritt, etwa einen anderen Speicherort wählen; technische Details nur auf Wunsch. Während längerer Aktionen Status und Sperre gegen mehrfaches Starten anzeigen.
**Abnahme:** Erfolg, Abbruch, fehlende Quelldatei, Schreibschutz und fehlgeschlagenes Öffnen sind unterscheidbar. Ein Fehler führt weder zu einer Erfolgsmeldung noch zum Verlust der aktuellen Ansicht. Die Meldung ist auch per Hilfstechnologie wahrnehmbar. Abhängig von U01.
### U03 · PDF und Bildschirmsummen müssen dieselbe Bedeutung haben
**P0 · M · Codebefund.** [GenerateStatementAsync](../src/Duempelkas.Infrastructure/Services/PdfStatementService.cs) druckt alle gelieferten Einträge, einschließlich gelöschter Zeilen, ohne Löschkennzeichnung. Die Saldenberechnung lässt gelöschte Einträge aus; die gemeinsame PDF filtert sie ebenfalls aus. Für eine Kassenführung sieht das wie ein Rechenfehler aus.
**Maßnahme:** normale Auszüge enthalten nur wirksame Buchungen. Falls eine Historie gewünscht ist, diese ausdrücklich als separaten Modus mit Status und erklärter Summenbildung anbieten. Zeitraum, Konto und Bedeutung des Endstands in jedem Export eindeutig nennen.
**Abnahme:** Beispiel mit Einnahme, Ausgabe, Umbuchung und gelöschter Buchung liefert erklärbar gleiche Summen in Kontoansicht, Einzelkonto-PDF und gemeinsamer Übersicht. Die unterschiedliche Darstellung interner Umbuchungen muss dabei ausdrücklich berücksichtigt werden. Tests prüfen Inhalte und Summen; eine visuelle PDF-Prüfung kontrolliert Lesbarkeit und Seitenumbrüche.
### U04 · Jahreskontext beim Navigieren und Buchen erhalten
**P1 · M · Codebefund.** [Dashboard](../src/Duempelkas.App/Pages/Dashboard.razor.cs) und [AccountDetail](../src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs) starten jeweils unabhängig im aktuellen Jahr. Neue Dialoge verwenden immer das heutige Datum. Eine Buchung kann damit außerhalb des sichtbaren Jahres gespeichert werden; ein auf der Startseite gewähltes Jahr geht beim Kontoaufruf verloren.
**Maßnahme:** „Jahr: 2026“ statt „Ansicht 2026“, Filter beim Wechsel zwischen Übersicht und Konto erhalten, aktuellen Zeitraum neben Liste und Summen nennen. Bei abweichendem Buchungsjahr vor dem Speichern gezielt informieren und danach „Buchung im Jahr 2025 anzeigen“ anbieten. Kein stilles Umsetzen des Datums auf ein anderes Jahr. Bei leerer Liste zwischen „noch keine Buchungen“ und „keine Buchungen in diesem Zeitraum“ unterscheiden.
**Abnahme:** Jahr 2025 auf der Übersicht wählen, Konto öffnen und zurückkehren erhält 2025. Eine Buchung mit Datum 2026 wird im 2025-Filter erklärt und auffindbar gemacht. Auch „Alle Jahre“, Jahreswechsel und eine noch leere Jahresauswahl funktionieren. Ein fachlich unverändertes Datum darf nicht allein aus Bequemlichkeit umgeschrieben werden.
### U05 · Aufgaben in Alltagssprache benennen und gewichten
**P1 · S · Code und sichtbare Kontoansicht.** Große Schaltflächen sind bereits hilfreich; „Buchung“, „PDF“, „Restore“, „Saldo“ und „Umsätze“ verlangen jedoch Vorwissen. Auf der Startseite stehen häufige und seltene Aktionen gleichberechtigt nebeneinander.
**Maßnahme:** „Einnahme oder Ausgabe erfassen“, „Geld zwischen Konten verschieben“, „Als PDF speichern“, „Sicherung erstellen“, „Sicherung zurückspielen“. „Kontostand“ statt „Saldo“; „Änderung im Jahr“ statt des uneindeutigen Netto-Begriffs „Umsätze“. Eine konsistente Anrede verwenden. Sicherung zurückspielen unter klar benannten weiteren Aktionen platzieren, weiterhin auffindbar. Sichtbarer Hinweis beim Kontenverschieben: „Erfasst nur die Buchung. Führt keine Banküberweisung aus.“
**Abnahme:** Beschriftungen passen ohne Abschneiden in die überarbeitete Leiste; die bisherige feste Breite von 75 px muss dafür angepasst werden. Eine Testperson findet Erfassen, PDF und Sicherung ohne Erklärung von Symbolen. Vertraute Begriffe wie „Umbuchung“ können als ergänzende Erläuterung erhalten bleiben.
### U06 · Buchungsformular erklärt Konto, Art und ungültige Eingaben
**P1 · M · Code und Live-Dialog.** Der neue Dialog zeigt „Neue Buchung“, standardmäßig Einnahme, Betrag 0 und einen deaktivierten Hinzufügen-Knopf. Es fehlt ein sichtbarer Grund für die Sperre; das betroffene Konto steht nicht im Dialog. Beim Bearbeiten fehlt die Art-Auswahl vollständig.
**Maßnahme:** Konto im Titel nennen, Einnahme/Ausgabe deutlich auswählbar machen und beim Bearbeiten wenigstens sichtbar anzeigen. Beispiele wie „Mitgliedsbeitrag“ oder „Porto“; Hinweis „Betrag ohne Minuszeichen“. Feldnahe Hinweise bei fehlender Beschreibung, ungültigem Datum oder Betrag. Eingaben `12,50`, `12.50` und eingefügte Beträge bewusst spezifizieren und ohne heimliche Wertänderung behandeln. Eine versehentlich falsche Buchungsart braucht einen verständlichen Korrekturweg; vor einer direkten Änderung fachliche Regeln festlegen.
**Abnahme:** Leeres Formular erklärt, was fehlt. Deutsche Dezimaleingaben und mehr als zwei Nachkommastellen haben ein definiertes, geprüftes Verhalten. Negative Beträge werden verständlich behandelt. Das Speichern bleibt nach Fehlern mit erhaltenen Eingaben möglich; die Buchungsart ist jederzeit erkennbar.
### U07 · Speichern bestätigen und Doppelbuchungen verhindern
**P1 · M · Codebefund.** [AddEntryDialog](../src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs) und [AddTransferDialog](../src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs) haben keinen sichtbaren Speichervorgang und keine eigene Sperre gegen erneutes Auslösen. Nach Erfolg schließt der Dialog ohne ausdrückliche Bestätigung auf der Kontoseite.
**Maßnahme:** während des Speicherns „Wird gespeichert …“, Aktion gegen Mehrfachauslösung schützen, nach Erfolg „Einnahme über 12,50 € gespeichert“ und die neue Zeile kurz hervorheben. Bei Fehlern Eingaben erhalten. Für Serienerfassung optional „Speichern und weitere erfassen“ ergänzen, ohne die normale Aktion zu verkomplizieren. Ähnliche Buchungen allenfalls als Hinweis behandeln, da gleiche Beträge legitim sind.
**Abnahme:** Doppelklick und wiederholtes Enter während einer verzögerten Speicherung erzeugen genau eine Buchung. Ein fehlgeschlagener Versuch erzeugt keine irreführende Bestätigung. Erneutes bewusstes Erfassen bleibt möglich. Kein zusätzlicher Bestätigungsdialog bei jeder normalen Einnahme.
### U08 · Dialoge und Kontokarten vollständig per Tastatur bedienen
**P1 · M · Code und Live-Teilprüfung.** Dialoge bestehen aus allgemeinen `div`-Elementen, Beschriftungen sind überwiegend nicht per `for`/`id` mit Feldern verbunden. [Kontokarten](../src/Duempelkas.App/Components/Accounts/AccountCard.razor) sind klickbare `div`s. Beim live geöffneten Buchungsdialog wurde kein Eingabefeld automatisch fokussiert; Escape schloss ihn nicht. Der Accessibility-Baum enthielt weiterhin Hintergrundelemente; ein vollständiger Screenreader-Test wurde nicht durchgeführt.
**Maßnahme:** semantische Links für Konten, echte Schaltflächen für Aktionen, Dialogrolle und beschrifteter Titel, Fokus ins erste sinnvolle Feld, Fokus im Dialog halten und beim Schließen zum Auslöser zurückgeben. Escape schließt unveränderte Dialoge. Bei veränderten Eingaben Verlust abfangen. Enter nur in passenden Formularen als Speichern, nie als unbeabsichtigte Löschbestätigung. Labels, Fehlermeldungen und Eingaben technisch verbinden.
**Abnahme:** Konto öffnen, Buchung eingeben und abbrechen sind ausschließlich mit Tab, Umschalt-Tab, Enter und Escape möglich. Während eines modalen Dialogs sind Hintergrundaktionen nicht bedienbar. Fokus ist sichtbar, Titel/Feldname/Fehler werden vorgelesen. Unsichere Bestätigungen starten auf Abbrechen.
### U09 · Kleine Symbolaktionen lesbar und leichter treffbar machen
**P1 · S · Code und Live-Ansicht.** Stift, Papierkorb und Wiederherstellen sind kleine Symbolknöpfe. Tooltips helfen erst beim Darüberfahren; in langen Zeilen ist die Zuordnung fehleranfällig. `title`-Attribute sind bereits vorhanden, ersetzen aber keine sichtbare Erklärung.
**Maßnahme:** größere Trefferflächen und sichtbare Texte oder ein pro Zeile beschriftetes Aktionsmenü. Als Projektziel etwa 40–44 CSS-Pixel hohe Trefferbereiche vorsehen. Eindeutige zugängliche Namen mit Buchungsbezug; Bearbeiten und Löschen mit ausreichend Abstand. Wiederherstellen als Wort anbieten.
**Abnahme:** ungeübte Mausbedienung trifft die gewünschte Aktion zuverlässig, auch bei Windows-Skalierung. Tab-Fokus nennt die zugehörige Buchung. Kein Verlust von Tabelleninhalt durch unnötig große Aktionsspalten; bei schmaler Ansicht ein geprüftes Alternativlayout.
### U10 · Löschen, Wiederherstellen und verbundene Umbuchungen erklären
**P1 · M · Code und sichtbare gelöschte Zeilen.** Durchgestrichene Buchungen bleiben zwischen aktiven Zeilen stehen. Die [Bestätigungen](../src/Duempelkas.App/Pages/Accounts/AccountDetail.razor) nennen nur den Titel; [EntryService](../src/Duempelkas.Infrastructure/Services/EntryService.cs) verändert bei Umbuchungen auch die Gegenbuchung.
**Maßnahme:** gelöschte Einträge standardmäßig ausblenden, mit „Gelöschte Buchungen anzeigen (2)“ erreichbar halten; sichtbarer Status „Gelöscht – zählt nicht zum Kontostand“. Bestätigung mit Datum, Betrag und Konto. Bei Umbuchungen beide betroffenen Konten und gemeinsame Wirkung nennen. Nach Erfolg eine klar zugeordnete Rückgängig-Aktion anbieten, soweit fachlich und technisch eindeutig.
**Abnahme:** Entfernen und Wiederherstellen einer Umbuchung aktualisiert beide Seiten nachvollziehbar; Bestätigung benennt dies vorab. Versehentliches Löschen ist ohne Fachwissen rückgängig zu machen. Eine sichtbare Historie darf nicht als mitgerechnete Buchung missverstanden werden. Abhängig von U03 für konsistente Exporte.
### U11 · Umbuchungen als Richtung zwischen zwei Konten zeigen
**P1 · M · Codebefund.** [AddTransferDialog](../src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor) zeigt nur „Zielkonto“. Beim Bearbeiten einer empfangenen Buchung ist das angezeigte verknüpfte Konto jedoch das abgebende Konto. Das begünstigt eine falsche Interpretation.
**Maßnahme:** immer „Von: Barkasse → Nach: Girokonto“ zeigen; auf beiden Buchungsseiten dieselbe fachliche Richtung. Den festen bzw. änderbaren Teil ausdrücklich benennen. Kurze Zusammenfassung „Barkasse −50,00 €, Girokonto +50,00 €; Gesamtsumme unverändert“. Bei nur einem vorhandenen Konto erklären, warum keine Umbuchung möglich ist.
**Abnahme:** Öffnen von beiden Seiten zeigt dieselbe Richtung. Änderungen betreffen die beabsichtigte Seite und behalten die Verknüpfung. Gleiche Quelle und Ziel werden auch im Dienst abgewiesen. Geld wird nicht doppelt als Vereinseinnahme dargestellt.
### U12 · Anfangsbestand und Jahresübertrag vor unbeabsichtigter Änderung schützen
**P1 · L · Codebefund.** [UpdateCarryoverAsync](../src/Duempelkas.Infrastructure/Services/AccountService.cs) verändert den gemeinsamen Anfangsbestand anhand des gewünschten Jahresübertrags. Der [Dialog](../src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor) zeigt weder Jahr noch Folgen. Ein scheinbarer lokaler Jahreswert kann andere Jahre beeinflussen.
**Maßnahme:** Einrichtung eines neuen Kontos und spätere Bestandskorrektur getrennt erklären. Einfache Formel anzeigen: „Anfangsbestand + Einnahmen − Ausgaben = Kontostand“. Spätere Änderung als bewusste Korrektur mit vorher/nachher und Hinweis auf betroffene Jahre. Ob künftig jahresbezogene Korrekturen statt eines gemeinsamen Anfangsbestands gebraucht werden, muss fachlich entschieden werden; das ist keine reine Textänderung.
**Abnahme:** Eine Testperson kann die Wirkung vor dem Speichern richtig erklären. Automatisierte Mehrjahresbeispiele prüfen die vereinbarte Semantik. Bei Änderung der Speicherung sind Datenmigration und Bestandserhalt eigene Abnahmepunkte.
### U13 · Schrift, Kontrast und Fenstergröße an reale Arbeitsplätze anpassen
**P1 · M · CSS und Live-Ansicht; keine gemessene Kontrastbewertung.** [app.css](../src/Duempelkas.Desktop/wwwroot/css/app.css) verwendet viele kleine Schriftgrößen und halbtransparente gelöschte Zeilen. Die Dialoge haben feste Mindestbreiten und keine eigene begrenzte Höhe mit Scrollbereich. Im aufgenommenen dunklen Fenster sind Zusatztexte und Symbolaktionen deutlich kleiner als die Hauptsumme.
**Maßnahme:** gut lesbare Grundschrift, weniger kleine Großbuchstaben, skalierbare Abstände und optional „Schriftgröße: normal/groß“. Beide Farbdarstellungen mit gemessenem Kontrast prüfen, Zeichen und Text zusätzlich zu Farben erhalten. Dialoge müssen innerhalb des verfügbaren Fensters scrollbar bleiben; wichtige Knöpfe dürfen nicht außerhalb des sichtbaren Bereichs liegen.
**Abnahme:** alle Hauptaufgaben im unterstützten Mindestfenster 768 × 480 sowie bei 125/150/200 % Windows-Skalierung prüfen. Lange Namen, Beträge und Fehlermeldungen bleiben lesbar; kein verdecktes Speichern/Abbrechen. Helle und dunkle Darstellung getrennt kontrollieren. Kein Versprechen vollständiger Barrierefreiheit allein aufgrund dieser Stichprobe.
### U14 · Erste Nutzung und Hilfe direkt am Bedarf anbieten
**P2 · M · Codebefund.** Die leere Startseite fordert zum ersten Konto auf; der Kontodialog fragt nur nach einem Namen. Die vorhandene ausführliche Anleitung ist im [Layout](../src/Duempelkas.App/Components/Layout/MainLayout.razor) nicht als Hilfe erreichbar.
**Maßnahme:** überspringbare kurze Einführung: Vereinsname, erstes Konto mit Beispielen, erklärter Anfangsbestand, erste Buchung, Sicherung. „Hilfe“ sichtbar anbieten, lokal verfügbar und passend zur Programmversion. Kurze Beispiele direkt bei Übertrag und Umbuchung; vollständige Anleitung ergänzend. Keine Pflichtregistrierung und keine langen Erklärungen vor jeder Buchung.
**Abnahme:** eine neue Person richtet mit selbst gewählten Angaben ein Konto ein und findet die Hilfe ohne Internet. Überspringen und späteres Fortsetzen funktionieren. Keine automatischen Beispielbuchungen im echten Datenbestand. U01 und U12 vor einem geführten Sicherungs-/Anfangsbestandsablauf umsetzen.
### U15 · Buchungen ohne langes Scrollen wiederfinden
**P2 · M · Codebefund.** [EntryTable](../src/Duempelkas.App/Components/Accounts/EntryTable.razor) bietet keine Suche und keine bedienbare Sortierung. Bei vielen Einträgen reichen Jahresfilter und fortlaufende Nummer allein nicht aus.
**Maßnahme:** einfache Suche nach Bezeichnung oder Buchungsnummer, optional Datum und Betrag. Aktive Filter mit „Filter zurücksetzen“ sichtbar machen. Falls später Seitennavigation nötig wird, Trefferzahl und Position zeigen. Bei gefilterter Liste eindeutig zwischen Kontostand des Jahres und Summe der Treffer unterscheiden.
**Abnahme:** eine bekannte Buchung wird aus einem größeren Beispieldatenbestand ohne Durchscrollen gefunden. „Keine Treffer“ enthält eine Rückkehr zur vollständigen Liste. Suchen ändert den Kontostand nicht stillschweigend. Sonderzeichen und deutsche Betragsnotation sind berücksichtigt.
### U16 · Offline-Betrieb und verständliche Fehlerauswege absichern
**P1 · M · Codebefund, Offline-Auswirkung noch zu prüfen.** [index.html](../src/Duempelkas.Desktop/wwwroot/index.html) lädt Bootstrap und Symbole von externen CDNs. Das allgemeine Fehlerbanner spricht von einem „unbehandelten Fehler“. Die Nicht-gefunden-Ansicht bietet keinen eigenen Rückweg.
**Maßnahme:** benötigte Darstellungsmittel lokal mitliefern, wenn das Kassenbuch offline nutzbar sein soll. Fehlertexte mit „Was ist passiert?“ und sicherer nächster Aktion; keine technischen Stapelspuren als einzige Hilfe. Eingaben nach erwartbaren Speicherfehlern erhalten. Für unerwartete Fehler einen Hinweis zur Meldung, mit optional kopierbaren technischen Details ohne unnötige personenbezogene Buchungsdaten. Nicht gefunden: „Zur Kontenübersicht“ anbieten.
**Abnahme:** Start ohne Netz und mit leerem WebView-Cache zeigt lesbare, bedienbare Oberflächen einschließlich Symbolen. Simulierter Speicherfehler bewahrt Eingaben; Neuladen behauptet keinen unbestätigten Speichervorgang. Offline-Verhalten ist vor einer entsprechenden Produktzusage getestet.
## Vorschlag für spätere Umsetzungspakete
Die IDs bleiben stabil, damit dieser Pull Request als Diskussions- und Auswahlgrundlage dienen kann. Eine Auswahl bedeutet noch keine abgeschlossene Umsetzung.
- [ ] **A – Verlässliche Daten:** U01, U02, U03; isolierte Sicherungs-/Restore-Tests und PDF-Inhaltsprüfung zuerst.
- [ ] **B – Sicher erfassen:** U04, U06, U07, U11, U12; Jahreskontext, Eingabe und fachliche Auswirkungen gemeinsam behandeln.
- [ ] **C – Verständlich bedienen:** U05, U08, U09, U10, U13; konsistente Komponenten für Dialoge und Aktionen verwenden.
- [ ] **D – Selbstständig arbeiten:** U14, U15, U16; Offline-Grundlage U16 kann wegen P1 vorgezogen werden.
**Bewusst zurückstellen:** Bankanbindung, umfangreiche Kategorien, neue Auswertungszentralen, automatische fachliche Korrekturen und ein vollständiger Umbau der Oberfläche. Dafür liegt hier kein belegter Bedarf vor. Auch eine Jahressperre wäre ein eigener fachlicher Auftrag, keine beiläufige Usability-Änderung.
## Validierung mit der tatsächlichen Zielgruppe
Vor größerer Umsetzung drei bis fünf Personen mit geringer Computererfahrung an einem separaten Beispieldatenbestand beobachten. Diese kleine Runde liefert qualitative Hinweise, keine repräsentativen Kennzahlen. Hilfestellung erst geben, wenn eine Aufgabe sonst abgebrochen würde; Stellen und Gründe protokollieren.
1. Ein Konto öffnen und seinen Kontostand erklären.
2. Einen Mitgliedsbeitrag von 12,50 € und eine Ausgabe von 4,20 € erfassen.
3. Eine Buchung aus dem Vorjahr finden und erklären, weshalb sie im aktuellen Jahr nicht sichtbar ist.
4. Eine versehentlich erfasste Buchung korrigieren; Löschen und Rückweg verstehen.
5. 50 € von Barkasse nach Girokonto umbuchen und beide Kontostände sowie die Vereinssumme erklären.
6. Einen Jahresauszug speichern und die Datei wiederfinden.
7. Eine Sicherung erstellen, deren Speicherort nennen und in einer isolierten Testumgebung den vorherigen Stand wiederherstellen.
Je Aufgabe festhalten: ohne Hilfe abgeschlossen, Zahl der nötigen Hilfen, Fehlbuchungen, verlorene Eingaben, subjektive Sicherheit sowie auffällige Begriffe. Zeiten können Orientierung geben; zunächst keine willkürlichen Zeitgrenzen als Erfolgsversprechen setzen. Ziel ist, dass die Kernaufgaben ohne Erklärung der Symbole und ohne Eingriff einer technischen Hilfsperson gelingen.
## Prüfnachweis für diesen Dokumentations-PR
Nur Analyse und Planung; keine Änderung am Programmverhalten. Vor Veröffentlichung Markdown-Verweise auf lokale Quellen, eindeutige Maßnahmen-IDs und `git diff --check` prüfen. Automatisierte Anwendungstests werden für diese reine Dokumentationsänderung nicht erneut ausgeführt; die vorherige Prüfung des Basiscommits bestand mit 12/12 Tests in Debug und Release. Das ersetzt keine spätere Abnahme der hier empfohlenen Änderungen.
+29
View File
@@ -0,0 +1,29 @@
# Umsetzung U01–U07
Reihenfolge und je ein eigener Commit:
1. **U01:** Gemeinsamer Datenpfad, SQLite-Online-Sicherung, geprüfte Wiederherstellung mit Rückfallsicherung und koordinierten Schreibzugriffen. Nur isolierte Testdaten wiederherstellen.
2. **U02:** Strukturierte Ergebnisse (Erfolg, Abbruch, Fehler), sichtbare Meldungen, sichere Dateiaktionen und Sperre während Dateioperationen.
3. **U03:** Nur wirksame Buchungen exportieren; Zeitraum, Anfangs-/Endstand und interne Umbuchungen erklären. PDF-Inhalte und Darstellung prüfen.
4. **U04:** Gemeinsamer Jahreskontext, sichtbarer Zeitraum, Hinweis und Rückweg bei abweichendem Buchungsjahr; Datum beibehalten.
5. **U05:** Alltagssprache, flexible Aktionsleiste, Wiederherstellung unter weiteren Aktionen, Hinweis zur Umbuchung.
6. **U06:** Konto und Art anzeigen, feldnahe Validierung. Komma oder Punkt als Dezimaltrenner, keine Tausendertrenner; höchstens zwei Nachkommastellen, positive Beträge. Art beim Bearbeiten fest: falsche Buchung löschen und neu erfassen.
7. **U07:** Speichersperre, Fortschritt, Fehler mit erhaltenen Eingaben, ausdrückliche Erfolgsmeldung und Hervorhebung.
Prüfung: gezielte automatisierte Regressionen, Debug/Release, Desktop-Prüfung und PDF-Sichtprüfung soweit verfügbar. Anschließend Commits pushen und PR-Beschreibung mit Ergebnissen und Grenzen aktualisieren. U08–U16 bleiben außerhalb dieses Auftrags.
## U01 – Sicherungsumfang
Die Sicherung enthält Konten, Anfangsbestände, Buchungen einschließlich gelöschter Zeilen, Umbuchungsverknüpfungen und Datenbankschema. Vereinsname, Farbdarstellung und Fenstergröße liegen außerhalb der Datenbank und sind **nicht enthalten**. Eine Kopie auf derselben Festplatte schützt nicht gegen Geräteausfall; dafür die Sicherung zusätzlich auf einem unabhängigen Datenträger aufbewahren.
Wiederherstellen akzeptiert nur vollständige Datenbanken derselben Schemaversion. Vorher wird die ausgewählte Datei als konsistenter SQLite-Snapshot zwischengespeichert und auf Integrität, Fremdschlüssel, Migrationen und alle Modellspalten geprüft. Die bestehende Datenbank erhält eine eindeutige `.before-restore-…bak` daneben. Diese Rückfallsicherungen werden nicht automatisch gelöscht; nach geprüfter Wiederherstellung bewusst archivieren. Anwendungsschreibzugriffe warten während der Wiederherstellung, SQLite koordiniert offene Leser. Der Austausch erfolgt mit der SQLite-Backup-API im bestehenden Datenbankhandle. Keine automatische Sicherung beim Beenden.
Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buchungsstand und Rückfallsicherung, beschädigte/fremde SQLite-Datei und fehlende Quelle. Kein echter Vereinsdatenbestand wurde zurückgespielt.
## U02 – Dateiaktionen
Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen Details, Speicherpfad und „Ordner öffnen“. Abbruch ist neutral. Dateiaktionen sperren erneutes Auslösen; Fehler beim Erzeugen oder Öffnen einer PDF bleiben in der aktuellen Ansicht sichtbar. Native Dateidialoge melden Start-/Prozessfehler und erhalten Unicode-Pfade. Gespeichert wird über eine temporäre Datei, damit Schreibfehler eine bestehende Datei nicht verstümmeln. Prüfung: 18/18 Debug-Tests bestanden.
## U03 – Gleiche Bedeutung in PDF und Oberfläche
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
@@ -0,0 +1,40 @@
@inject IFileSaveService FileSaveService
@if (Result != null)
{
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
<p>@Result.Message</p>
@if (Result.Path != null)
{
<p class="text-break">@Result.Path</p>
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
}
@if (Result.Details != null)
{
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
}
@if (openResult != null)
{
<p role="alert">@openResult.Message</p>
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
}
</div>
}
@code {
[Parameter] public FileOperationResult? Result { get; set; }
private FileOperationResult? openResult;
private FileOperationResult? previous;
private bool busy;
protected override void OnParametersSet()
{
if (previous != Result) { openResult = null; previous = Result; }
}
private async Task OpenFolder()
{
if (busy || Result?.Path == null) return;
busy = true;
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
finally { busy = false; }
}
}
@@ -7,6 +7,9 @@
@inject NavigationManager Navigation
<div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
@if (account == null)
{
<div class="text-center py-5">
@@ -35,7 +38,7 @@
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
<i class="bi bi-arrow-left-right"></i> Umbuchung
</button>
<button class="btn btn-dark btn-nav" @onclick="HandleExport">
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF
</button>
|
@@ -32,6 +32,8 @@ public partial class AccountDetail
private string? confirmRestoreEntryTitle;
private string? savedPdfPath;
private FileOperationResult? fileResult;
private bool fileBusy;
private EntryDto? editingEntry;
private EntryDto? editingTransferEntry;
@@ -177,20 +179,40 @@ public partial class AccountDetail
}
private async Task HandleExport()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private async Task HandleOpenSavedPdf()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
await FileSaveService.OpenFileAsync(savedPdfPath);
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
}
savedPdfPath = null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private void CancelOpenSavedPdf()
+6 -9
View File
@@ -8,6 +8,9 @@
@inject NavigationManager NavigationManager
<div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
@@ -25,14 +28,14 @@
<i class="bi bi-plus-lg"></i> Neues Konto
</button>
|
<button class="btn-nav btn-success" @onclick="HandleBackupAsync">
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
<i class="bi bi-save"></i> Backup
</button>
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync" disabled="@fileBusy">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
|
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF
</button>
|
@@ -54,12 +57,6 @@
</div>
}
@if (!string.IsNullOrWhiteSpace(operationMessage))
{
<div class="alert @operationMessageClass mb-3" role="alert">
@operationMessage
</div>
}
@if (accounts == null)
{
+41 -22
View File
@@ -17,9 +17,9 @@ public partial class Dashboard
private int? selectedYear = DateTime.Now.Year;
private List<int> availableYears = [];
private string clubName = string.Empty;
private string? operationMessage;
private string operationMessageClass = "alert-info";
private string? savedPdfPath;
private FileOperationResult? fileResult;
private bool fileBusy;
private string currentTheme = "light";
#endregion
@@ -100,11 +100,16 @@ public partial class Dashboard
private async Task HandleBackupAsync()
{
var message = await BackupService.CreateBackupAsync();
if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
SetOperationMessage(message, false);
fileResult = await BackupService.CreateBackupAsync();
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private Task HandleRestoreAsync()
@@ -119,26 +124,37 @@ public partial class Dashboard
}
private async Task HandleConfirmRestore()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
showRestoreConfirm = false;
var message = await BackupService.RestoreBackupAsync();
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
fileResult = await BackupService.RestoreBackupAsync();
if (fileResult.Status == FileOperationStatus.Success) await LoadAll();
if (isSuccess)
{
NavigationManager.NavigateTo("/", forceLoad: true);
}
else
{
SetOperationMessage(message, isSuccess);
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private async Task HandleDashboardExportAsync()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private void OpenYearFilterDialog()
@@ -159,13 +175,22 @@ public partial class Dashboard
}
private async Task HandleOpenSavedPdf()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
await FileSaveService.OpenFileAsync(savedPdfPath);
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
}
savedPdfPath = null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private void CancelOpenSavedPdf()
@@ -177,12 +202,6 @@ public partial class Dashboard
#region Helpers
private void SetOperationMessage(string message, bool success)
{
operationMessage = message;
operationMessageClass = success ? "alert-success" : "alert-danger";
}
private static string FormatCurrency(decimal amount)
{
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
@@ -1,7 +1,9 @@
using Duempelkas.App.Services.Models;
namespace Duempelkas.App.Services;
public interface IBackupService
{
Task<string> CreateBackupAsync();
Task<string> RestoreBackupAsync();
Task<FileOperationResult> CreateBackupAsync();
Task<FileOperationResult> RestoreBackupAsync();
}
@@ -0,0 +1,7 @@
namespace Duempelkas.App.Services;
public interface IFileDialogService
{
Task<string?> SaveAsync(string suggestedFileName);
Task<string?> OpenBackupAsync();
}
@@ -1,7 +1,10 @@
using Duempelkas.App.Services.Models;
namespace Duempelkas.App.Services;
public interface IFileSaveService
{
Task<string?> SaveFileAsync(byte[] content, string suggestedFileName);
Task OpenFileAsync(string filePath);
Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName);
Task<FileOperationResult> OpenFileAsync(string filePath);
Task<FileOperationResult> OpenFolderAsync(string filePath);
}
@@ -0,0 +1,10 @@
namespace Duempelkas.App.Services.Models;
public enum FileOperationStatus { Success, Cancelled, Error }
public sealed record FileOperationResult(FileOperationStatus Status, string Message, string? Path = null, string? Details = null)
{
public static FileOperationResult Cancelled() => new(FileOperationStatus.Cancelled, "Vorgang abgebrochen. Es wurde nichts geändert.");
public static FileOperationResult Error(Exception error) => new(FileOperationStatus.Error,
"Die Dateiaktion konnte nicht abgeschlossen werden. Bitte den Speicherort und die Zugriffsrechte prüfen oder einen anderen Speicherort wählen.", Details: error.Message);
}
+7 -3
View File
@@ -38,11 +38,15 @@ class Program
{
try
{
File.Copy(legacyDbPath, dbPath);
using var source = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={legacyDbPath};Mode=ReadOnly");
using var target = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={dbPath}");
source.Open();
target.Open();
source.BackupDatabase(target);
}
catch
catch (Exception ex)
{
// Ignore copy errors and continue with a fresh DB.
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht ?bernommen werden. Der Start wurde abgebrochen; die urspr?ngliche Datei bleibt erhalten.", ex);
}
}
@@ -10,6 +10,7 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{
services.AddSingleton(new DatabaseLocation(connectionString));
services.AddDbContext<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
services.AddDbContextFactory<FinanceDbContext>(options =>
@@ -19,6 +20,7 @@ public static class DependencyInjection
services.AddScoped<IEntryService, EntryService>();
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
services.AddScoped<IPdfStatementService, PdfStatementService>();
services.AddSingleton<IFileDialogService, FileDialogService>();
services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>();
@@ -11,7 +11,13 @@ public class AccountService : IAccountService
{
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
private readonly DatabaseLocation? _location;
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
{
_dbFactory = dbFactory;
_location = location;
}
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
{
@@ -20,6 +26,7 @@ public class AccountService : IAccountService
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts
@@ -71,6 +78,7 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
@@ -87,6 +95,7 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = new Account { Name = name };
@@ -97,6 +106,7 @@ public class AccountService : IAccountService
public async Task RenameAccountAsync(int accountId, string newName)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -107,6 +117,7 @@ public class AccountService : IAccountService
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -123,6 +134,7 @@ public class AccountService : IAccountService
return;
}
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -138,6 +150,7 @@ public class AccountService : IAccountService
public async Task DeleteAccountAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
@@ -1,3 +1,4 @@
using Duempelkas.App.Services.Models;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Duempelkas.App.Services;
@@ -7,140 +8,126 @@ namespace Duempelkas.Infrastructure.Services;
public class BackupService : IBackupService
{
private static readonly string DbPath = Path.Combine(
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
"duempelkas.db");
private readonly DatabaseLocation location;
public async Task<string> CreateBackupAsync()
private readonly IFileDialogService dialogs;
public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null)
{
if (!File.Exists(DbPath))
{
return "Keine Datenbankdatei gefunden.";
this.location = location;
this.dialogs = dialogs ?? new FileDialogService();
}
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
if (string.IsNullOrWhiteSpace(backupPath))
public async Task<FileOperationResult> CreateBackupAsync()
{
return "Backup wurde abgebrochen.";
}
backupPath = Path.ChangeExtension(backupPath, ".bak");
try
{
File.Copy(DbPath, backupPath, overwrite: true);
return $"Backup gespeichert: {backupPath}";
}
catch (Exception ex)
{
return $"Backup fehlgeschlagen: {ex.Message}";
var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
await CreateBackupToAsync(path);
return new(FileOperationStatus.Success, "Sicherung gespeichert.", path);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
}
public async Task<string> RestoreBackupAsync()
public async Task<FileOperationResult> RestoreBackupAsync()
{
var backupPath = await ShowOpenDialogAsync();
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Wiederherstellung wurde abgebrochen.";
}
if (!File.Exists(backupPath))
{
return "Die gewählte Backup-Datei wurde nicht gefunden.";
}
try
{
// Ensure no pooled connection keeps stale handles to the previous DB file.
SqliteConnection.ClearAllPools();
var targetDirectory = Path.GetDirectoryName(DbPath);
if (!string.IsNullOrWhiteSpace(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
var path = await dialogs.OpenBackupAsync();
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
var fallback = await RestoreFromAsync(path);
return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
}
File.Copy(backupPath, DbPath, overwrite: true);
SqliteConnection.ClearAllPools();
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
}
catch (Exception ex)
public async Task CreateBackupToAsync(string path)
{
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
}
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
using var lease = await DatabaseLease.AcquireAsync(location);
RejectActivePath(path);
await Task.Run(() =>
{
return Task.Run(() =>
var staged = System.IO.Path.GetFullPath(path) + $".{Guid.NewGuid():N}.tmp";
try
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
Snapshot(location.Path, staged);
File.Move(staged, path, overwrite: true);
}
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.DefaultExt = 'bak'
$dialog.AddExtension = $true
$dialog.OverwritePrompt = $true
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Backup speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
return RunPowerShellDialog(script);
finally { if (File.Exists(staged)) File.Delete(staged); }
});
}
private static Task<string?> ShowOpenDialogAsync()
public async Task<string> RestoreFromAsync(string path)
{
return Task.Run(() =>
using var lease = await DatabaseLease.AcquireAsync(location);
return await Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
RejectActivePath(path);
// Stage the selected database first, so a changing/removable source cannot
// change between validation and restore. BackupDatabase includes WAL data.
var staged = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"duempelkas-{Guid.NewGuid():N}.db");
try
{
return null;
Snapshot(path, staged);
Validate(staged);
var fallback = location.Path + $".before-restore-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.bak";
Snapshot(location.Path, fallback);
// SQLite's online backup atomically replaces database contents, retaining
// the active file/handles and coordinating locks. Never copy over an open DB.
Snapshot(staged, location.Path);
return fallback;
}
var script = @"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.Multiselect = $false
$dialog.Title = 'Backup auswählen'
if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
";
return RunPowerShellDialog(script);
finally { if (File.Exists(staged)) File.Delete(staged); }
});
}
private static string? RunPowerShellDialog(string script)
private void RejectActivePath(string path)
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"") }\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null)
{
return null;
if (new[] { location.Path, location.Path + "-wal", location.Path + "-shm", location.Path + "-journal" }
.Contains(System.IO.Path.GetFullPath(path), StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException("Bitte eine andere Datei als die aktive Datenbank wählen.");
}
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
private static SqliteConnection Open(string path, SqliteOpenMode mode)
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = System.IO.Path.GetFullPath(path), Mode = mode, Pooling = false
}.ToString());
try { connection.Open(); return connection; }
catch { connection.Dispose(); throw; }
}
return string.IsNullOrWhiteSpace(result) ? null : result;
private static void Snapshot(string source, string destination)
{
if (string.Equals(System.IO.Path.GetFullPath(source), System.IO.Path.GetFullPath(destination), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Quelle und Ziel müssen verschiedene Dateien sein.");
using var input = Open(source, SqliteOpenMode.ReadOnly);
using var output = Open(destination, SqliteOpenMode.ReadWriteCreate);
input.BackupDatabase(output);
}
private static void Validate(string path)
{
using var connection = Open(path, SqliteOpenMode.ReadOnly);
using var check = connection.CreateCommand();
check.CommandText = "PRAGMA integrity_check";
if (!string.Equals(check.ExecuteScalar()?.ToString(), "ok", StringComparison.Ordinal))
throw new InvalidOperationException("Die Sicherung ist beschädigt.");
check.CommandText = "PRAGMA foreign_key_check";
using (var reader = check.ExecuteReader())
if (reader.Read()) throw new InvalidOperationException("Die Sicherung enthält ungültige Verknüpfungen.");
var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder<Duempelkas.Infrastructure.Persistence.FinanceDbContext>();
Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions.UseSqlite(options, connection);
using var db = new Duempelkas.Infrastructure.Persistence.FinanceDbContext(options.Options);
var expected = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetMigrations(db.Database).ToArray();
var applied = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetAppliedMigrations(db.Database).ToArray();
if (!expected.SequenceEqual(applied))
throw new InvalidOperationException("Diese Sicherung passt nicht zu dieser Programmversion.");
// Materialize every mapped column to reject a forged/missing schema before touching active data.
_ = db.Accounts.ToArray();
_ = db.Entries.ToArray();
_ = db.TransferLinks.ToArray();
}
}
@@ -0,0 +1,12 @@
namespace Duempelkas.Infrastructure.Services;
internal sealed class DatabaseLease(SemaphoreSlim? gate) : IDisposable
{
public static async Task<DatabaseLease> AcquireAsync(DatabaseLocation? location)
{
if (location != null) await location.Writes.WaitAsync();
return new DatabaseLease(location?.Writes);
}
public void Dispose() => gate?.Release();
}
@@ -0,0 +1,12 @@
using Microsoft.Data.Sqlite;
namespace Duempelkas.Infrastructure.Services;
public sealed class DatabaseLocation(string connectionString)
{
public string Path { get; } = System.IO.Path.GetFullPath(new SqliteConnectionStringBuilder(connectionString).DataSource);
public string ConnectionString { get; } = connectionString;
// All application mutations and restore share this gate. SQLite's backup API
// also coordinates readers and external connections through SQLite locks.
public SemaphoreSlim Writes { get; } = new(1, 1);
}
@@ -11,7 +11,13 @@ public class EntryService : IEntryService
{
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
private readonly DatabaseLocation? _location;
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
{
_dbFactory = dbFactory;
_location = location;
}
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
{
@@ -21,6 +27,7 @@ public class EntryService : IEntryService
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var query = db.Entries.Where(e => e.AccountId == accountId);
@@ -67,6 +74,7 @@ public class EntryService : IEntryService
public async Task<List<int>> GetEntryYearsAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
@@ -79,6 +87,7 @@ public class EntryService : IEntryService
public async Task<List<int>> GetAllEntryYearsAsync()
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
@@ -90,6 +99,7 @@ public class EntryService : IEntryService
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var displayId = await GenerateDisplayIdAsync(db, date.Year);
@@ -114,6 +124,7 @@ public class EntryService : IEntryService
if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
@@ -163,6 +174,7 @@ public class EntryService : IEntryService
public async Task DeleteEntryAsync(int entryId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -185,6 +197,7 @@ public class EntryService : IEntryService
public async Task RestoreEntryAsync(int entryId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -207,6 +220,7 @@ public class EntryService : IEntryService
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -236,6 +250,7 @@ public class EntryService : IEntryService
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -0,0 +1,44 @@
using System.Diagnostics;
using System.Text;
using Duempelkas.App.Services;
namespace Duempelkas.Infrastructure.Services;
public sealed class FileDialogService : IFileDialogService
{
public Task<string?> SaveAsync(string suggestedFileName) => ShowAsync(suggestedFileName);
public Task<string?> OpenBackupAsync() => ShowAsync(null);
private static Task<string?> ShowAsync(string? suggestedFileName) => Task.Run(() =>
{
if (!OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("Der Dateidialog ist auf diesem System nicht verfügbar.");
var save = suggestedFileName != null;
var extension = save ? (Path.GetExtension(suggestedFileName) ?? "") : ".bak";
var script = $$"""
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.{{(save ? "SaveFileDialog" : "OpenFileDialog")}}
$dialog.Filter = 'Dateien (*{{extension.Replace("'", "''")}})|*{{extension.Replace("'", "''")}}'
$dialog.Title = '{{(save ? "Datei speichern" : "Sicherung auswählen")}}'
{{(save ? "$dialog.OverwritePrompt = $true\n$dialog.AddExtension = $true\n$dialog.DefaultExt = '" + extension.TrimStart('.').Replace("'", "''") + "'\n$dialog.FileName = '" + suggestedFileName!.Replace("'", "''") + "'" : "$dialog.Multiselect = $false")}}
try { if ($dialog.ShowDialog() -eq 'OK') { [Console]::Write($dialog.FileName) } } finally { $dialog.Dispose() }
""";
var start = new ProcessStartInfo("powershell.exe")
{
RedirectStandardOutput = true, RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8, UseShellExecute = false, CreateNoWindow = true
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-STA");
start.ArgumentList.Add("-EncodedCommand");
start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
using var process = Process.Start(start) ?? throw new IOException("Der Dateidialog konnte nicht gestartet werden.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0) throw new IOException("Der Dateidialog konnte nicht geöffnet werden. " + error);
return string.IsNullOrWhiteSpace(output) ? null : output;
});
}
@@ -1,90 +1,49 @@
using Duempelkas.App.Services.Models;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Duempelkas.App.Services;
namespace Duempelkas.Infrastructure.Services;
public class FileSaveService : IFileSaveService
public class FileSaveService(IFileDialogService dialogs) : IFileSaveService
{
public async Task<string?> SaveFileAsync(byte[] content, string suggestedFileName)
public async Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName)
{
var filePath = await ShowSaveDialogAsync(suggestedFileName);
if (filePath == null) return null;
await File.WriteAllBytesAsync(filePath, content);
return filePath;
string? staged = null;
try
{
var path = await dialogs.SaveAsync(suggestedFileName);
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
staged = path + $".{Guid.NewGuid():N}.tmp";
await File.WriteAllBytesAsync(staged, content);
File.Move(staged, path, overwrite: true);
return new(FileOperationStatus.Success, "Datei gespeichert.", path);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
finally { try { if (staged != null && File.Exists(staged)) File.Delete(staged); } catch { } }
}
public Task OpenFileAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
return Task.CompletedTask;
}
public Task<FileOperationResult> OpenFileAsync(string filePath) => OpenAsync(filePath, false);
public Task<FileOperationResult> OpenFolderAsync(string filePath) => OpenAsync(filePath, true);
return Task.Run(() =>
private static Task<FileOperationResult> OpenAsync(string filePath, bool folder) => Task.Run(() =>
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
try
{
Process.Start(new ProcessStartInfo
var path = folder ? Path.GetDirectoryName(Path.GetFullPath(filePath))! : filePath;
if (folder ? !Directory.Exists(path) : !File.Exists(path))
throw new FileNotFoundException("Der gespeicherte Pfad ist nicht mehr vorhanden.");
var start = new ProcessStartInfo { UseShellExecute = true };
if (OperatingSystem.IsWindows()) start.FileName = path;
else { start.FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; start.ArgumentList.Add(path); }
using var process = Process.Start(start) ?? throw new IOException("Kein Programm zum Öffnen gefunden.");
return new FileOperationResult(FileOperationStatus.Success, folder ? "Ordner geöffnet." : "Datei geöffnet.");
}
catch (Exception ex)
{
FileName = filePath,
UseShellExecute = true
return new FileOperationResult(FileOperationStatus.Error,
"Der Pfad konnte nicht geöffnet werden. Die gespeicherte Datei bleibt erhalten; bitte den Speicherort im Datei-Explorer öffnen.", filePath, ex.Message);
}
});
return;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", filePath);
return;
}
Process.Start("xdg-open", filePath);
});
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Fallback: save to Documents folder
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
}
var extension = Path.GetExtension(suggestedFileName);
var filterName = extension == ".pdf" ? "PDF-Dateien" : "Dateien";
var filter = $"{filterName} (*{extension})|*{extension}|Alle Dateien (*.*)|*.*";
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = '{filter}'
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Datei speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"\"")}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return null;
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return string.IsNullOrWhiteSpace(result) ? null : result;
});
}
}
@@ -66,6 +66,8 @@ public class PdfStatementService : IPdfStatementService
{
col.Item().Text(clubName).Bold().FontSize(20);
col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1);
col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}" : "Zeitraum: Alle Jahre").FontSize(9);
col.Item().Text("Nur wirksame Buchungen. Umbuchungen zählen im einzelnen Konto als Zu- oder Abgang.").FontSize(8);
col.Item().PaddingTop(5).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
@@ -94,7 +96,7 @@ public class PdfStatementService : IPdfStatementService
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
});
foreach (var entry in entries)
foreach (var entry in entries.Where(e => !e.IsDeleted))
{
var titleText = entry.Title;
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
@@ -127,11 +129,16 @@ public class PdfStatementService : IPdfStatementService
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance);
}
else
{
SummaryRow(summaryCol, "Anfangsbestand:", balance.CarryoverBalance);
}
SummaryRow(summaryCol, year.HasValue ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
summaryCol.Item().PaddingTop(5).Row(row =>
{
row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
row.RelativeItem().Text(year.HasValue ? $"Kontostand am 31.12.{year}:" : "Kontostand nach allen Buchungen:").Bold().FontSize(12);
row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12)
.FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
});
@@ -302,6 +309,8 @@ public class PdfStatementService : IPdfStatementService
? $"Übersicht aller Konten ({year.Value})"
: "Übersicht aller Konten";
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1);
col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}; Endstände am 31.12.{year}." : "Zeitraum: Alle Jahre; Endstände nach allen Buchungen.").FontSize(9);
col.Item().Text("Nur wirksame Buchungen. Interne Umbuchungen erscheinen einmal mit Minus und Plus; die Vereinssumme bleibt unverändert.").FontSize(8);
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
.FontSize(10)
.SemiBold()
@@ -338,15 +347,14 @@ public class PdfStatementService : IPdfStatementService
}
});
if (year.HasValue && carryoverByAccountId is not null)
{
BodyCell(table, string.Empty);
BodyCell(table, string.Empty);
BodyCell(table, $"Übertrag von {year.Value - 1}", alignRight: true);
BodyCell(table, year.HasValue ? $"Übertrag von {year.Value - 1}" : "Anfangsbestand", alignRight: true);
foreach (var account in accounts)
{
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id));
BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance);
}
}
@@ -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);
}
}
@@ -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();
@@ -9,6 +9,7 @@
<ItemGroup>
<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,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);
}
}