Files
LehrerApp/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
T
adminandClaude Opus 5.5 e4a81e8974
CI / build-and-test (push) Canceled after 0s
Klausur-Deckblatt: Fortschrittsbalken und Erzeugung im Hintergrund
Ein Klassensatz dauert spürbar und fror den Dialog ein. GenerateAsync
setzt jetzt per Task.Run, RenderBatchToPdf meldet jeden gesetzten
Datensatz über ein optionales IProgress<int>. Das abschließende
Document.Merge meldet nichts, dort läuft der Balken unbestimmt.
Währenddessen sind die Schaltflächen gesperrt und der Dialog bleibt
offen. Ein einzelnes Deckblatt wird nicht mehr doppelt gesetzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:00:15 +02:00

296 lines
12 KiB
C#

using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ExamCoverSheetTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-cover-tests-{Guid.NewGuid():N}");
private readonly LearningGroup _group = new() { Name = "9 Ch G", SchoolYear = "2025/26", GradeLevel = 9 };
private readonly Student _anna = new() { FirstName = "Anna", LastName = "Adler" };
private readonly Student _ben = new() { FirstName = "Ben", LastName = "Bauer" };
private readonly Exam _exam;
private readonly FakeResults _results = new();
private const string Layout = """
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 20 $Student.Name size=12
#pragma flow-slot body x=20 y=40 w=170 h=230
#pragma end-page-template
#pragma content-flow body
TEXT $Exam.Title size=14
FLOWDRAWBOX $Exam.PointsTable w=170 h=120 fit=content gap=10
TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" size=11 gap=8
TEXT "Note: $Result.Grade" size=14 gap=4
#pragma end-content-flow
""";
public ExamCoverSheetTests()
{
Directory.CreateDirectory(_directory);
_exam = new Exam
{
GroupId = _group.Id, Title = "Atombau und PSE", Date = new DateOnly(2025, 12, 18), ExamNumber = 1,
Tasks =
[
new() { Nr = 1, MaxPoints = 5 }, new() { Nr = 2, MaxPoints = 12 },
new() { Nr = 3, MaxPoints = 3, IsBonus = true },
],
GradingKey = [new() { Grade = "1", MinPercent = 90 }, new() { Grade = "6", MinPercent = 0 }],
};
}
[Fact]
public void PointsTable_BrichtBeiFesterSpaltenbreiteInWeitereBloeckeUm()
{
var eight = Enumerable.Range(1, 8).Select(n => new ExamCoverTaskColumn(n.ToString(), n, false, null)).ToList();
var oneBlock = ExamPointsTableDrawingBuilder.Build(eight.Take(6).ToList(), 170);
var twoBlocks = ExamPointsTableDrawingBuilder.Build(eight, 170);
Assert.Equal(6, ExamPointsTableDrawingBuilder.ColumnsPerBlock(170));
Assert.True(twoBlocks.ContentHeight > 2 * oneBlock.ContentHeight);
// Spalten bleiben gleich breit - der zweite Block verkleinert nichts.
Assert.Equal(Widths(oneBlock), Widths(twoBlocks));
Assert.All(twoBlocks.Commands.OfType<DrawRectangle>(), r => Assert.True(r.X + r.Width <= 170));
}
[Fact]
public void PointsTable_Bonusaufgabe_OhneNummerMitPlusVorDerPunktzahl()
{
var drawing = ExamPointsTableDrawingBuilder.Build(
[
new("1", 5, false, 4.5), new("", 3, true, 1),
]);
var texts = drawing.Commands.OfType<DrawStringEx>().Select(x => x.Text).ToList();
Assert.Equal(["Aufgabe", "Max. Punktzahl", "Punktzahl", "Nr. 1", "5", "4,5", "Bonus", "+3", "1"], texts);
}
[Fact]
public void PointsTable_VorlageInPunkt_SkaliertDenMillimeterEntwurf()
{
List<ExamCoverTaskColumn> columns = [new("1", 5, false, null)];
var scale = 72f / 25.4f;
var millimeters = ExamPointsTableDrawingBuilder.Build(columns, 170);
var points = ExamPointsTableDrawingBuilder.Build(columns, 170 * scale, scale);
Assert.Equal(millimeters.ContentHeight * scale, points.ContentHeight, 2);
}
[Fact]
public void Dialog_ZeigtNurDeckblattVorlagen_AusBeidenBibliotheken()
{
var letters = Store(); var worksheets = Store();
Import(letters, "brief", "Brief", null);
Import(letters, "deckblatt-a", "Deckblatt A", TemplateReportTypes.ExamCover);
Import(worksheets, "deckblatt-b", "Deckblatt B", TemplateReportTypes.ExamCover);
var vm = Build([letters, worksheets]);
Assert.Equal(["Deckblatt A", "Deckblatt B"], vm.Templates.Select(x => x.Name));
}
[Fact]
public void Dialog_VorDerKorrektur_BlankoVorausgewaehlt_NachBeginnAusgefuellt()
{
Assert.False(Build([Store()]).FillResults);
_results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4, 10, 0], TotalPoints = 14 });
Assert.True(Build([Store()]).FillResults);
}
[Fact]
public void Dialog_AbwesenderSchueler_IstStandardmaessigAbgewaehlt()
{
_results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _ben.Id, Absent = true });
var vm = Build([Store()]);
Assert.True(vm.Students.Single(s => s.Model.Id == _anna.Id).IsIncluded);
Assert.False(vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded);
}
[Fact]
public async Task Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
{
_results.Add(new ExamResult
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var vm = Build([store]);
var output = Path.Combine(_directory, "deckblaetter.pdf");
Assert.True(await vm.GenerateAsync(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.Equal(2, document.NumberOfPages);
var anna = Normalize(document.GetPage(1).Text);
var ben = Normalize(document.GetPage(2).Text);
Assert.Contains(Normalize(_anna.FullName), anna);
Assert.Contains(Normalize(_ben.FullName), ben);
Assert.Contains("AtombauundPSE", anna);
// Bonusaufgabe zählt nicht zur erreichbaren Punktzahl; erreichte Bonuspunkte schon.
Assert.Contains("Gesamtpunkte:17,5von17Punkte", anna);
Assert.Contains("Note:1", anna);
Assert.Contains("+3", anna);
// Ben ist noch nicht korrigiert: Linien zum Ausfüllen statt Punkten und Note.
Assert.Contains($"Gesamtpunkte:{ExamCoverPlaceholderBuilder.BlankPoints}von17Punkte", ben);
Assert.Contains("1 davon ohne Punkte und Note", vm.StatusMessage);
Assert.False(vm.IsGenerating);
Assert.True(vm.CanGenerate);
}
[Fact]
public async Task Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
{
_results.Add(new ExamResult
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var vm = Build([store]);
vm.FillResults = false;
var output = Path.Combine(_directory, "blanko.pdf");
Assert.True(await vm.GenerateAsync(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.DoesNotContain("17,5", Normalize(document.GetPage(1).Text));
}
[Fact]
public async Task Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus()
{
_exam.Tasks = Enumerable.Range(1, 80).Select(n => new ExamTask { Nr = n, MaxPoints = 1 }).ToList();
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var vm = Build([store]);
Assert.True(await vm.GenerateAsync(Path.Combine(_directory, "lang.pdf")));
Assert.Contains("passen nicht auf eine Seite", vm.StatusMessage);
}
[Theory]
[InlineData(GroupType.Course, Niveau.E, "E-Kurs")]
[InlineData(GroupType.Course, Niveau.G, "G-Kurs")]
[InlineData(GroupType.Course, Niveau.Foerder, "")]
[InlineData(GroupType.Course, null, "")]
[InlineData(GroupType.Class, Niveau.G, "")]
public void CourseLabel_NurImKursMitEOderGNiveau(GroupType type, Niveau? niveau, string expected) =>
Assert.Equal(expected, ExamCoverPlaceholderBuilder.CourseLabel(new LearningGroup { Type = type }, niveau));
[Fact]
public void Platzhalter_KursAusKlausurOderMitgliedschaft_KuerzelAusDemDialog()
{
_group.Type = GroupType.Course;
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var template = store.Load(store.GetTemplates().Single());
Dictionary<string, PlaceholderValue> Values(Niveau? studentNiveau) => ExamCoverPlaceholderBuilder.Build(
_exam, _group, "Chemie", _anna, null, false, new GradingService(), template, new DateOnly(2026, 1, 1),
studentNiveau, " MON HED ");
Assert.Equal(new TextValue("E-Kurs"), Values(Niveau.E)["Exam.Course"]);
Assert.Equal(new TextValue("MON HED"), Values(null)["Teacher.Abbreviation"]);
_exam.Niveau = Niveau.G;
Assert.Equal(new TextValue("G-Kurs"), Values(Niveau.E)["Exam.Course"]);
}
[Fact]
public void Platzhalter_TitelAusDemDialog_LeerNimmtDenKlausurtitel()
{
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var template = store.Load(store.GetTemplates().Single());
Dictionary<string, PlaceholderValue> Values(string? title) => ExamCoverPlaceholderBuilder.Build(
_exam, _group, "Chemie", _anna, null, false, new GradingService(), template, new DateOnly(2026, 1, 1),
title: title);
Assert.Equal(new TextValue("Klassenarbeit Nr. 1"), Values(" Klassenarbeit Nr. 1 ")["Exam.Title"]);
Assert.Equal(new TextValue("Atombau und PSE"), Values(" ")["Exam.Title"]);
Assert.Equal(new TextValue("Atombau und PSE"), Values(null)["Exam.Title"]);
}
[Fact]
public void Dialog_TitelIstMitDemKlausurtitelVorbelegt()
{
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var vm = Build([store]);
Assert.Equal("Atombau und PSE", vm.CoverTitle);
vm.CoverTitle = "Klassenarbeit Nr. 1";
Assert.Equal("Atombau und PSE", _exam.Title);
}
// Der Vorlagen-Designer bietet genau diesen Katalog an - liefert die Hauptapp einen Namen
// nicht oder mit anderem Typ, scheitert die Vorlage erst beim Drucken.
[Fact]
public void Platzhalter_DeckenDenKatalogDesDesignersMitGleichenTypenAb()
{
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var template = store.Load(store.GetTemplates().Single());
var values = ExamCoverPlaceholderBuilder.Build(_exam, _group, "Chemie", _anna, null, false,
new GradingService(), template, new DateOnly(2026, 1, 1));
Assert.All(ExamCoverPlaceholders.All, known =>
{
Assert.True(values.ContainsKey(known.Name), $"{known.Name} fehlt");
Assert.Equal(known.Type, values[known.Name].Type);
});
}
[Fact]
public void TeacherSettings_SpeichertDasKuerzel()
{
new TeacherSettingsService(_directory).SaveAbbreviation(" HED ");
Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
}
private static string Normalize(string text) => text.Replace(" ", "");
private static List<float> Widths(DrawingValue drawing) =>
drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList();
private ExamCoverSheetDialogViewModel Build(IReadOnlyList<TemplateStore> stores) =>
new(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]), new FakeMemberships([]), _results,
new GradingService(), stores, new QuestTemplateRenderer());
private TemplateStore Store() => new(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
private void Import(TemplateStore store, string id, string name, string? reportType)
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest
{
Id = id, Name = name,
Placeholders =
[
new("Student.Name", PlaceholderType.Text), new("Exam.Title", PlaceholderType.Text),
new("Exam.MaxPoints", PlaceholderType.Text), new("Result.Points", PlaceholderType.Text),
new("Result.Grade", PlaceholderType.Text),
new(ExamPointsTableDrawingBuilder.PlaceholderName, PlaceholderType.Drawing),
],
};
if (reportType is not null) manifest.Metadata[TemplateMetadataKeys.ReportType] = reportType;
TemplatePackage.Create(source, manifest, Layout, new Dictionary<string, byte[]>());
store.Import(source);
}
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
}