Merge branch 'unstable': WebUntis-iCal-Abgleich für Vertretungen/Ausfälle

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 18:43:49 +02:00
co-authored by Claude Sonnet 5
30 changed files with 3559 additions and 8 deletions
@@ -124,9 +124,25 @@ public interface ISubstitutionEntryRepository
{ {
List<SubstitutionEntry> GetAll(); List<SubstitutionEntry> GetAll();
List<SubstitutionEntry> GetByDate(DateOnly date); List<SubstitutionEntry> GetByDate(DateOnly date);
/// Für den WebUntis-Abgleich (idempotentes Update statt Duplikat je iCal-UID).
SubstitutionEntry? GetByExternalId(string externalId);
void Save(SubstitutionEntry entry); void Save(SubstitutionEntry entry);
void Delete(Guid id); void Delete(Guid id);
} }
/// Zuletzt bekannter Zustand aller WebUntis-iCal-Termine, für den Abgleich bei jedem Abruf.
public interface IUntisSnapshotRepository
{
List<UntisSnapshotEntry> GetAll();
void Save(UntisSnapshotEntry entry);
void Delete(Guid id);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository
{
List<UntisSlotMapping> GetAll();
void Save(UntisSlotMapping mapping);
void Delete(Guid id);
}
public interface IDocumentationRepository public interface IDocumentationRepository
{ {
List<Documentation> GetByStudent(Guid studentId); List<Documentation> GetByStudent(Guid studentId);
+6
View File
@@ -210,6 +210,12 @@ public class SubstitutionEntry
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse"). /// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public string? Notes { get; set; } public string? Notes { get; set; }
/// Gesetzt, wenn dieser Eintrag automatisch aus dem WebUntis-iCal-Abgleich entstanden ist —
/// trägt die stabile iCal-UID des auslösenden Termins. Macht wiederholte Abgleich-Läufe
/// idempotent (Update statt Duplikat, siehe ISubstitutionEntryRepository.GetByExternalId) und
/// unterscheidet automatisch erzeugte von von Hand eingetragenen Ausnahmen. Bei Handeinträgen
/// (SubstitutionEntryDialogViewModel) bleibt es null.
public string? ExternalId { get; set; }
} }
/// <summary> /// <summary>
+69
View File
@@ -0,0 +1,69 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Zuletzt bekannter Zustand eines einzelnen WebUntis-iCal-Termins (ein VEVENT) — die lokale
/// "Sicherungskopie", gegen die jeder neue Abruf verglichen wird, um Änderungen zu erkennen
/// (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden"). <see cref="Uid"/>
/// ist die stabile iCal-UID des Termins (pro Wochen-Slot über das ganze Schuljahr gleich) und
/// dient als fachlicher Schlüssel für den Abgleich — bewusst kein <c>[BsonId]</c> darauf, da
/// LehrerApp.Core absichtlich frei von LiteDB/Avalonia-Abhängigkeiten bleibt (siehe CLAUDE.md);
/// die Suche nach <see cref="Uid"/> läuft stattdessen über eine gefilterte Repository-Abfrage.
/// </summary>
public class UntisSnapshotEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Vom Nutzer bestätigte Zuordnung eines regulären WebUntis-Wochenmusters (Wochentag + Uhrzeit +
/// Fach-Kürzel + Klassen-Token aus der Beschreibung) zu einer bestehenden <see cref="LearningGroup"/>
/// (<see cref="SubstitutionKind.Lesson"/>) oder — für Termine ohne Klassenbezug, z.B. Aufsichten —
/// zu einer festen Pause (<see cref="SubstitutionKind.Supervision"/>, <see cref="AfterPeriod"/>
/// statt <see cref="GroupId"/>/<see cref="PeriodNumber"/>; Nutzer-Feedback: "Zwei Termine sind
/// meine Aufsichten, die nicht zugeordnet werden können [...] vom Zeitraster und von der Dauer her
/// könnten die erfasst werden" — Aufsichten liegen in Pausen, nicht auf einem Unterrichtsstunden-
/// Zeitraster, brauchen also einen eigenen Auflösungsweg statt PeriodNumber). Nur bestätigte
/// (<see cref="Confirmed"/> true) Zuordnungen lösen automatisch geschriebene
/// <see cref="SubstitutionEntry"/>-Einträge aus (Nutzer-Feedback: die erstmalige Zuordnung ist
/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine
/// Bestätigung, bevor sie aktiv wird; siehe UntisMappingReviewDialog).
/// </summary>
public class UntisSlotMapping
{
public Guid Id { get; set; } = Guid.NewGuid();
public DayOfWeek Weekday { get; set; }
public TimeOnly StartTime { get; set; }
public string? Summary { get; set; }
public string ClassToken { get; set; } = "";
public SubstitutionKind Kind { get; set; } = SubstitutionKind.Lesson;
/// Nur bei <see cref="SubstitutionKind.Lesson"/> gesetzt.
public Guid? GroupId { get; set; }
/// Erste/primäre Stunde, zum Bestätigungszeitpunkt über PeriodScheduleService aufgelöst (siehe
/// UntisMatchingService) — hier gespeichert, damit UntisDiffService selbst framework-frei
/// bleibt und keine eigene Uhrzeit→Stunde-Auflösung braucht. Nur bei
/// <see cref="SubstitutionKind.Lesson"/> gesetzt.
public int? PeriodNumber { get; set; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt (siehe UntisSlotMatch.CoveredPeriods) —
/// bei einer Doppelstunde mehr als eine. Entscheidet, welche TimetableSlots als "durch WebUntis
/// bestätigt" gelten (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, und trotzdem
/// erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" — ohne diese Liste blieb
/// die zweite Stunde einer Doppelstunde immer "unzugeordnet", weil WebUntis dafür nur einen
/// einzigen, am Anfang beginnenden Termin meldet). Nur bei <see cref="SubstitutionKind.Lesson"/>
/// gesetzt.
public List<int> CoveredPeriods { get; set; } = [];
/// Nur bei <see cref="SubstitutionKind.Supervision"/> gesetzt — wie bei
/// <see cref="SupervisionDuty.AfterPeriod"/>, ebenfalls zum Bestätigungszeitpunkt aufgelöst
/// (die Pause direkt vor der WebUntis-Startzeit, 0 = vor der 1. Stunde).
public int? AfterPeriod { get; set; }
public bool Confirmed { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+143
View File
@@ -0,0 +1,143 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.Core.Services;
/// <summary>Ein einzelner Termin (VEVENT) aus einer geparsten iCal-Datei — z.B. aus dem
/// WebUntis-Stundenplan-Export (TODO.md, "WebUntis-iCal-Abgleich").</summary>
public class UntisIcsEvent
{
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public DayOfWeek Weekday => Date.DayOfWeek;
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
}
/// <summary>
/// Bewusst kein vollständiger RFC-5545-Parser, sondern genau der Ausschnitt, den der reale
/// WebUntis-Export tatsächlich nutzt (verifiziert an einem echten Export, siehe Planungsdokument):
/// flache VEVENT-Liste ohne RRULE-Wiederholung, ohne Line-Folding, ohne VALARM. `DTSTART;TZID=...`
/// wird als lokale Wanduhrzeit gelesen (die App kennt ohnehin nur "lokale Zeit" der Schule, siehe
/// PeriodScheduleService) — ein `...Z`-Suffix (UTC) wird defensiv unterstützt, auch wenn im realen
/// Export nicht beobachtet. Termine mit unbekanntem DTSTART-Format werden übersprungen statt die
/// gesamte Verarbeitung abzubrechen (gleiche Fail-soft-Haltung wie EventApplier in LehrerApp.Sync).
/// </summary>
public static class IcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static List<UntisIcsEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
var events = new List<UntisIcsEvent>();
Dictionary<string, (string Params, string Value)>? current = null;
foreach (var line in lines)
{
if (line == "BEGIN:VEVENT") { current = []; continue; }
if (line == "END:VEVENT")
{
if (current is not null && TryBuildEvent(current, out var evt)) events.Add(evt);
current = null;
continue;
}
if (current is null) continue;
var (key, paramsPart, value) = SplitPropertyLine(line);
if (key.Length == 0) continue;
current[key] = (paramsPart, value);
}
return events;
}
// RFC 5545 Line-Folding: eine Fortsetzungszeile beginnt mit Leerzeichen/Tab und gehört zur
// Vorzeile — im realen WebUntis-Export nicht beobachtet (Zeilen deutlich unter dem 75-Oktett-
// Limit), aber defensiv unterstützt, falls sich der Export einmal ändert.
private static List<string> Unfold(string icsText)
{
var rawLines = icsText.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
var result = new List<string>();
foreach (var raw in rawLines)
{
if ((raw.StartsWith(' ') || raw.StartsWith('\t')) && result.Count > 0)
result[^1] += raw[1..];
else
result.Add(raw);
}
return result;
}
// "KEY;PARAM=VAL;PARAM2=VAL2:VALUE" — der erste unescapte Doppelpunkt trennt Wert vom Rest.
private static (string Key, string Params, string Value) SplitPropertyLine(string line)
{
var colonIndex = line.IndexOf(':');
if (colonIndex < 0) return ("", "", "");
var head = line[..colonIndex];
var value = line[(colonIndex + 1)..];
var semiIndex = head.IndexOf(';');
return semiIndex < 0 ? (head, "", value) : (head[..semiIndex], head[(semiIndex + 1)..], value);
}
private static bool TryBuildEvent(Dictionary<string, (string Params, string Value)> props, out UntisIcsEvent evt)
{
evt = new UntisIcsEvent();
if (!props.TryGetValue("UID", out var uid) || string.IsNullOrWhiteSpace(uid.Value)) return false;
evt.Uid = uid.Value;
if (!props.TryGetValue("DTSTART", out var dtStart) || !TryParseLocalDateTime(dtStart, out var start))
return false;
evt.Date = DateOnly.FromDateTime(start);
evt.StartTime = TimeOnly.FromDateTime(start);
if (props.TryGetValue("DTEND", out var dtEnd) && TryParseLocalDateTime(dtEnd, out var end))
evt.EndTime = TimeOnly.FromDateTime(end);
evt.Summary = props.TryGetValue("SUMMARY", out var summary) && !string.IsNullOrWhiteSpace(summary.Value)
? Unescape(summary.Value) : null;
evt.Location = props.TryGetValue("LOCATION", out var location) ? Unescape(location.Value) : "";
evt.Description = props.TryGetValue("DESCRIPTION", out var description) ? Unescape(description.Value) : "";
evt.Status = props.TryGetValue("STATUS", out var status) && !string.IsNullOrWhiteSpace(status.Value)
? status.Value : "CONFIRMED";
return true;
}
// "TZID=Europe/Berlin:20260817T075000" (lokale Wanduhrzeit) oder "20260817T075000Z" (UTC).
private static bool TryParseLocalDateTime((string Params, string Value) prop, out DateTime local)
{
local = default;
var value = prop.Value;
var isUtc = value.EndsWith('Z');
var digits = isUtc ? value[..^1] : value;
if (!DateTime.TryParseExact(digits, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
return false;
local = isUtc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: parsed;
return true;
}
private static string Unescape(string value)
{
var sb = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] == '\\' && i + 1 < value.Length)
{
var next = value[i + 1];
sb.Append(next switch { ';' => ';', ',' => ',', 'n' or 'N' => '\n', '\\' => '\\', _ => next });
i++;
}
else sb.Append(value[i]);
}
return sb.ToString();
}
}
+228
View File
@@ -0,0 +1,228 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed class UntisDiffResult
{
/// SubstitutionEntry-Kandidaten (Id ist immer neu vergeben — der Aufrufer entscheidet über
/// ISubstitutionEntryRepository.GetByExternalId, ob ein vorhandener Eintrag aktualisiert
/// statt dupliziert werden muss; siehe UntisSyncService).
public List<SubstitutionEntry> SubstitutionsToSave { get; init; } = [];
public List<UntisSnapshotEntry> SnapshotToSave { get; init; } = [];
/// Verschwundene, bereits als Ausfall verarbeitete Zeilen — werden aus dem Snapshot entfernt,
/// damit derselbe Ausfall nicht bei jedem weiteren Poll erneut erkannt wird.
public List<Guid> SnapshotIdsToDelete { get; init; } = [];
/// ExternalIds automatisch erzeugter SubstitutionEntry-Zeilen, die NICHT (mehr) gebraucht
/// werden — z.B. weil eine früher erkannte Abweichung sich (durch einen Bugfix oder eine
/// erneute Zuordnung) als nicht mehr abweichend herausstellt, oder eine zuvor als fehlend
/// gemeldete Stunde jetzt wieder im Feed auftaucht. Nutzer-Feedback: "Es ist immer noch so"
/// (nachdem der eigentliche Vergleichsfehler bereits behoben war) — Ursache: einmal erzeugte
/// automatische Einträge wurden nie wieder entfernt, selbst wenn der Vergleich sie beim
/// nächsten Poll nicht mehr als Abweichung einstufte; sie blieben als Karteileichen stehen.
/// Existiert kein Eintrag mit dieser ExternalId, ist das Löschen ein no-op (siehe UntisSyncService).
public List<string> SubstitutionExternalIdsToDelete { get; init; } = [];
}
/// <summary>
/// Stufe 2 des WebUntis-Abgleichs: ein neuer iCal-Abruf gegen den zuletzt gespeicherten
/// Schnappschuss (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden").
/// Framework-frei wie <see cref="UntisMatchingService"/> — nimmt nur einfache Objekte/Listen
/// entgegen, kein Datenbankzugriff.
/// </summary>
public class UntisDiffService
{
public const int DefaultLookaheadDays = 14;
public UntisDiffResult Diff(List<UntisIcsEvent> newEvents, List<UntisSnapshotEntry> previousSnapshot,
List<UntisSlotMapping> confirmedMappings, DateOnly today, int lookaheadDays = DefaultLookaheadDays,
List<SupervisionDuty>? existingSupervisionDuties = null, HashSet<DateOnly>? freeDates = null)
{
var previousByUid = previousSnapshot.ToDictionary(s => s.Uid);
// GroupBy statt direktem ToDictionary: robust gegen mehrere Mappings für denselben Slot
// (z.B. Altdaten vor einem Bugfix) - die zuletzt angelegte gewinnt, statt mit einer
// ArgumentException abzubrechen und dadurch den gesamten Poll (Timer-Callback) zu killen.
var mappingByWeekdayStart = confirmedMappings
.Where(m => m.Confirmed && (m.Kind == SubstitutionKind.Lesson ? m.PeriodNumber is not null : m.AfterPeriod is not null))
.GroupBy(m => (m.Weekday, m.StartTime))
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
// Regelmäßige, ohnehin schon jede Woche im Stundenplan sichtbare Aufsichten (siehe
// SupervisionDuty/Einstellungen "Aufsichten") - eine bestätigte Supervision-Zuordnung, die
// KEINER davon entspricht, ist per Definition eine zusätzliche/Vertretungsaufsicht und soll
// deshalb selbst als reguläres Vorkommnis (nicht erst bei Abweichung) im Plan auftauchen.
var regularDutyKeys = (existingSupervisionDuties ?? [])
.Select(d => (d.Weekday, d.AfterPeriod))
.ToHashSet();
var substitutions = new List<SubstitutionEntry>();
var snapshotToSave = new List<UntisSnapshotEntry>();
var externalIdsToDelete = new List<string>();
var newEventsByUid = newEvents.ToDictionary(e => e.Uid);
foreach (var evt in newEvents)
{
snapshotToSave.Add(new UntisSnapshotEntry
{
Id = previousByUid.TryGetValue(evt.Uid, out var existing) ? existing.Id : Guid.NewGuid(),
Uid = evt.Uid, Date = evt.Date, StartTime = evt.StartTime, EndTime = evt.EndTime,
Summary = evt.Summary, Location = evt.Location, Description = evt.Description,
Status = evt.Status, LastSeenAt = DateTime.UtcNow,
});
if (!mappingByWeekdayStart.TryGetValue((evt.Weekday, evt.StartTime), out var mapping)) continue;
if (string.Equals(evt.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
{
substitutions.Add(mapping.Kind == SubstitutionKind.Lesson
? BuildCancelledLesson(evt.Date, mapping, evt.Uid)
: BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt."));
continue;
}
if (mapping.Kind == SubstitutionKind.Lesson)
{
if (DeviationReason(evt, mapping) is { } reason)
substitutions.Add(BuildChangedLesson(evt, mapping, reason));
else
externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt
}
else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value)))
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht
// doch der Sync nur so halb Sinn" - ohne passende reguläre Aufsicht IST dieses
// Vorkommnis selbst schon die meldenswerte Vertretung, nicht erst eine Abweichung
// davon.
substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Zusätzliche Aufsicht laut WebUntis."));
}
else
{
externalIdsToDelete.Add(evt.Uid); // jetzt als reguläre Aufsicht erkannt, keine Meldung mehr nötig
}
}
// Aufsichten: weiterhin rein reaktiv gegen den letzten Snapshot (kein fester wöchentlicher
// Anspruch, siehe "Zusätzliche Aufsicht" oben - ein Verschwinden ist nur meldenswert, wenn
// die Aufsicht vorher tatsächlich einmal gesehen wurde).
var snapshotIdsToDelete = new List<Guid>();
foreach (var previous in previousSnapshot)
{
if (newEventsByUid.ContainsKey(previous.Uid)) continue;
if (previous.Date < today || previous.Date > today.AddDays(lookaheadDays)) continue;
if (!mappingByWeekdayStart.TryGetValue((previous.Date.DayOfWeek, previous.StartTime), out var mapping)) continue;
if (mapping.Kind != SubstitutionKind.Supervision) continue;
var vanishedId = BuildVanishedExternalId(previous.Date, mapping);
substitutions.Add(BuildSupervisionNote(previous.Date, mapping, vanishedId, "Laut WebUntis entfallen oder übernommen."));
snapshotIdsToDelete.Add(previous.Id);
}
// Unterrichtsstunden: zusätzlich AKTIV prüfen, ob für jedes vom Feed bereits abgedeckte
// künftige Datum dieses Wochentags ein passender Termin existiert - rein reaktives
// Schnappschuss-Diffing (oben) erkennt nur Termine, die zwischen zwei Abrufen
// VERSCHWINDEN, nicht solche, die schon beim allerersten Abruf nie im Feed auftauchten
// (Nutzer-Feedback: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht
// nicht im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht
// stattfinden" - WebUntis hatte diesen Ausfall von Anfang an nie als Termin gelistet, es
// gab also nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können). Begrenzt auf
// das vom Feed tatsächlich abgedeckte Zeitfenster (jüngstes gesehenes Datum), damit nicht
// Tage jenseits des vom Feed veröffentlichten Horizonts fälschlich als Ausfall gelten, und
// um Ferien/Feiertage bereinigt (freeDates), an denen WebUntis ohnehin keinen Termin führt.
if (newEvents.Count > 0)
{
var maxKnownDate = newEvents.Max(e => e.Date);
var horizonEnd = maxKnownDate < today.AddDays(lookaheadDays) ? maxKnownDate : today.AddDays(lookaheadDays);
var eventDates = newEvents.Select(e => (e.Date, e.StartTime)).ToHashSet();
var freeDateSet = freeDates ?? [];
foreach (var mapping in mappingByWeekdayStart.Values.Where(m => m.Kind == SubstitutionKind.Lesson))
{
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
{
if (date.DayOfWeek != mapping.Weekday) continue;
if (freeDateSet.Contains(date)) continue;
if (eventDates.Contains((date, mapping.StartTime)))
{
// War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt
// wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab.
externalIdsToDelete.Add(BuildMissingExternalId(date, mapping));
continue;
}
substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping)));
}
}
}
return new UntisDiffResult
{
SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave,
SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete,
};
}
// Nutzer-Feedback: "Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt [...] Diese
// manuelle Verknüpfung ist aber jetzt scheinbar vergessen" — Ursache: ClassToken wird beim
// Bestätigen kompakt ohne Leerzeichen gespeichert ("10a;10b;10c"), WebUntis trennt die
// Klassen in DESCRIPTION aber mit "; " (Semikolon + Leerzeichen), z.B. "10a; 10b; 10c;
// Gastro HED". Ein erster Fix entfernte Leerzeichen nur auf der evt.Description-Seite — blieb
// aber weiterhin falsch, wenn mapping.ClassToken selbst ein eingebettetes Leerzeichen enthält
// (z.B. wenn die reale DESCRIPTION mehrere Klassen NICHT mit ";", sondern mit "," trennt:
// ExtractClassTokens fand dann kein ";" und behandelte "10a, 10b, 10c" als EIN Token mit
// Leerzeichen/Kommas statt drei getrennte - ein Vergleich gegen die leerzeichenbereinigte
// Description konnte dieses Token dann NIE mehr finden, unabhängig von der Auswahl im Dialog).
// Fix: beide Seiten symmetrisch von Leerzeichen befreien (gleiches Prinzip wie
// UntisMatchingService.Normalize für den Gruppennamen-Abgleich), plus ExtractClassTokens
// akzeptiert jetzt sowohl ";" als auch "," als Trennzeichen zwischen Klassen.
//
// Trotzdem meldet der Nutzer weiterhin falsche Vertretungen für genau diese Art Muster
// ("Immer die Kurse mit Lerngruppen, die aus mehreren Klassen zusammengesetzt sind") - der
// genaue Grund lässt sich ohne Einblick in die echten DESCRIPTION-Werte aus seinem Feed nicht
// mehr blind erraten. Statt eines weiteren ungetesteten Rateversuchs schreibt eine abweichende
// Vertretung deshalb jetzt den GENAUEN Vergleich (roher evt.Summary/evt.Description gegen den
// gespeicherten mapping.Summary/ClassToken) in ihre eigene Beschreibung - der Nutzer kann die
// Ursache dann direkt im Stundenplan ablesen, ohne die iCal-URL erneut teilen zu müssen.
private static string? DeviationReason(UntisIcsEvent evt, UntisSlotMapping mapping)
{
if (evt.Summary != mapping.Summary)
return $"Fach weicht ab: WebUntis meldet „{evt.Summary ?? "(leer)"}“, erwartet war „{mapping.Summary ?? "(leer)"}“.";
if (!RemoveWhitespace(evt.Description).Contains(RemoveWhitespace(mapping.ClassToken)))
return $"Klasse weicht ab: WebUntis-Beschreibung „{evt.Description}“ enthält nicht den erwarteten Klassen-Token „{mapping.ClassToken}“.";
return null;
}
private static string RemoveWhitespace(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).ToArray());
private static SubstitutionEntry BuildCancelledLesson(DateOnly date, UntisSlotMapping mapping, string externalId) => new()
{
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = mapping.PeriodNumber,
Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId,
};
private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new()
{
Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber,
GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken,
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}",
ExternalId = evt.Uid,
};
// Aufsichten haben keinen TimetableSlot im Hintergrund (siehe SubstitutionEntry.Kind-Doc) -
// deshalb Kind=Supervision statt Cancelled, auch wenn der Termin verschwunden ist.
private static SubstitutionEntry BuildSupervisionNote(DateOnly date, UntisSlotMapping mapping,
string externalId, string reason) => new()
{
Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = mapping.AfterPeriod,
Description = $"Automatisch über WebUntis-Abgleich erkannt: {reason}", ExternalId = externalId,
};
// Ein verschwundener Termin hat keine eigene UID mehr im neuen Fetch - ein aus Slot+Datum
// abgeleiteter Ersatzschlüssel dient weiter als Idempotenz-Schlüssel für spätere Polls.
private static string BuildVanishedExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"vanished-{mapping.Id}-{date:yyyyMMdd}";
// Für eine von Anfang an fehlende Stunde gibt es nie eine echte iCal-UID - ein aus
// Slot+Datum abgeleiteter Schlüssel macht wiederholte Polls trotzdem idempotent (Update
// statt Duplikat), unabhängig vom Snapshot-Stand.
private static string BuildMissingExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"missing-{mapping.Id}-{date:yyyyMMdd}";
}
@@ -0,0 +1,241 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster
/// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem
/// Stundenplan in der App zuordnet").</summary>
public sealed class UntisWeeklyPattern
{
public DayOfWeek Weekday { get; init; }
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public string? Summary { get; init; }
/// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten
/// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]).
public List<string> ClassTokens { get; init; } = [];
public int OccurrenceCount { get; init; }
}
public sealed class UntisSlotMatch
{
public required UntisWeeklyPattern Pattern { get; init; }
/// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) —
/// entspricht CoveredPeriods[0].
public int? PeriodNumber { get; init; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis
/// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:5009:20 für Stunde 1+2), während der
/// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann.
/// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der
/// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet,
/// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind").
public List<int> CoveredPeriods { get; init; } = [];
/// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause
/// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod.
public int? AfterPeriod { get; init; }
public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0;
public Guid? SuggestedGroupId { get; init; }
public bool IsConfident { get; init; }
}
public sealed class UntisMatchResult
{
public List<UntisSlotMatch> Matches { get; init; } = [];
/// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ
/// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt").
public List<TimetableSlot> UnmatchedTimetableSlots { get; init; } = [];
}
/// <summary>
/// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise
/// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen
/// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei
/// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder
/// HTTP-Zugriff, dadurch ohne Mocking testbar.
/// </summary>
public class UntisMatchingService
{
// Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster
// und den tatsächlichen WebUntis-Zeiten.
private const int StartTimeToleranceMinutes = 3;
public UntisMatchResult BuildMatches(List<UntisIcsEvent> events, List<LearningGroup> groups,
List<TimetableSlot> timetableSlots, PeriodScheduleService periodSchedule)
{
var teacherToken = DetectTeacherToken(events);
var patterns = BuildWeeklyPatterns(events, teacherToken);
var matches = new List<UntisSlotMatch>();
foreach (var pattern in patterns)
{
// Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind
// meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in
// einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener,
// grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber.
if (pattern.ClassTokens.Count == 0)
{
matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) });
continue;
}
var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule);
var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule);
var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots);
matches.Add(new UntisSlotMatch
{
Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods,
SuggestedGroupId = groupId, IsConfident = confident,
});
}
var matchedSlotKeys = matches
.Where(m => m.SuggestedGroupId is not null)
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
.Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value)))
.ToHashSet();
var unmatchedSlots = timetableSlots
.Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)))
.ToList();
return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots };
}
/// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed
/// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument).
private static string? DetectTeacherToken(List<UntisIcsEvent> events) =>
events
.Select(e => LastToken(e.Description))
.Where(t => t is not null)
.GroupBy(t => t)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault();
private static string? LastToken(string description)
{
var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0 ? null : parts[^1];
}
private static List<UntisWeeklyPattern> BuildWeeklyPatterns(List<UntisIcsEvent> events, string? teacherToken)
{
var withTokens = events.Select(e => new
{
Event = e,
ClassTokens = ExtractClassTokens(e.Description, teacherToken),
});
var patterns = new List<UntisWeeklyPattern>();
foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime)))
{
var modal = group
.GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens)))
.OrderByDescending(g => g.Count())
.First();
patterns.Add(new UntisWeeklyPattern
{
Weekday = group.Key.Weekday,
StartTime = group.Key.StartTime,
EndTime = group.Key.EndTime,
Summary = modal.Key.Summary,
ClassTokens = modal.Key.ClassTokens.Length == 0
? [] : modal.Key.ClassTokens.Split(';').ToList(),
OccurrenceCount = modal.Count(),
});
}
return patterns;
}
// "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt).
// Trennzeichen zwischen mehreren Klassen wurde nur an einem einzelnen echten Beispiel mit EINER
// Klasse verifiziert (siehe Planungsdokument) - für kombinierte/differenzierte Gruppen ist nicht
// sicher belegt, ob WebUntis ";" oder "," verwendet, deshalb werden beide akzeptiert statt sich
// auf eine Annahme festzulegen.
private static readonly char[] ClassTokenSeparators = [';', ','];
private static List<string> ExtractClassTokens(string description, string? teacherToken)
{
var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken)
? description[..^teacherToken.Length].TrimEnd()
: description;
if (string.IsNullOrWhiteSpace(withoutTeacher)) return [];
return withoutTeacher.Split(ClassTokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule)
{
var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime);
if (exact is not null) return exact.PeriodNumber;
var closest = periodSchedule.Periods
.Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes)))
.Where(t => t.DiffMinutes <= StartTimeToleranceMinutes)
.OrderBy(t => t.DiffMinutes)
.FirstOrDefault();
return closest.Period?.PeriodNumber;
}
// Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer
// einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten
// Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods).
private static List<int> ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule)
{
var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes);
return periodSchedule.Periods
.Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance
&& p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance)
.OrderBy(p => p.PeriodNumber)
.Select(p => p.PeriodNumber)
.ToList();
}
// Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der
// 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine
// Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit.
private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) =>
periodSchedule.Periods
.Where(p => p.End <= startTime)
.OrderByDescending(p => p.End)
.Select(p => (int?)p.PeriodNumber)
.FirstOrDefault() ?? 0;
private static (Guid? GroupId, bool Confident) ResolveGroup(List<string> classTokens, int? periodNumber,
List<LearningGroup> groups, List<TimetableSlot> timetableSlots)
{
if (classTokens.Count == 0) return (null, false);
var candidates = classTokens
.Select(token => ResolveSingleGroup(token, groups))
.Where(g => g is not null)
.Select(g => g!)
.Distinct()
.ToList();
if (candidates.Count == 1) return (candidates[0].Id, true);
if (candidates.Count == 0) return (null, false);
// Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe
// bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat.
if (periodNumber is { } period)
{
var withExistingSlot = candidates
.Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period))
.ToList();
if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true);
}
return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog
}
private static LearningGroup? ResolveSingleGroup(string token, List<LearningGroup> groups)
{
var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase));
if (exact is not null) return exact;
var normalizedToken = Normalize(token);
return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken);
}
private static string Normalize(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray());
}
+73
View File
@@ -955,6 +955,79 @@ public sealed class RepositoryTests
Assert.Equal("Vertretung 8a", result[0].Description); Assert.Equal("Vertretung 8a", result[0].Description);
} }
[Fact]
public void SubstitutionEntryRepository_GetByExternalId_FindetAutomatischErzeugtenEintrag()
{
using var db = NewInMemoryContext();
var repo = new SubstitutionEntryRepository(db);
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Cancelled, PeriodNumber = 3, ExternalId = "38818-2004584-2004587" });
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Cancelled, PeriodNumber = 4 });
var result = repo.GetByExternalId("38818-2004584-2004587");
Assert.NotNull(result);
Assert.Equal(new DateOnly(2026, 3, 12), result!.Date);
Assert.Null(repo.GetByExternalId("unbekannt"));
}
// ── UntisSnapshotRepository / UntisSlotMappingRepository ─────────────────
[Fact]
public void UntisSnapshotRepository_SaveUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
repo.Save(new UntisSnapshotEntry
{
Uid = "38926-2013812-2013815", Date = new DateOnly(2026, 8, 17),
StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", Location = "Medien", Description = "10c HED",
});
var result = repo.GetAll();
Assert.Single(result);
Assert.Equal("38926-2013812-2013815", result[0].Uid);
Assert.Equal("SOL", result[0].Summary);
}
[Fact]
public void UntisSnapshotRepository_Save_AktualisiertVorhandenenEintrag()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
var entry = new UntisSnapshotEntry { Uid = "38926-2013812-2013815", Location = "Medien" };
repo.Save(entry);
entry.Location = "A1";
entry.Status = "CANCELLED";
repo.Save(entry);
var result = Assert.Single(repo.GetAll());
Assert.Equal("A1", result.Location);
Assert.Equal("CANCELLED", result.Status);
}
[Fact]
public void UntisSlotMappingRepository_SaveDeleteUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSlotMappingRepository(db);
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50),
Summary = "SOL", ClassToken = "10c", GroupId = groupId, Confirmed = true,
};
repo.Save(mapping);
Assert.Single(repo.GetAll());
repo.Delete(mapping.Id);
Assert.Empty(repo.GetAll());
}
// ── WorkTaskRepository ──────────────────────────────────────────────────── // ── WorkTaskRepository ────────────────────────────────────────────────────
[Fact] [Fact]
+2
View File
@@ -66,6 +66,8 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays"); public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties"); public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries"); public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash"); public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
public void Checkpoint() => _db.Checkpoint(); public void Checkpoint() => _db.Checkpoint();
@@ -742,6 +742,8 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList(); public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList(); db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) =>
db.SubstitutionEntries.FindOne(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry) public void Save(SubstitutionEntry entry)
{ {
db.SubstitutionEntries.Upsert(entry); db.SubstitutionEntries.Upsert(entry);
@@ -754,6 +756,26 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
} }
} }
// Bewusst kein db.OnChange hier (anders als sonst überall): UntisSnapshotEntry ist eine rein
// lokale, hochfrequente Abgleich-Zwischenablage (jeder Poll aktualisiert alle Zeilen), deren
// Sync nur Rauschen erzeugen würde. UntisSlotMapping ist an die pro Gerät hinterlegte
// WebUntis-URL gebunden (WebUntisSettingsService, wie SyncSettingsService nicht synchronisiert)
// und deshalb ebenfalls sinnvollerweise lokal — jedes Gerät bestätigt seine Zuordnung einmal
// selbst über den Review-Dialog.
public class UntisSnapshotRepository(LiteDbContext db) : IUntisSnapshotRepository
{
public List<UntisSnapshotEntry> GetAll() => db.UntisSnapshotEntries.FindAll().ToList();
public void Save(UntisSnapshotEntry entry) => db.UntisSnapshotEntries.Upsert(entry);
public void Delete(Guid id) => db.UntisSnapshotEntries.Delete(id);
}
public class UntisSlotMappingRepository(LiteDbContext db) : IUntisSlotMappingRepository
{
public List<UntisSlotMapping> GetAll() => db.UntisSlotMappings.FindAll().ToList();
public void Save(UntisSlotMapping mapping) => db.UntisSlotMappings.Upsert(mapping);
public void Delete(Guid id) => db.UntisSlotMappings.Delete(id);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{ {
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+27
View File
@@ -31,6 +31,14 @@ public static class TestSupport
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new WebUntisSettingsService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf. /// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static SyncSettingsService BuildSyncSettingsService() public static SyncSettingsService BuildSyncSettingsService()
{ {
@@ -422,10 +430,29 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository
public void Add(SubstitutionEntry e) => _all.Add(e); public void Add(SubstitutionEntry e) => _all.Add(e);
public List<SubstitutionEntry> GetAll() => _all.ToList(); public List<SubstitutionEntry> GetAll() => _all.ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList(); public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) => _all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
} }
public class FakeUntisSnapshots : IUntisSnapshotRepository
{
private readonly List<UntisSnapshotEntry> _all = [];
public void Add(UntisSnapshotEntry e) => _all.Add(e);
public List<UntisSnapshotEntry> GetAll() => _all.ToList();
public void Save(UntisSnapshotEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisSlotMappings : IUntisSlotMappingRepository
{
private readonly List<UntisSlotMapping> _all = [];
public void Add(UntisSlotMapping m) => _all.Add(m);
public List<UntisSlotMapping> GetAll() => _all.ToList();
public void Save(UntisSlotMapping mapping) { _all.RemoveAll(m => m.Id == mapping.Id); _all.Add(mapping); }
public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id);
}
public class FakeWorkTasks : IWorkTaskRepository public class FakeWorkTasks : IWorkTaskRepository
{ {
private readonly List<WorkTask> _all = []; private readonly List<WorkTask> _all = [];
@@ -34,6 +34,7 @@ public sealed class SettingsViewModelTests
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
@@ -289,6 +290,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -314,6 +316,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -343,6 +346,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -1,5 +1,6 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using Xunit; using Xunit;
@@ -12,7 +13,8 @@ public sealed class TimetableViewModelTests
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null, FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null, FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null, SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null) FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null)
{ {
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -25,7 +27,8 @@ public sealed class TimetableViewModelTests
holidays ?? new FakeSchoolHolidays(), holidays ?? new FakeSchoolHolidays(),
calendarSettings ?? new SchoolCalendarSettingsService(tempPath), calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService(), new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries()); supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService());
} }
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt — /// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
@@ -727,6 +730,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation); Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation);
} }
[Fact]
public void Load_ZusaetzlicheAufsichtInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" - dieselbe Anzeige-Infrastruktur wie beim Ausfall oben, hier für
// eine automatisch erkannte zusätzliche Aufsicht ohne reguläre SupervisionDuty.
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Monday).AddDays(7);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Supervision, AfterPeriod = 1,
Description = "Automatisch über WebUntis-Abgleich erkannt: Zusätzliche Aufsicht laut WebUntis.",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
Assert.DoesNotContain(vm.WeekItems, c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
vm.NextWeekCommand.Execute(null);
var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First();
Assert.True(mondayCell.IsSubstitutionSupervision);
Assert.Contains("Zusätzliche Aufsicht", mondayCell.SupervisionLocation);
}
[Fact] [Fact]
public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige() public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige()
{ {
@@ -930,6 +958,38 @@ public sealed class TimetableViewModelTests
Assert.Equal("6a auf Klassenfahrt", cell.Topic); Assert.Equal("6a auf Klassenfahrt", cell.Topic);
} }
[Fact]
public void Load_AusfallInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Nächste Woche habe ich eine Stunde Ausfall [...] wäre es doch auch
// schön, wenn das im Stundenplan für die nächste Woche irgendwie erkenntlich ist" - genau
// der Weg, den ein automatisch von WebUntis erkannter Ausfall nimmt (derselbe
// SubstitutionEntry-Mechanismus wie bei Handeinträgen), hier erstmals mit WeekOffset != 0
// geprüft.
var subject = new Subject { Name = "Naturwissenschaften", ShortName = "NAT" };
var group = new LearningGroup { Name = "6a", SubjectId = subject.Id };
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Thursday).AddDays(7);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Thursday, PeriodNumber = 3 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Cancelled, PeriodNumber = 3,
Description = "Automatisch über WebUntis-Abgleich erkannt.",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), substitutions: substitutions);
// Aktuelle Woche: an dieser Stelle regulär, noch kein Ausfall sichtbar.
var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.False(currentWeekCell.IsCancelled);
vm.NextWeekCommand.Execute(null);
var nextWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.True(nextWeekCell.IsCancelled);
Assert.Equal("Automatisch über WebUntis-Abgleich erkannt.", nextWeekCell.Topic);
}
[Fact] [Fact]
public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde() public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde()
{ {
@@ -1041,4 +1101,79 @@ public sealed class TimetableViewModelTests
Assert.Single(vm.UpcomingExams); Assert.Single(vm.UpcomingExams);
} }
// ── WebUntis-Abweichung ───────────────────────────────────────────────────
[Fact]
public void Load_UntisAbgleichNichtAktiviert_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_UntisAktivSlotOhneBestaetigteZuordnung_ZeigtAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisSettings: untisSettings);
Assert.True(vm.HasUntisMismatch);
Assert.Contains("1 Stundenplan-Eintrag", vm.UntisMismatchLabel);
}
[Fact]
public void Load_UntisAktivMitBestaetigterZuordnung_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_DoppelstundenZuordnungBestaetigtBeideTimetableSlots()
{
// Regression: "ich habe aber jetzt alles zugeordnet, und trotzdem erhalte ich die
// Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" - eine WebUntis-Doppelstunde
// bestätigt beide zugehörigen TimetableSlots, nicht nur den ersten.
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, CoveredPeriods = [1, 2],
GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
Assert.False(vm.HasUntisMismatch);
}
} }
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisMappingReviewDialogViewModelTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untisreview-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
private static UntisSyncService BuildUntisSyncService(FakeUntisSlotMappings mappings) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
new FakeUntisSnapshots(), mappings, new FakeSubstitutionEntries(), new FakeGroups([]),
new FakeTimetableSlots(), new FakeSupervisionDuties(), new FakeSchoolHolidays(),
new PublicHolidayService(), new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static UntisMappingReviewDialogViewModel BuildDialogVm(FakeUntisSlotMappings mappings,
List<LearningGroup>? groups = null) =>
new(BuildUntisSyncService(mappings), mappings, groups ?? [], new FakeSubjects([]));
private static List<UntisGroupOption> Options(params LearningGroup[] groups) =>
groups.Select(g => new UntisGroupOption(g, null)).ToList();
private static UntisSlotMatch BuildLessonMatch(Guid? groupId, string classToken = "10c") => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", ClassTokens = [classToken], OccurrenceCount = 40,
},
PeriodNumber = 1, SuggestedGroupId = groupId, IsConfident = groupId is not null,
};
private static UntisSlotMatch BuildSupervisionMatch() => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 20), EndTime = new TimeOnly(9, 40),
Summary = null, ClassTokens = [], OccurrenceCount = 40,
},
AfterPeriod = 2,
};
[Fact]
public void UntisMappingRow_VorschlagWirdVorausgewaehlt()
{
var group = new LearningGroup { Name = "10c" };
var row = new UntisMappingRow(BuildLessonMatch(group.Id), Options(group), existing: null);
Assert.Equal(group.Id, row.SelectedGroup?.Group.Id);
Assert.True(row.IsConfident);
Assert.False(row.IsSupervisionCandidate);
}
[Fact]
public void UntisMappingRow_OhneVorschlag_BleibtLeer()
{
var row = new UntisMappingRow(BuildLessonMatch(null), [], existing: null);
Assert.Null(row.SelectedGroup);
Assert.False(row.IsConfident);
}
[Fact]
public void UntisGroupOption_GleicherNameVerschiedeneFaecher_ZeigtFachImDisplayLabel()
{
// Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das Fach dabei, kann ich nicht
// sicher die richtige Lerngruppe hier auswählen."
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, "Chemie");
Assert.Equal("10c (Chemie)", option.DisplayLabel);
}
[Fact]
public void UntisGroupOption_OhneFach_ZeigtNurDenNamen()
{
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, null);
Assert.Equal("10c", option.DisplayLabel);
}
[Fact]
public void UntisMappingRow_AufsichtsMuster_WirdAlsSupervisionCandidateErkanntUndKannAufgeloestWerden()
{
// Nutzer-Feedback: "Zwei Termine sind meine Aufsichten [...] vom Zeitraster und von der
// Dauer her könnten die erfasst werden" - kein Klassenbezug, aber AfterPeriod auflösbar.
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null);
Assert.True(row.IsSupervisionCandidate);
Assert.True(row.CanResolve);
Assert.False(row.ConfirmAsSupervision);
}
[Fact]
public void Save_SchreibtNurZeilenMitAusgewaehlterGruppeAlsBestaetigt()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(null, "9z"), Options(groupA, groupB), existing: null)); // ignoriert
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.True(saved.Confirmed);
Assert.Equal(SubstitutionKind.Lesson, saved.Kind);
Assert.Equal(groupA.Id, saved.GroupId);
Assert.Equal(1, saved.PeriodNumber);
}
[Fact]
public void Save_AufsichtBestaetigt_SchreibtSupervisionMapping()
{
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings);
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null) { ConfirmAsSupervision = true };
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(SubstitutionKind.Supervision, saved.Kind);
Assert.Null(saved.GroupId);
Assert.Equal(2, saved.AfterPeriod);
Assert.True(saved.Confirmed);
}
[Fact]
public void Save_ErneutesSpeichernDerselbenZeile_AktualisiertStattDupliziert()
{
// Regression für "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" -
// wiederholtes Bestätigen desselben Slots darf keine zweite Zeile anlegen (sonst
// ArgumentException in UntisDiffService.Diff beim nächsten Poll, siehe dortigen Test).
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm1 = BuildDialogVm(mappings, [groupA, groupB]);
vm1.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm1.SaveCommand.Execute(null);
var firstSaved = Assert.Single(mappings.GetAll());
// Dialog erneut geöffnet - die bereits bestätigte Gruppe muss vorbefüllt sein.
var existing = mappings.GetAll().Single();
var row2 = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing);
Assert.Equal(groupA.Id, row2.SelectedGroup?.Group.Id);
row2.SelectedGroup = Options(groupB).Single(); // Nutzer korrigiert
var vm2 = BuildDialogVm(mappings, [groupA, groupB]);
vm2.Rows.Add(row2);
vm2.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(firstSaved.Id, saved.Id);
Assert.Equal(groupB.Id, saved.GroupId);
}
[Fact]
public void Save_NutzerAendertVorschlag_UebernimmtNeueAuswahl()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
var row = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null);
row.SelectedGroup = Options(groupB).Single();
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
Assert.Equal(groupB.Id, Assert.Single(mappings.GetAll()).GroupId);
}
}
@@ -0,0 +1,319 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den HTTP-freien Verarbeitungskern von UntisSyncService — kein echter Abruf nötig,
/// synthetischer ICS-Text nach dem realen WebUntis-Format (siehe Planungsdokument).
public sealed class UntisSyncServiceTests
{
private static string BuildIcs(string uid, string dtstart, string? summary, string description, string status = "CONFIRMED") =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" +
"BEGIN:VEVENT\n" +
$"UID:{uid}\n" +
$"STATUS:{status}\n" +
$"DTSTART;TZID=Europe/Berlin:{dtstart}\n" +
$"DTEND;TZID=Europe/Berlin:{dtstart}\n" +
(summary is null ? "" : $"SUMMARY:{summary}\n") +
$"DESCRIPTION:{description}\n" +
"END:VEVENT\nEND:VCALENDAR\n";
private static string EmptyIcs() => "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n";
private static UntisSyncService BuildService(FakeUntisSnapshots? snapshots = null,
FakeUntisSlotMappings? mappings = null, FakeSubstitutionEntries? substitutions = null,
FakeGroups? groups = null, FakeTimetableSlots? timetableSlots = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSchoolHolidays? schoolHolidays = null) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
snapshots ?? new FakeUntisSnapshots(), mappings ?? new FakeUntisSlotMappings(),
substitutions ?? new FakeSubstitutionEntries(), groups ?? new FakeGroups([]),
timetableSlots ?? new FakeTimetableSlots(), supervisionDuties ?? new FakeSupervisionDuties(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(),
new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untissync-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void ProcessIcsText_UnveraendertesEreignis_SchreibtKeineVertretungAberSnapshot()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var service = BuildService(snapshots: snapshots, mappings: mappings);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, result.EventCount);
Assert.Equal(0, result.SubstitutionCount);
Assert.Single(snapshots.GetAll());
}
[Fact]
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions);
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Erneuter Poll mit demselben (noch immer abweichenden) Termin darf keinen zweiten
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus mehreren
// Klassen zusammengesetzt sind" — nachdem der eigentliche Vergleichsfehler (ClassToken/
// Trennzeichen) behoben war, blieben bereits erzeugte falsche Vertretungen trotzdem stehen:
// ProcessIcsText schrieb bislang nur NEUE/weiterhin abweichende Kandidaten, räumte aber nie
// eine zuvor automatisch erzeugte Zeile ab, sobald der nächste Poll gar keine Abweichung mehr
// fand. Der Nutzer sah dadurch dieselbe (jetzt stale) Karteileiche, egal wie oft er neu
// zuordnete oder pollte.
[Fact]
public void ProcessIcsText_AbweichungLoestSichSpaeterAuf_EntferntDieZuvorErzeugteVertretung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Poll 1: Fach weicht (noch) ab - Vertretung wird angelegt.
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Poll 2: derselbe Termin weicht jetzt nicht mehr ab (z.B. weil sich die WebUntis-Daten
// geändert haben oder ein Vergleichsfehler zwischenzeitlich behoben wurde) - die zuvor
// erzeugte Vertretung darf nicht als Karteileiche stehen bleiben.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(0, second.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
// ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht
// im Plan [...] die Klasse ist dort weg" — end-to-end über ProcessIcsText, inkl. der neuen
// Ferien-Ausschluss-Verdrahtung (ISchoolHolidayRepository -> UntisDiffService.freeDates).
[Fact]
public void ProcessIcsText_VonAnfangAnFehlendeStunde_WirdAlsAusfallGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Kein Termin für missingDate im Feed (die 8c ist weg) - ein unbeteiligter, etwas
// späterer Termin belegt lediglich den Feed-Abdeckungshorizont darüber hinaus.
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(3, entry.PeriodNumber);
}
[Fact]
public void ProcessIcsText_FehlendeStundeAnFerientag_WirdNichtGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday
{
StartDate = DateOnly.FromDateTime(missingDate), EndDate = DateOnly.FromDateTime(missingDate),
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, schoolHolidays: schoolHolidays);
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_OhneBestaetigteZuordnung_SchreibtNieEineVertretung()
{
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void BuildMatchPreview_LiefertVorschlagFuerBekannteGruppe()
{
var group = new LearningGroup { Name = "10c" };
var service = BuildService(groups: new FakeGroups([group]));
var preview = service.BuildMatchPreview(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, preview.EventCount);
var match = Assert.Single(preview.Matches.Matches);
Assert.Equal(group.Id, match.SuggestedGroupId);
}
// ── Nutzer-Feedback: "Ich habe eine Aufsicht vertretungsweise bekommen [...] Die fehlt
// natürlich in der Woche drauf wieder. Wird dann bis zum Ende des gültigen Stundenplans diese
// Stunde als entfallene Aufsicht geführt?" — die "entfallen"-Meldung ist an die konkrete,
// tatsächlich gesehene Snapshot-Zeile eines Datums gekoppelt, nicht an eine dauerhaft
// erwartete wöchentliche Wiederholung: sobald sie einmal als entfallen gemeldet und aus dem
// Snapshot entfernt wurde, gibt es nichts mehr, das in einer Folgewoche erneut "verschwinden"
// könnte, solange WebUntis für diesen Slot keinen neuen Termin mehr listet. Mit einer
// passenden regulären SupervisionDuty, damit dieser Test ausschließlich den
// Verschwinden-Mechanismus prüft, unabhängig von der "Zusätzliche Aufsicht"-Erkennung unten.
[Fact]
public void ProcessIcsText_RegulaereAufsichtVerschwindetEinmalig_MeldetEntfallenNurEinmal()
{
var eventDate = DateTime.Today.AddDays(3); // innerhalb des 14-Tage-Lookaheads
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
// Poll 1: Feed enthält die reguläre Aufsicht wie erwartet - keine Meldung nötig.
var first = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, first.SubstitutionCount);
Assert.Single(snapshots.GetAll());
// Poll 2: WebUntis listet an diesem Slot keinen Termin mehr (einmalig von jemand anderem
// übernommen). Genau EINE "entfallen"-Meldung, Snapshot-Zeile wird bereinigt.
var second = service.ProcessIcsText(EmptyIcs());
Assert.Equal(1, second.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Empty(snapshots.GetAll());
// Poll 3, 4, ...: weiterhin kein Termin an diesem Slot - keine erneute Meldung, "bis zum
// Ende des gültigen Stundenplans" bleibt es bei dem einen Eintrag.
var third = service.ProcessIcsText(EmptyIcs());
Assert.Equal(0, third.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" — eine bestätigte Aufsichts-Zuordnung OHNE passende reguläre
// SupervisionDuty (Einstellungen "Aufsichten") ist selbst schon die meldenswerte Vertretung
// und soll deshalb sofort bei jedem Vorkommen als SubstitutionEntry sichtbar werden, nicht
// erst bei einer Abweichung davon.
[Fact]
public void ProcessIcsText_ZusaetzlicheAufsichtOhneRegulaereDuty_WirdSofortGemeldet()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Equal(1, entry.AfterPeriod);
Assert.Contains("Zusätzliche Aufsicht", entry.Description);
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_AufsichtMitRegulaererDuty_MeldetNichtsBeiNormalerAnwesenheit()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ConfirmMappings_SpeichertAlsBestaetigt()
{
var mappings = new FakeUntisSlotMappings();
var service = BuildService(mappings: mappings);
var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Monday, GroupId = Guid.NewGuid() };
service.ConfirmMappings([mapping]);
Assert.True(Assert.Single(mappings.GetAll()).Confirmed);
}
}
@@ -0,0 +1,89 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisSettingsServiceTests
{
private const string SampleUrl = "https://example.webuntis.com/WebUntis/ical_export?school=example&id=test&elemId=1&token=geheim";
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void SetEnabled_PersistiertUeberNeueInstanz()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetEnabled(true);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.Enabled);
}
[Fact]
public void SetIcalUrl_IstVerschluesseltAbrufbar()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
[Fact]
public void IcalUrl_UeberlebtNeueInstanzMitDemselbenPfad()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetIcalUrl(SampleUrl);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.IsConfigured);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
}
[Fact]
public void KonfigurationsdateiEnthaeltNichtDasTokenImKlartext()
{
var path = BuildTempPath();
var service = new WebUntisSettingsService(path);
service.SetIcalUrl(SampleUrl);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
Assert.DoesNotContain("token=geheim", raw);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.Null(service.GetIcalUrl());
Assert.False(service.Enabled);
}
[Fact]
public void SetLastSync_PersistiertZeitstempelUndStatus()
{
var path = BuildTempPath();
var at = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
new WebUntisSettingsService(path).SetLastSync(at, "3 Vertretungen erkannt");
var reloaded = new WebUntisSettingsService(path);
Assert.Equal(at, reloaded.LastSyncAt);
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
}
}
+20
View File
@@ -165,6 +165,8 @@ public static class AppBootstrapper
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>(); services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>(); services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>(); services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
@@ -186,6 +188,24 @@ public static class AppBootstrapper
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) }); services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
services.AddSingleton<AiPlanningService>(); services.AddSingleton<AiPlanningService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
services.AddSingleton<UntisMatchingService>();
services.AddSingleton<UntisDiffService>();
if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new UntisSyncService(
new HttpClient(), untisSettings,
sp.GetRequiredService<IUntisSnapshotRepository>(), sp.GetRequiredService<IUntisSlotMappingRepository>(),
sp.GetRequiredService<ISubstitutionEntryRepository>(), sp.GetRequiredService<IGroupRepository>(),
sp.GetRequiredService<ITimetableSlotRepository>(), sp.GetRequiredService<ISupervisionDutyRepository>(),
sp.GetRequiredService<ISchoolHolidayRepository>(), sp.GetRequiredService<PublicHolidayService>(),
sp.GetRequiredService<SchoolCalendarSettingsService>(), sp.GetRequiredService<PeriodScheduleService>(),
sp.GetRequiredService<UntisMatchingService>(), sp.GetRequiredService<UntisDiffService>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Sync (optional nur wenn Server konfiguriert) ──────────────────── // ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData); var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings); services.AddSingleton(syncSettings);
@@ -0,0 +1,195 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
/// <summary>Ergebnis eines einzelnen Verarbeitungsdurchlaufs (Abruf oder Test-Text) — für
/// Statusanzeige/Logging.</summary>
public sealed record UntisPollResult(int EventCount, int SubstitutionCount);
/// <summary>Für den Zuordnungs-Review-Dialog: das Rohergebnis der Musteranalyse (Stufe 1) für
/// einen konkreten Abruf, ohne dass dabei schon etwas gespeichert wird.</summary>
public sealed record UntisMatchPreview(int EventCount, UntisMatchResult Matches);
/// <summary>
/// Orchestriert den periodischen WebUntis-iCal-Abgleich (siehe TODO.md/Planungsdokument): Abruf
/// per HTTP, Parsen (IcsParser), Musterabgleich (UntisMatchingService) sowie laufender
/// Schnappschuss-Abgleich (UntisDiffService), dessen Ergebnis über die Repositories geschrieben
/// wird. Gleiches Timer/Gate/Dispose-Muster wie LehrerApp.Sync.SyncEngine.
///
/// Der reine Verarbeitungskern (<see cref="ProcessIcsText"/>) ist bewusst ohne HTTP-Zugriff
/// gehalten (public statt internal, da diese Codebasis kein InternalsVisibleTo nutzt), damit er
/// direkt mit vorgefertigtem ICS-Text getestet werden kann, ohne einen echten Abruf zu brauchen.
/// </summary>
public class UntisSyncService : IDisposable
{
private const int PollIntervalMinutes = 60;
private readonly HttpClient _http;
private readonly WebUntisSettingsService _settings;
private readonly IUntisSnapshotRepository _snapshots;
private readonly IUntisSlotMappingRepository _mappings;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IGroupRepository _groups;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly PeriodScheduleService _periodSchedule;
private readonly UntisMatchingService _matchingService;
private readonly UntisDiffService _diffService;
private readonly AppLogger? _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Timer _timer;
/// Feuert, wenn ein Poll tatsächlich Vertretungen geschrieben hat — Timetable/Dashboard können
/// dann bei Bedarf neu laden (EventApplier-Muster: direktes Schreiben an ViewModels vorbei).
public event Action? DataChanged;
public UntisSyncService(HttpClient http, WebUntisSettingsService settings, IUntisSnapshotRepository snapshots,
IUntisSlotMappingRepository mappings, ISubstitutionEntryRepository substitutions, IGroupRepository groups,
ITimetableSlotRepository timetableSlots, ISupervisionDutyRepository supervisionDuties,
ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
UntisMatchingService matchingService, UntisDiffService diffService, AppLogger? logger = null)
{
_http = http; _settings = settings; _snapshots = snapshots; _mappings = mappings;
_substitutions = substitutions; _groups = groups; _timetableSlots = timetableSlots;
_supervisionDuties = supervisionDuties;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_periodSchedule = periodSchedule; _matchingService = matchingService; _diffService = diffService;
_logger = logger;
_timer = new Timer(async _ => await PollAsync(), null,
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
}
public async Task PollAsync()
{
if (!await _gate.WaitAsync(0)) return;
try
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrEmpty(url)) return;
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
}
catch (Exception ex)
{
_logger?.Error("WebUntis-Abgleich: Abruf fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
return;
}
UntisPollResult result;
try
{
result = ProcessIcsText(icsText);
}
catch (Exception ex)
{
// Darf NICHT aus PollAsync herausfallen: der Timer-Callback fängt keine
// Ausnahmen ab, eine unbehandelte Exception hier würde den gesamten Prozess
// beenden (gleiche Begründung wie EventApplier in LehrerApp.Sync).
_logger?.Error("WebUntis-Abgleich: Verarbeitung fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
return;
}
_settings.SetLastSync(DateTime.UtcNow,
$"{result.SubstitutionCount} Änderung(en) erkannt ({result.EventCount} Termine geprüft).");
_logger?.Info($"WebUntis-Abgleich: {result.SubstitutionCount} Änderung(en) aus {result.EventCount} Terminen.");
if (result.SubstitutionCount > 0) DataChanged?.Invoke();
}
finally { _gate.Release(); }
}
// Reiner Kern ohne HTTP - direkt mit vorgefertigtem ICS-Text testbar (kein InternalsVisibleTo
// in dieser Codebasis üblich, siehe CLAUDE.md - deshalb public statt internal).
public UntisPollResult ProcessIcsText(string icsText)
{
var events = IcsParser.Parse(icsText);
var previousSnapshot = _snapshots.GetAll();
var confirmedMappings = _mappings.GetAll();
var today = DateOnly.FromDateTime(DateTime.Today);
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
foreach (var candidate in diffResult.SubstitutionsToSave)
{
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
if (existing is not null) candidate.Id = existing.Id;
_substitutions.Save(candidate);
}
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
// ExternalId, ist das ein no-op.
foreach (var externalId in diffResult.SubstitutionExternalIdsToDelete)
{
var stale = _substitutions.GetByExternalId(externalId);
if (stale is not null) _substitutions.Delete(stale.Id);
}
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count);
}
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
// meldet, an denen WebUntis ohnehin keine Termine führt. Gleiche Logik wie
// TimetableViewModel.IsFreeDay, hier separat gehalten statt geteilt, da UntisDiffService
// (LehrerApp.Core) bewusst framework-frei bleibt und keine Desktop-ViewModels referenziert.
private HashSet<DateOnly> BuildFreeDates(DateOnly today)
{
var horizonEnd = today.AddDays(90);
var freeDates = new HashSet<DateOnly>();
foreach (var year in new[] { today.Year, today.Year + 1 })
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State))
freeDates.Add(h.Date);
var schoolHolidays = _schoolHolidays.GetAll();
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
if (schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate))
freeDates.Add(date);
return freeDates;
}
/// Für den Zuordnungs-Review-Dialog (Stufe 1, ohne etwas zu speichern).
public UntisMatchPreview BuildMatchPreview(string icsText)
{
var events = IcsParser.Parse(icsText);
var groups = _groups.GetAll();
var timetableSlots = _timetableSlots.GetAll();
var matches = _matchingService.BuildMatches(events, groups, timetableSlots, _periodSchedule);
return new UntisMatchPreview(events.Count, matches);
}
public async Task<UntisMatchPreview?> FetchAndBuildMatchPreviewAsync()
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrEmpty(url)) return null;
var icsText = await _http.GetStringAsync(url);
return BuildMatchPreview(icsText);
}
public void ConfirmMappings(IEnumerable<UntisSlotMapping> confirmed)
{
foreach (var mapping in confirmed)
{
mapping.Confirmed = true;
_mappings.Save(mapping);
}
}
public void Dispose()
{
_timer.Dispose();
_gate.Dispose();
}
}
@@ -0,0 +1,93 @@
using LehrerApp.Sync.Crypto;
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal class WebUntisSettingsConfig
{
public bool Enabled { get; set; }
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
}
/// <summary>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
/// Verschlüsselung über <see cref="SyncCrypto"/> aus LehrerApp.Sync läuft — Core bleibt bewusst
/// frei von Abhängigkeiten außerhalb von .NET selbst (siehe CLAUDE.md).
///
/// Die iCal-URL trägt ein eingebettetes Auth-Token und wird deshalb wie ein Passwort behandelt:
/// nie im Klartext persistiert, nur AES-256-GCM-verschlüsselt (gleicher Mechanismus wie beim
/// KI-Backend-Token) mit einem eigenen, dateirechte-geschützten Schlüssel.
/// </summary>
public class WebUntisSettingsService
{
private readonly string _configPath;
private readonly string _keyPath;
private readonly byte[] _urlKey;
private WebUntisSettingsConfig _config;
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
public WebUntisSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "webuntis-settings.json");
_keyPath = Path.Combine(appDataPath, "webuntis-url.key");
_urlKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey();
_config = Load();
}
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
public void SetIcalUrl(string url)
{
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
Save();
}
public string? GetIcalUrl() =>
_config.EncryptedIcalUrl is null ? null : SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
public void ClearIcalUrl()
{
_config.EncryptedIcalUrl = null;
_config.Enabled = false;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
_config.LastSyncStatus = status;
Save();
}
private byte[] GenerateAndSaveKey()
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, _keyPath);
return key;
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
private WebUntisSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<WebUntisSettingsConfig>(File.ReadAllText(_configPath))
?? new WebUntisSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new WebUntisSettingsConfig();
}
}
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@@ -62,6 +63,8 @@ public partial class TimetableViewModel : ObservableObject
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly ISubstitutionEntryRepository _substitutions; private readonly ISubstitutionEntryRepository _substitutions;
private readonly IUntisSlotMappingRepository _untisMappings;
private readonly WebUntisSettingsService _untisSettings;
public ObservableCollection<TimetableCellItem> Cells { get; } = []; public ObservableCollection<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = []; public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
@@ -86,6 +89,13 @@ public partial class TimetableViewModel : ObservableObject
[ObservableProperty] private int _weekOffset; [ObservableProperty] private int _weekOffset;
public bool IsCurrentWeek => WeekOffset == 0; public bool IsCurrentWeek => WeekOffset == 0;
// ── WebUntis-Abweichung (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt") ──────
// Kein erneuter iCal-Abruf hier - vergleicht nur den lokal bereits bestätigten
// Zuordnungsstand (UntisSlotMapping, siehe UntisMappingReviewDialog) gegen die aktuellen
// TimetableSlots. Bleibt komplett verborgen, solange der Abgleich nicht aktiviert ist.
[ObservableProperty] private bool _hasUntisMismatch;
[ObservableProperty] private string _untisMismatchLabel = "";
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; } public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; } public Func<Task>? OnAddSubstitution { get; set; }
@@ -97,12 +107,14 @@ public partial class TimetableViewModel : ObservableObject
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear, PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions) ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings)
{ {
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear; _publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions; _supervisionDuties = supervisionDuties; _substitutions = substitutions;
_untisMappings = untisMappings; _untisSettings = untisSettings;
Load(); Load();
} }
@@ -143,8 +155,33 @@ public partial class TimetableViewModel : ObservableObject
BuildToday(today); BuildToday(today);
BuildHoursWarnings(); BuildHoursWarnings();
BuildUpcomingExams(today); BuildUpcomingExams(today);
LoadUntisMismatch();
} }
private void LoadUntisMismatch()
{
if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; }
// CoveredPeriods statt nur PeriodNumber: bei einer von WebUntis zu einem Termin
// zusammengefassten Doppelstunde bestätigt eine einzige Zuordnung mehrere TimetableSlots
// auf einmal (siehe UntisSlotMapping.CoveredPeriods-Dokumentation).
var confirmedKeys = _untisMappings.GetAll()
.Where(m => m.Confirmed && m.Kind == SubstitutionKind.Lesson && m.GroupId is not null)
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
.Select(period => (m.Weekday, period, GroupId: m.GroupId!.Value)))
.ToHashSet();
var mismatchCount = _slots.GetAll()
.Count(s => !confirmedKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)));
HasUntisMismatch = mismatchCount > 0;
UntisMismatchLabel = mismatchCount == 1
? "1 Stundenplan-Eintrag ohne bestätigte WebUntis-Zuordnung."
: $"{mismatchCount} Stundenplan-Einträge ohne bestätigte WebUntis-Zuordnung.";
}
[RelayCommand]
private void ReviewUntisMismatch() => OnNavigateToSettings?.Invoke(SettingsTab.WebUntis);
// ── Anstehende Klausurtermine (4.4.3) ──────────────────────────────────── // ── Anstehende Klausurtermine (4.4.3) ────────────────────────────────────
/// <summary> /// <summary>
@@ -0,0 +1,188 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning;
/// <summary>
/// Zeigt die erkannten regulären WebUntis-Wochenmuster und lässt den Nutzer die vorgeschlagene
/// Lerngruppe je Muster bestätigen oder ändern, bevor der laufende Abgleich (UntisSyncService)
/// automatisch Vertretungen dafür schreibt (Nutzer-Feedback: die erstmalige Zuordnung ist
/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine
/// Bestätigung). Termine ohne Klassenbezug (Aufsichten/Springstunden, Nutzer-Feedback: "Zwei
/// Termine sind meine Aufsichten, die nicht zugeordnet werden können") lassen sich stattdessen per
/// Checkbox als Aufsicht bestätigen. Zusätzlich informativ: Stundenplan-Einträge, zu denen kein
/// WebUntis-Muster mehr passt ("Stundenplan passt nicht mehr").
///
/// Beim erneuten Öffnen werden bereits bestätigte Zuordnungen aus dem Repository vorbefüllt
/// (Nutzer-Feedback: "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" — die
/// Auswahl wurde zwar gespeichert, beim nächsten Öffnen aber von der frischen Musterkennung
/// überschrieben, was wie ein Datenverlust wirkte) und beim Speichern per vorhandener Id
/// aktualisiert statt dupliziert (siehe UntisMappingRow.BuildMapping).
/// </summary>
public partial class UntisMappingReviewDialogViewModel : ObservableObject
{
private readonly UntisSyncService _untisSync;
private readonly IUntisSlotMappingRepository _mappings;
/// Gruppenname + Fach zur Auswahl (Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das
/// Fach dabei, kann ich nicht sicher die richtige Lerngruppe hier auswählen") — dieselbe
/// Namenskonflikt-Begründung wie in TimetableSlotDialogViewModel, hier über eine kleine
/// Anzeige-Hülle statt eines Label-Strings, da dieser Dialog direkt an LearningGroup-Objekte
/// bindet.
public List<UntisGroupOption> Groups { get; }
public ObservableCollection<UntisMappingRow> Rows { get; } = [];
public ObservableCollection<string> UnmatchedSlotLabels { get; } = [];
public bool HasUnmatchedSlots => UnmatchedSlotLabels.Count > 0;
public bool HasNoRows => !IsLoading && Rows.Count == 0;
[ObservableProperty] private bool _isLoading = true;
[ObservableProperty] private string _errorMessage = "";
public bool Result { get; private set; }
public UntisMappingReviewDialogViewModel(UntisSyncService untisSync, IUntisSlotMappingRepository mappings,
List<LearningGroup> groups, ISubjectRepository subjects)
{
_untisSync = untisSync;
_mappings = mappings;
var subjectNames = subjects.GetAll().ToDictionary(s => s.Id, s => s.Name);
Groups = groups.OrderBy(g => g.Name)
.Select(g => new UntisGroupOption(g, g.SubjectId is { } sid ? subjectNames.GetValueOrDefault(sid) : null))
.ToList();
}
public async Task LoadAsync()
{
IsLoading = true;
ErrorMessage = "";
try
{
var preview = await _untisSync.FetchAndBuildMatchPreviewAsync();
Rows.Clear();
UnmatchedSlotLabels.Clear();
if (preview is null) { ErrorMessage = "Keine iCal-URL hinterlegt."; return; }
// Bei mehreren Mappings für denselben Slot (z.B. Altdaten vor dem Bugfix) gewinnt das
// zuletzt angelegte - gleiche Regel wie UntisDiffService.
var existingByKey = _mappings.GetAll()
.GroupBy(m => (m.Weekday, m.StartTime))
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
foreach (var match in preview.Matches.Matches.OrderBy(m => m.Pattern.Weekday).ThenBy(m => m.Pattern.StartTime))
{
existingByKey.TryGetValue((match.Pattern.Weekday, match.Pattern.StartTime), out var existing);
Rows.Add(new UntisMappingRow(match, Groups, existing));
}
foreach (var slot in preview.Matches.UnmatchedTimetableSlots)
UnmatchedSlotLabels.Add($"{WeekdayLabel(slot.Weekday)}, {slot.PeriodNumber}. Stunde");
OnPropertyChanged(nameof(HasUnmatchedSlots));
}
catch (Exception ex) { ErrorMessage = $"Abruf fehlgeschlagen: {ex.Message}"; }
finally { IsLoading = false; OnPropertyChanged(nameof(HasNoRows)); }
}
[RelayCommand]
private void Save()
{
var confirmed = Rows.Select(r => r.BuildMapping()).Where(m => m is not null).Select(m => m!);
_untisSync.ConfirmMappings(confirmed);
Result = true;
}
private static string WeekdayLabel(DayOfWeek weekday) => weekday switch
{
DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag", DayOfWeek.Saturday => "Samstag", DayOfWeek.Sunday => "Sonntag",
_ => weekday.ToString(),
};
}
public partial class UntisMappingRow : ObservableObject
{
private readonly Guid? _existingMappingId;
public UntisSlotMatch Match { get; }
public string WeekdayLabel { get; }
public string TimeLabel { get; }
public string PatternLabel { get; }
public bool IsConfident => Match.IsConfident;
public bool IsSupervisionCandidate => Match.IsSupervisionCandidate;
public bool CanResolve => IsSupervisionCandidate ? Match.AfterPeriod is not null : Match.PeriodNumber is not null;
[ObservableProperty] private UntisGroupOption? _selectedGroup;
[ObservableProperty] private bool _confirmAsSupervision;
public UntisMappingRow(UntisSlotMatch match, List<UntisGroupOption> groups, UntisSlotMapping? existing)
{
Match = match;
_existingMappingId = existing?.Id;
WeekdayLabel = match.Pattern.Weekday switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr",
DayOfWeek.Saturday => "Sa", _ => "So",
};
TimeLabel = IsSupervisionCandidate
? $"{match.Pattern.StartTime:HH:mm}{match.Pattern.EndTime:HH:mm} (Pause nach Stunde {match.AfterPeriod})"
: match.CoveredPeriods.Count > 1
? $"{match.CoveredPeriods[0]}.{match.CoveredPeriods[^1]}. Stunde ({match.Pattern.StartTime:HH:mm}, Doppelstunde)"
: match.PeriodNumber is { } period
? $"{period}. Stunde ({match.Pattern.StartTime:HH:mm})"
: $"{match.Pattern.StartTime:HH:mm} (keine passende Stunde im Stundenraster)";
PatternLabel = IsSupervisionCandidate
? "Aufsicht / Springstunde (kein Klassenbezug)"
: match.Pattern.ClassTokens.Count == 0
? (match.Pattern.Summary ?? "(ohne Fach)")
: $"{match.Pattern.Summary ?? "?"} · {string.Join(", ", match.Pattern.ClassTokens)}";
if (IsSupervisionCandidate)
{
_confirmAsSupervision = existing is { Confirmed: true, Kind: SubstitutionKind.Supervision };
}
else
{
_selectedGroup = existing is { Confirmed: true, Kind: SubstitutionKind.Lesson, GroupId: { } existingGroupId }
? groups.FirstOrDefault(g => g.Group.Id == existingGroupId)
: match.SuggestedGroupId is { } suggestedId ? groups.FirstOrDefault(g => g.Group.Id == suggestedId) : null;
}
}
/// null, wenn diese Zeile nicht (mehr) bestätigt ist — wird beim Speichern übersprungen.
internal UntisSlotMapping? BuildMapping()
{
if (IsSupervisionCandidate)
{
if (!ConfirmAsSupervision || Match.AfterPeriod is null) return null;
return new UntisSlotMapping
{
Id = _existingMappingId ?? Guid.NewGuid(),
Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime,
Summary = Match.Pattern.Summary, ClassToken = "",
Kind = SubstitutionKind.Supervision, AfterPeriod = Match.AfterPeriod,
Confirmed = true,
};
}
if (SelectedGroup is null || Match.PeriodNumber is null) return null;
return new UntisSlotMapping
{
Id = _existingMappingId ?? Guid.NewGuid(),
Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime,
Summary = Match.Pattern.Summary, ClassToken = string.Join(";", Match.Pattern.ClassTokens),
Kind = SubstitutionKind.Lesson, GroupId = SelectedGroup.Group.Id, PeriodNumber = Match.PeriodNumber,
CoveredPeriods = Match.CoveredPeriods, Confirmed = true,
};
}
}
public sealed class UntisGroupOption(LearningGroup group, string? subjectName)
{
public LearningGroup Group { get; } = group;
public string DisplayLabel { get; } = string.IsNullOrEmpty(subjectName) ? group.Name : $"{group.Name} ({subjectName})";
}
@@ -36,8 +36,9 @@ public enum SettingsTab
Privacy = 10, Privacy = 10,
Sync = 11, Sync = 11,
Ai = 12, Ai = 12,
Appearance = 13, WebUntis = 13,
Trash = 14, Appearance = 14,
Trash = 15,
} }
// ── Haupt-ViewModel ─────────────────────────────────────────────────────────── // ── Haupt-ViewModel ───────────────────────────────────────────────────────────
@@ -191,6 +192,16 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _aiIsLoggedIn; [ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private string _aiBalanceDisplay = ""; [ObservableProperty] private string _aiBalanceDisplay = "";
// ── WebUntis-iCal-Abgleich (Nutzer-Feedback) ──────────────────────────────
[ObservableProperty] private bool _untisEnabled;
[ObservableProperty] private bool _untisIsConfigured;
[ObservableProperty] private string _untisIcalUrlInput = "";
[ObservableProperty] private string _untisUrlError = "";
[ObservableProperty] private string _untisStatusDisplay = "";
[ObservableProperty] private bool _untisFetchBusy;
public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Synchronisation (Kapitel 10) ────────────────────────────────────────── // ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
[ObservableProperty] private string _syncServerUrl = ""; [ObservableProperty] private string _syncServerUrl = "";
@@ -268,6 +279,8 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly UntisSyncService? _untisSync;
private readonly SyncSettingsService _syncSettings; private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth; private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue; private readonly EventQueue _eventQueue;
@@ -289,10 +302,12 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab, AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null) SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null)
{ {
_logger = logger; _logger = logger;
_syncKeyRecovery = syncKeyRecovery; _syncKeyRecovery = syncKeyRecovery;
@@ -320,6 +335,8 @@ public partial class SettingsViewModel : ObservableObject
_letterTemplates = letterTemplates; _letterTemplates = letterTemplates;
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisSync = untisSync;
_syncSettings = syncSettings; _syncSettings = syncSettings;
_syncAuth = syncAuth; _syncAuth = syncAuth;
_eventQueue = eventQueue; _eventQueue = eventQueue;
@@ -342,6 +359,7 @@ public partial class SettingsViewModel : ObservableObject
LoadSupervisionDuties(); LoadSupervisionDuties();
LoadLetterTemplates(); LoadLetterTemplates();
LoadAiSettings(); LoadAiSettings();
LoadUntisSettings();
LoadSyncSettings(); LoadSyncSettings();
LoadSyncConflicts(); LoadSyncConflicts();
} }
@@ -485,6 +503,60 @@ public partial class SettingsViewModel : ObservableObject
AiBalanceDisplay = ""; AiBalanceDisplay = "";
} }
// ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ────
//
// Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab
// (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen).
private void LoadUntisSettings()
{
UntisEnabled = _untisSettings.Enabled;
UntisIsConfigured = _untisSettings.IsConfigured;
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
}
[RelayCommand]
private void UntisSaveUrl()
{
UntisUrlError = "";
if (string.IsNullOrWhiteSpace(UntisIcalUrlInput)) { UntisUrlError = "iCal-URL erforderlich."; return; }
if (!Uri.TryCreate(UntisIcalUrlInput, UriKind.Absolute, out _)) { UntisUrlError = "Ungültige URL."; return; }
_untisSettings.SetIcalUrl(UntisIcalUrlInput.Trim());
_untisSettings.SetEnabled(true);
UntisIcalUrlInput = "";
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void UntisRemove()
{
_untisSettings.ClearIcalUrl();
LoadUntisSettings();
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private async Task UntisFetchNow()
{
if (_untisSync is null) { UntisStatusDisplay = "Abgleich nicht aktiv — App neu starten."; return; }
UntisFetchBusy = true;
try
{
await _untisSync.PollAsync();
LoadUntisSettings();
}
finally { UntisFetchBusy = false; }
}
[RelayCommand]
private async Task UntisReviewMapping()
{
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
}
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ─────── // ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
// //
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen // Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
@@ -23,11 +23,22 @@
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,Auto,*">
<shared:PageHeader Grid.Row="0" Margin="32,28,32,0" Title="Stundenplan" <shared:PageHeader Grid.Row="0" Margin="32,28,32,0" Title="Stundenplan"
Subtitle="Wiederkehrendes wöchentliches Muster, keine konkreten Termine"/> Subtitle="Wiederkehrendes wöchentliches Muster, keine konkreten Termine"/>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}"> <!-- WebUntis-Abweichung (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt") -->
<Border Grid.Row="1" Margin="32,12,32,0" Padding="12,8" CornerRadius="6"
Background="#332196F3" IsVisible="{Binding HasUntisMismatch}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⚠" FontSize="14" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding UntisMismatchLabel}" FontSize="12" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content="Prüfen…" FontSize="11" Padding="8,3"
Command="{Binding ReviewUntisMismatchCommand}"/>
</Grid>
</Border>
<TabbedPage Grid.Row="2" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<!-- Tab: Heute (Standardansicht) --> <!-- Tab: Heute (Standardansicht) -->
<ContentPage Header="Heute"> <ContentPage Header="Heute">
@@ -0,0 +1,88 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
x:Class="LehrerApp.Desktop.Views.Planning.UntisMappingReviewDialog"
x:DataType="vm:UntisMappingReviewDialogViewModel"
Title="WebUntis-Zuordnung prüfen"
Width="560" Height="620" MinWidth="480" MinHeight="440"
CanResize="True" WindowStartupLocation="CenterOwner">
<Window.Styles>
<Style Selector="TextBlock.hint">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Opacity" Value="0.6"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14" Margin="0,0,12,0">
<TextBlock Text="WebUntis-Zuordnung prüfen" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Erkannte reguläre Wochenmuster aus dem iCal-Feed — nur bestätigte Zeilen (mit ausgewählter Gruppe) lösen künftig automatisch erkannte Vertretungen/Ausfälle aus."/>
<TextBlock Text="Abgleich läuft…" IsVisible="{Binding IsLoading}" FontSize="13"/>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding Rows}" IsVisible="{Binding !IsLoading}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UntisMappingRow">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="10,8" Margin="0,0,0,6">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Width="60">
<TextBlock Text="{Binding WeekdayLabel}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding TimeLabel}" Classes="hint" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0" VerticalAlignment="Center">
<TextBlock Text="{Binding PatternLabel}" FontSize="13"/>
<TextBlock Text="Mehrdeutig — bitte Gruppe wählen oder ignorieren" Classes="hint"
Foreground="#F59E0B"
IsVisible="{Binding !IsConfident}"/>
</StackPanel>
<ComboBox Grid.Column="2" Width="190" VerticalAlignment="Center"
IsVisible="{Binding !IsSupervisionCandidate}"
ItemsSource="{Binding $parent[ItemsControl].((vm:UntisMappingReviewDialogViewModel)DataContext).Groups}"
SelectedItem="{Binding SelectedGroup}"
IsEnabled="{Binding CanResolve}"
PlaceholderText="Ignorieren">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:UntisGroupOption">
<TextBlock Text="{Binding DisplayLabel}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox Grid.Column="2" Content="Als Aufsicht bestätigen" VerticalAlignment="Center"
IsVisible="{Binding IsSupervisionCandidate}"
IsChecked="{Binding ConfirmAsSupervision}"
IsEnabled="{Binding CanResolve}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine regulären Wochenmuster im Feed gefunden." Classes="emptyhint"
IsVisible="{Binding HasNoRows}"/>
<StackPanel Spacing="4" IsVisible="{Binding HasUnmatchedSlots}">
<Separator Margin="0,4"/>
<TextBlock Text="Stundenplan-Einträge ohne passendes WebUntis-Muster:" FontSize="12" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding UnmatchedSlotLabels}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="12" Opacity="0.75" Margin="0,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"
IsEnabled="{Binding !IsLoading}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,27 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Planning;
namespace LehrerApp.Desktop.Views.Planning;
public partial class UntisMappingReviewDialog : Window
{
public UntisMappingReviewDialog() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is UntisMappingReviewDialogViewModel vm) _ = vm.LoadAsync();
}
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is UntisMappingReviewDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -921,6 +921,41 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: WebUntis-iCal-Abgleich (Nutzer-Feedback) -->
<ContentPage Header="Stundenplan-Abgleich">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ruft periodisch den persönlichen iCal-Export von WebUntis ab, um Vertretungen, Ausfälle und Raumänderungen automatisch zu erkennen und in den Stundenplan zu übernehmen. Der Link enthält ein Zugangs-Token und wird verschlüsselt gespeichert."/>
<StackPanel Spacing="8" IsVisible="{Binding !UntisIsConfigured}">
<StackPanel Spacing="4">
<TextBlock Text="iCal-URL" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisIcalUrlInput}" PasswordChar="●"
PlaceholderText="https://…/WebUntis/ical_export?..."/>
</StackPanel>
<TextBlock Text="{Binding UntisUrlError}" Foreground="Red" FontSize="12"
IsVisible="{Binding UntisUrlError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern und aktivieren" Command="{Binding UntisSaveUrlCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding UntisIsConfigured}">
<TextBlock Text="iCal-URL hinterlegt (verschlüsselt gespeichert)." FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding UntisStatusDisplay}" FontSize="12" Opacity="0.7"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Jetzt abrufen" Command="{Binding UntisFetchNowCommand}"
IsEnabled="{Binding !UntisFetchBusy}"/>
<Button Content="Zuordnung prüfen…" Command="{Binding UntisReviewMappingCommand}"/>
<Button Content="Entfernen" Command="{Binding UntisRemoveCommand}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Darstellung (12.4) --> <!-- Tab: Darstellung (12.4) -->
<ContentPage Header="Darstellung"> <ContentPage Header="Darstellung">
<ScrollViewer> <ScrollViewer>
@@ -1,8 +1,12 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.Views.Planning;
using LehrerApp.Desktop.Views.Shared; using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -26,9 +30,26 @@ public partial class SettingsView : UserControl
vm.OnPickRecoveryFile = PickRecoveryFile; vm.OnPickRecoveryFile = PickRecoveryFile;
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog; vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme; vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
} }
} }
private async Task ShowUntisMappingReviewDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
var untisSync = App.Services.GetService<UntisSyncService>();
if (owner is null || untisSync is null) return;
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll();
var mappings = App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IUntisSlotMappingRepository>();
var subjects = App.Services.GetRequiredService<LehrerApp.Core.Interfaces.ISubjectRepository>();
var dialog = new UntisMappingReviewDialog
{
DataContext = new UntisMappingReviewDialogViewModel(untisSync, mappings, groups, subjects),
};
await dialog.ShowDialog<bool>(owner);
}
private async Task<bool> SaveRecoveryFile(string content) private async Task<bool> SaveRecoveryFile(string content)
{ {
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
+150
View File
@@ -0,0 +1,150 @@
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
/// Tests gegen synthetische, aber am realen WebUntis-Export orientierte ICS-Fixtures (siehe
/// Planungsdokument "WebUntis-iCal-Abgleich") — kein Rückgriff auf den echten Nutzer-Feed.
public class IcsParserTests
{
private const string CalendarHeader =
"BEGIN:VCALENDAR\nPRODID:-//Ben Fortuna//iCal4j 1.0//EN\nVERSION:2.0\nCALSCALE:GREGORIAN\n";
private const string CalendarFooter = "END:VCALENDAR\n";
private static string Wrap(string vevents) => CalendarHeader + vevents + CalendarFooter;
[Fact]
public void Parse_LiestGrundlegendesVeventKorrekt()
{
var ics = Wrap(
"BEGIN:VEVENT\n" +
"DTSTAMP:20260822T195925Z\n" +
"UID:38926-2013812-2013815\n" +
"STATUS:CONFIRMED\n" +
"DTSTART;TZID=Europe/Berlin:20260817T075000\n" +
"DTEND;TZID=Europe/Berlin:20260817T092000\n" +
"SUMMARY:SOL\n" +
"LOCATION:Medien\n" +
"DESCRIPTION:10c HED\n" +
"END:VEVENT\n");
var events = IcsParser.Parse(ics);
var evt = Assert.Single(events);
Assert.Equal("38926-2013812-2013815", evt.Uid);
Assert.Equal(new DateOnly(2026, 8, 17), evt.Date);
Assert.Equal(new TimeOnly(7, 50), evt.StartTime);
Assert.Equal(new TimeOnly(9, 20), evt.EndTime);
Assert.Equal("SOL", evt.Summary);
Assert.Equal("Medien", evt.Location);
Assert.Equal("10c HED", evt.Description);
Assert.Equal("CONFIRMED", evt.Status);
}
[Fact]
public void Parse_LiestMehrereVeventsUnabhaengigVoneinander()
{
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nDTSTART;TZID=Europe/Berlin:20260817T075000\nSUMMARY:NAT\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:2\nDTSTART;TZID=Europe/Berlin:20260818T094000\nSUMMARY:Mat_E\nEND:VEVENT\n");
var events = IcsParser.Parse(ics);
Assert.Equal(2, events.Count);
Assert.Equal(["1", "2"], events.Select(e => e.Uid));
Assert.Equal(["NAT", "Mat_E"], events.Select(e => e.Summary));
}
[Fact]
public void Parse_EventOhneSummary_SummaryBleibtNull()
{
var ics = Wrap(
"BEGIN:VEVENT\n" +
"UID:38229-1957614\n" +
"DTSTART;TZID=Europe/Berlin:20260818T092000\n" +
"DTEND;TZID=Europe/Berlin:20260818T094000\n" +
"LOCATION:Zwingli\n" +
"DESCRIPTION:HED\n" +
"END:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Null(evt.Summary);
Assert.Equal("HED", evt.Description);
}
[Fact]
public void Parse_EscapedSemikolaInDescription_WirdAufgeloest()
{
var ics = Wrap(
"BEGIN:VEVENT\n" +
"UID:38806-2002907-2002910\n" +
"DTSTART;TZID=Europe/Berlin:20260818T094000\n" +
"SUMMARY:Mat_E\n" +
"DESCRIPTION:10a\\; 10b\\; 10c\\; Gastro HED\n" +
"END:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal("10a; 10b; 10c; Gastro HED", evt.Description);
}
[Fact]
public void Parse_UtcZeitstempelMitZ_WirdNachEuropeBerlinKonvertiert()
{
// 20260817T055000Z UTC = 07:50 Europe/Berlin im Sommer (UTC+2) — defensiver Fall,
// im echten WebUntis-Export nicht beobachtet (der nutzt durchgehend TZID=Europe/Berlin).
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nDTSTART:20260817T055000Z\nEND:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal(new DateOnly(2026, 8, 17), evt.Date);
Assert.Equal(new TimeOnly(7, 50), evt.StartTime);
}
[Fact]
public void Parse_StatusCancelled_WirdUebernommen()
{
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nSTATUS:CANCELLED\nDTSTART;TZID=Europe/Berlin:20260817T075000\nEND:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal("CANCELLED", evt.Status);
}
[Fact]
public void Parse_VeventOhneUidOderOhneDtstart_WirdUebersprungen()
{
var ics = Wrap(
"BEGIN:VEVENT\nDTSTART;TZID=Europe/Berlin:20260817T075000\nSUMMARY:OhneUid\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:ohne-dtstart\nSUMMARY:X\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:defektesDatum\nDTSTART;TZID=Europe/Berlin:keinDatum\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:gueltig\nDTSTART;TZID=Europe/Berlin:20260817T075000\nEND:VEVENT\n");
var events = IcsParser.Parse(ics);
var evt = Assert.Single(events);
Assert.Equal("gueltig", evt.Uid);
}
[Fact]
public void Parse_GefalteteFortsetzungszeile_WirdAngehaengt()
{
// RFC-5545 Line-Folding: Fortsetzungszeile beginnt mit einem Leerzeichen.
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nDTSTART;TZID=Europe/Berlin:20260817T075000\n" +
"DESCRIPTION:10a\\; 10b\\; \n 10c\\; Gastro HED\nEND:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal("10a; 10b; 10c; Gastro HED", evt.Description);
}
[Fact]
public void Parse_LeererText_LiefertLeereListe()
{
Assert.Empty(IcsParser.Parse(""));
}
}
+516
View File
@@ -0,0 +1,516 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class UntisDiffServiceTests
{
private static readonly DateOnly Monday = new(2026, 8, 17); // ein Montag
private static readonly TimeOnly SlotStart = new(7, 50);
private static readonly TimeOnly SlotEnd = new(9, 20);
private static UntisSlotMapping BuildMapping(Guid groupId, bool confirmed = true) => new()
{
Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "SOL", ClassToken = "10c",
GroupId = groupId, PeriodNumber = 1, Confirmed = confirmed,
};
private static UntisSlotMapping BuildSupervisionMapping(bool confirmed = true) => new()
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 20), Kind = SubstitutionKind.Supervision,
AfterPeriod = 1, Confirmed = confirmed,
};
private static UntisIcsEvent BuildEvent(string uid, string? summary = "SOL", string description = "10c HED",
DateOnly? date = null, string status = "CONFIRMED") => new()
{
Uid = uid, Date = date ?? Monday, StartTime = SlotStart, EndTime = SlotEnd,
Summary = summary, Description = description, Status = status,
};
[Fact]
public void Diff_UnveraendertesEreignis_ErzeugtKeineVertretung()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { BuildEvent("1") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
var snapshot = Assert.Single(result.SnapshotToSave);
Assert.Equal("1", snapshot.Uid);
}
[Fact]
public void Diff_AbweichendesFach_ErzeugtVertretungsstunde()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { BuildEvent("1", summary: "NAT") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Lesson, entry.Kind);
Assert.Equal("1", entry.ExternalId);
Assert.Equal(1, entry.PeriodNumber);
// Diagnose direkt in der Beschreibung (siehe DeviationReason) - damit der Nutzer im
// Stundenplan selbst ablesen kann, welcher Vergleich genau fehlgeschlagen ist, ohne
// Rohdaten aus dem Feed teilen zu müssen.
Assert.Contains("Fach weicht ab", entry.Description);
}
[Fact]
public void Diff_AbweichendeKlasse_ErzeugtVertretungsstunde()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { BuildEvent("1", description: "10d HED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Contains("Klasse weicht ab", entry.Description);
}
// ── Regression: "Der Mathematik E-Kurs [...] ist ein Kurs aus den Klassen 10a, 10b und 10c
// [...] Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt. Diese manuelle
// Verknüpfung ist aber jetzt scheinbar vergessen und es taucht jedes Mal die Vertretung auf"
// — WebUntis trennt kombinierte Klassen in DESCRIPTION mit "; " (Semikolon+Leerzeichen), der
// gespeicherte ClassToken aber kompakt ohne Leerzeichen ("10a;10b;10c") - ein reiner
// Teilstring-Vergleich schlug dadurch für JEDE bestätigte kombinierte Gruppe fehl.
[Fact]
public void Diff_KombinierteGruppeUnveraendert_ErzeugtKeineFalscheVertretung()
{
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "Mat_E", ClassToken = "10a;10b;10c",
GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
};
// Echtes WebUntis-Format: "; " (Semikolon + Leerzeichen) zwischen den Klassen.
var events = new List<UntisIcsEvent> { BuildEvent("1", summary: "Mat_E", description: "10a; 10b; 10c HED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
}
[Fact]
public void Diff_KombinierteGruppeEchteAbweichung_WirdWeiterhinErkannt()
{
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "Mat_E", ClassToken = "10a;10b;10c",
GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
};
var events = new List<UntisIcsEvent> { BuildEvent("1", summary: "NAT", description: "10a; 10b; 10c HED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Single(result.SubstitutionsToSave);
}
// ── Regression, zweiter Anlauf: der erste Fix (Leerzeichen nur auf evt.Description-Seite
// entfernt) reichte nicht, wenn mapping.ClassToken SELBST ein eingebettetes Leerzeichen trägt
// — das passiert, wenn die reale DESCRIPTION Klassen nicht mit ";", sondern mit "," trennt:
// ExtractClassTokens fand dann kein ";" und speicherte "10a, 10b, 10c" als EIN Token samt
// Kommas/Leerzeichen statt drei getrennte Tokens. Ein Vergleich gegen die leerzeichenbereinigte
// Description konnte dieses Token dann strukturell NIE finden, unabhängig von der im Dialog
// gewählten Gruppe - genau das vom Nutzer beschriebene "immer noch nicht behoben".
[Fact]
public void Diff_KombinierteGruppeMitKommaGetrenntemClassToken_ErzeugtKeineFalscheVertretung()
{
var mapping = new UntisSlotMapping
{
// So sieht ClassToken aus, wenn ExtractClassTokens die Klassen mangels ";" nicht
// aufsplitten konnte und "10a, 10b, 10c" als ein einziges Token übernommen hat.
Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "Mat_E", ClassToken = "10a, 10b, 10c",
GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
};
var events = new List<UntisIcsEvent> { BuildEvent("1", summary: "Mat_E", description: "10a, 10b, 10c HED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
}
// ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus
// mehreren Klassen zusammengesetzt sind" — eine zuvor erkannte Abweichung, die beim nächsten
// Poll nicht mehr besteht, muss die ursprünglich erzeugte Vertretung wieder aufräumen, statt
// als Karteileiche stehen zu bleiben.
[Fact]
public void Diff_UnveraendertesEreignis_MeldetDessenExternalIdZumAufraeumen()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { BuildEvent("1") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Contains("1", result.SubstitutionExternalIdsToDelete);
}
[Fact]
public void Diff_AufsichtWirdZurRegulaerenDuty_MeldetExternalIdZumAufraeumen()
{
var mapping = BuildSupervisionMapping();
var events = new List<UntisIcsEvent>
{
new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" },
};
var duties = new List<SupervisionDuty> { new() { Weekday = mapping.Weekday, AfterPeriod = mapping.AfterPeriod!.Value } };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday, existingSupervisionDuties: duties);
Assert.Empty(result.SubstitutionsToSave);
Assert.Contains("1", result.SubstitutionExternalIdsToDelete);
}
[Fact]
public void Diff_VormalsFehlendeStundeIstWiederImFeed_MeldetMissingExternalIdZumAufraeumen()
{
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "8c",
GroupId = groupId, PeriodNumber = 3, Confirmed = true,
};
var thisThursday = Monday.AddDays(3);
var currentEvents = new List<UntisIcsEvent>
{
new() { Uid = "regular-1", Date = thisThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10), Summary = "NAT", Description = "8c HED" },
};
var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
var expectedMissingId = $"missing-{mapping.Id}-{thisThursday:yyyyMMdd}";
Assert.Contains(expectedMissingId, result.SubstitutionExternalIdsToDelete);
}
[Fact]
public void Diff_StatusCancelled_ErzeugtAusfall()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { BuildEvent("1", status: "CANCELLED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(1, entry.PeriodNumber);
}
[Fact]
public void Diff_VerschwundenerTerminInnerhalbLookahead_ErzeugtAusfall()
{
var mapping = BuildMapping(Guid.NewGuid());
var nextMonday = Monday.AddDays(7); // gleicher Wochentag wie das Mapping, innerhalb des Lookaheads
var previousSnapshot = new List<UntisSnapshotEntry>
{
new() { Id = Guid.NewGuid(), Uid = "1", Date = nextMonday, StartTime = SlotStart, EndTime = SlotEnd },
};
// "Heute" (Monday, der today-Parameter unten) fand ganz normal statt - sonst würde die
// neue aktive Prüfung (siehe unten) auch dafür fälschlich einen Ausfall vermuten, da für
// dieses Testszenario sonst kein Termin am Montag-Slot im aktuellen Fetch vorläge. Der
// zweite, unbeteiligte Termin belegt den Feed-Abdeckungshorizont über nextMonday hinaus -
// ohne mindestens einen Termin im aktuellen Fetch wüsste Diff nicht, wie weit WebUntis den
// Kalender überhaupt schon veröffentlicht hat.
var currentEvents = new List<UntisIcsEvent>
{
BuildEvent("today-1", date: Monday),
BuildEvent("other", summary: "ANDERES", description: "9x HED", date: nextMonday.AddDays(3)),
};
var result = new UntisDiffService().Diff(currentEvents, previousSnapshot, [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(nextMonday, entry.Date);
// Anders als bei Aufsichten (siehe unten) räumt die aktive Prüfung für
// Unterrichtsstunden die alte Snapshot-Zeile nicht explizit weg - sie bleibt als
// harmloser Datensatz stehen, auf den nichts mehr zugreift (gleiche Haltung wie bei
// verwaisten UntisSlotMapping-Zeilen, siehe TODO.md).
Assert.Empty(result.SnapshotIdsToDelete);
}
[Fact]
public void Diff_VerschwundenerTerminAusserhalbLookahead_WirdIgnoriert()
{
var mapping = BuildMapping(Guid.NewGuid());
var previousSnapshot = new List<UntisSnapshotEntry>
{
new() { Id = Guid.NewGuid(), Uid = "1", Date = Monday.AddDays(30), StartTime = SlotStart, EndTime = SlotEnd },
};
var result = new UntisDiffService().Diff([], previousSnapshot, [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
Assert.Empty(result.SnapshotIdsToDelete);
}
[Fact]
public void Diff_VerschwundenerTerminInDerVergangenheit_WirdIgnoriert()
{
var mapping = BuildMapping(Guid.NewGuid());
var previousSnapshot = new List<UntisSnapshotEntry>
{
new() { Id = Guid.NewGuid(), Uid = "1", Date = Monday.AddDays(-1), StartTime = SlotStart, EndTime = SlotEnd },
};
var result = new UntisDiffService().Diff([], previousSnapshot, [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
}
[Fact]
public void Diff_OhneBestaetigteZuordnung_ErzeugtNieEineVertretung()
{
var unconfirmed = BuildMapping(Guid.NewGuid(), confirmed: false);
var events = new List<UntisIcsEvent> { BuildEvent("1", summary: "NAT") };
var result = new UntisDiffService().Diff(events, [], [unconfirmed], Monday);
Assert.Empty(result.SubstitutionsToSave);
// Der Termin fließt trotzdem in den Snapshot ein (für einen späteren Abgleich, sobald
// die Zuordnung bestätigt wird).
Assert.Single(result.SnapshotToSave);
}
[Fact]
public void Diff_VorhandeneSnapshotZeile_BehaeltIhreId()
{
var mapping = BuildMapping(Guid.NewGuid());
var existingId = Guid.NewGuid();
var previousSnapshot = new List<UntisSnapshotEntry> { new() { Id = existingId, Uid = "1", Date = Monday } };
var events = new List<UntisIcsEvent> { BuildEvent("1") };
var result = new UntisDiffService().Diff(events, previousSnapshot, [mapping], Monday);
Assert.Equal(existingId, Assert.Single(result.SnapshotToSave).Id);
}
// ── Regression: "Donnerstag nächste Woche in der 3. Stunde ist ein Stundenausfall" ─────────
[Fact]
public void Diff_VerschwundeneStundeAnKonkretemDonnerstag_ErzeugtAusfallMitRichtigerStundennummer()
{
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "6a",
GroupId = groupId, PeriodNumber = 3, Confirmed = true,
};
var nextThursday = Monday.AddDays(((int)DayOfWeek.Thursday - (int)Monday.DayOfWeek + 7) % 7 + 7);
var previousSnapshot = new List<UntisSnapshotEntry>
{
new() { Id = Guid.NewGuid(), Uid = "38818-1", Date = nextThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10) },
};
// Diese Woche fand die Stunde ganz normal statt (sonst würde die neue aktive Prüfung dafür
// ebenfalls fälschlich einen Ausfall vermuten) - nur nextThursday fehlt. Der dritte,
// unbeteiligte Termin belegt den Feed-Abdeckungshorizont über nextThursday hinaus.
var thisThursday = Monday.AddDays(3);
var currentEvents = new List<UntisIcsEvent>
{
new() { Uid = "38818-0", Date = thisThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10), Summary = "NAT", Description = "6a HED" },
new() { Uid = "other", Date = nextThursday.AddDays(1), StartTime = new TimeOnly(9, 40), Summary = "ANDERES", Description = "9x HED" },
};
var result = new UntisDiffService().Diff(currentEvents, previousSnapshot, [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(3, entry.PeriodNumber);
Assert.Equal(nextThursday, entry.Date);
}
// ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht
// im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht stattfinden" —
// anders als der Donnerstag-Fall oben gab es hier NIE einen vorherigen Snapshot-Eintrag: der
// Ausfall stand schon beim allerersten Abruf fest, WebUntis hat dafür nie einen Termin
// gelistet. Reines Schnappschuss-Diffing (vorher/nachher vergleichen) kann das grundsätzlich
// nicht erkennen — nur die aktive "erwartetes Datum fehlt komplett" Prüfung.
[Fact]
public void Diff_VonAnfangAnFehlendeStunde_OhneVorherigenSnapshot_ErzeugtTrotzdemAusfall()
{
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "8c",
GroupId = groupId, PeriodNumber = 3, Confirmed = true,
};
var thisThursday = Monday.AddDays(3);
var nextThursday = thisThursday.AddDays(7);
// Kein previousSnapshot - der erste Abruf überhaupt zeigt die 8c bereits als weg.
var currentEvents = new List<UntisIcsEvent>
{
new() { Uid = "regular-1", Date = thisThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10), Summary = "NAT", Description = "8c HED" },
// nextThursday absichtlich ausgelassen - die 8c ist an diesem Tag weg.
new() { Uid = "other", Date = nextThursday.AddDays(1), StartTime = new TimeOnly(9, 40), Summary = "ANDERES", Description = "9x HED" },
};
var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(3, entry.PeriodNumber);
Assert.Equal(nextThursday, entry.Date);
}
[Fact]
public void Diff_FehlendeStundeAnFerientag_WirdNichtAlsAusfallGemeldet()
{
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "8c",
GroupId = groupId, PeriodNumber = 3, Confirmed = true,
};
var holidayThursday = Monday.AddDays(3);
var currentEvents = new List<UntisIcsEvent>
{
// holidayThursday absichtlich ausgelassen - liegt aber in den Ferien, kein Ausfall.
new() { Uid = "other", Date = holidayThursday.AddDays(1), StartTime = new TimeOnly(9, 40), Summary = "ANDERES", Description = "9x HED" },
};
var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday,
freeDates: [holidayThursday]);
Assert.Empty(result.SubstitutionsToSave);
}
[Fact]
public void Diff_FehlendeStundeJenseitsDesFeedHorizonts_WirdIgnoriert()
{
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "8c",
GroupId = groupId, PeriodNumber = 3, Confirmed = true,
};
// Der Feed deckt nur bis übermorgen ab - ein Donnerstag weit dahinter darf nicht als
// Ausfall gelten, nur weil der Feed noch nicht so weit veröffentlicht wurde.
var currentEvents = new List<UntisIcsEvent> { BuildEvent("nearby", date: Monday.AddDays(2)) };
var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
}
// ── Aufsichten (Nutzer-Feedback: "Zwei Termine sind meine Aufsichten, die nicht zugeordnet
// werden können") ────────────────────────────────────────────────────────────────────────
[Fact]
public void Diff_AufsichtStatusCancelled_ErzeugtSupervisionEintragStattAusfall()
{
var mapping = BuildSupervisionMapping();
var events = new List<UntisIcsEvent>
{
new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CANCELLED" }, // Dienstag
};
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Equal(1, entry.AfterPeriod);
Assert.Null(entry.PeriodNumber);
}
[Fact]
public void Diff_VerschwundeneAufsicht_ErzeugtSupervisionEintrag()
{
var mapping = BuildSupervisionMapping();
var nextTuesday = Monday.AddDays(1);
var previousSnapshot = new List<UntisSnapshotEntry>
{
new() { Id = Guid.NewGuid(), Uid = "1", Date = nextTuesday, StartTime = mapping.StartTime },
};
var result = new UntisDiffService().Diff([], previousSnapshot, [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Equal(1, entry.AfterPeriod);
}
[Fact]
public void Diff_AufsichtMitPassenderRegulaererDuty_ErzeugtBeiNormalerAnwesenheitKeinenEintrag()
{
var mapping = BuildSupervisionMapping();
var events = new List<UntisIcsEvent>
{
new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, // Dienstag
};
var duties = new List<SupervisionDuty> { new() { Weekday = mapping.Weekday, AfterPeriod = mapping.AfterPeriod!.Value } };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday, existingSupervisionDuties: duties);
Assert.Empty(result.SubstitutionsToSave);
}
[Fact]
public void Diff_AufsichtOhnePassendeRegulaereDuty_ErzeugtSofortZusaetzlicheAufsichtsMeldung()
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" - ohne passende SupervisionDuty ist jedes Vorkommen selbst schon
// die meldenswerte Vertretung.
var mapping = BuildSupervisionMapping();
var events = new List<UntisIcsEvent>
{
new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, // Dienstag
};
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Equal(1, entry.AfterPeriod);
Assert.Equal("1", entry.ExternalId);
Assert.Contains("Zusätzliche Aufsicht", entry.Description);
}
[Fact]
public void Diff_AufsichtOhnePassendeRegulaereDutyAberFalscherWeekday_ErzeugtKeineMeldung()
{
var mapping = BuildSupervisionMapping();
var events = new List<UntisIcsEvent>
{
new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, // Dienstag
};
// Passende Duty existiert, aber an einem ANDEREN Wochentag - darf nicht fälschlich als
// regulär durchgehen.
var duties = new List<SupervisionDuty> { new() { Weekday = DayOfWeek.Wednesday, AfterPeriod = mapping.AfterPeriod!.Value } };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday, existingSupervisionDuties: duties);
Assert.Single(result.SubstitutionsToSave);
}
// ── Robustheit gegen mehrere Mappings für denselben Slot (siehe UntisMappingReviewDialog) ───
[Fact]
public void Diff_MehrereMappingsFuerDenselbenSlot_WirftNichtSondernNutztNeuesten()
{
var groupId = Guid.NewGuid();
var older = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = SlotStart, GroupId = groupId, PeriodNumber = 1,
Confirmed = true, CreatedAt = DateTime.UtcNow.AddDays(-2),
};
var newer = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = SlotStart, GroupId = Guid.NewGuid(), PeriodNumber = 1,
Confirmed = true, CreatedAt = DateTime.UtcNow,
};
var events = new List<UntisIcsEvent> { BuildEvent("1", summary: "NAT") };
var result = new UntisDiffService().Diff(events, [], [older, newer], Monday);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Equal(newer.GroupId, entry.GroupId);
}
}
@@ -0,0 +1,271 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class UntisMatchingServiceTests
{
private static PeriodScheduleService BuildPeriodSchedule()
{
using var temp = new TempAppData();
var service = new PeriodScheduleService(temp.Path);
service.SetPeriods(
[
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(9, 20) },
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 40), End = new TimeOnly(11, 10) },
]);
return service;
}
// Realistische Einzelstunden (45 min) statt der bereits 90 Minuten langen Stunde 1 aus
// BuildPeriodSchedule() - für die Doppelstunden-Tests, die zwei aufeinanderfolgende einzelne
// Stunden von einem einzigen, zusammengefassten WebUntis-Termin abgedeckt sehen wollen.
private static PeriodScheduleService BuildPeriodScheduleWithSinglePeriods()
{
using var temp = new TempAppData();
var service = new PeriodScheduleService(temp.Path);
service.SetPeriods(
[
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 40), End = new TimeOnly(10, 25) },
]);
return service;
}
private static UntisIcsEvent Event(DayOfWeek weekday, TimeOnly start, TimeOnly end, string? summary,
string description, string uid = "1")
{
// Montag = 18.08.2026, Mittwoch = 20.08.2026 usw. - beliebiger Anker, nur der Wochentag zählt.
var anchor = new DateOnly(2026, 8, 17); // Montag
var offset = ((int)weekday - (int)anchor.DayOfWeek + 7) % 7;
return new UntisIcsEvent
{
Uid = uid, Date = anchor.AddDays(offset), StartTime = start, EndTime = end,
Summary = summary, Description = description,
};
}
[Fact]
public void BuildMatches_EindeutigeKlasse_WirdSicherZugeordnet()
{
var group = new LearningGroup { Name = "10c" };
var events = Enumerable.Range(0, 5)
.Select(i => Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", $"u{i}"))
.ToList();
var result = new UntisMatchingService().BuildMatches(events, [group], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(1, match.PeriodNumber);
Assert.Equal(group.Id, match.SuggestedGroupId);
Assert.True(match.IsConfident);
}
[Fact]
public void BuildMatches_NimmtHaeufigstesMusterProSlot()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", "a"),
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", "b"),
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", "c"),
// Eine einzelne Abweichung (z.B. bereits eine Vertretung) darf das Muster nicht kippen.
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "NAT", "10d HED", "d"),
};
var result = new UntisMatchingService().BuildMatches(events, [groupA, groupB], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(groupA.Id, match.SuggestedGroupId);
Assert.Equal(3, match.Pattern.OccurrenceCount);
}
[Fact]
public void BuildMatches_KombinierteKlassen_BevorzugtGruppeMitVorhandenemSlot()
{
var groupA = new LearningGroup { Name = "10a" };
var groupB = new LearningGroup { Name = "10b" };
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Wednesday, new TimeOnly(9, 40), new TimeOnly(11, 10), "Mat_E", "10a; 10b HED"),
};
var existingSlot = new TimetableSlot { GroupId = groupB.Id, Weekday = DayOfWeek.Wednesday, PeriodNumber = 3 };
var result = new UntisMatchingService().BuildMatches(events, [groupA, groupB], [existingSlot], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(groupB.Id, match.SuggestedGroupId);
Assert.True(match.IsConfident);
}
[Fact]
public void BuildMatches_KombinierteKlassenKommaGetrennt_WerdenEbenfallsAufgesplittet()
{
// Nicht empirisch belegt, ob WebUntis mehrere Klassen mit ";" oder "," trennt (das einzige
// real verifizierte Beispiel hatte nur eine Klasse) - ExtractClassTokens darf sich deshalb
// nicht auf ";" verlassen, sonst landet "10a, 10b, 10c" als EIN Token mit Leerzeichen statt
// drei getrennten (siehe UntisDiffServiceTests: Diff_KombinierteGruppeMitKommaGetrenntemClassToken...).
var groupA = new LearningGroup { Name = "10a" };
var groupB = new LearningGroup { Name = "10b" };
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Wednesday, new TimeOnly(9, 40), new TimeOnly(11, 10), "Mat_E", "10a, 10b HED"),
};
var existingSlot = new TimetableSlot { GroupId = groupB.Id, Weekday = DayOfWeek.Wednesday, PeriodNumber = 3 };
var result = new UntisMatchingService().BuildMatches(events, [groupA, groupB], [existingSlot], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(["10a", "10b"], match.Pattern.ClassTokens);
Assert.Equal(groupB.Id, match.SuggestedGroupId);
Assert.True(match.IsConfident);
}
[Fact]
public void BuildMatches_KeinePassendeGruppe_BleibtUnbestaetigt()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "9z HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [new LearningGroup { Name = "10c" }], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Null(match.SuggestedGroupId);
Assert.False(match.IsConfident);
}
[Fact]
public void BuildMatches_TermineOhneKlassenanteil_WerdenNichtAlsMusterMitGruppeGefuehrt()
{
// Nur Lehrkraft-Kürzel in DESCRIPTION (z.B. Aufsicht/Springstunde) - kein Klassenbezug.
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Tuesday, new TimeOnly(9, 20), new TimeOnly(9, 40), null, "HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [new LearningGroup { Name = "10c" }], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Null(match.SuggestedGroupId);
Assert.Empty(match.Pattern.ClassTokens);
Assert.True(match.IsSupervisionCandidate);
Assert.Null(match.PeriodNumber);
}
[Fact]
public void BuildMatches_AufsichtOhneKlassenbezug_LoestPauseNachVorherigerStundeAuf()
{
// Nutzer-Feedback: "Zwei Termine sind meine Aufsichten, die nicht zugeordnet werden
// können. Vom Zeitraster und von der Dauer her, könnten die erfasst werden" - anders als
// eine Unterrichtsstunde (exakte Startzeit) muss eine Aufsicht IMMER auflösbar sein, auch
// wenn sie mitten in einer Pause liegt statt exakt auf einer konfigurierten Stundenzeit.
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Tuesday, new TimeOnly(9, 25), new TimeOnly(9, 35), null, "HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.True(match.IsSupervisionCandidate);
Assert.Equal(1, match.AfterPeriod); // Pause direkt nach der 1. Stunde (Ende 9:20)
}
[Fact]
public void BuildMatches_AufsichtVorDerErstenStunde_LoestAfterPeriodNullAuf()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Tuesday, new TimeOnly(7, 0), new TimeOnly(7, 40), null, "HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(0, match.AfterPeriod);
}
[Fact]
public void BuildMatches_TimetableSlotOhnePassendesMuster_LandetInUnmatchedList()
{
var group = new LearningGroup { Name = "10c" };
var orphanSlot = new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Friday, PeriodNumber = 3 };
var result = new UntisMatchingService().BuildMatches([], [group], [orphanSlot], BuildPeriodSchedule());
Assert.Empty(result.Matches);
Assert.Contains(orphanSlot, result.UnmatchedTimetableSlots);
}
// ── Doppelstunden (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, und trotzdem
// erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind") ─────────────────────
//
// WebUntis meldet eine Doppelstunde als EINEN Termin über beide Stundenzeiten hinweg, während
// der Stundenplan der App dafür zwei TimetableSlot-Einträge (eine Stunde je Slot) haben kann.
[Fact]
public void BuildMatches_Doppelstunde_LoestBeideStundennummernAlsCoveredPeriodsAuf()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED"),
};
var result = new UntisMatchingService().BuildMatches(
events, [new LearningGroup { Name = "10c" }], [], BuildPeriodScheduleWithSinglePeriods());
var match = Assert.Single(result.Matches);
Assert.Equal([1, 2], match.CoveredPeriods);
Assert.Equal(1, match.PeriodNumber);
}
[Fact]
public void BuildMatches_Doppelstunde_BestaetigtBeideTimetableSlotsAlsZugeordnet()
{
var group = new LearningGroup { Name = "10c" };
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED"),
};
var timetableSlots = new List<TimetableSlot>
{
new() { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 },
new() { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 },
};
var result = new UntisMatchingService().BuildMatches(
events, [group], timetableSlots, BuildPeriodScheduleWithSinglePeriods());
// Beide Stunden der Doppelstunde gelten als zugeordnet, nicht nur die erste.
Assert.Empty(result.UnmatchedTimetableSlots);
}
[Fact]
public void BuildMatches_EinzelneStunde_UeberdecktNurGenauEinePeriode()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(9, 40), new TimeOnly(10, 25), "NAT", "6a HED"),
};
var result = new UntisMatchingService().BuildMatches(
events, [new LearningGroup { Name = "6a" }], [], BuildPeriodScheduleWithSinglePeriods());
Assert.Equal([3], Assert.Single(result.Matches).CoveredPeriods);
}
private sealed class TempAppData : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-untismatching-tests-{Guid.NewGuid():N}");
public TempAppData() => Directory.CreateDirectory(Path);
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
}
}
+270
View File
@@ -890,6 +890,276 @@ war zudem redundant (das Bearbeiten-Tab ist direkt anklickbar) und fühlte sich
- `TimetableViewModel.ShowEditorCommand` entfernt (kein Aufrufer mehr) statt umbenannt — es tat - `TimetableViewModel.ShowEditorCommand` entfernt (kein Aufrufer mehr) statt umbenannt — es tat
ohnehin nur `ActiveTabIndex = 1`, was jetzt nirgends mehr gebraucht wird. ohnehin nur `ActiveTabIndex = 1`, was jetzt nirgends mehr gebraucht wird.
**Nachtrag zu 4.3, neunte Iteration (WebUntis-iCal-Abgleich):** Nutzer-Feedback: der eigene
Stundenplan ist zwar schon in der App hinterlegt, aber das Schulsystem (WebUntis) veröffentlicht
zusätzlich einen persönlichen iCal-Feed mit Echtzeit-Änderungen (Vertretung, Ausfall,
Raumänderung). Wunsch: die App soll diesen Feed periodisch selbst abrufen, in zwei Stufen — erst
eine "fuzzy logic", die die regulären WebUntis-Fächer dem eigenen Stundenplan zuordnet und
"bewacht", dass keines davon plötzlich nicht mehr passt, dann ein Abgleich gegen einen lokalen
Schnappschuss, um konkrete Änderungen zu erkennen und als `SubstitutionEntry` zu übernehmen.
Vor dem Entwurf wurde der echte iCal-Feed des Nutzers einmalig testweise abgerufen (danach
sofort wieder gelöscht), um nicht blind gegen die RFC-5545-Spezifikation zu entwickeln: iCal4j-
generiert, keine RRULE-Wiederholung (jede Wochenstunde ist bereits ein eigenes VEVENT über ein
Schuljahr, UID pro Wochen-Slot stabil), kein Line-Folding, keine VALARM-Blöcke — ein
selbstgeschriebener schlanker Parser reicht, keine neue NuGet-Abhängigkeit (Nutzerentscheidung,
Alternative wäre `Ical.Net` gewesen). Alle gesampelten Termine hatten `STATUS:CONFIRMED` (Feed
kurz nach Schuljahresbeginn abgerufen) — das Design verlässt sich deshalb primär auf den
Schnappschuss-Abgleich, nicht auf eine bestimmte WebUntis-Kodierung von Vertretungen.
- **`LehrerApp.Core/Services/IcsParser.cs`**: minimaler RFC-5545-Teilparser (VEVENT-Blöcke,
`KEY;PARAM=VAL:VALUE`-Zeilen, Escaping, defensives Line-Unfolding und UTC-`Z`-Handling trotz im
echten Feed nicht beobachtet) → `List<UntisIcsEvent>`.
- **`LehrerApp.Core/Services/UntisMatchingService.cs`** (Stufe 1): leitet aus einem vollen Fetch
das reguläre Wochenmuster ab (häufigste Fach/Klassen-Kombination je Wochentag+Uhrzeit über alle
Wochen), löst Uhrzeit → Stundennummer über `PeriodScheduleService` auf und Klassen-Token →
`LearningGroup` (exakt, dann normalisiert; bei kombinierten Klassen wird die Gruppe mit
vorhandenem `TimetableSlot` bevorzugt). Lehrkraft-Kürzel wird nicht hartkodiert, sondern als
häufigstes letztes Wort in `DESCRIPTION` erkannt. Liefert zusätzlich die Gegenrichtung:
vorhandene `TimetableSlot`s ohne passendes iCal-Muster.
- **`LehrerApp.Core/Services/UntisDiffService.cs`** (Stufe 2): vergleicht einen neuen Fetch gegen
den letzten lokalen Schnappschuss je iCal-UID — abweichendes Fach/Klasse → `SubstitutionEntry`
(`Kind=Lesson`), `STATUS:CANCELLED` oder ein im Lookahead-Fenster (14 Tage) verschwundener
Termin → `Kind=Cancelled`. Nur Termine mit einer **bestätigten** `UntisSlotMapping` erzeugen
automatisch Einträge — unbestätigte Muster fließen nur in die Abweichungs-Zählung ein.
- **Neue Modelle** (`LehrerApp.Core/Models/UntisSync.cs`): `UntisSnapshotEntry` (lokale
Sicherungskopie je Termin, für den Abgleich) und `UntisSlotMapping` (vom Nutzer bestätigte
Zuordnung Wochenmuster → Gruppe, inkl. aufgelöster Stundennummer). `SubstitutionEntry` um
`ExternalId` (iCal-UID) erweitert — macht wiederholte Abgleich-Läufe idempotent (Update statt
Duplikat, neue `ISubstitutionEntryRepository.GetByExternalId`). Beide neuen Repositories feuern
bewusst **keinen** `db.OnChange` (siehe Kommentar in `AllRepositories.cs`) — der Schnappschuss
ist reines lokales Abgleich-Zwischenmaterial, die Zuordnung hängt an der pro Gerät hinterlegten
WebUntis-URL, beides ergibt über Sync keinen Mehrwert.
- **`WebUntisSettingsService`** (`LehrerApp.Desktop/Services/`): exaktes Abbild von
`AiSettingsService`/`SyncSettingsService` — die iCal-URL trägt ein eingebettetes Auth-Token und
wird deshalb wie ein Passwort behandelt (AES-256-GCM über `SyncCrypto`, eigener
dateirechte-geschützter Schlüssel, nie Klartext in der JSON-Konfigurationsdatei).
- **`UntisSyncService`** (`LehrerApp.Desktop/Services/`): Timer/Gate/Dispose-Muster wie
`SyncEngine` (60-Minuten-Intervall, `SemaphoreSlim(1,1)` mit `WaitAsync(0)` statt Warteschlange),
nur registriert, wenn URL hinterlegt und aktiviert (`AppBootstrapper.cs`, gleiches
bedingte-Registrierung-Muster wie beim Sync-Server). Der reine Verarbeitungskern
(`ProcessIcsText`) ist ohne HTTP-Zugriff gehalten und direkt mit vorgefertigtem ICS-Text
testbar (public statt internal, da diese Codebasis kein `InternalsVisibleTo` nutzt).
- **Settings-Tab "Stundenplan-Abgleich"**: iCal-URL-Eingabe (maskiert), Aktivieren, "Jetzt
abrufen", Status letzter Abgleich, "Zuordnung prüfen…" öffnet `UntisMappingReviewDialog` — Liste
der erkannten Wochenmuster mit vorgeschlagener Gruppe (vorbefüllt bei sicherem Match), die der
Nutzer bestätigt oder ändert, bevor automatische Vertretungen dafür geschrieben werden (bewusste
Design-Entscheidung: die erstmalige Zuordnung ist fehleranfällig, laufende Tages-Änderungen
danach nicht mehr — sie landen im ohnehin jederzeit von Hand korrigierbaren
`SubstitutionEntry`-Mechanismus).
- **Abweichungs-Banner** im Stundenplan (`TimetableViewModel.HasUntisMismatch`): vergleicht nur
den bereits lokal bestätigten Zuordnungsstand gegen die aktuellen `TimetableSlot`s (kein
erneuter iCal-Abruf beim Seitenaufruf) — bleibt komplett verborgen, solange der Abgleich nicht
aktiviert ist.
- Wie beim Sync-Server deckt ein `AppBootstrapper.RestartApplication()` das Registrieren von
`UntisSyncService` ab (nur einmalig beim Start bedingt registriert, kein
Live-Re-Registrierungspfad).
**Nachtrag zu 4.3, zehnte Iteration (Bugfix Zuordnungs-Gedächtnis + Aufsichten):**
Nutzer-Feedback nach erstem echtem Ausprobieren: *"Kann es sein, dass er meine Verbesserungen gar
nicht einspeichert"* — Ursache war, dass `UntisMappingReviewDialogViewModel.LoadAsync` beim
erneuten Öffnen die Zeilen immer frisch aus der Mustererkennung aufgebaut hat, ohne zuvor
bestätigte `UntisSlotMapping`s zu berücksichtigen — eine manuelle Korrektur war zwar tatsächlich
gespeichert, wurde beim nächsten Öffnen aber von der reinen Algorithmus-Vermutung überschrieben
angezeigt. Zusätzlich legte jedes Speichern für denselben Slot ein **neues** `UntisSlotMapping`
mit neuer Id an, statt das vorhandene zu aktualisieren — bei zwei Mappings für denselben
(Weekday,StartTime)-Schlüssel hätte `UntisDiffService.Diff` beim nächsten Poll mit einer
`ArgumentException` abgebrochen (unbehandelt im Timer-Callback, hätte den gesamten Prozess
beendet).
- `UntisMappingReviewDialogViewModel` bekommt `IUntisSlotMappingRepository` injiziert, lädt
bestehende Zuordnungen vor dem Aufbau der Zeilen und übergibt sie an `UntisMappingRow`, das die
vorherige Bestätigung bevorzugt vor dem reinen Algorithmus-Vorschlag vorbefüllt.
- `UntisMappingRow.BuildMapping()` (neu) trägt beim erneuten Bestätigen desselben Slots die
vorhandene Id weiter, statt immer `Guid.NewGuid()` zu vergeben — Speichern ist jetzt ein
echtes Update, kein Duplikat.
- `UntisDiffService` baut die Mapping-Lookup-Tabelle zusätzlich defensiv per `GroupBy` statt
direktem `ToDictionary` (das zuletzt angelegte Mapping gewinnt), falls doch einmal mehrere
Zeilen für denselben Slot existieren — kein Crash mehr, nur ein stillschweigend ignoriertes
Altmapping. `UntisSyncService.PollAsync` fängt außerdem jede Ausnahme aus der Verarbeitung
selbst ab (vorher nur der HTTP-Abruf) und trägt sie in den Sync-Status ein, statt den
Timer-Callback unbehandelt abstürzen zu lassen.
Zweites Feedback: *"Zwei Termine sind meine Aufsichten, die nicht zugeordnet werden können. Vom
Zeitraster und von der Dauer her, könnten die erfasst werden, oder es muss noch diese Option
geben."* — Termine ohne Klassenbezug (Aufsicht/Springstunde, DESCRIPTION nur Lehrkraft-Kürzel)
liegen typischerweise in einer Pause zwischen zwei Unterrichtsstunden, nicht auf einer
konfigurierten Stunden-Startzeit — `UntisMatchingService.ResolvePeriodNumber` (exakte oder nur
minutengenau tolerante Übereinstimmung) konnte sie deshalb grundsätzlich nie auflösen, und der
Review-Dialog bot dafür auch keine passende Bestätigungsoption (nur eine Lerngruppen-Auswahl).
- `UntisSlotMapping` um `Kind` (`SubstitutionKind`, Standard `Lesson`) und `AfterPeriod` erweitert;
`GroupId`/`PeriodNumber` sind jetzt nullable (nur bei `Kind=Lesson` gesetzt).
- `UntisMatchingService.BuildMatches`: Muster ohne Klassenanteil bekommen statt einer
Stundennummer ein `AfterPeriod` — die Pause direkt vor der Startzeit (letzte Stunde, deren Ende
≤ Startzeit liegt; 0 vor der ersten Stunde) — anders als bei Unterrichtsstunden **immer**
auflösbar, da eine Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer
Startzeit.
- `UntisMappingReviewDialog`: Zeilen ohne Klassenbezug zeigen statt der Lerngruppen-ComboBox eine
Checkbox "Als Aufsicht bestätigen".
- `UntisDiffService`: Kind-abhängige Behandlung — bei `Supervision` erzeugt ein verschwundener
oder als `STATUS:CANCELLED` markierter Termin einen `SubstitutionEntry { Kind = Supervision,
AfterPeriod = ... }` statt `Cancelled` (das bleibt Unterrichtsstunden vorbehalten, da die
Anzeige dafür Gruppe/Fach aus einem `TimetableSlot` herleitet, den es für Aufsichten nicht
gibt). Feingranulare "noch da, aber Ort geändert"-Erkennung für Aufsichten bewusst nicht
gebaut (kein zusätzliches Location-Feld auf dem Mapping) — reicht für den gemeldeten Fall
(Aufsicht verschwindet/wird übernommen) und hält den Umfang klein.
**Nachtrag zu 4.3, elfte Iteration (Bugfix Doppelstunden):** Nutzer-Feedback nach dem
Zuordnen aller Muster: *"Ich habe aber jetzt alles zugeordnet, und trotzdem erhalte ich die
Warnung, dass 16 Stunden ohne Untis-Zuordnung sind."* Ursache: WebUntis fasst eine Doppelstunde
(zwei aufeinanderfolgende Stunden desselben Fachs/derselben Gruppe) zu einem EINZIGEN VEVENT über
beide Stundenzeiten hinweg zusammen (z.B. 07:5009:20 für Stunde 1+2), während der Stundenplan der
App dafür zwei separate `TimetableSlot`-Einträge haben kann. `UntisMatchingService` löste bisher
nur eine einzelne Stundennummer aus der Startzeit auf — die zweite Stunde jeder Doppelstunde
konnte dadurch nie als zugeordnet gelten, unabhängig davon, was im Review-Dialog bestätigt wurde
(sie tauchte dort als eigene Zeile gar nicht erst auf).
- `UntisSlotMatch`/`UntisSlotMapping` um `CoveredPeriods` (`List<int>`) erweitert — alle Stunden,
die ein WebUntis-Termin überdeckt (via neuer `UntisMatchingService.ResolveCoveredPeriods`:
alle konfigurierten Stundenraster-Einträge, die vollständig innerhalb [Start, Ende) des Termins
liegen). `PeriodNumber` bleibt als erste/primäre Stunde erhalten (u.a. für
`UntisDiffService`, das weiterhin nur die erste Stunde einer Doppelstunde in einen
`SubstitutionEntry` schreibt — bewusste Vereinfachung, siehe unten).
- `UntisMatchingService.BuildMatches`/`TimetableViewModel.LoadUntisMismatch`: der
"zugeordnet"-Abgleich prüft jetzt gegen ALLE `CoveredPeriods` einer bestätigten Zuordnung, nicht
nur gegen die erste Stunde.
- `UntisMappingReviewDialog` zeigt Doppelstunden-Zeilen als "1.2. Stunde (07:50, Doppelstunde)"
statt nur der ersten Stunde, damit sichtbar ist, dass eine Bestätigung beide Stunden abdeckt.
- Bewusst nicht angegangen: `UntisDiffService` schreibt bei einer geänderten/ausgefallenen
Doppelstunde weiterhin nur einen `SubstitutionEntry` für die erste Stunde (ein Eintrag kann nur
eine `PeriodNumber` tragen) — für den gemeldeten Fall (Zuordnungs-Zählung im Stundenplan-Banner)
nicht relevant, bleibt als bekannte Einschränkung dokumentiert statt den Umfang zu sprengen.
**Nachtrag zu 4.3, zwölfte Iteration (verifiziert: einmalige Vertretungsaufsicht meldet sich nur
einmal ab):** Nutzer-Sorge, nachdem eine einmalige Vertretungsaufsicht für eine einzelne Woche im
Review-Dialog als Aufsicht bestätigt wurde: *"Die fehlt natürlich in der Woche drauf wieder. Wird
dann bis zum Ende des gültigen Stundenplans diese Stunde als entfallene Aufsicht geführt?"* — Kein
Codefehler, aber ein berechtigter Verdacht angesichts einer dauerhaft bestätigten
`UntisSlotMapping`; per neuem Regressionstest
(`UntisSyncServiceTests.ProcessIcsText_EinmaligeVertretungsaufsicht_MeldetEntfallenNurEinmal`)
verifiziert, dass die "entfallen"-Erkennung in `UntisDiffService` an die konkrete, tatsächlich im
Snapshot gesehene Zeile EINES Datums gekoppelt ist, nicht an eine dauerhaft erwartete
wöchentliche Wiederholung der Zuordnung: sobald eine verschwundene Zeile einmal gemeldet wurde,
wird ihre Snapshot-Zeile gelöscht (`SnapshotIdsToDelete`) — für künftige Wochen entsteht ohne
einen neuen, tatsächlich von WebUntis gemeldeten Termin an diesem Slot gar keine neue
Snapshot-Zeile mehr, die erneut "verschwinden" könnte. Einzige bekannte Randnotiz: die bestätigte
`UntisSlotMapping` selbst bleibt als (harmloser) verwaister Datensatz in der Datenbank stehen, da
ihr Muster nach dem einmaligen Vorkommen bei einem erneuten Abruf nicht mehr auftaucht und der
Review-Dialog dafür deshalb auch keine Zeile zum Entfernen mehr anbietet — bislang nicht als
eigenständiges Problem gemeldet, deshalb kein eigener Aufräum-Mechanismus gebaut.
**Nachtrag zu 4.3, dreizehnte Iteration (Sichtbarkeit im Wochenraster + zusätzliche Aufsichten +
Fach in der Gruppenauswahl):** Nutzer-Feedback: *"Wäre es doch auch schön, wenn das im
Stundenplan für die nächste Woche irgendwie erkenntlich ist [...] Auch die Extra-Aufsicht ist
dann nicht im Plan. So macht doch der Sync nur so halb Sinn."*
- **Sichtbarkeit verifiziert, kein Code nötig:** Das Wochenraster (`TimetableViewModel.
BuildWeekOverview`) liest `SubstitutionEntry` bereits für die jeweils angezeigte Woche
(`WeekOffset`), unabhängig davon, ob der Eintrag von Hand oder automatisch über den
WebUntis-Abgleich entstanden ist — beides landet in derselben Tabelle. Ein Ausfall ersetzt die
reguläre Kachel vollständig (Aufschrift "Ausfall" + `Description` direkt sichtbar als Text,
keine Extra-Hover-Lösung nötig, siehe `WeekCellItem.ForCancelled`/`TimetableView.axaml`).
Per neuem Test (`Load_AusfallInDerFolgewoche_ErscheintImWochenrasterNachNavigation`,
`WeekOffset` über `NextWeekCommand` statt direkter Zuweisung, da nur die Befehle `Load()` erneut
auslösen) erstmals mit `WeekOffset != 0` bestätigt — vorher gab es dafür keinen Test.
- **Echte Lücke gefunden und behoben:** `UntisDiffService` erzeugte für eine bestätigte
Aufsichts-Zuordnung bisher nur bei `STATUS:CANCELLED` oder Verschwinden einen Eintrag — das
reguläre Vorkommen selbst (die Vertretungsaufsicht in der Woche, in der sie tatsächlich
stattfindet) blieb unsichtbar. Neue Logik: `UntisDiffService.Diff` bekommt optional
`existingSupervisionDuties` (`ISupervisionDutyRepository`, über `UntisSyncService`
durchgereicht) — eine bestätigte Supervision-Zuordnung OHNE passende reguläre `SupervisionDuty`
an Wochentag+Pause gilt selbst schon als meldenswerte (zusätzliche) Vertretung und erzeugt bei
jedem tatsächlichen Vorkommen sofort einen `SubstitutionEntry` (idempotent über die iCal-UID
als `ExternalId`). Mit passender regulärer Duty bleibt es wie zuvor bei reiner
Abweichungs-Erkennung (Cancelled/verschwunden).
- **Fach in der Kursauswahl:** Nutzer-Feedback: *"Meine Klasse habe ich 3-mal. Ohne das Fach
dabei, kann ich nicht sicher die richtige Lerngruppe hier auswählen."* Gleiche Begründung wie
bei `TimetableSlotDialogViewModel` ("10c (Chemie)" bei Namenskonflikt) — hier über eine neue
kleine Anzeige-Hülle `UntisGroupOption { LearningGroup Group; string DisplayLabel; }` gelöst
(bewusst immer mit Fach statt nur bei erkanntem Konflikt, da dieser Dialog direkt an
`LearningGroup`-Objekte statt an Label-Strings bindet — einfacher als eine
Konfliktbevorzugungs-Logik nachzubauen). `UntisMappingReviewDialogViewModel` bekommt dafür
`ISubjectRepository` injiziert.
**Nachtrag zu 4.3, vierzehnte Iteration (Bugfix: von Anfang an fehlende Stunden):** Nutzer-
Feedback: *"Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht im Plan
[...] die Klasse ist dort weg, also der Unterricht wird definitiv nicht stattfinden."* Ursache:
das gesamte Diffing war bis dahin rein **reaktiv** — es erkannte nur Termine, die zwischen zwei
Abrufen aus dem Feed **verschwanden** (vorher gesehen, jetzt weg). Ein Ausfall, den WebUntis von
Anfang an nie als Termin gelistet hatte (weil er schon beim allerersten Abruf der App feststand),
hinterließ nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können — für das
Schnappschuss-Diffing sah es aus, als hätte es diese Stunde nie gegeben, es gab also nichts zu
erkennen.
- `UntisDiffService.Diff` bekommt eine zweite, **aktive** Prüfung für bestätigte
`Lesson`-Zuordnungen: für jedes vom Feed bereits abgedeckte künftige Datum des Zuordnungs-
Wochentags (bis zum jüngsten im aktuellen Fetch gesehenen Datum, `newEvents.Max(Date)` —
begrenzt auf das, was WebUntis tatsächlich schon veröffentlicht hat, damit nicht Tage jenseits
des Feed-Horizonts fälschlich als Ausfall gelten) wird geprüft, ob ein passender Termin
existiert; fehlt er, wird unabhängig vom Schnappschuss-Verlauf ein `SubstitutionEntry` erzeugt
(idempotent über einen aus Zuordnung+Datum abgeleiteten Schlüssel statt einer iCal-UID, die es
für eine fehlende Stunde naturgemäß nie gab).
- Die bisherige rein reaktive Schnappschuss-Erkennung für `Lesson`-Zuordnungen entfällt dadurch
(sie ist jetzt ein Sonderfall der aktiven Prüfung) — für Aufsichten (`Supervision`) bleibt sie
unverändert bestehen, da dort kein fester wöchentlicher Anspruch existiert (siehe Nachtrag zur
dreizehnten Iteration).
- Neuer Parameter `freeDates` (Ferien/Feiertage) verhindert, dass die aktive Prüfung während
Schulferien fälschlich Ausfälle meldet — `UntisSyncService` berechnet ihn aus
`ISchoolHolidayRepository`/`PublicHolidayService`/`SchoolCalendarSettingsService`, dieselbe
Logik wie `TimetableViewModel.IsFreeDay`, hier bewusst separat gehalten statt geteilt, da
`UntisDiffService` (LehrerApp.Core) absichtlich frei von Desktop-ViewModel-Abhängigkeiten
bleibt.
- Der am 26.08. vom Nutzer vermutete Sonderfall (eigener Unterrichtsausfall, möglicherweise durch
einen manuell eingetragenen Sondereinsatz überschrieben) wurde nicht weiter untersucht — vom
Nutzer selbst als plausible, nicht fehlerhafte Erklärung eingeordnet.
**Nachtrag zu 4.3, fünfzehnte Iteration (Bugfix: manuelle Zuordnung bei kombinierten Kursen
wirkungslos):** Nutzer-Feedback: *"Der Mathematik E-Kurs Dienstag 3.&4. Stunde ist ein Kurs aus
den Klassen 10a, 10b und 10c. Die Logik möchte ihn immer meiner Klasse 10c alleine geben. Ich
habe das aufgelöst und den Mathematik E-Kurs ausgewählt. Diese manuelle Verknüpfung ist aber
jetzt scheinbar vergessen und es taucht jedes Mal die Vertretung auf."* Ursache: beim Bestätigen
wird `UntisSlotMapping.ClassToken` kompakt ohne Leerzeichen gespeichert (`"10a;10b;10c"`),
WebUntis trennt die Klassen in `DESCRIPTION` aber mit `"; "` (Semikolon + Leerzeichen, z.B.
`"10a; 10b; 10c; Gastro HED"`, siehe echtes Beispiel im Planungsdokument). Der Abweichungs-
Vergleich in `UntisDiffService.HasDeviated` war ein reiner Teilstring-Vergleich
(`evt.Description.Contains(mapping.ClassToken)`) — der schlug dadurch für **jede** kombinierte/
differenzierte Gruppe (mehr als ein Klassen-Token) strukturell fehl, unabhängig davon, ob die
manuell gewählte Gruppe stimmte: jede einzelne Bestätigung eines Kurses mit mehreren Klassen
wurde bei jedem Poll erneut als "abweichend" gemeldet.
- Fix: `HasDeviated` entfernt vor dem Teilstring-Vergleich alle Leerzeichen aus der rohen
`DESCRIPTION` (gleiches Prinzip wie `UntisMatchingService.Normalize` beim
Gruppennamen-Abgleich) — formatunabhängig, verlässt sich nicht auf eine bestimmte
WebUntis-Trennzeichen-Konvention.
- Betraf ausschließlich kombinierte Gruppen (mehr als ein Klassen-Token) — einzelne Klassen
(`"10c"` in `"10c HED"`) waren nie betroffen, da dort kein Trennzeichen im Spiel ist; deshalb
ist der Fehler dem Nutzer erst bei diesem speziellen Kurs aufgefallen.
**Nachtrag zu 4.3, sechzehnte Iteration (Bugfix: falsche Vertretungen für kombinierte Gruppen
blieben trotz behobenem Vergleich stehen):** Nutzer-Feedback nach der fünfzehnten Iteration: "Es
klappt nicht. [...] Die falschen Vertretungen stehen noch bei den Kursen. Immer die Kurse mit
Lerngruppen, die aus mehreren Klassen zusammengesetzt sind." Zwei Ursachen, nacheinander gefunden:
- Der Fix aus der fünfzehnten Iteration entfernte Leerzeichen nur auf der `evt.Description`-Seite,
nicht auf `mapping.ClassToken` selbst — schlug also weiterhin fehl, sobald `ClassToken` ein
eingebettetes Leerzeichen trägt (z.B. wenn `ExtractClassTokens` die Klassen mangels bekanntem
Trennzeichen nicht aufsplitten konnte). Fix: `RemoveWhitespace` symmetrisch auf beide Seiten
angewendet; `ExtractClassTokens` akzeptiert jetzt zusätzlich `,` als Trennzeichen (nicht nur
`;`), da nie an einem echten kombinierten Termin verifiziert wurde, welches WebUntis tatsächlich
verwendet. Zur Eingrenzung ohne weitere Rateversuche schreibt eine abweichende Vertretung jetzt
außerdem den genauen fehlgeschlagenen Vergleich (roher Fach-/Klassen-Wert) in ihre eigene
Beschreibung (`UntisDiffService.DeviationReason`).
- Der eigentliche, tiefere Bug: der Nutzer bestätigte danach neu und der Vergleich lief
nachweislich mit aktuellem Code (die neue Diagnose-Beschreibung erschien auf einer anderen,
echten Abweichung), aber die falschen Vertretungen für Mathe-E-Kurs/Chemie G blieben ohne die
neue Diagnose-Beschreibung stehen — sie waren Karteileichen aus einem Poll VOR dem Fix.
`UntisDiffService.Diff` schrieb bislang ausschließlich neu erkannte/weiterhin bestehende
Abweichungen; es gab keinen Pfad, der eine zuvor automatisch erzeugte `SubstitutionEntry`
wieder entfernt, sobald ein späterer Poll dieselbe Zeile nicht mehr als Abweichung einstuft.
Fix: `UntisDiffResult` bekommt `SubstitutionExternalIdsToDelete` — befüllt für (a) eine
Unterrichtsstunde, die nicht mehr abweicht, (b) eine Aufsicht, die jetzt einer regulären
`SupervisionDuty` entspricht, und (c) eine zuvor als fehlend gemeldete Stunde, die im Feed
wieder auftaucht. `UntisSyncService.ProcessIcsText` löscht dafür den vorhandenen Eintrag über
`ISubstitutionEntryRepository.GetByExternalId`/`Delete` (no-op, falls keiner existiert).
- Damit räumen sich einmal fälschlich erzeugte automatische Vertretungen künftig von selbst auf,
sobald der zugrundeliegende Vergleich beim nächsten Poll keine Abweichung mehr findet — nicht
nur bei diesem konkreten Bugfix, sondern auch bei jeder künftigen Korrektur der Zuordnung durch
den Nutzer selbst.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl. ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.