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:
@@ -124,9 +124,25 @@ public interface ISubstitutionEntryRepository
|
||||
{
|
||||
List<SubstitutionEntry> GetAll();
|
||||
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 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
|
||||
{
|
||||
List<Documentation> GetByStudent(Guid studentId);
|
||||
|
||||
@@ -210,6 +210,12 @@ public class SubstitutionEntry
|
||||
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
|
||||
public string Description { 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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:50–09: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());
|
||||
}
|
||||
@@ -955,6 +955,79 @@ public sealed class RepositoryTests
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -66,6 +66,8 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
|
||||
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
|
||||
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 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> GetByDate(DateOnly date) =>
|
||||
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)
|
||||
{
|
||||
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 List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
|
||||
@@ -31,6 +31,14 @@ public static class TestSupport
|
||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
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.
|
||||
public static SyncSettingsService BuildSyncSettingsService()
|
||||
{
|
||||
@@ -422,10 +430,29 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository
|
||||
public void Add(SubstitutionEntry e) => _all.Add(e);
|
||||
public List<SubstitutionEntry> GetAll() => _all.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 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
|
||||
{
|
||||
private readonly List<WorkTask> _all = [];
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class SettingsViewModelTests
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
|
||||
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
|
||||
@@ -289,6 +290,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||
@@ -314,6 +316,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||
@@ -343,6 +346,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using Xunit;
|
||||
@@ -12,7 +13,8 @@ public sealed class TimetableViewModelTests
|
||||
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
|
||||
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = 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
|
||||
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
@@ -25,7 +27,8 @@ public sealed class TimetableViewModelTests
|
||||
holidays ?? new FakeSchoolHolidays(),
|
||||
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
|
||||
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 —
|
||||
@@ -727,6 +730,31 @@ public sealed class TimetableViewModelTests
|
||||
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]
|
||||
public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige()
|
||||
{
|
||||
@@ -930,6 +958,38 @@ public sealed class TimetableViewModelTests
|
||||
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]
|
||||
public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde()
|
||||
{
|
||||
@@ -1041,4 +1101,79 @@ public sealed class TimetableViewModelTests
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,8 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
|
||||
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
|
||||
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
|
||||
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
||||
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -186,6 +188,24 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
|
||||
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) ────────────────────
|
||||
var syncSettings = new SyncSettingsService(appData);
|
||||
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.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
@@ -62,6 +63,8 @@ public partial class TimetableViewModel : ObservableObject
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||
private readonly ISubstitutionEntryRepository _substitutions;
|
||||
private readonly IUntisSlotMappingRepository _untisMappings;
|
||||
private readonly WebUntisSettingsService _untisSettings;
|
||||
|
||||
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
|
||||
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
|
||||
@@ -86,6 +89,13 @@ public partial class TimetableViewModel : ObservableObject
|
||||
[ObservableProperty] private int _weekOffset;
|
||||
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 Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
public Func<Task>? OnAddSubstitution { get; set; }
|
||||
@@ -97,12 +107,14 @@ public partial class TimetableViewModel : ObservableObject
|
||||
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
|
||||
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
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;
|
||||
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
|
||||
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
|
||||
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
|
||||
_untisMappings = untisMappings; _untisSettings = untisSettings;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -143,8 +155,33 @@ public partial class TimetableViewModel : ObservableObject
|
||||
BuildToday(today);
|
||||
BuildHoursWarnings();
|
||||
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) ────────────────────────────────────
|
||||
|
||||
/// <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,
|
||||
Sync = 11,
|
||||
Ai = 12,
|
||||
Appearance = 13,
|
||||
Trash = 14,
|
||||
WebUntis = 13,
|
||||
Appearance = 14,
|
||||
Trash = 15,
|
||||
}
|
||||
|
||||
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
|
||||
@@ -191,6 +192,16 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||
[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) ──────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _syncServerUrl = "";
|
||||
@@ -268,6 +279,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
private readonly AiPlanningService _aiPlanning;
|
||||
private readonly WebUntisSettingsService _untisSettings;
|
||||
private readonly UntisSyncService? _untisSync;
|
||||
private readonly SyncSettingsService _syncSettings;
|
||||
private readonly SyncAuthService _syncAuth;
|
||||
private readonly EventQueue _eventQueue;
|
||||
@@ -289,10 +302,12 @@ public partial class SettingsViewModel : ObservableObject
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||||
WebUntisSettingsService untisSettings,
|
||||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
||||
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
|
||||
AppearanceSettingsService appearance, TrashViewModel trashTab,
|
||||
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null)
|
||||
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
|
||||
UntisSyncService? untisSync = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_syncKeyRecovery = syncKeyRecovery;
|
||||
@@ -320,6 +335,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_letterTemplates = letterTemplates;
|
||||
_aiSettings = aiSettings;
|
||||
_aiPlanning = aiPlanning;
|
||||
_untisSettings = untisSettings;
|
||||
_untisSync = untisSync;
|
||||
_syncSettings = syncSettings;
|
||||
_syncAuth = syncAuth;
|
||||
_eventQueue = eventQueue;
|
||||
@@ -342,6 +359,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadSupervisionDuties();
|
||||
LoadLetterTemplates();
|
||||
LoadAiSettings();
|
||||
LoadUntisSettings();
|
||||
LoadSyncSettings();
|
||||
LoadSyncConflicts();
|
||||
}
|
||||
@@ -485,6 +503,60 @@ public partial class SettingsViewModel : ObservableObject
|
||||
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 ───────
|
||||
//
|
||||
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
|
||||
|
||||
@@ -23,11 +23,22 @@
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<shared:PageHeader Grid.Row="0" Margin="32,28,32,0" Title="Stundenplan"
|
||||
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) -->
|
||||
<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>
|
||||
</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) -->
|
||||
<ContentPage Header="Darstellung">
|
||||
<ScrollViewer>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Desktop.Views.Planning;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -26,9 +30,26 @@ public partial class SettingsView : UserControl
|
||||
vm.OnPickRecoveryFile = PickRecoveryFile;
|
||||
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
|
||||
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)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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:50–09: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
|
||||
- [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.
|
||||
|
||||
Reference in New Issue
Block a user