feat: Dunkelmodus, Fenstergröße merken, Papierkorb für Löschvorgänge (12.4, 14.3, 14.6)

Dunkelmodus über neuen Einstellungen-Tab "Darstellung" (Systemvorgabe/Hell/Dunkel),
Fenstergröße/Maximiert-Status wird über Sitzungen hinweg gemerkt (bewusst ohne
Fensterposition), und ein generischer Snapshot-basierter Papierkorb (30 Tage) für
Sitzpläne, Noten, Notenschlüssel-Vorlagen, Aufgaben und Zeiteinträge. Details und
bewusste Scope-Entscheidungen (Spaltenbreiten zurückgestellt, Farb-Audit für
Dunkelmodus offen, welche Entitäten der Papierkorb abdeckt) in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 16:26:10 +02:00
co-authored by Claude Sonnet 5
parent d9d007cd4b
commit 331033db5c
20 changed files with 1023 additions and 12 deletions
@@ -0,0 +1,62 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AppearanceSettingsServiceTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void Load_OhneVorhandeneDatei_LiefertSystemvorgabe() =>
Assert.Equal(AppTheme.System, new AppearanceSettingsService(BuildTempPath()).Load());
[Theory]
[InlineData(AppTheme.Light)]
[InlineData(AppTheme.Dark)]
[InlineData(AppTheme.System)]
public void SaveUndLoad_PersistiertUeberNeueInstanz(AppTheme theme)
{
var path = BuildTempPath();
new AppearanceSettingsService(path).Save(theme);
var reloaded = new AppearanceSettingsService(path).Load();
Assert.Equal(theme, reloaded);
}
[Fact]
public void Load_BeschaedigteDatei_FaelltAufSystemvorgabeZurueck()
{
var path = BuildTempPath();
File.WriteAllText(Path.Combine(path, "appearancesettings.json"), "{ kein json");
Assert.Equal(AppTheme.System, new AppearanceSettingsService(path).Load());
}
}
public sealed class AppThemeDisplayTests
{
[Theory]
[InlineData(AppTheme.System, "Systemvorgabe")]
[InlineData(AppTheme.Light, "Hell")]
[InlineData(AppTheme.Dark, "Dunkel")]
public void Label_LiefertDeutscheBeschriftung(AppTheme theme, string expected) =>
Assert.Equal(expected, AppThemeDisplay.Label(theme));
[Fact]
public void FromLabel_UndLabel_SindZueinanderInvers()
{
foreach (var theme in Enum.GetValues<AppTheme>())
Assert.Equal(theme, AppThemeDisplay.FromLabel(AppThemeDisplay.Label(theme)));
}
[Fact]
public void FromLabel_UnbekanntesLabel_LiefertSystemvorgabe() =>
Assert.Equal(AppTheme.System, AppThemeDisplay.FromLabel("Nonsens"));
}
+29
View File
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
@@ -69,6 +70,19 @@ public static class TestSupport
/// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen.
public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated);
/// Neue, leere Fakes je Aufruf - für Tests, die nicht speziell TrashViewModel-Verhalten prüfen.
public static TrashViewModel BuildTrashViewModel() => new(
new FakeTrash(), new FakeGrades(), new FakeWorkTasks(), new FakeTimeEntries(),
new FakeSeatingPlans(), new FakeGradingKeyTemplates());
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static AppearanceSettingsService BuildAppearanceSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AppearanceSettingsService(tempPath);
}
/// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe
/// sync.key stören.
public static SyncKeyRecoveryService BuildSyncKeyRecoveryService()
@@ -123,6 +137,17 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
_all.Add(plan);
}
public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id);
// Trash-Mechanismus (14.3) lebt real in LiteDbContext.MoveToTrash/RestoreFromTrash - Fakes
// bilden ihn bewusst nicht nach, ViewModel-Tests brauchen das nicht.
public void Restore(Guid trashId) { }
}
public class FakeTrash(List<TrashedItem>? initial = null) : ITrashRepository
{
private readonly List<TrashedItem> _all = initial ?? [];
public void Add(TrashedItem item) => _all.Add(item);
public List<TrashedItem> GetAll() => _all.OrderByDescending(t => t.DeletedAt).ToList();
public void PurgeOlderThan(DateTime cutoffUtc) => _all.RemoveAll(t => t.DeletedAt < cutoffUtc);
}
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
@@ -190,6 +215,7 @@ public class FakeGrades : IGradeRepository
_all.Add(grade);
}
public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeExams(List<Exam> all) : IExamRepository
@@ -227,6 +253,7 @@ public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id);
public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeSchemes : IGradingSchemeRepository
@@ -407,6 +434,7 @@ public class FakeWorkTasks : IWorkTaskRepository
public List<WorkTask> GetAll() => _all.ToList();
public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeTimeEntries : ITimeEntryRepository
@@ -419,6 +447,7 @@ public class FakeTimeEntries : ITimeEntryRepository
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList();
public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeReportGrades : IReportGradeRepository
@@ -16,7 +16,8 @@ public sealed class SettingsViewModelTests
FakeSupervisionDuties? supervisionDuties = null,
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null,
SyncKeyRecoveryService? syncKeyRecovery = null)
SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null,
TrashViewModel? trashTab = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -36,7 +37,37 @@ public sealed class SettingsViewModelTests
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService());
syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService(),
appearance ?? TestSupport.BuildAppearanceSettingsService(),
trashTab ?? TestSupport.BuildTrashViewModel());
}
// ── Darstellung (12.4) ────────────────────────────────────────────────────────────────────
[Fact]
public void SelectedTheme_SpiegeltDenGespeichertenStandBeimStartWider()
{
var appearance = TestSupport.BuildAppearanceSettingsService();
appearance.Save(AppTheme.Dark);
var vm = BuildViewModel(appearance: appearance);
Assert.Equal(AppTheme.Dark, vm.SelectedTheme);
Assert.Equal("Dunkel", vm.SelectedThemeName);
}
[Fact]
public void SelectedThemeName_Aendern_PersistiertUndBenachrichtigtDenCodeBehind()
{
var appearance = TestSupport.BuildAppearanceSettingsService();
var vm = BuildViewModel(appearance: appearance);
AppTheme? notified = null;
vm.OnThemeChanged = t => notified = t;
vm.SelectedThemeName = "Dunkel";
Assert.Equal(AppTheme.Dark, notified);
Assert.Equal(AppTheme.Dark, appearance.Load());
}
// ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ────────────────────────────
@@ -259,7 +290,8 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.SelectedStateName = "Bayern";
@@ -283,7 +315,8 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.PeriodTimes[0].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45";
@@ -311,7 +344,8 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.PeriodTimes[0].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00";
@@ -0,0 +1,99 @@
using LehrerApp.Core.Models;
using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TrashViewModelTests
{
private static TrashViewModel BuildViewModel(LiteDbContext db) => new(
new TrashRepository(db), new GradeRepository(db), new WorkTaskRepository(db),
new TimeEntryRepository(db), new SeatingPlanRepository(db), new GradingKeyTemplateRepository(db));
[Fact]
public void Konstruktor_LaedtVorhandenePapierkorbEintraege()
{
using var db = new LiteDbContext(new MemoryStream());
var taskRepo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Elternbrief schreiben" };
taskRepo.Save(task);
taskRepo.Delete(task.Id);
var vm = BuildViewModel(db);
Assert.True(vm.HasItems);
var item = Assert.Single(vm.Items);
Assert.Equal("Aufgabe", item.TypeLabel);
Assert.Equal("Elternbrief schreiben", item.Summary);
}
[Fact]
public void LeererPapierkorb_HasItemsIstFalsch()
{
using var db = new LiteDbContext(new MemoryStream());
var vm = BuildViewModel(db);
Assert.False(vm.HasItems);
Assert.Empty(vm.Items);
}
[Fact]
public void RestoreCommand_StelltAufgabeWiederHerUndAktualisiertDieListe()
{
using var db = new LiteDbContext(new MemoryStream());
var taskRepo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Klausuren korrigieren" };
taskRepo.Save(task);
taskRepo.Delete(task.Id);
var vm = BuildViewModel(db);
var item = Assert.Single(vm.Items);
vm.RestoreCommand.Execute(item);
Assert.NotNull(db.Tasks.FindById(task.Id));
Assert.False(vm.HasItems);
Assert.Contains("Klausuren korrigieren", vm.Status);
}
[Fact]
public void RestoreCommand_MitNullTutNichts()
{
using var db = new LiteDbContext(new MemoryStream());
var vm = BuildViewModel(db);
vm.RestoreCommand.Execute(null);
Assert.Equal("", vm.Status);
}
[Theory]
[InlineData(nameof(Grade), "Note")]
[InlineData(nameof(WorkTask), "Aufgabe")]
[InlineData(nameof(TimeEntry), "Zeiteintrag")]
[InlineData(nameof(SeatingPlan), "Sitzplan")]
[InlineData(nameof(GradingKeyTemplate), "Notenschlüssel-Vorlage")]
public void TypeLabel_UebersetztDenEntityTypeInsDeutsche(string entityType, string expectedLabel)
{
using var db = new LiteDbContext(new MemoryStream());
db.TrashedItems.Insert(new TrashedItem { EntityType = entityType, Summary = "Testeintrag" });
var vm = BuildViewModel(db);
var item = Assert.Single(vm.Items);
Assert.Equal(expectedLabel, item.TypeLabel);
}
[Fact]
public void TypeLabel_UnbekannterEntityTypeFaelltAufDenRohenNamenZurueck()
{
using var db = new LiteDbContext(new MemoryStream());
db.TrashedItems.Insert(new TrashedItem { EntityType = "Sonstiges", Summary = "Testeintrag" });
var vm = BuildViewModel(db);
Assert.Equal("Sonstiges", Assert.Single(vm.Items).TypeLabel);
}
}
@@ -0,0 +1,65 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WindowSettingsServiceTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-windowsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void Load_OhneVorhandeneDatei_LiefertStandardgroesse()
{
var service = new WindowSettingsService(BuildTempPath());
var settings = service.Load();
Assert.Equal(1280, settings.Width);
Assert.Equal(800, settings.Height);
Assert.False(settings.IsMaximized);
}
[Fact]
public void SaveUndLoad_PersistiertUeberNeueInstanz()
{
var path = BuildTempPath();
new WindowSettingsService(path).Save(new WindowSettings { Width = 1600, Height = 950, IsMaximized = true });
var reloaded = new WindowSettingsService(path).Load();
Assert.Equal(1600, reloaded.Width);
Assert.Equal(950, reloaded.Height);
Assert.True(reloaded.IsMaximized);
}
/// Eine winzige oder negative Größe (z.B. durch eine beschädigte/manuell editierte Datei)
/// darf das Fenster beim nächsten Start nicht unbenutzbar klein machen.
[Fact]
public void Load_UnplausibelKleineGroesse_FaelltAufStandardgroesseZurueck()
{
var path = BuildTempPath();
new WindowSettingsService(path).Save(new WindowSettings { Width = 5, Height = 5 });
var reloaded = new WindowSettingsService(path).Load();
Assert.Equal(1280, reloaded.Width);
Assert.Equal(800, reloaded.Height);
}
[Fact]
public void Load_BeschaedigteDatei_FaelltAufStandardgroesseZurueck()
{
var path = BuildTempPath();
File.WriteAllText(Path.Combine(path, "windowsettings.json"), "{ kein json");
var settings = new WindowSettingsService(path).Load();
Assert.Equal(1280, settings.Width);
Assert.Equal(800, settings.Height);
}
}