CI / build-and-test (push) Waiting to run
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170) aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in "pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template) blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer - sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF. CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template (LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
335 lines
16 KiB
C#
335 lines
16 KiB
C#
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<string, byte[]>());
|
|
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<string, byte[]>());
|
|
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<LetterContactChoice>
|
|
{
|
|
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<string> { "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<string, byte[]>());
|
|
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); }
|
|
}
|