feat(usability): explain booking fields and validate decimal input (U06)

This commit is contained in:
2026-11-02 06:32:32 +01:00
parent 33a8abf5cc
commit 76ba79b63e
12 changed files with 205 additions and 42 deletions
+4
View File
@@ -35,3 +35,7 @@ Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück
## U05 – Alltagssprache ## U05 – Alltagssprache
Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung ausführlich. Flexible Breiten und Zeilenumbruch ersetzen die feste 75-px-Breite. Rückspielen liegt weiterhin auffindbar unter „Weitere Aktionen“ und erklärt Umfang und Rückfallsicherung. „Kontostand“ und „Änderung im Jahr“ ersetzen Fachbegriffe. Umbuchung erklärt ausdrücklich, dass keine Banküberweisung ausgelöst wird. Anrede in den betroffenen Abläufen: du. Prüfung: 21/21 Debug-Tests; native Sichtprüfung folgt im Abschluss. Ein Nutzertest mit der Zielgruppe ist dadurch nicht ersetzt. Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung ausführlich. Flexible Breiten und Zeilenumbruch ersetzen die feste 75-px-Breite. Rückspielen liegt weiterhin auffindbar unter „Weitere Aktionen“ und erklärt Umfang und Rückfallsicherung. „Kontostand“ und „Änderung im Jahr“ ersetzen Fachbegriffe. Umbuchung erklärt ausdrücklich, dass keine Banküberweisung ausgelöst wird. Anrede in den betroffenen Abläufen: du. Prüfung: 21/21 Debug-Tests; native Sichtprüfung folgt im Abschluss. Ein Nutzertest mit der Zielgruppe ist dadurch nicht ersetzt.
## U06 – Verständliche Eingabe
Konto im Dialogtitel, sichtbare Art beim Bearbeiten und erklärter Korrekturweg: abbrechen, falsche Buchung löschen, neu erfassen. Keine Änderung der Buchungsart bestehender Daten. Feldnahe, technisch zugeordnete Hinweise für Beschreibung, Datum und Betrag. Komma/Punkt sowie eingefügte einfache Beträge mit Rand-Leerzeichen werden akzeptiert; Minus, Tausendertrenner, Währungszeichen, Überlauf und mehr als zwei Nachkommastellen abgewiesen, ohne Rundung. Dienste prüfen dieselben fachlichen Mindestregeln. Nach erneutem Rendern bleiben geänderte Eingaben erhalten. Prüfung: 35/35 Debug-Tests, darunter Parsing-Grenzfälle und gerenderte Formularprüfung mit Korrektur ungültiger Eingaben. Längere Buchungsdialoge können innerhalb des Fensters scrollen.
@@ -1,30 +1,45 @@
@inject IEntryService EntryService @inject IEntryService EntryService
<div class="dialog-backdrop"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
<h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5> <h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
<div class="form-container"> <div class="form-container">
<label class="form-label" for="entry-type">Art</label>
@if (EditEntry == null) @if (EditEntry == null)
{ {
<label class="form-label">Art</label> <select id="entry-type" @bind="entryType">
<select @bind="entryType">
<option value="@EntryType.Income">Einnahme</option> <option value="@EntryType.Income">Einnahme</option>
<option value="@EntryType.Expense">Ausgabe</option> <option value="@EntryType.Expense">Ausgabe</option>
</select> </select>
} }
else
{
<div><strong>@(entryType == EntryType.Income ? "Einnahme" : "Ausgabe")</strong>
<p class="text-muted">Falsche Art? Diese Buchung abbrechen, in der Kontoliste löschen und mit der richtigen Art neu erfassen.</p>
</div>
}
<label class="form-label">Datum</label> <label class="form-label" for="entry-date">Datum</label>
<input type="date" @bind="date" /> <div>
<input id="entry-date" type="date" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" aria-describedby="entry-date-help" aria-invalid="@(date == default ? "true" : "false")" />
<label class="form-label">Bezeichnung</label> @if (date == default) { <p id="entry-date-help" class="text-danger">Bitte ein gültiges Datum eingeben.</p> }
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung" />
<label class="form-label">Betrag (€)</label>
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
</div> </div>
@if (OutsideYear) <label class="form-label" for="entry-title">Beschreibung</label>
<div>
<input id="entry-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="z. B. Mitgliedsbeitrag oder Porto" aria-describedby="entry-title-help" aria-invalid="@(string.IsNullOrWhiteSpace(title) ? "true" : "false")" />
@if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> }
</div>
<label class="form-label" for="entry-amount">Betrag (€)</label>
<div>
<input id="entry-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" placeholder="12,50" aria-describedby="entry-amount-help" aria-invalid="@(!ValidAmount ? "true" : "false")" />
<p id="entry-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0. Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen, auch beim Einfügen. Es wird nicht gerundet.</p>
</div>
</div>
@if (OutsideYear && date != default)
{ {
<div class="alert alert-warning" role="status"> <div class="alert alert-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt. Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
@@ -33,12 +48,10 @@
} }
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" <button class="btn btn-success" @onclick="Save" disabled="@(!CanSave)">
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0 || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))"> <i class="bi bi-check-lg"></i> Speichern
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
</button> </button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</div> </div>
</div> </div>
@@ -27,12 +27,20 @@ public partial class AddEntryDialog
private int? confirmedYear; private int? confirmedYear;
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value; private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
[Parameter] public string AccountName { get; set; } = string.Empty;
private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
#region Fields #region Fields
private EntryType entryType = EntryType.Income; private EntryType entryType = EntryType.Income;
private DateTime date = DateTime.Today; private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
private string title = string.Empty; private string title = string.Empty;
private decimal amount; private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private bool initialized;
private int? initializedEntryId;
#endregion #endregion
@@ -40,12 +48,15 @@ public partial class AddEntryDialog
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
if (initialized && initializedEntryId == EditEntry?.Id) return;
initialized = true;
initializedEntryId = EditEntry?.Id;
if (EditEntry != null) if (EditEntry != null)
{ {
entryType = EditEntry.Type; entryType = EditEntry.Type;
date = EditEntry.Date; dateText = EditEntry.Date.ToString("yyyy-MM-dd");
title = EditEntry.Title; title = EditEntry.Title;
amount = EditEntry.Amount; amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
} }
} }
@@ -55,7 +66,7 @@ public partial class AddEntryDialog
private async Task Save() private async Task Save()
{ {
if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return; if (!CanSave) return;
if (EditEntry != null) if (EditEntry != null)
{ {
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount); await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
@@ -2,7 +2,7 @@
@inject IAccountService AccountService @inject IAccountService AccountService
<div class="dialog-backdrop"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content booking-dialog" @onclick:stopPropagation="true">
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5> <h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p> <p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
@@ -18,13 +18,14 @@
</select> </select>
<label class="form-label">Datum</label> <label class="form-label">Datum</label>
<input type="date" class="form-control" @bind="date" /> <input type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" />
<label class="form-label">Bezeichnung</label> <label class="form-label">Bezeichnung</label>
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" /> <input type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
<label class="form-label">Betrag (€)</label> <label class="form-label">Betrag (€)</label>
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" /> <div><input type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
<p id="transfer-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.</p></div>
</div> </div>
@if (OutsideYear) @if (OutsideYear)
@@ -30,15 +30,20 @@ public partial class AddTransferDialog
private List<AccountSummaryDto> accounts = new(); private List<AccountSummaryDto> accounts = new();
private int targetAccountId; private int targetAccountId;
private DateTime date = DateTime.Today; private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
private string title = string.Empty; private string title = string.Empty;
private decimal amount; private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private bool initialized;
private int? initializedEntryId;
#endregion #endregion
#region Properties #region Properties
private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0; private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
#endregion #endregion
@@ -51,12 +56,15 @@ public partial class AddTransferDialog
accounts = await AccountService.GetAllAccountsAsync(); accounts = await AccountService.GetAllAccountsAsync();
} }
if (initialized && initializedEntryId == EditEntry?.Id) return;
initialized = true;
initializedEntryId = EditEntry?.Id;
if (EditEntry != null) if (EditEntry != null)
{ {
targetAccountId = EditEntry.LinkedAccountId ?? 0; targetAccountId = EditEntry.LinkedAccountId ?? 0;
date = EditEntry.Date; dateText = EditEntry.Date.ToString("yyyy-MM-dd");
title = EditEntry.Title; title = EditEntry.Title;
amount = EditEntry.Amount; amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
} }
} }
@@ -66,7 +74,7 @@ public partial class AddTransferDialog
private async Task Save() private async Task Save()
{ {
if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return; if (!CanSave) return;
if (EditEntry != null) if (EditEntry != null)
{ {
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount); await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
@@ -111,7 +111,7 @@
@if (showAddEntry) @if (showAddEntry)
{ {
<AddEntryDialog AccountId="AccountId" ContextYear="selectedYear" <AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
OnSave="HandleEntryCreated" OnSave="HandleEntryCreated"
OnCancel="() => showAddEntry = false" /> OnCancel="() => showAddEntry = false" />
} }
@@ -175,7 +175,7 @@
@if (editingEntry != null) @if (editingEntry != null)
{ {
<AddEntryDialog AccountId="AccountId" ContextYear="selectedYear" <AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
EditEntry="editingEntry" EditEntry="editingEntry"
OnSave="HandleEntryEdited" OnSave="HandleEntryEdited"
OnCancel="() => editingEntry = null" /> OnCancel="() => editingEntry = null" />
@@ -0,0 +1,30 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Duempelkas.App.Services;
public static partial class BookingInput
{
[GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)]
private static partial Regex AmountPattern();
public static bool TryAmount(string text, out decimal amount)
{
amount = 0;
var plain = text.Trim();
return AmountPattern().IsMatch(plain)
&& decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out amount)
&& amount > 0;
}
public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
public static void Validate(DateTime date, string title, decimal amount)
{
if (date == default) throw new ArgumentException("Bitte ein gültiges Datum eingeben.");
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Bitte eine Beschreibung eingeben.");
if (amount <= 0 || decimal.Round(amount, 2) != amount)
throw new ArgumentException("Betrag ohne Minuszeichen, größer als 0 und mit höchstens zwei Nachkommastellen eingeben.");
}
}
@@ -597,3 +597,8 @@ input[type="number"].no-spinner {
.more-actions { align-self: center; } .more-actions { align-self: center; }
.more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; } .more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; }
.form-container > div { min-width: 0; }
.form-container p { margin: .35rem 0 0; font-size: .9rem; }
.booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; }
@@ -99,6 +99,8 @@ public class EntryService : IEntryService
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount) public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{ {
if (!Enum.IsDefined(type)) throw new ArgumentException("Ungültige Buchungsart.");
BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location); using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
@@ -110,7 +112,7 @@ public class EntryService : IEntryService
DisplayId = displayId, DisplayId = displayId,
Type = type, Type = type,
Date = date, Date = date,
Title = title, Title = title.Trim(),
Amount = amount Amount = amount
}; };
db.Entries.Add(entry); db.Entries.Add(entry);
@@ -121,6 +123,7 @@ public class EntryService : IEntryService
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount) public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
{ {
BookingInput.Validate(date, title, amount);
if (sourceAccountId == targetAccountId) if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich."); throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
@@ -136,7 +139,7 @@ public class EntryService : IEntryService
DisplayId = transferDisplayId, DisplayId = transferDisplayId,
Type = EntryType.Expense, Type = EntryType.Expense,
Date = date, Date = date,
Title = title, Title = title.Trim(),
Amount = amount Amount = amount
}; };
@@ -149,7 +152,7 @@ public class EntryService : IEntryService
DisplayId = transferDisplayId, DisplayId = transferDisplayId,
Type = EntryType.Income, Type = EntryType.Income,
Date = date, Date = date,
Title = title, Title = title.Trim(),
Amount = amount Amount = amount
}; };
@@ -220,6 +223,7 @@ public class EntryService : IEntryService
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{ {
BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location); using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
@@ -230,7 +234,7 @@ public class EntryService : IEntryService
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
entry.Date = date; entry.Date = date;
entry.Title = title; entry.Title = title.Trim();
entry.Amount = amount; entry.Amount = amount;
if (link != null) if (link != null)
@@ -240,7 +244,7 @@ public class EntryService : IEntryService
if (otherEntry != null) if (otherEntry != null)
{ {
otherEntry.Date = date; otherEntry.Date = date;
otherEntry.Title = title; otherEntry.Title = title.Trim();
otherEntry.Amount = amount; otherEntry.Amount = amount;
} }
} }
@@ -250,6 +254,7 @@ public class EntryService : IEntryService
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{ {
BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location); using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
@@ -266,10 +271,10 @@ public class EntryService : IEntryService
// Update date, title, amount on both sides // Update date, title, amount on both sides
entry.Date = date; entry.Date = date;
entry.Title = title; entry.Title = title.Trim();
entry.Amount = amount; entry.Amount = amount;
otherEntry.Date = date; otherEntry.Date = date;
otherEntry.Title = title; otherEntry.Title = title.Trim();
otherEntry.Amount = amount; otherEntry.Amount = amount;
// If the linked account has changed, move the other entry to the new account // If the linked account has changed, move the other entry to the new account
@@ -0,0 +1,51 @@
using Bunit;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure.Services;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class BookingFormTests
{
[Fact]
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
{
using var context = new BunitContext();
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
Assert.Contains("Barkasse", dialog.Find("h5").TextContent);
Assert.Contains("Bitte beschreiben", dialog.Markup);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-title").Input("Porto");
dialog.Find("#entry-amount").Input("12,501");
Assert.Equal("12,501", dialog.Find("#entry-amount").GetAttribute("value"));
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-amount").Input("-12,50");
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-amount").Input("12,50");
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-date").Input("");
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
Assert.Contains("Bitte ein gültiges Datum", dialog.Markup);
dialog.Find("#entry-date").Input("2026-01-02");
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
}
[Fact]
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
{
using var context = new BunitContext();
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.EditEntry, entry));
Assert.Contains("Ausgabe", dialog.Markup);
Assert.Contains("mit der richtigen Art neu erfassen", dialog.Markup);
dialog.Find("#entry-title").Input("Korrigierter Text");
dialog.Render(p => p.Add(x => x.EditEntry, entry));
Assert.Equal("Korrigierter Text", dialog.Find("#entry-title").GetAttribute("value"));
}
}
@@ -0,0 +1,35 @@
using Duempelkas.App.Services;
using Xunit;
namespace Duempelkas.Tests;
public class BookingInputTests
{
[Theory]
[InlineData("12,50", true)]
[InlineData("12.50", true)]
[InlineData(" 12,50 ", true)]
[InlineData("12,500", false)]
[InlineData("-12,50", false)]
[InlineData("1.234,56", false)]
[InlineData("1,234.56", false)]
[InlineData("12,50 €", false)]
[InlineData("0", false)]
[InlineData("", false)]
[InlineData("9999999999999999999999999999999999999", false)]
public void DecimalInput_HasExplicitRules(string text, bool valid)
{
Assert.Equal(valid, BookingInput.TryAmount(text, out var amount));
if (valid) Assert.Equal(12.50m, amount);
}
[Fact]
public void ServiceValidation_RejectsInvalidData()
{
Assert.Throws<ArgumentException>(() => BookingInput.Validate(default, "Test", 1m));
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, " ", 1m));
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", -1m));
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", 1.001m));
Assert.Equal(default, BookingInput.Date("2026-02-30"));
}
}
@@ -42,9 +42,9 @@ public class YearNavigationTests
page.WaitForElement(".summary-section"); page.WaitForElement(".summary-section");
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click(); page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
var dialog = page.FindComponent<AddEntryDialog>(); var dialog = page.FindComponent<AddEntryDialog>();
dialog.Find("input[type=date]").Change("2026-01-02"); dialog.Find("input[type=date]").Input("2026-01-02");
dialog.Find("input[type=text]").Change("Außerhalb"); dialog.Find("#entry-title").Input("Außerhalb");
dialog.Find("input[type=number]").Change("12.50"); dialog.Find("#entry-amount").Input("12.50");
Assert.Contains("außerhalb", dialog.Markup); Assert.Contains("außerhalb", dialog.Markup);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("input[type=checkbox]").Change(true); dialog.Find("input[type=checkbox]").Change(true);