Files
LehrerApp/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
T
2026-10-22 11:24:43 +02:00

344 lines
15 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());
}
[Theory]
[InlineData(RepeatGradeRule.Better, "bessere Note")]
[InlineData(RepeatGradeRule.Repeat, "Note der Wiederholungsarbeit")]
[InlineData(RepeatGradeRule.Original, "Note der ersten Arbeit")]
public void Wiederholungsbaustein_ZeigtDieGespeicherteBewertungsregel(RepeatGradeRule rule, string expected)
{
var original = new Exam { GroupId = _group.Id, Title = "Erste Arbeit", Date = _exam.Date.AddDays(-14) };
_exam.RepeatOfExamId = original.Id; _exam.RepeatGradeRule = rule;
var template = new LoadedTemplate(new TemplateManifest(), new LayoutParser().Parse(
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Exam.RepeatInfo fit=content"), new Dictionary<string, byte[]>());
var drawing = GradeReportDrawingBuilder.Repeat(_exam, original, new ExamResult { Grade = "4" }, template);
var text = string.Join(" ", drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text));
Assert.Contains(expected, text);
Assert.Contains("Note der ersten Arbeit: 4", text);
_exam.IsMakeUp = true;
text = string.Join(" ", GradeReportDrawingBuilder.Repeat(_exam, original, null, template).Commands.OfType<DrawStringEx>().Select(c => c.Text));
Assert.Contains("Nachschreibarbeit", text);
Assert.Contains("ersetzt die versäumte", text);
}
[Fact]
public async Task Zwischenstandsbaustein_VerlangtDurchgangUndAlleAusgewaehltenSchueler()
{
var store = Store();
var source = Path.Combine(_directory, "zwischenstand.lavorlage");
TemplatePackage.Create(source, new TemplateManifest { Id = "zwischenstand", Name = "Zwischenstand",
Metadata = new() { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.ExamCover },
Placeholders = [new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing)] },
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Student.InterimGrades fit=content", new Dictionary<string, byte[]>());
store.Import(source);
var interims = new FakeInterimGrades();
interims.Save(new() { StudentId = _anna.Id, GroupId = _group.Id, Date = _exam.Date, Occasion = "Sprechtag", OverallValue = "2" });
var vm = new ExamCoverSheetDialogViewModel(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]),
new FakeMemberships([]), _results, new GradingService(), [store], new QuestTemplateRenderer(), interims: interims);
var output = Path.Combine(_directory, "zwischenstand.pdf");
Assert.True(vm.UsesInterimGrades);
Assert.False(await vm.GenerateAsync(output));
Assert.Contains("auswählen", vm.StatusMessage);
vm.SelectedInterimRun = vm.InterimRuns.Single();
Assert.False(await vm.GenerateAsync(output));
Assert.Contains(_ben.FullName, vm.StatusMessage);
Assert.False(File.Exists(output));
vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded = false;
Assert.True(await vm.GenerateAsync(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.Contains("Sprechtag", document.GetPage(1).Text);
}
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); }
}