Usability U01–U16: sichere Abläufe und Bedienkorrekturen #1

Merged
troogs merged 25 commits from docs/usability-analyse into main 2026-11-02 21:47:44 +01:00
4 changed files with 74 additions and 5 deletions
Showing only changes of commit 122d4de809 - Show all commits
+4
View File
@@ -23,3 +23,7 @@ Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buc
## U02 – Dateiaktionen ## 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. 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.
@@ -66,6 +66,8 @@ public class PdfStatementService : IPdfStatementService
{ {
col.Item().Text(clubName).Bold().FontSize(20); col.Item().Text(clubName).Bold().FontSize(20);
col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1); 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(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); col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
}); });
@@ -94,7 +96,7 @@ public class PdfStatementService : IPdfStatementService
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold(); .PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
}); });
foreach (var entry in entries) foreach (var entry in entries.Where(e => !e.IsDeleted))
{ {
var titleText = entry.Title; var titleText = entry.Title;
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName)) if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
@@ -127,11 +129,16 @@ public class PdfStatementService : IPdfStatementService
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance); 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 ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue); SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
summaryCol.Item().PaddingTop(5).Row(row => 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) row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12)
.FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1); .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 ({year.Value})"
: "Übersicht aller Konten"; : "Übersicht aller Konten";
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1); 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)}") col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
.FontSize(10) .FontSize(10)
.SemiBold() .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, 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) foreach (var account in accounts)
{ {
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id)); BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance);
} }
} }
@@ -95,6 +95,62 @@ public class DashboardPdfStatementServiceTests : IDisposable
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF"); 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() public void Dispose()
{ {
_db.Database.CloseConnection(); _db.Database.CloseConnection();
@@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="PdfPig" Version="0.1.16" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1"> <PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>