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

This commit is contained in:
2026-11-02 19:49:31 +01:00
parent f284b85771
commit 71eed68e21
27 changed files with 275 additions and 24 deletions
@@ -37,7 +37,9 @@ public class CarryoverCorrectionTests
var dialog = f.Context.Render<EditCarryoverDialog>(p => p.Add(x => x.CurrentAmount, 110m)
.Add(x => x.InitialBalance, 100m).Add(x => x.Year, 2026).Add(x => x.AccountName, "Barkasse")
.Add(x => x.OnSave, value => saved = value));
Assert.Equal("110,00", dialog.Find("#carryover").GetAttribute("value"));
dialog.Find("#carryover").Input("120,00");
Assert.Equal("120,00", dialog.Find("#carryover").GetAttribute("value"));
Assert.Contains("frühere und spätere Jahre", dialog.Markup);
Assert.Contains("110,00", dialog.Find(".correction-preview").TextContent);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
@@ -0,0 +1,29 @@
using System.Text.RegularExpressions;
using Xunit;
namespace Duempelkas.Tests;
public class DesktopResourceTests
{
[Fact]
public void StartupStylesAndFonts_AreDeliveredLocally()
{
var root = new DirectoryInfo(AppContext.BaseDirectory);
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent;
Assert.NotNull(root);
var web = Path.Combine(root.FullName, "src", "Duempelkas.Desktop", "wwwroot");
var html = File.ReadAllText(Path.Combine(web, "index.html"));
Assert.DoesNotContain("https://", html);
foreach (Match match in Regex.Matches(html, "(?:href|src)=\"([^\"]+)\""))
{
var path = match.Groups[1].Value;
if (path.Length == 0 || path == "/" || path.StartsWith("_framework")) continue;
Assert.True(File.Exists(Path.Combine(web, path)), path);
}
var icons = Path.Combine(web, "vendor", "bootstrap-icons");
var css = File.ReadAllText(Path.Combine(icons, "bootstrap-icons.min.css"));
foreach (Match match in Regex.Matches(css, "url\\([\"']?([^\"')]+)"))
Assert.True(File.Exists(Path.Combine(icons, match.Groups[1].Value.Split('?')[0])));
Assert.True(File.Exists(Path.Combine(icons, "LICENSE")));
}
}
@@ -27,6 +27,7 @@ public class EntryLifecycleUiTests
Assert.Contains("zählt nicht zum Kontostand", page.Markup);
page.Find(".toast-success .btn").Click();
page.WaitForAssertion(() => Assert.Single(page.FindAll("tbody tr")));
page.WaitForAssertion(() => Assert.Empty(page.FindAll(".deleted-status")));
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.False(e.IsDeleted));
}
}
@@ -8,6 +8,15 @@ namespace Duempelkas.Tests;
public class EntryPresentationTests
{
[Fact]
public void EmptyAccount_ShowsExplanationRatherThanParameterName()
{
using var f = new LedgerUiFixture();
f.Service<Microsoft.AspNetCore.Components.NavigationManager>().NavigateTo("/accounts/2");
var page = f.Context.Render<Duempelkas.App.Components.Routes>();
page.WaitForAssertion(() => Assert.Contains("Noch keine Buchungen vorhanden.", page.Markup));
Assert.DoesNotContain("emptyEntriesMessage", page.Markup);
}
[Fact]
public void Actions_HaveVisibleTextAndBookingSpecificNames()
{
@@ -0,0 +1,28 @@
using Bunit;
using Duempelkas.App.Components;
using Duempelkas.App.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class ErrorRecoveryTests
{
[Fact]
public void UnexpectedError_OffersRecoveryWithoutLeakingBookingData()
{
using var context = new BunitContext();
context.Services.AddSingleton<ToastService>();
var boundary = context.Render<AppErrorBoundary>(p => p.AddChildContent<Throws>());
boundary.WaitForAssertion(() => Assert.Contains("nicht bestätigt", boundary.Markup));
Assert.Contains("Zur Kontenübersicht", boundary.Markup);
Assert.Contains("InvalidOperationException", boundary.Markup);
Assert.DoesNotContain("SECRET_BOOKING_DATA", boundary.Markup);
}
private sealed class Throws : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder) => throw new InvalidOperationException("SECRET_BOOKING_DATA");
}
}
@@ -0,0 +1,42 @@
using Bunit;
using Duempelkas.App.Components;
using Duempelkas.App.Services;
using Duempelkas.Domain.Enums;
using Microsoft.AspNetCore.Components;
using Xunit;
namespace Duempelkas.Tests;
public class LayoutControlTests
{
[Fact]
public async Task TextScaleControl_ChangesPresentationWithoutChangingLedger()
{
using var f = new LedgerUiFixture();
var entries = f.Service<IEntryService>();
await entries.CreateEntryAsync(1, EntryType.Expense, DateTime.Today, "Porto mit sehr langer Beschreibung und Umlauten äöü", 4.20m);
f.Service<NavigationManager>().NavigateTo("/accounts/1");
var page = f.Context.Render<Routes>();
page.WaitForElement("#text-scale").Change("200");
Assert.Contains(f.Context.JSInterop.Invocations, i => i.Identifier == "duempelkasReading.set" && Equals(i.Arguments[0], 200));
Assert.Equal(95.80m, (await f.Service<IBalanceQueryService>().GetAccountBalanceAsync(1)).TotalBalance);
Capture("account", page.Markup);
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe")).Click();
page.WaitForElement("#entry-title");
Capture("booking", page.Markup);
}
private static void Capture(string name, string markup)
{
var output = Environment.GetEnvironmentVariable("DUEMPELKAS_LAYOUT_EVIDENCE");
if (output == null) return;
Directory.CreateDirectory(output);
var root = new DirectoryInfo(AppContext.BaseDirectory);
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent;
var web = new Uri(Path.Combine(root!.FullName, "src", "Duempelkas.Desktop", "wwwroot") + Path.DirectorySeparatorChar).AbsoluteUri;
File.WriteAllText(Path.Combine(output, name + ".html"), "<!doctype html><html lang='de'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>" +
$"<link rel='stylesheet' href='{web}vendor/bootstrap/bootstrap.min.css'><link rel='stylesheet' href='{web}vendor/bootstrap-icons/bootstrap-icons.min.css'><link rel='stylesheet' href='{web}css/app.css'>" +
"</head><body>" + markup + $"<script src='{web}js/app.js'></script>" +
"<script>document.querySelectorAll('dialog').forEach(d=>d.showModal());</script></body></html>");
}
}
@@ -23,6 +23,9 @@ public class ModalDialogTests
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.Equal(1, cancelled);
Assert.Contains("Ungespeicherte Eingaben", modal.Markup);
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.Empty(modal.FindAll("[role=alertdialog]"));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
modal.Find("[data-discard-continue]").Click();
Assert.Empty(modal.FindAll("[role=alertdialog]"));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());