Klausur-Deckblatt: Fortschrittsbalken und Erzeugung im Hintergrund
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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 09:00:15 +02:00
1 parent 24a30881f3
commit e4a81e8974
9 files changed
+93 -23

No files matched your search

@@ -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);
}
@@ -31,12 +31,20 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
/// <summary>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.</summary>
[ObservableProperty] private string _coverTitle;
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
[ObservableProperty] private int _progressValue;
[ObservableProperty] private int _progressMaximum = 1;
/// <summary>Alle Deckblätter sind gesetzt, das Sammel-PDF entsteht — dieser Schritt meldet
/// keinen Zwischenstand, der Balken läuft deshalb unbestimmt.</summary>
[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<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<ExamCoverStudentChoice> 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);
}
/// <summary>Schreibt das Sammel-PDF und meldet den Erfolg.</summary>
public bool Generate(string outputPath)
/// <summary>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.</summary>
public async Task<bool> 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<int>(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)
@@ -49,13 +49,21 @@
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Grid.Row="3">
<StackPanel Spacing="6" Margin="0,10,0,0" IsVisible="{Binding IsGenerating}">
<ProgressBar Minimum="0" Maximum="{Binding ProgressMaximum}" Value="{Binding ProgressValue}"
IsIndeterminate="{Binding IsMerging}" Height="6"/>
<TextBlock Text="{Binding ProgressText}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="4" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"
IsEnabled="{Binding !IsGenerating}"/>
<Button Grid.Column="2" Content="PDF erzeugen…" HorizontalAlignment="Stretch" Click="OnGenerate"
IsEnabled="{Binding !HasNoTemplates}"/>
IsEnabled="{Binding CanGenerate}"/>
</Grid>
</Grid>
@@ -7,7 +7,13 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class ExamCoverSheetDialog : Window
{
public ExamCoverSheetDialog() => InitializeComponent();
public ExamCoverSheetDialog()
{
InitializeComponent();
// Solange das PDF entsteht, bleibt der Dialog offen — sonst liefe die Erzeugung ohne
// sichtbare Rückmeldung weiter.
Closing += (_, e) => e.Cancel = DataContext is ExamCoverSheetDialogViewModel { IsGenerating: true };
}
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
@@ -21,7 +27,7 @@ public partial class ExamCoverSheetDialog : Window
});
if (file is null) return;
var path = file.Path.LocalPath;
if (vm.Generate(path))
if (await vm.GenerateAsync(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
@@ -61,14 +61,17 @@ public sealed class ExamCoverTemplatingTests : IDisposable
var tall = new DrawingValue(
[new DrawStringEx(0, 290, 12, 170, "LANG", DrawingTextAlignment.AlignLeft, 10)], 300);
var progress = new RecordingProgress();
var result = new QuestTemplateRenderer().RenderBatchToPdf(template,
[
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = ShortDrawing }),
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = tall }),
]);
], progress);
using var document = UglyToad.PdfPig.PdfDocument.Open(result.Pdf);
Assert.Equal([1, 2], result.PageCounts);
Assert.Equal([1, 2], progress.Reported);
Assert.Equal(3, document.NumberOfPages);
Assert.Contains("TABELLE", document.GetPage(1).Text);
}
@@ -394,3 +394,11 @@ internal sealed class DictionaryProvider(IReadOnlyDictionary<string, Placeholder
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
}
/// <summary>Zeichnet Meldungen synchron auf — <see cref="Progress{T}"/> würde sie ohne
/// SynchronizationContext erst später auf dem Threadpool zustellen.</summary>
internal sealed class RecordingProgress : IProgress<int>
{
public List<int> Reported { get; } = [];
public void Report(int value) => Reported.Add(value);
}
+4 -1
View File
@@ -192,7 +192,10 @@ public sealed record BatchRenderResult(byte[] Pdf, IReadOnlyList<int> PageCounts
public interface ITemplateBatchRenderer
{
/// <summary>Rendert dieselbe Vorlage je Datensatz und fügt alles zu einem PDF zusammen.</summary>
BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data);
/// <param name="progress">Bekommt nach jedem gesetzten Datensatz deren bisherige Anzahl. Nach
/// dem letzten folgt noch das Zusammenfügen, das sich nicht weiter unterteilen lässt.</param>
BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data,
IProgress<int>? progress = null);
}
public interface ITemplatePreviewRenderer
+10 -4
View File
@@ -14,17 +14,23 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data)
public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data,
IProgress<int>? progress = null)
{
if (data.Count == 0) throw new ArgumentException("Mindestens ein Datensatz ist erforderlich.", nameof(data));
var documents = data.Select(x => BuildDocument(template, ValidateData(template, x))).ToList();
// Seitenzahl je Datensatz getrennt ermitteln: das zusammengeführte Dokument verrät nicht
// mehr, welcher Datensatz unerwartet auf eine Folgeseite übergelaufen ist.
var pageCounts = documents.Select(x => PdfPageCounter.Count(x.GeneratePdf())).ToList();
var single = new List<byte[]>(documents.Count);
foreach (var document in documents)
{
single.Add(document.GeneratePdf());
progress?.Report(single.Count);
}
var pdf = documents.Count == 1
? documents[0].GeneratePdf()
? single[0]
: Document.Merge(documents).UseOriginalPageNumbers().GeneratePdf();
return new(pdf, pageCounts);
return new(pdf, single.Select(PdfPageCounter.Count).ToList());
}
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
+7
View File
@@ -4876,6 +4876,13 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
liefert zusätzlich die Seitenzahl je Datensatz; läuft ein Deckblatt wegen zu vieler Aufgaben
auf eine zweite Seite, warnt der Dialog.
Nachgezogen (Nutzer-Wunsch): Fortschrittsbalken im Dialog, weil ein Klassensatz spürbar
dauert. `GenerateAsync` setzt im Hintergrund (`Task.Run`), `RenderBatchToPdf` meldet über
ein optionales `IProgress<int>` jeden gesetzten Datensatz; das abschließende
`Document.Merge` meldet nichts, dort läuft der Balken unbestimmt ("Sammel-PDF wird
zusammengefügt …"). Währenddessen sind beide Schaltflächen gesperrt und der Dialog lässt
sich nicht schließen. Ein einzelnes Deckblatt wird dabei nicht mehr doppelt gesetzt.
**TemplateDesigner:** "Als Klausur-Deckblatt einrichten" setzt `report-type=exam-cover` und
legt alle Deckblatt-Platzhalter mit Beispielwerten an; vorhandene Platzhalter bleiben, ein
abweichender Typ wird gemeldet statt überschrieben. Neuer Sonderinhalt "Punktetabelle