CI / build-and-test (push) Canceled after 0s
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>
296 lines
12 KiB
C#
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); }
|
|
}
|