feat: PDF-Druck für Sitzplan, Notenliste, Klausur-Notenspiegel, Kompetenzbericht, Dokumentation

QuestPDF als PDF-Bibliothek eingeführt (11.3), bewusst nur in LehrerApp.Desktop referenziert -
zieht SkiaSharp-Native-Binaries mit, die im Server-Docker-Image nichts verloren haben. Neuer
PdfExportService mit gemeinsamem Kopf-/Fußzeilen-Layout (Titel, Stand-Datum, Seitenzahlen) und
schlanken Druck-DTOs statt ViewModels, damit die Erzeugung ohne Avalonia-Bezug testbar bleibt.
Speicherdialog läuft über den bestehenden ExportService (neues PDF-Format).

Fünf Druckvorlagen (11.4) als "Als PDF"-Button direkt in der jeweiligen Ansicht:
- Sitzplan: Raster mit Tafel-Banner, Tischabständen als echte Lücken, ausgeblendeten Plätzen als
  leere Rasterposition.
- Notenliste: druckt exakt die aktuell angezeigte Matrix (Zeitraum/Darstellung/Sortierung).
- Klausur-Notenspiegel: Kennzahlen, Notenverteilung und Aufgabenanalyse mit Balkendiagrammen,
  auffällig schwache Aufgaben rot markiert.
- Kompetenzbericht: Analyse-Tabelle für den ausgewählten Schüler (erledigt zugleich 8.3.3).
- Schülerdokumentation: alle Einträge chronologisch, vertrauliche/Entwurfs-Einträge markiert.

Layout an gerenderten Muster-PDFs visuell geprüft, nicht nur per Unit-Test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 14:27:05 +02:00
co-authored by Claude Sonnet 5
parent 4cce84c7dd
commit 7365947edc
18 changed files with 785 additions and 15 deletions
@@ -0,0 +1,157 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class PdfExportServiceTests
{
private static readonly PdfExportService Service = new();
private static void AssertIsPdf(byte[] bytes)
{
Assert.True(bytes.Length > 500, "PDF ist verdächtig klein.");
Assert.Equal("%PDF"u8.ToArray(), bytes[..4]);
}
[Fact]
public void BuildSeatingPlanPdf_VollstaendigerPlan_LiefertGueltigesPdf()
{
var data = new SeatingPlanPrintData("Standard", "Raum B204 · 2 × 3 Plätze",
Columns: 3, IsBoardAtBottom: false, ColumnGapWidths: [0, 90],
Seats:
[
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
new SeatPrintCell(0, 1, null, false),
new SeatPrintCell(0, 2, "Muster, Ben", false),
new SeatPrintCell(1, 0, null, false),
new SeatPrintCell(1, 1, "Probe, Cem", false),
new SeatPrintCell(1, 2, null, false),
]);
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
}
/// Ausgeblendete Plätze (kein Tisch im Raum, siehe SeatingPlan.HiddenSeats) müssen als leere
/// Rasterposition mitgedruckt werden, ohne die Erzeugung zu stören.
[Fact]
public void BuildSeatingPlanPdf_MitAusgeblendetenPlaetzenUndTafelUnten_LiefertGueltigesPdf()
{
var data = new SeatingPlanPrintData("Physikraum", "",
Columns: 2, IsBoardAtBottom: true, ColumnGapWidths: [],
Seats:
[
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
new SeatPrintCell(0, 1, null, true),
new SeatPrintCell(1, 0, null, true),
new SeatPrintCell(1, 1, null, false),
]);
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
}
[Fact]
public void BuildGradeListPdf_MatrixMitMehrerenSpalten_LiefertGueltigesPdf()
{
var data = new GradeListPrintData("8a Chemie", "2026/27", "1. Halbjahr",
ColumnHeaders: ["12.09. Klausur 1", "Mündlich 20.09.", "Hausaufgaben 01.10."],
Rows:
[
new GradeListPrintRow("Beispiel, Anna", ["2", "1", ""], "1.67"),
new GradeListPrintRow("Muster, Ben", ["abwesend", "3", "2"], "2.50"),
new GradeListPrintRow("Probe, Cem", ["", "", ""], ""),
]);
AssertIsPdf(Service.BuildGradeListPdf(data));
}
/// Ein Kurs kann übers Jahr viele Notenspalten ansammeln - die Tabelle muss auch dann noch
/// erzeugbar sein (QuestPDF staucht die Spalten, statt zu werfen).
[Fact]
public void BuildGradeListPdf_VieleSpalten_LiefertGueltigesPdf()
{
var headers = Enumerable.Range(1, 20).Select(i => $"Note {i}").ToList();
var rows = Enumerable.Range(1, 30).Select(i => new GradeListPrintRow(
$"Schüler {i}", [.. Enumerable.Repeat("2", 20)], "2.00")).ToList();
AssertIsPdf(Service.BuildGradeListPdf(new GradeListPrintData("LK Chemie", "2026/27",
"Gesamtes Schuljahr", headers, rows)));
}
[Fact]
public void BuildExamStatisticsPdf_MitVerteilungUndAufgabenanalyse_LiefertGueltigesPdf()
{
var data = new ExamStatisticsPrintData("Klausur 1 - Säuren und Basen", "12.09.2026", "E",
Stats:
[
new LabelValue("Bewertet", "24"),
new LabelValue("Abwesend", "2"),
new LabelValue("Durchschnitt", "2.71"),
new LabelValue("Median", "3"),
new LabelValue("Anteil nicht ausreichend (Note 5/6)", "8.3 %"),
],
Distribution:
[
new GradeDistributionPrintRow("1", 3, 12.5),
new GradeDistributionPrintRow("2", 8, 33.3),
new GradeDistributionPrintRow("3", 7, 29.2),
new GradeDistributionPrintRow("4", 4, 16.7),
new GradeDistributionPrintRow("5", 2, 8.3),
new GradeDistributionPrintRow("6", 0, 0),
],
TaskAnalysis:
[
new TaskAnalysisPrintRow("1. Titration", 78.4, false),
new TaskAnalysisPrintRow("2. pH-Berechnung", 42.1, true),
]);
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
}
/// Auch ohne Aufgabenstruktur (Klausur ohne Teilaufgaben) und ohne Niveau muss der
/// Notenspiegel druckbar sein - der Abschnitt Aufgabenanalyse entfällt dann einfach.
[Fact]
public void BuildExamStatisticsPdf_OhneAufgabenUndNiveau_LiefertGueltigesPdf()
{
var data = new ExamStatisticsPrintData("Test", "01.10.2026", "",
[new LabelValue("Bewertet", "0")], [], []);
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
}
[Fact]
public void BuildCompetencyReportPdf_LiefertGueltigesPdf()
{
var data = new CompetencyReportPrintData("8a Chemie", "Beispiel, Anna",
"3 von 5 behandelt · 2 geprüft · 2 mit Ergebnisdaten", "Wiederholungsbedarf unter 60 %",
[
new CompetencyReportPrintRow("Stoff-Teilchen (ST)", "ST1",
"Aggregatzustände auf Teilchenebene erklären", "2× behandelt", "1× geprüft",
"72.5 % · 24 Schüler · 3 Aufgabenwerte", "85 % · 3 Aufgabenwerte", "Stand solide"),
new CompetencyReportPrintRow("Chemische Reaktion (CR)", "CR2",
"Reaktionsgleichungen aufstellen", "1× behandelt · 1× geplant", "1× geprüft",
"48.3 % · 24 Schüler · 2 Aufgabenwerte", "40 % · 2 Aufgabenwerte", "Wiederholungsbedarf"),
new CompetencyReportPrintRow("Energie (EN)", "", "Energiediagramme deuten",
"", "1× geplant", "", "", "Noch keine Bewertung"),
]);
AssertIsPdf(Service.BuildCompetencyReportPdf(data));
}
[Fact]
public void BuildStudentDocumentationPdf_MitVertraulichenUndEntwurfsEintraegen_LiefertGueltigesPdf()
{
var data = new StudentDocumentationPrintData("Beispiel, Anna",
[
new DocumentationPrintEntry("12.09.2026", "Beobachtung", "Unterricht gestört",
"Wiederholt dazwischengerufen, nach Ermahnung gebessert.",
["Kritisch"], IsConfidential: false, IsDraft: false),
new DocumentationPrintEntry("20.09.2026", "Elterngespräch", "Telefonat Mutter",
"Absprache: wöchentliche Rückmeldung im Hausaufgabenheft.",
[], IsConfidential: true, IsDraft: false),
new DocumentationPrintEntry("01.10.2026", "Beobachtung", "Schnellnotiz",
"", ["Nacharbeiten"], IsConfidential: false, IsDraft: true),
]);
AssertIsPdf(Service.BuildStudentDocumentationPdf(data));
}
}