From e4a81e8974108f1aede60289350ca7c052d6b3cf Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 8 Oct 2026 09:00:15 +0200 Subject: [PATCH] Klausur-Deckblatt: Fortschrittsbalken und Erzeugung im Hintergrund MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 --- .../ExamCoverSheetTests.cs | 14 ++++--- .../Groups/ExamCoverSheetDialogViewModel.cs | 37 ++++++++++++++++--- .../Views/Groups/ExamCoverSheetDialog.axaml | 16 ++++++-- .../Groups/ExamCoverSheetDialog.axaml.cs | 10 ++++- .../ExamCoverTemplatingTests.cs | 5 ++- LehrerApp.Templating.Tests/TemplatingTests.cs | 8 ++++ LehrerApp.Templating/Model.cs | 5 ++- LehrerApp.Templating/QuestTemplateRenderer.cs | 14 +++++-- TODO.md | 7 ++++ 9 files changed, 93 insertions(+), 23 deletions(-) diff --git a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs index aa97caf..85f9c15 100644 --- a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs +++ b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs @@ -120,7 +120,7 @@ public sealed class ExamCoverSheetTests : IDisposable } [Fact] - public void Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler() + public async Task Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler() { _results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 }); @@ -129,7 +129,7 @@ public sealed class ExamCoverSheetTests : IDisposable var vm = Build([store]); var output = Path.Combine(_directory, "deckblaetter.pdf"); - Assert.True(vm.Generate(output)); + Assert.True(await vm.GenerateAsync(output)); using var document = UglyToad.PdfPig.PdfDocument.Open(output); Assert.Equal(2, document.NumberOfPages); @@ -145,10 +145,12 @@ public sealed class ExamCoverSheetTests : IDisposable // 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 void Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko() + public async Task Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko() { _results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 }); @@ -158,21 +160,21 @@ public sealed class ExamCoverSheetTests : IDisposable vm.FillResults = false; var output = Path.Combine(_directory, "blanko.pdf"); - Assert.True(vm.Generate(output)); + 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 void Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus() + 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(vm.Generate(Path.Combine(_directory, "lang.pdf"))); + Assert.True(await vm.GenerateAsync(Path.Combine(_directory, "lang.pdf"))); Assert.Contains("passen nicht auf eine Seite", vm.StatusMessage); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs index 02df108..70efeb2 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs @@ -31,12 +31,20 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject /// Vorbelegt mit dem internen Klausurtitel, hier nur für diesen Druck änderbar — der /// Titel der Klausur bleibt unberührt. Leer gelassen gilt wieder der interne Titel. [ObservableProperty] private string _coverTitle; + [ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating; + [ObservableProperty] private int _progressValue; + [ObservableProperty] private int _progressMaximum = 1; + /// Alle Deckblätter sind gesetzt, das Sammel-PDF entsteht — dieser Schritt meldet + /// keinen Zwischenstand, der Balken läuft deshalb unbestimmt. + [ObservableProperty] private bool _isMerging; + [ObservableProperty] private string _progressText = ""; public string ExamTitle => _exam.Title; public string ExamLabel => $"{_exam.Title} · {_group.Name} · {_exam.Date:dd.MM.yyyy}"; public ObservableCollection Templates { get; } = []; public ObservableCollection Students { get; } = []; public bool HasNoTemplates => Templates.Count == 0; + public bool CanGenerate => !HasNoTemplates && !IsGenerating; public string SuggestedFileName => SanitizeFileName($"Deckblätter_{_group.Name}_{_exam.Title}.pdf"); public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName, @@ -68,11 +76,12 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject FillResults = Students.Any(s => s.IsCorrected); } - /// Schreibt das Sammel-PDF und meldet den Erfolg. - public bool Generate(string outputPath) + /// Schreibt das Sammel-PDF und meldet den Erfolg. Gesetzt wird im Hintergrund, damit + /// der Dialog bei einem Klassensatz bedienbar bleibt und den Fortschritt zeigen kann. + public async Task GenerateAsync(string outputPath) { StatusMessage = ""; - if (SelectedTemplate is null) return false; + if (SelectedTemplate is null || IsGenerating) return false; var included = Students.Where(s => s.IsIncluded).ToList(); if (included.Count == 0) { StatusMessage = "Kein Schüler ausgewählt."; return false; } try @@ -82,8 +91,15 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider( ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults, _grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle))).ToList(); - var result = _renderer.RenderBatchToPdf(loaded, data); - File.WriteAllBytes(outputPath, result.Pdf); + ReportProgress(0, included.Count); + IsGenerating = true; + var progress = new Progress(done => ReportProgress(done, included.Count)); + var result = await Task.Run(() => + { + var rendered = _renderer.RenderBatchToPdf(loaded, data, progress); + File.WriteAllBytes(outputPath, rendered.Pdf); + return rendered; + }); var message = $"{included.Count} Deckblätter erzeugt."; var blank = FillResults ? included.Count(s => !s.IsCorrected) : 0; @@ -96,6 +112,17 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject } catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException) { StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; } + finally { IsGenerating = false; } + } + + private void ReportProgress(int done, int total) + { + ProgressMaximum = total; + ProgressValue = done; + IsMerging = done >= total && total > 1; + ProgressText = IsMerging + ? "Sammel-PDF wird zusammengefügt …" + : $"{done} von {total} Deckblättern gesetzt …"; } private static string SanitizeFileName(string value) diff --git a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml index 1dd47de..d731c9e 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml @@ -49,13 +49,21 @@ - + + + + + + + -