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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user