using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Templating; using Xunit; namespace LehrerApp.Desktop.Tests; public sealed class CreateLetterDialogViewModelTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}"); public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory); [Fact] public void OhneVorlage_KannKeinenBriefErzeugen() { var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory)); Assert.False(vm.CanGenerate); Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase)); } [Fact] public void VerwendeteBriefanredeFehlt_BlockiertErzeugung() { var vm = Build(StudentWithContact(null), StoreWithTemplate( new PlaceholderDefinition("Anrede", PlaceholderType.Text, true))); Assert.False(vm.CanGenerate); Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase)); } [Fact] public void VollstaendigeDaten_ErzeugenFlachesPdf() { var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), new PlaceholderDefinition("Datum", PlaceholderType.Date, true), new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true)); var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store); vm.LetterText = "Dies ist der Inhalt."; var output = Path.Combine(_directory, "Brief.pdf"); Assert.True(vm.Generate(output)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } [Fact] public void KonstanterPflichttext_BenoetigtKeineExterneEingabe() { var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket")); var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store); var output = Path.Combine(_directory, "Konstant.pdf"); Assert.True(vm.CanGenerate); Assert.True(vm.Generate(output)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } [Fact] public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert() { var store = StoreWithTemplate(new PlaceholderDefinition( StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []); var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => drawing); var output = Path.Combine(_directory, "Kalender.pdf"); Assert.True(vm.UsesAttendanceCalendar); await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large)); Assert.True(vm.AttendanceCalendarConfigured); Assert.Contains("August 2026", vm.AttendanceCalendarSummary); Assert.Contains("3 Monate", vm.AttendanceCalendarSummary); Assert.Contains("Groß", vm.AttendanceCalendarSummary); Assert.True(vm.Generate(output)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } // Regression: Der DrawingValue-Builder hat früher immer eine feste Breite (170) angenommen, // unabhängig davon, wie breit die DRAWBOX in der jeweiligen Vorlage tatsächlich deklariert war - // bei breiteren (oder in "pt" statt "mm" deklarierten) Boxen blieb dadurch ein Großteil der // eigentlich verfügbaren Fläche ungenutzt leer (sichtbar in echten Elternbriefen als "Luft" // rechts/unterhalb des Kalenders). Der Dialog muss die real deklarierte Breite ermitteln und an // den Builder weiterreichen. [Fact] public void AdvancedContent_Anwesenheitskalender_NutztDieImLayoutDeklarierteBoxbreite() { var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)], }; var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage"); TemplatePackage.Create(source, manifest, $"PAGE 210 297 mm\nDRAWBOX 20 20 250 80 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}", new Dictionary()); var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); float? receivedWidth = null; float? receivedMillimeterScale = null; var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); }); Assert.True(vm.UsesAttendanceCalendar); Assert.Equal(250, receivedWidth); Assert.Equal(1, receivedMillimeterScale); } // Regression: Eine reale, aus einem Word-Dokument übernommene Vorlage deklariert die Seite in // "pt" statt "mm" (PAGE ... pt). Ohne Umrechnung würde das mm-entworfene Kalenderraster dort nur // rund ein Drittel der vorgesehenen physischen Größe erreichen (1 "mm-Einheit" würde als 1pt statt // als ~2.83pt gerendert). [Fact] public void AdvancedContent_Anwesenheitskalender_RechnetBoxbreiteBeiPunktBasierterVorlageUm() { var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)], }; var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage"); TemplatePackage.Create(source, manifest, $"PAGE 595.32 841.92 pt\nDRAWBOX 71 350 487 177 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}", new Dictionary()); var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); float? receivedWidth = null; float? receivedMillimeterScale = null; var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); }); Assert.True(vm.UsesAttendanceCalendar); Assert.Equal(487, receivedWidth); Assert.NotNull(receivedMillimeterScale); Assert.Equal(2.8346f, receivedMillimeterScale!.Value, 0.001f); } [Fact] public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus() { var store = StoreWithTemplate(new PlaceholderDefinition( StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); AttendanceCalendarOptions? requested = null; var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) => { requested = options; return Task.CompletedTask; }); await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium)); Assert.NotNull(requested); Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth); Assert.Equal(2, requested.NormalizedMonthCount); Assert.False(vm.IsRefreshingAttendanceData); Assert.Equal("", vm.AttendanceRefreshError); } [Fact] public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren() { var store = StoreWithTemplate(new PlaceholderDefinition( StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung.")); await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium)); Assert.True(vm.AttendanceCalendarConfigured); Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError); Assert.False(vm.IsRefreshingAttendanceData); } [Fact] public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt() { var store = StoreWithTemplate(new PlaceholderDefinition( StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel", new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []); var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing); Assert.False(vm.UsesAttendanceCalendar); Assert.True(vm.UsesAbsenceDayList); Assert.True(vm.UsesAttendanceAdvancedContent); Assert.True(vm.CanGenerate); } [Fact] public void EigenerPlatzhalter_KannImDialogEingegebenWerden() { var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), new PlaceholderDefinition("Datum", PlaceholderType.Date, true), new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true), new PlaceholderDefinition("Betreff", PlaceholderType.Text, true)); var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store); vm.LetterText = "Dies ist der Inhalt."; var betreff = Assert.Single(vm.CustomPlaceholders); Assert.Equal("Betreff", betreff.Name); Assert.False(vm.CanGenerate); Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase)); betreff.TextValue = "Wichtiger Termin"; var output = Path.Combine(_directory, "MitBetreff.pdf"); Assert.True(vm.CanGenerate); Assert.True(vm.Generate(output)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } [Fact] public void StudentName_WirdAutomatischMitVollemNamenBefuellt() { var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), new PlaceholderDefinition("Datum", PlaceholderType.Date, true), new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true), new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true)); var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store); vm.LetterText = "Dies ist der Inhalt."; Assert.Empty(vm.CustomPlaceholders); Assert.True(vm.CanGenerate); var output = Path.Combine(_directory, "StudentName.pdf"); Assert.True(vm.Generate(output)); } [Fact] public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar() { var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), new PlaceholderDefinition("Datum", PlaceholderType.Date, true), new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true)); var vm = Build(StudentWithContact(null), store); vm.LetterText = "Dies ist der Inhalt."; Assert.Equal("", vm.Anrede); Assert.False(vm.CanGenerate); Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase)); vm.Anrede = "Sehr geehrte Familie Beispiel,"; var output = Path.Combine(_directory, "AnredeManuell.pdf"); Assert.True(vm.CanGenerate); Assert.True(vm.Generate(output)); } [Fact] public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel() { var student = new Student { FirstName = "Lena", LastName = "Beispiel", Contacts = [ new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }, new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }, ], }; var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true))); Assert.Equal(2, vm.Contacts.Count); Assert.Contains("Frau Beispiel", vm.AddressPreview); Assert.Contains("Erste Str. 1", vm.AddressPreview); vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel"); Assert.Contains("Herr Beispiel", vm.AddressPreview); Assert.Contains("Zweite Str. 2", vm.AddressPreview); Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview); } [Fact] public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau() { var contacts = new List { new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }), new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }), }; var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]); Assert.Same(contacts[1], dialogVm.SelectedContact); Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview); dialogVm.SelectedContact = contacts[0]; Assert.Contains("Erste Str. 1", dialogVm.AddressPreview); } private CreateLetterDialogViewModel Build(Student student, TemplateStore store) => new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([])); private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions) { var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage"); var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] }; var lines = new List { "PAGE 210 297 mm" }; var y = 20; foreach (var definition in definitions) { var element = definition.Type switch { PlaceholderType.Multiline => "TEXTBOX", PlaceholderType.Drawing => "DRAWBOX", _ => "TEXT", }; lines.Add(element switch { "TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}", "DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}", _ => $"TEXT 20 {y} ${definition.Name}", }); y += 20; } TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary()); var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store; } private static Student StudentWithContact(string? salutation) => new() { FirstName = "Lena", LastName = "Beispiel", Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation, Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }], }; public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } }