WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)

Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt
Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle
Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen
in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über
"KL"+getKlassen-ID, automatisch aufgelöst - noch nicht mit echtem WebUntis-Zugang
gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 21:19:39 +02:00
co-authored by Claude Sonnet 5
parent f9f5f75aa8
commit 7245196689
17 changed files with 649 additions and 7 deletions
@@ -0,0 +1,65 @@
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests
{
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
var max = rows[1];
Assert.Equal("Muster Max", max.StudentName);
Assert.Equal(2, max.TotalAbsentPeriods);
Assert.Equal(90, max.TotalAbsentMinutes);
Assert.Equal(["Che", "Kunst"], max.Subjects);
Assert.Equal([3, 5], max.PeriodNumbers.Order());
Assert.Equal(["nicht entsch."], max.Statuses);
Assert.Equal(["Absent"], max.AbsenceReasons);
Assert.Equal("Krank", max.Note);
Assert.Null(max.ExcuseNote);
Assert.True(max.CountsAsFullDay);
var andere = rows[0];
Assert.Equal("Andere Schuelerin", andere.StudentName);
Assert.Equal(1, andere.TotalAbsentPeriods);
Assert.Equal(20, andere.TotalAbsentMinutes);
Assert.Equal(["Deu"], andere.Subjects);
Assert.Null(andere.Note);
Assert.Equal("Zug verpasst", andere.ExcuseNote);
Assert.False(andere.CountsAsFullDay);
}
[Fact]
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
}
}
@@ -1,5 +1,6 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
@@ -29,7 +30,9 @@ public sealed class GroupDetailViewModelTests
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups),
new ClassTeacherTabViewModel(
new WebUntisIntegrationService(new HttpClient(), TestSupport.BuildWebUntisSettingsService()), groups));
vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -30,6 +30,41 @@ public sealed class WebUntisIntegrationServiceTests
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
}
[Fact]
public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
{
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
"Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}")); // logout (Dispose)
var settings = TestSupport.BuildWebUntisSettingsService();
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
await service.ConnectAsync(new WebUntisCredentials(
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var entries = await service.GetClassAbsencesAsync("10c",
new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
var entry = Assert.Single(entries);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal("nicht entsch.", entry.Status);
// Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
Assert.Single(handler.Requests, request =>
request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
request.Uri.Contains("klasseOrStudentgroupId=KL874"));
}
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),