diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index facfa20..5fb1472 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -226,6 +226,58 @@ public sealed class ClassTeacherViewModelsTests Assert.All(rectangles, c => Assert.True(c.X + c.Width <= contentWidth + 0.01f)); } + private static ClassAbsenceDaySummaryRow[] BuildFehltage(int count) => + [.. Enumerable.Range(1, count).Select(day => new ClassAbsenceDaySummaryRow( + new DateOnly(2026, 9, day), "Ada Müller", 1, 0, 0, + ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, true))]; + + // Regression: eine sehr lange einspaltige Fehlzeitenliste (FLOWDRAWBOX ohne automatischen + // Seitenumbruch-Schutz) konnte in der echten Vorlage zu einer unerklärlichen Leerseite führen. + // Ab ausreichend Zeilen UND ausreichend breiter Box wird deshalb zweispaltig (chronologisch: + // erste Hälfte links, Rest rechts) gerendert - das halbiert die benötigte Höhe ungefähr. + [Fact] + public void ElternbriefFehltage_RendertZweispaltigWennBoxBreitGenugIst() + { + var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1); + var absences = BuildFehltage(10); + + var einspaltig = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences, + contentWidth: 170); + var zweispaltig = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences, + contentWidth: 400); + + // Zweispaltig braucht ungefähr die Hälfte der Höhe der einspaltigen Variante für dieselben + // Daten (5 Zeilen je Spalte statt 10 Zeilen in einer Spalte, jeweils plus eine Kopfzeile). + Assert.True(zweispaltig.ContentHeight < einspaltig.ContentHeight * .7f, + $"Zweispaltig ({zweispaltig.ContentHeight}) sollte deutlich niedriger sein als einspaltig ({einspaltig.ContentHeight})."); + var texte = zweispaltig.Commands.OfType().Select(c => c.Text).ToList(); + Assert.Equal(2, texte.Count(t => t == "Datum")); // eine Kopfzeile je Spalte + Assert.Contains("01.09.2026", texte); + Assert.Contains("10.09.2026", texte); + var rechteckeUndTexte = zweispaltig.Commands.Select(c => c switch + { + DrawRectangle r => (float?)(r.X + r.Width), DrawStringEx s => s.X + s.Width, _ => null, + }).Where(x => x is not null); + Assert.All(rechteckeUndTexte, x => Assert.True(x <= 400.01f)); + } + + // Regression: bei einer zu schmalen Box (wie der echten Vorlage dieses Nutzers, deren + // FLOWDRAWBOX für die Fehltagesliste aktuell nur 170 Einheiten breit ist) müssen zwei + // nebeneinanderliegende Spalten zu schmal für Datum/Umfang/Status werden - deshalb bleibt es + // dort bewusst einspaltig, auch bei vielen Zeilen. + [Fact] + public void ElternbriefFehltage_BleibtEinspaltigWennBoxZuSchmalFuerZweiSpaltenIst() + { + var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1); + var absences = BuildFehltage(10); + + var drawing = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences, + contentWidth: 170); + var texte = drawing.Commands.OfType().Select(c => c.Text).ToList(); + + Assert.Equal(1, texte.Count(t => t == "Datum")); + } + [Fact] public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() { diff --git a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs index 7b731a3..69d88c3 100644 --- a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs +++ b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs @@ -200,17 +200,59 @@ public static class StudentAbsenceDayListDrawingBuilder var extentColumnWidth = contentWidth * (66f / referenceWidth); var statusColumnX = contentWidth * (114f / referenceWidth); var statusColumnWidth = contentWidth * (54f / referenceWidth); - commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6")); - commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale, - dateColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); - commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale, - extentColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); - commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale, - statusColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); - y += rowHeight; + + (string Extent, string Status, string StatusColor) Format(ClassAbsenceDaySummaryRow row) + { + var extent = row.CountsAsFullDay + ? "Ganzer Fehltag" + : row.TotalAbsentPeriods > 0 + ? $"Fehlzeit · {row.TotalAbsentPeriods} Std." + : $"Fehlzeit · {row.TotalAbsentMinutes} Min."; + var status = row.IsUnexcused + ? "Unentschuldigt" + : row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase) + ? "Entschuldigt" + : row.FriendlyStatusLabel; + return (extent, status, row.IsUnexcused ? "#C62828" : "#2E7D32"); + } + + void DrawRows(float columnX, float columnWidth, float dColumnX, float dColumnWidth, float eColumnX, + float eColumnWidth, float sColumnX, float sColumnWidth, + IReadOnlyList columnRows) + { + var rowY = y; + commands.Add(new DrawRectangle(columnX, rowY, columnWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6")); + commands.Add(new DrawStringEx(dColumnX, rowY + scale * millimeterScale, rowHeight - scale * millimeterScale, + dColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); + commands.Add(new DrawStringEx(eColumnX, rowY + scale * millimeterScale, rowHeight - scale * millimeterScale, + eColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); + commands.Add(new DrawStringEx(sColumnX, rowY + scale * millimeterScale, rowHeight - scale * millimeterScale, + sColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); + rowY += rowHeight; + for (var index = 0; index < columnRows.Count; index++) + { + var row = columnRows[index]; + var (extent, status, statusColor) = Format(row); + var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB"; + commands.Add(new DrawRectangle(columnX, rowY, columnWidth, rowHeight, "#E5E7EB", .3f, fill)); + commands.Add(new DrawStringEx(dColumnX, rowY + scale * millimeterScale, + rowHeight - scale * millimeterScale, dColumnWidth, + row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); + commands.Add(new DrawStringEx(eColumnX, rowY + scale * millimeterScale, + rowHeight - scale * millimeterScale, eColumnWidth, + extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); + commands.Add(new DrawStringEx(sColumnX, rowY + scale * millimeterScale, + rowHeight - scale * millimeterScale, sColumnWidth, + status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor, Bold: row.IsUnexcused)); + rowY += rowHeight; + } + } if (rows.Count == 0) { + DrawRows(0, contentWidth, dateColumnX, dateColumnWidth, extentColumnX, extentColumnWidth, + statusColumnX, statusColumnWidth, []); + y += rowHeight; commands.Add(new DrawStringEx(dateColumnX, y + 2 * scale * millimeterScale, rowHeight, contentWidth - 2 * dateColumnX, "Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280", Italic: true)); @@ -218,34 +260,45 @@ public static class StudentAbsenceDayListDrawingBuilder } else { - for (var index = 0; index < rows.Count; index++) + // Ab genug Zeilen zweispaltig (chronologisch: erste Hälfte links, Rest rechts) - spart bei + // längeren Listen spürbar Höhe. Nur wenn die Box auch wirklich breit genug für zwei volle + // Spalten ist (>= 2x die Einspalten-Referenzbreite + Zwischenraum) - sonst blieben Datum/ + // Umfang/Status in jeder Hälfte zu schmal und würden abgeschnitten, siehe die reale + // Vorlage dieses Nutzers, deren FLOWDRAWBOX für diesen Platzhalter aktuell nur 170 statt + // z.B. 400+ Einheiten breit ist (dort bleibt es also bewusst einspaltig). + const float columnGapDesign = 8f; + var minTwoColumnWidth = 2 * referenceWidth + columnGapDesign; + if (rows.Count >= 4 && contentWidth >= minTwoColumnWidth) { - var row = rows[index]; - var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB"; - var extent = row.CountsAsFullDay - ? "Ganzer Fehltag" - : row.TotalAbsentPeriods > 0 - ? $"Fehlzeit · {row.TotalAbsentPeriods} Std." - : $"Fehlzeit · {row.TotalAbsentMinutes} Min."; - var status = row.IsUnexcused - ? "Unentschuldigt" - : row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase) - ? "Entschuldigt" - : row.FriendlyStatusLabel; - var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32"; - commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill)); - commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale, - rowHeight - scale * millimeterScale, dateColumnWidth, - row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale, - Color: "#374151")); - commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale, - rowHeight - scale * millimeterScale, extentColumnWidth, - extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); - commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale, - rowHeight - scale * millimeterScale, statusColumnWidth, - status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor, - Bold: row.IsUnexcused)); - y += rowHeight; + var columnGap = contentWidth * (columnGapDesign / referenceWidth); + var columnWidth = (contentWidth - columnGap) / 2; + var leftCount = (rows.Count + 1) / 2; + var left = rows.Take(leftCount).ToList(); + var right = rows.Skip(leftCount).ToList(); + // Spaltenanteile innerhalb EINER Hälfte, gegen eine 81-Einheiten-Referenz austariert: + // 81 + columnGapDesign(8) + 81 = 170 = referenceWidth, dieselbe Gesamtbreite wie das + // einspaltige Layout, nur eben in zwei Hälften aufgeteilt. + const float halfReferenceWidth = 81f; + (float X, float Width) Fraction(float leftEdge, float x, float width) => + (leftEdge + columnWidth * (x / halfReferenceWidth), columnWidth * (width / halfReferenceWidth)); + var (leftDateX, leftDateWidth) = Fraction(0, 1, 19); + var (leftExtentX, leftExtentWidth) = Fraction(0, 21, 32); + var (leftStatusX, leftStatusWidth) = Fraction(0, 54, 26); + var rightEdge = columnWidth + columnGap; + var (rightDateX, rightDateWidth) = Fraction(rightEdge, 1, 19); + var (rightExtentX, rightExtentWidth) = Fraction(rightEdge, 21, 32); + var (rightStatusX, rightStatusWidth) = Fraction(rightEdge, 54, 26); + DrawRows(0, columnWidth, leftDateX, leftDateWidth, leftExtentX, leftExtentWidth, leftStatusX, + leftStatusWidth, left); + DrawRows(rightEdge, columnWidth, rightDateX, rightDateWidth, rightExtentX, rightExtentWidth, + rightStatusX, rightStatusWidth, right); + y += rowHeight * (1 + Math.Max(left.Count, right.Count)); + } + else + { + DrawRows(0, contentWidth, dateColumnX, dateColumnWidth, extentColumnX, extentColumnWidth, + statusColumnX, statusColumnWidth, rows); + y += rowHeight * (1 + rows.Count); } } return new DrawingValue(commands, y);