WIP (unstable): lokaler Cache für Klassenlehrer-Fehlzeiten & Klassenbucheinträge
Nutzer-Feedback: die WebUntis-Berichtszeilen sind starr genug für ein eigenes Datenmodell, Warnungen sollen sofort da sein statt bei jedem Öffnen neu abgerufen zu werden - vor allem darf derselbe Bericht nicht mehrfach pro Stunde abgerufen werden, nur weil die Ansicht mehrfach geöffnet wird (Sorge, bei WebUntis aufzufallen). Neue Modelle UntisAbsenceCacheEntry/UntisClassRegisterCacheEntry (1:1 zu den bestehenden DTOs) + UntisCacheFetchState, bewusst nicht synchronisiert (gleiches "kein db.OnChange"-Muster wie UntisSnapshotEntry/AnnualPlanEvent) - jedes Gerät ruft WebUntis selbst ab, die Zeilenzahl wächst übers Schuljahr gewollt an. UntisReportCacheService: festes heißes Fenster der letzten 14 Tage, höchstens stündlich automatisch aufgefrischt; alles Ältere gilt als endgültig und wird dauerhaft aus dem Cache bedient. Die Entscheidungslogik (Plan) ist als reine, ohne Repositories/HTTP testbare Funktion ausgelagert. Klassenlehrer-Ansichten nutzen den Cache-Service statt WebUntisIntegrationService direkt; ein zusätzlicher Button umgeht die Stundensperre bewusst für manuelle Abrufe. Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -474,6 +474,44 @@ public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
|
||||
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||
}
|
||||
|
||||
public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository
|
||||
{
|
||||
private readonly List<UntisAbsenceCacheEntry> _all = [];
|
||||
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => _all.AddRange(entries);
|
||||
}
|
||||
|
||||
public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
|
||||
{
|
||||
private readonly List<UntisClassRegisterCacheEntry> _all = [];
|
||||
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
|
||||
}
|
||||
|
||||
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
|
||||
{
|
||||
private readonly List<UntisCacheFetchState> _all = [];
|
||||
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
|
||||
_all.FirstOrDefault(s => s.ClassName == className && s.Kind == kind);
|
||||
public void Save(UntisCacheFetchState state)
|
||||
{
|
||||
_all.RemoveAll(s => s.ClassName == state.ClassName && s.Kind == state.Kind);
|
||||
_all.Add(state);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeWorkTasks : IWorkTaskRepository
|
||||
{
|
||||
private readonly List<WorkTask> _all = [];
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisReportCacheServiceTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 8, 25);
|
||||
private static readonly DateOnly HotStart = Today.AddDays(-14);
|
||||
|
||||
// ── Plan(): reine Entscheidungslogik, keine Repositories/HTTP nötig ──────────
|
||||
|
||||
[Fact]
|
||||
public void Plan_ErsterAufruf_LaedtHeissesFensterUndDeckungReichtBisFensteranfang()
|
||||
{
|
||||
var plan = UntisReportCacheService.Plan(null, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
Assert.Equal(HotStart, plan.HotWindowStart);
|
||||
Assert.Equal(Today, plan.HotWindowEnd);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
Assert.Equal(Int(HotStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_InnerhalbEinerStunde_KeinErneutesHeissesFenster()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_ForceRefresh_LoestTrotzKuerzlichemAbrufAus()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: true, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_MehrAlsEineStundeVergangen_FrischtHeissesFensterErneutAuf()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddHours(-2), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_AnfrageVorDemHeissenFenster_KeinHeissesFensterAberKalterErstabruf()
|
||||
{
|
||||
var requestStart = HotStart.AddDays(-30);
|
||||
var requestEnd = HotStart.AddDays(-5);
|
||||
|
||||
var plan = UntisReportCacheService.Plan(null, requestStart, requestEnd, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.True(plan.FetchColdRange);
|
||||
Assert.Equal(requestStart, plan.ColdRangeStart);
|
||||
Assert.Equal(HotStart.AddDays(-1), plan.ColdRangeEnd);
|
||||
Assert.Equal(Int(requestStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_BereitsKaltAbgedeckterBereich_KeinAbrufNoetig()
|
||||
{
|
||||
var coveredSince = HotStart.AddDays(-60);
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, coveredSince.AddDays(10), HotStart.AddDays(-20),
|
||||
Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_ErweitertKalteAbdeckungNurBisZumBenoetigtenStart()
|
||||
{
|
||||
var coveredSince = HotStart.AddDays(-30);
|
||||
var earlierStart = HotStart.AddDays(-90);
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, earlierStart, HotStart.AddDays(-40),
|
||||
Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.True(plan.FetchColdRange);
|
||||
Assert.Equal(earlierStart, plan.ColdRangeStart);
|
||||
Assert.Equal(coveredSince.AddDays(-1), plan.ColdRangeEnd);
|
||||
Assert.Equal(Int(earlierStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||
|
||||
// ── Ende-zu-Ende: erster Aufruf ruft WebUntis ab, zweiter Aufruf binnen einer Stunde nicht ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassRegisterEventsAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
|
||||
{
|
||||
var todayLabel = DateTime.Today.ToString("dd.MM.yy");
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
|
||||
$"10c\t{todayLabel}\tNAT\tMuster Max\tmueller\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
|
||||
var absenceCache = new FakeUntisAbsenceCache();
|
||||
var classRegisterCache = new FakeUntisClassRegisterCache();
|
||||
var fetchStates = new FakeUntisCacheFetchStates();
|
||||
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, fetchStates);
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var first = await cache.GetClassRegisterEventsAsync("10c", today, today);
|
||||
Assert.Single(first);
|
||||
Assert.Equal(4, handler.Requests.Count);
|
||||
|
||||
var second = await cache.GetClassRegisterEventsAsync("10c", today, today);
|
||||
Assert.Single(second);
|
||||
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||
public List<string> Requests { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request.RequestUri?.ToString() ?? "");
|
||||
await Task.Yield();
|
||||
return _responses.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user