Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3d021d09 | ||
|
|
7fbf035cbb | ||
|
|
9b04d7eb98 | ||
|
|
229ef79e75 | ||
|
|
cd08bbbbc4 | ||
|
|
442e3d5e3d | ||
|
|
3f484ce3c8 | ||
|
|
3f8dab4abd | ||
|
|
b3b86f0cf4 | ||
|
|
9d375caf14 | ||
|
|
cf8de08501 | ||
|
|
265ffe9a74 | ||
|
|
0c8bb58e1b | ||
|
|
69cc16bbd2 | ||
|
|
2b29dea824 | ||
|
|
056e864edd | ||
|
|
33c15ffe3e | ||
|
|
5e16ebf1bf | ||
|
|
7245196689 | ||
|
|
f9f5f75aa8 | ||
|
|
7fa8a93c82 | ||
|
|
87a7badb44 | ||
|
|
17475a781f | ||
|
|
1db16f8b69 | ||
|
|
34a9fdf73b | ||
|
|
0f10f754d0 | ||
|
|
5841d96c5b | ||
|
|
a30aab0fa5 | ||
|
|
07788dcfcb | ||
|
|
788ae6ad32 | ||
|
|
83a430f8ee | ||
|
|
e76884a58e | ||
|
|
1b32166ce0 | ||
|
|
cc973418f3 | ||
|
|
dd5ecb0334 | ||
|
|
4a59bff9de | ||
|
|
4b9bcfe85d | ||
|
|
4eb4d0a946 | ||
|
|
e65a729f97 | ||
|
|
e7c3fdd4ec | ||
|
|
59bd8abb45 | ||
|
|
7b883555bf | ||
|
|
038337997f | ||
|
|
331033db5c | ||
|
|
d9d007cd4b | ||
|
|
7365947edc | ||
|
|
4cce84c7dd | ||
|
|
c5a1157a7d | ||
|
|
391628ae36 | ||
|
|
52a1d45539 | ||
|
|
4d2a89b14b | ||
|
|
f73bd6c9ec | ||
|
|
0faa3f54de | ||
|
|
f514d1d58c | ||
|
|
28beffe3b0 |
@@ -4,3 +4,6 @@
|
||||
# Mindestens 32 zufällige Zeichen – z.B. generiert mit:
|
||||
# openssl rand -base64 32
|
||||
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
|
||||
|
||||
# Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen.
|
||||
GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org)
|
||||
|
||||
@@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
|
||||
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
|
||||
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
|
||||
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
|
||||
- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die
|
||||
persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und
|
||||
personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden.
|
||||
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
|
||||
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
|
||||
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
|
||||
configured (`AppBootstrapper.LoadServerUrl`).
|
||||
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` →
|
||||
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
|
||||
`LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser.
|
||||
|
||||
### MVVM conventions (Desktop)
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<!-- Datenbank -->
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
|
||||
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
|
||||
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
|
||||
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
||||
|
||||
<!-- API -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class AttachmentStoreTests
|
||||
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
|
||||
|
||||
Assert.Null(store.OpenRead("user-2", "shared-id"));
|
||||
Assert.NotNull(store.OpenRead("user-1", "shared-id"));
|
||||
using var user1Attachment = store.OpenRead("user-1", "shared-id");
|
||||
Assert.NotNull(user1Attachment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class DwdWeatherServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseStationCatalog_LiestFestbreitenformatUndFindetNaechsteStation()
|
||||
{
|
||||
const string catalog = """
|
||||
ID ICAO NAME LAT LON ELEV
|
||||
----- ---- -------------------- ----- ------- -----
|
||||
10641 EDDF OFFENBACH-WETTERPARK 50.08 8.78 119
|
||||
10382 EDDT BERLIN-TEGEL 52.56 13.31 37
|
||||
""";
|
||||
|
||||
var stations = DwdWeatherService.ParseStationCatalog(catalog);
|
||||
var nearest = DwdWeatherService.FindNearestStation(stations, 50.1, 8.7);
|
||||
|
||||
Assert.Equal(2, stations.Count);
|
||||
Assert.Equal("10641", nearest!.Id);
|
||||
Assert.Equal("OFFENBACH-WETTERPARK", nearest.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMosmix_KonvertiertEinheitenUndBehaeltFehlwerteAlsNull()
|
||||
{
|
||||
var now = DateTime.UtcNow.AddHours(1).ToString("yyyy-MM-dd'T'HH:00:00.000'Z'");
|
||||
var kml = $$"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
|
||||
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
|
||||
<dwd:IssueTime>2026-08-23T15:00:00Z</dwd:IssueTime>
|
||||
<dwd:ForecastTimeSteps><dwd:TimeStep>{{now}}</dwd:TimeStep></dwd:ForecastTimeSteps>
|
||||
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
|
||||
<dwd:Forecast dwd:elementName="TTT"><dwd:value>293.15</dwd:value></dwd:Forecast>
|
||||
<dwd:Forecast dwd:elementName="FF"><dwd:value>5</dwd:value></dwd:Forecast>
|
||||
<dwd:Forecast dwd:elementName="FX1"><dwd:value>-</dwd:value></dwd:Forecast>
|
||||
<dwd:Forecast dwd:elementName="RR1c"><dwd:value>1.25</dwd:value></dwd:Forecast>
|
||||
<dwd:Forecast dwd:elementName="R101"><dwd:value>67.6</dwd:value></dwd:Forecast>
|
||||
<dwd:Forecast dwd:elementName="SunD1"><dwd:value>1800</dwd:value></dwd:Forecast>
|
||||
<dwd:Forecast dwd:elementName="Neff"><dwd:value>75</dwd:value></dwd:Forecast>
|
||||
<dwd:Forecast dwd:elementName="ww"><dwd:value>61</dwd:value></dwd:Forecast>
|
||||
</kml:ExtendedData></kml:Placemark></kml:Document>
|
||||
</kml:kml>
|
||||
""";
|
||||
|
||||
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
|
||||
var hour = Assert.Single(parsed.Hours);
|
||||
|
||||
Assert.Equal(20, hour.TemperatureC);
|
||||
Assert.Equal(18, hour.WindSpeedKmh);
|
||||
Assert.Null(hour.WindGustKmh);
|
||||
Assert.Equal(1.3, hour.PrecipitationMm);
|
||||
Assert.Equal(68, hour.PrecipitationProbabilityPercent);
|
||||
Assert.Equal(30, hour.SunshineDurationMinutes);
|
||||
Assert.Equal(61, hour.WeatherCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMosmix_LiefertMehrAlsDreiTageFuerFolgewoche()
|
||||
{
|
||||
var start = DateTime.UtcNow.AddHours(1);
|
||||
var timeSteps = string.Join("", Enumerable.Range(0, 100)
|
||||
.Select(i => $"<dwd:TimeStep>{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}</dwd:TimeStep>"));
|
||||
var values = string.Join(' ', Enumerable.Repeat("293.15", 100));
|
||||
var kml = $$"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
|
||||
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
|
||||
<dwd:ForecastTimeSteps>{{timeSteps}}</dwd:ForecastTimeSteps>
|
||||
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
|
||||
<dwd:Forecast dwd:elementName="TTT"><dwd:value>{{values}}</dwd:value></dwd:Forecast>
|
||||
</kml:ExtendedData></kml:Placemark></kml:Document>
|
||||
</kml:kml>
|
||||
""";
|
||||
|
||||
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
|
||||
|
||||
Assert.Equal(100, parsed.Hours.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
|
||||
{
|
||||
var xml = """
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>warnung-1</identifier><status>Actual</status>
|
||||
<info><language>de-DE</language><event>GEWITTER</event><severity>Severe</severity>
|
||||
<onset>2026-08-23T18:00:00Z</onset><expires>2099-08-23T20:00:00Z</expires>
|
||||
<headline>Amtliche Warnung</headline><description>Test</description>
|
||||
<area><polygon>50.0,8.0 50.0,9.0 51.0,9.0 51.0,8.0 50.0,8.0</polygon></area>
|
||||
</info>
|
||||
</alert>
|
||||
""";
|
||||
|
||||
var warning = Assert.Single(DwdWeatherService.ParseCapArchive(
|
||||
Zip("warning.xml", xml), DateTime.UtcNow));
|
||||
|
||||
Assert.True(DwdWeatherService.PointInPolygon(warning.Polygons[0], 50.5, 8.5));
|
||||
Assert.False(DwdWeatherService.PointInPolygon(warning.Polygons[0], 52.0, 8.5));
|
||||
Assert.Equal("GEWITTER", warning.Warning.Event);
|
||||
}
|
||||
|
||||
private static byte[] Zip(string fileName, string content)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var entry = archive.CreateEntry(fileName);
|
||||
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
|
||||
writer.Write(content);
|
||||
}
|
||||
return memory.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class NominatimGeocoderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GeocodeAsync_NutztStrukturierteDeutschlandSucheUndLiestDisplayName()
|
||||
{
|
||||
Uri? requestedUri = null;
|
||||
var handler = new RecordingHandler(request =>
|
||||
{
|
||||
requestedUri = request.RequestUri;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""[{"lat":"50.105","lon":"8.761","display_name":"Testschule, Offenbach, Deutschland"}]""",
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://nominatim.openstreetmap.org/") };
|
||||
var geocoder = new NominatimGeocoder(http);
|
||||
|
||||
var result = await geocoder.GeocodeAsync(new SchoolLocationRequest
|
||||
{
|
||||
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "63000", City = "Offenbach",
|
||||
}, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(50.105, result!.Latitude);
|
||||
Assert.Equal("Testschule, Offenbach, Deutschland", result.DisplayName);
|
||||
Assert.Contains("countrycodes=de", requestedUri!.Query);
|
||||
Assert.Contains("street=Schulweg%201", requestedUri.Query);
|
||||
Assert.Contains("postalcode=63000", requestedUri.Query);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> response)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromResult(response(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class SchoolWeatherStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void SaveLocation_TrenntNutzerUndErsetztVorhandenenStandort()
|
||||
{
|
||||
using var temp = new TempStore();
|
||||
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
|
||||
temp.Store.SaveLocation("zwei", Location("Berlin", 52.5, 13.4));
|
||||
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
|
||||
|
||||
Assert.Equal("Bonn", temp.Store.GetLocation("eins")!.City);
|
||||
Assert.Equal("Berlin", temp.Store.GetLocation("zwei")!.City);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLocation_AenderungEntferntWettercacheDesAltenOrts()
|
||||
{
|
||||
using var temp = new TempStore();
|
||||
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
|
||||
temp.Store.SaveWeather("eins", new WeatherSnapshot { StationId = "alt", RetrievedAt = DateTime.UtcNow });
|
||||
|
||||
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
|
||||
|
||||
Assert.Null(temp.Store.GetWeather("eins"));
|
||||
}
|
||||
|
||||
private static SchoolLocationProfile Location(string city, double lat, double lon) => new()
|
||||
{
|
||||
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "12345", City = city,
|
||||
Latitude = lat, Longitude = lon, ResolvedAddress = city, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private sealed class TempStore : IDisposable
|
||||
{
|
||||
private readonly string _path = Path.Combine(Path.GetTempPath(), $"lehrerapp-weather-tests-{Guid.NewGuid():N}");
|
||||
public SchoolWeatherStore Store { get; }
|
||||
public TempStore() { Directory.CreateDirectory(_path); Store = new(_path); }
|
||||
public void Dispose()
|
||||
{
|
||||
Store.Dispose();
|
||||
if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using LehrerApp.Sync;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class SyncProtocolVersionMiddlewareTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/api/sync/push")]
|
||||
[InlineData("/api/sync/plain/push")]
|
||||
[InlineData("/api/sync/attachments/datei")]
|
||||
[InlineData("/api/snapshot/upload")]
|
||||
public async Task FehlendeVersion_BlockiertAlleSyncKanaeleVorDemEndpoint(string path)
|
||||
{
|
||||
var endpointReached = false;
|
||||
var middleware = Middleware(() => endpointReached = true);
|
||||
var context = Context(path);
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.False(endpointReached);
|
||||
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
|
||||
Assert.Equal(SyncProtocol.CurrentVersion,
|
||||
context.Response.Headers[SyncProtocol.VersionHeaderName].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbweichendeVersion_BlockiertRequest()
|
||||
{
|
||||
var endpointReached = false;
|
||||
var middleware = Middleware(() => endpointReached = true);
|
||||
var context = Context("/api/sync/push");
|
||||
context.Request.Headers[SyncProtocol.VersionHeaderName] = "0";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.False(endpointReached);
|
||||
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AktuelleVersion_LaesstRequestZumEndpointDurch()
|
||||
{
|
||||
var endpointReached = false;
|
||||
var middleware = Middleware(() => endpointReached = true);
|
||||
var context = Context("/api/sync/push");
|
||||
context.Request.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(endpointReached);
|
||||
Assert.NotEqual(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NichtSyncEndpoint_BrauchtKeineVersion()
|
||||
{
|
||||
var endpointReached = false;
|
||||
var middleware = Middleware(() => endpointReached = true);
|
||||
var context = Context("/api/auth/login");
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(endpointReached);
|
||||
}
|
||||
|
||||
private static SyncProtocolVersionMiddleware Middleware(Action reached) => new(_ =>
|
||||
{
|
||||
reached();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
private static DefaultHttpContext Context(string path)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = path;
|
||||
context.Response.Body = new MemoryStream();
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden
|
||||
/// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im
|
||||
/// SchoolWeatherStore persistiert.</summary>
|
||||
public sealed class DwdWeatherService(HttpClient http)
|
||||
{
|
||||
private const string StationCatalogUrl =
|
||||
"https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication";
|
||||
private const string ForecastUrl =
|
||||
"https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz";
|
||||
private const string WarningsUrl =
|
||||
"https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip";
|
||||
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private List<MosmixStation>? _stations;
|
||||
private DateTime _stationsLoadedAt;
|
||||
private readonly Dictionary<string, CachedForecast> _forecastCache = [];
|
||||
private CachedWarnings? _warningsCache;
|
||||
|
||||
public async Task<WeatherSnapshot> GetAsync(SchoolLocationProfile location,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var stations = await GetStationsAsync(cancellationToken);
|
||||
var station = FindNearestStation(stations, location.Latitude, location.Longitude)
|
||||
?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen.");
|
||||
var forecast = await GetForecastAsync(station, cancellationToken);
|
||||
var warnings = await GetWarningsAsync(cancellationToken);
|
||||
|
||||
return new WeatherSnapshot
|
||||
{
|
||||
StationId = station.Id,
|
||||
StationName = station.Name,
|
||||
StationDistanceKm = Math.Round(DistanceKm(
|
||||
location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1),
|
||||
ForecastIssuedAt = forecast.IssuedAt,
|
||||
RetrievedAt = DateTime.UtcNow,
|
||||
Forecast = forecast.Hours,
|
||||
Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude))
|
||||
.Select(w => w.Warning)
|
||||
.OrderByDescending(w => SeverityRank(w.Severity))
|
||||
.ThenBy(w => w.Onset)
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task<List<MosmixStation>> GetStationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7))
|
||||
return _stations;
|
||||
var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken);
|
||||
_stations = ParseStationCatalog(text);
|
||||
_stationsLoadedAt = DateTime.UtcNow;
|
||||
return _stations;
|
||||
}
|
||||
|
||||
private async Task<ParsedForecast> GetForecastAsync(MosmixStation station,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_forecastCache.TryGetValue(station.Id, out var cached) &&
|
||||
DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45))
|
||||
return cached.Forecast;
|
||||
|
||||
var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture,
|
||||
ForecastUrl, station.Id), cancellationToken);
|
||||
var parsed = ParseMosmix(bytes);
|
||||
_forecastCache[station.Id] = new(DateTime.UtcNow, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async Task<List<ParsedWarning>> GetWarningsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_warningsCache is not null &&
|
||||
DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5))
|
||||
return _warningsCache.Warnings;
|
||||
var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken);
|
||||
var warnings = ParseCapArchive(bytes, DateTime.UtcNow);
|
||||
_warningsCache = new(DateTime.UtcNow, warnings);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public static List<MosmixStation> ParseStationCatalog(string text)
|
||||
{
|
||||
var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled);
|
||||
var result = new List<MosmixStation>();
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var match = regex.Match(line.TrimEnd('\r'));
|
||||
if (!match.Success ||
|
||||
!double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) ||
|
||||
!double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
|
||||
continue;
|
||||
result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static MosmixStation? FindNearestStation(IEnumerable<MosmixStation> stations,
|
||||
double latitude, double longitude) => stations.MinBy(s =>
|
||||
DistanceKm(latitude, longitude, s.Latitude, s.Longitude));
|
||||
|
||||
public static ParsedForecast ParseMosmix(byte[] kmz)
|
||||
{
|
||||
using var memory = new MemoryStream(kmz);
|
||||
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
|
||||
var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument.");
|
||||
using var stream = entry.Open();
|
||||
var document = XDocument.Load(stream);
|
||||
XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd";
|
||||
|
||||
var times = document.Descendants(dwd + "TimeStep")
|
||||
.Select(x => ParseDate(x.Value))
|
||||
.Where(x => x.HasValue)
|
||||
.Select(x => x!.Value)
|
||||
.ToList();
|
||||
if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte.");
|
||||
|
||||
var values = document.Descendants(dwd + "Forecast")
|
||||
.Where(x => x.Attribute(dwd + "elementName") is not null)
|
||||
.ToDictionary(
|
||||
x => x.Attribute(dwd + "elementName")!.Value,
|
||||
x => ParseValues(x.Element(dwd + "value")?.Value ?? ""),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count
|
||||
? series[i] : null;
|
||||
var hours = new List<WeatherForecastHour>();
|
||||
var earliest = DateTime.UtcNow.AddHours(-1);
|
||||
// MOSMIX_L reicht bis +240 h. Die vollständige Spanne ist für die Unterrichtsplanung
|
||||
// sinnvoll, weil das Wochenraster auch in die Folgewoche geblättert werden kann.
|
||||
for (var i = 0; i < times.Count && hours.Count < 240; i++)
|
||||
{
|
||||
if (times[i] < earliest) continue;
|
||||
hours.Add(new WeatherForecastHour
|
||||
{
|
||||
ValidAt = times[i],
|
||||
TemperatureC = Round(Value("TTT", i) - 273.15, 1),
|
||||
WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
|
||||
WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
|
||||
PrecipitationMm = Round(Value("RR1c", i), 1),
|
||||
// Beide Zusatzwerte stammen aus derselben MOSMIX-L-Datei. R101 bezeichnet die
|
||||
// Wahrscheinlichkeit für >0,1 mm in der letzten Stunde, SunD1 liegt in Sekunden vor.
|
||||
PrecipitationProbabilityPercent = Round(Value("R101", i), 0),
|
||||
SunshineDurationMinutes = Round(Value("SunD1", i) / 60, 0),
|
||||
CloudCoverPercent = Round(Value("Neff", i), 0),
|
||||
WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours);
|
||||
}
|
||||
|
||||
public static List<ParsedWarning> ParseCapArchive(byte[] zip, DateTime nowUtc)
|
||||
{
|
||||
var result = new List<ParsedWarning>();
|
||||
using var memory = new MemoryStream(zip);
|
||||
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
|
||||
foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
var document = XDocument.Load(stream);
|
||||
XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2";
|
||||
var root = document.Root;
|
||||
if (root is null || root.Element(cap + "status")?.Value != "Actual") continue;
|
||||
var info = root.Elements(cap + "info").FirstOrDefault(x =>
|
||||
x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true)
|
||||
?? root.Elements(cap + "info").FirstOrDefault();
|
||||
if (info is null) continue;
|
||||
var expires = ParseDate(info.Element(cap + "expires")?.Value);
|
||||
if (expires is { } until && until < nowUtc) continue;
|
||||
|
||||
var polygons = info.Elements(cap + "area")
|
||||
.SelectMany(x => x.Elements(cap + "polygon"))
|
||||
.Select(x => ParsePolygon(x.Value))
|
||||
.Where(x => x.Count >= 3)
|
||||
.ToList();
|
||||
// Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht
|
||||
// zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt.
|
||||
if (polygons.Count == 0) continue;
|
||||
result.Add(new ParsedWarning(new WeatherWarning
|
||||
{
|
||||
Identifier = root.Element(cap + "identifier")?.Value ?? "",
|
||||
Event = info.Element(cap + "event")?.Value ?? "",
|
||||
Headline = info.Element(cap + "headline")?.Value ?? "",
|
||||
Description = info.Element(cap + "description")?.Value ?? "",
|
||||
Instruction = info.Element(cap + "instruction")?.Value ?? "",
|
||||
Severity = info.Element(cap + "severity")?.Value ?? "Unknown",
|
||||
Onset = ParseDate(info.Element(cap + "onset")?.Value),
|
||||
Expires = expires,
|
||||
}, polygons));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<double?> ParseValues(string raw) => raw
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
||||
? (double?)value : null)
|
||||
.ToList();
|
||||
|
||||
private static List<GeoPoint> ParsePolygon(string raw) => raw
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => x.Split(','))
|
||||
.Where(x => x.Length >= 2 &&
|
||||
double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) &&
|
||||
double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _))
|
||||
.Select(x => new GeoPoint(
|
||||
double.Parse(x[0], CultureInfo.InvariantCulture),
|
||||
double.Parse(x[1], CultureInfo.InvariantCulture)))
|
||||
.ToList();
|
||||
|
||||
public static bool PointInPolygon(IReadOnlyList<GeoPoint> polygon, double latitude, double longitude)
|
||||
{
|
||||
var inside = false;
|
||||
for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
|
||||
{
|
||||
var a = polygon[i];
|
||||
var b = polygon[j];
|
||||
if ((a.Latitude > latitude) != (b.Latitude > latitude) &&
|
||||
longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) /
|
||||
(b.Latitude - a.Latitude) + a.Longitude)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static bool Contains(IEnumerable<IReadOnlyList<GeoPoint>> polygons,
|
||||
double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude));
|
||||
|
||||
private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
const double earthRadiusKm = 6371.0;
|
||||
static double Rad(double degrees) => degrees * Math.PI / 180.0;
|
||||
var dLat = Rad(lat2 - lat1);
|
||||
var dLon = Rad(lon2 - lon1);
|
||||
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
|
||||
Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) *
|
||||
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(string? value) =>
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces,
|
||||
out var parsed) ? parsed.UtcDateTime : null;
|
||||
|
||||
private static double? Round(double? value, int digits) => value is null
|
||||
? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero);
|
||||
|
||||
private static int SeverityRank(string severity) => severity switch
|
||||
{
|
||||
"Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0,
|
||||
};
|
||||
|
||||
public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude);
|
||||
public sealed record ParsedForecast(DateTime? IssuedAt, List<WeatherForecastHour> Hours);
|
||||
public sealed record GeoPoint(double Latitude, double Longitude);
|
||||
public sealed record ParsedWarning(WeatherWarning Warning, List<List<GeoPoint>> Polygons);
|
||||
private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast);
|
||||
private sealed record CachedWarnings(DateTime LoadedAt, List<ParsedWarning> Warnings);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -149,6 +150,123 @@ public static class Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
// ── Schulstandort und DWD-Wetter ─────────────────────────────────────────
|
||||
|
||||
public static void MapSchoolWeatherEndpoints(this WebApplication app)
|
||||
{
|
||||
var g = app.MapGroup("/api/school").RequireAuthorization();
|
||||
|
||||
g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
var location = store.GetLocation(uid);
|
||||
return location is null
|
||||
? Results.NotFound("Noch kein Schulstandort hinterlegt.")
|
||||
: Results.Ok(location);
|
||||
});
|
||||
|
||||
g.MapPut("/location", async ([FromBody] SchoolLocationRequest request,
|
||||
ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
|
||||
var errors = ValidateLocation(request);
|
||||
if (errors.Count > 0) return Results.ValidationProblem(errors);
|
||||
|
||||
var existing = store.GetLocation(uid);
|
||||
if (existing is not null && SameLocation(existing, request))
|
||||
return Results.Ok(existing);
|
||||
|
||||
GeocodingResult? result;
|
||||
try { result = await geocoder.GeocodeAsync(request, cancellationToken); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Results.Problem("Die Adressprüfung hat zu lange gedauert.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
if (result is null)
|
||||
return Results.UnprocessableEntity(new
|
||||
{
|
||||
detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.",
|
||||
});
|
||||
|
||||
var profile = new SchoolLocationProfile
|
||||
{
|
||||
SchoolName = request.SchoolName.Trim(),
|
||||
Street = request.Street.Trim(),
|
||||
PostalCode = request.PostalCode.Trim(),
|
||||
City = request.City.Trim(),
|
||||
State = request.State,
|
||||
Latitude = result.Latitude,
|
||||
Longitude = result.Longitude,
|
||||
ResolvedAddress = result.DisplayName,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
store.SaveLocation(uid, profile);
|
||||
return Results.Ok(profile);
|
||||
});
|
||||
|
||||
g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store,
|
||||
DwdWeatherService weather, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
var location = store.GetLocation(uid);
|
||||
if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt.");
|
||||
|
||||
var cached = store.GetWeather(uid);
|
||||
if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
|
||||
return Results.Ok(cached);
|
||||
try
|
||||
{
|
||||
var current = await weather.GetAsync(location, cancellationToken);
|
||||
store.SaveWeather(uid, current);
|
||||
return Results.Ok(current);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException)
|
||||
{
|
||||
if (cached is not null)
|
||||
{
|
||||
cached.IsStale = true;
|
||||
return Results.Ok(cached);
|
||||
}
|
||||
return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
if (string.IsNullOrWhiteSpace(request.SchoolName))
|
||||
errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."];
|
||||
if (string.IsNullOrWhiteSpace(request.Street))
|
||||
errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."];
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$"))
|
||||
errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."];
|
||||
if (string.IsNullOrWhiteSpace(request.City))
|
||||
errors[nameof(request.City)] = ["Bitte den Ort angeben."];
|
||||
if (!Enum.IsDefined(request.State))
|
||||
errors[nameof(request.State)] = ["Das Bundesland ist ungültig."];
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) =>
|
||||
string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) &&
|
||||
string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
existing.State == request.State;
|
||||
|
||||
// ── JWT ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Jwt(string userId, string secret)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
@@ -67,7 +68,7 @@ public class EventStore(string dataPath) : IDisposable
|
||||
{
|
||||
var col = GetCol(userId);
|
||||
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
|
||||
.OrderBy(e => e.ServerSeq).Take(500)
|
||||
.OrderBy(e => e.ServerSeq).Take(SyncProtocol.PullBatchSize)
|
||||
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
||||
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public interface IGeocoder
|
||||
{
|
||||
Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
|
||||
|
||||
/// <summary>Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
|
||||
/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.</summary>
|
||||
public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
|
||||
{
|
||||
private static readonly SemaphoreSlim RequestGate = new(1, 1);
|
||||
private static DateTime _lastRequestAt = DateTime.MinValue;
|
||||
|
||||
public async Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var structured = Query(new Dictionary<string, string>
|
||||
{
|
||||
["street"] = address.Street,
|
||||
["postalcode"] = address.PostalCode,
|
||||
["city"] = address.City,
|
||||
});
|
||||
var result = await SearchRespectingRateLimitAsync(structured, cancellationToken);
|
||||
if (result is not null) return result;
|
||||
|
||||
// Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter
|
||||
// Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit.
|
||||
var freeForm = string.Join(", ", new[]
|
||||
{
|
||||
address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland",
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return await SearchRespectingRateLimitAsync(
|
||||
Query(new Dictionary<string, string> { ["q"] = freeForm }), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<GeocodingResult?> SearchRespectingRateLimitAsync(string query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await RequestGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt);
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken);
|
||||
_lastRequestAt = DateTime.UtcNow;
|
||||
var candidates = await http.GetFromJsonAsync<List<NominatimCandidate>>(
|
||||
$"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken);
|
||||
var candidate = candidates?.FirstOrDefault();
|
||||
if (candidate is null ||
|
||||
!double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) ||
|
||||
!double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
|
||||
return null;
|
||||
return new(latitude, longitude, candidate.DisplayName ?? "");
|
||||
}
|
||||
finally { RequestGate.Release(); }
|
||||
}
|
||||
|
||||
private static string Query(IReadOnlyDictionary<string, string> values) => string.Concat(
|
||||
values.Where(x => !string.IsNullOrWhiteSpace(x.Value))
|
||||
.Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}"));
|
||||
|
||||
private sealed class NominatimCandidate
|
||||
{
|
||||
[JsonPropertyName("lat")]
|
||||
public string? Lat { get; set; }
|
||||
[JsonPropertyName("lon")]
|
||||
public string? Lon { get; set; }
|
||||
[JsonPropertyName("display_name")]
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ if (args.Length > 0 && args[0] == "set-password")
|
||||
|
||||
var secret = builder.Configuration["JWT_SECRET"]
|
||||
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
|
||||
var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"]
|
||||
?? "LehrerApp-Server/1.0 (+https://science-teaching.de)";
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(o => o.TokenValidationParameters = new()
|
||||
@@ -81,10 +83,26 @@ builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
||||
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
||||
builder.Services.AddSingleton<PlainEventStore>(sp =>
|
||||
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
||||
builder.Services.AddSingleton<SchoolWeatherStore>(_ => new SchoolWeatherStore(data));
|
||||
builder.Services.AddHttpClient<IGeocoder, NominatimGeocoder>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
builder.Services.AddHttpClient("dwd", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
builder.Services.AddSingleton(sp => new DwdWeatherService(
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRateLimiter();
|
||||
// Muss vor Auth/Endpoints laufen: inkompatible Clients dürfen keinen Sync-Store erreichen.
|
||||
app.UseMiddleware<SyncProtocolVersionMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapAuthEndpoints(secret);
|
||||
@@ -93,5 +111,6 @@ app.MapAttachmentEndpoints();
|
||||
app.MapSnapshotEndpoints();
|
||||
app.MapReadableSnapshotEndpoints();
|
||||
app.MapPlainSyncEndpoints();
|
||||
app.MapSchoolWeatherEndpoints();
|
||||
app.Run();
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LiteDB;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher
|
||||
/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore.</summary>
|
||||
public sealed class SchoolWeatherStore : IDisposable
|
||||
{
|
||||
private readonly LiteDatabase _db;
|
||||
|
||||
public SchoolWeatherStore(string dataPath)
|
||||
{
|
||||
Directory.CreateDirectory(dataPath);
|
||||
_db = new LiteDatabase(Path.Combine(dataPath, "school-weather.db"));
|
||||
}
|
||||
|
||||
private ILiteCollection<LocationEntry> Locations
|
||||
{
|
||||
get
|
||||
{
|
||||
var col = _db.GetCollection<LocationEntry>("locations");
|
||||
col.EnsureIndex(x => x.UserId, unique: true);
|
||||
return col;
|
||||
}
|
||||
}
|
||||
|
||||
private ILiteCollection<WeatherEntry> Weather
|
||||
{
|
||||
get
|
||||
{
|
||||
var col = _db.GetCollection<WeatherEntry>("weather");
|
||||
col.EnsureIndex(x => x.UserId, unique: true);
|
||||
return col;
|
||||
}
|
||||
}
|
||||
|
||||
public SchoolLocationProfile? GetLocation(string userId) =>
|
||||
Locations.FindOne(x => x.UserId == userId)?.Profile;
|
||||
|
||||
public void SaveLocation(string userId, SchoolLocationProfile profile)
|
||||
{
|
||||
var existing = Locations.FindOne(x => x.UserId == userId);
|
||||
Locations.Upsert(new LocationEntry
|
||||
{
|
||||
Id = existing?.Id ?? ObjectId.NewObjectId(),
|
||||
UserId = userId,
|
||||
Profile = profile,
|
||||
});
|
||||
// Ein geänderter Standort darf niemals den Wetterstand des alten Orts liefern.
|
||||
Weather.DeleteMany(x => x.UserId == userId);
|
||||
}
|
||||
|
||||
public WeatherSnapshot? GetWeather(string userId) =>
|
||||
Weather.FindOne(x => x.UserId == userId)?.Snapshot;
|
||||
|
||||
public void SaveWeather(string userId, WeatherSnapshot snapshot)
|
||||
{
|
||||
var existing = Weather.FindOne(x => x.UserId == userId);
|
||||
Weather.Upsert(new WeatherEntry
|
||||
{
|
||||
Id = existing?.Id ?? ObjectId.NewObjectId(),
|
||||
UserId = userId,
|
||||
Snapshot = snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private sealed class LocationEntry
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string UserId { get; set; } = "";
|
||||
public SchoolLocationProfile Profile { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class WeatherEntry
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string UserId { get; set; } = "";
|
||||
public WeatherSnapshot Snapshot { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Sync;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Stoppt veraltete Sync-Clients vor Authentifizierung und insbesondere vor jedem Store-Zugriff.
|
||||
/// Die Prüfung gilt auch für Anhänge, Plain-Sync und Device-Pairing-Snapshots.
|
||||
/// </summary>
|
||||
public sealed class SyncProtocolVersionMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!IsProtectedSyncPath(context.Request.Path))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
|
||||
var clientVersion = context.Request.Headers[SyncProtocol.VersionHeaderName].ToString();
|
||||
if (string.Equals(clientVersion, SyncProtocol.CurrentVersion, StringComparison.Ordinal))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, new
|
||||
{
|
||||
type = "https://lehrerapp.local/problems/sync-version-mismatch",
|
||||
title = "Inkompatible Sync-Version",
|
||||
status = StatusCodes.Status426UpgradeRequired,
|
||||
detail = $"Der Server erwartet Sync-Version {SyncProtocol.CurrentVersion}.",
|
||||
expectedVersion = SyncProtocol.CurrentVersion,
|
||||
receivedVersion = string.IsNullOrEmpty(clientVersion) ? null : clientVersion,
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsProtectedSyncPath(PathString path) =>
|
||||
path.StartsWithSegments("/api/sync", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWithSegments("/api/snapshot", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -119,3 +119,58 @@ public class AiExplainResponse
|
||||
{
|
||||
public string Explanation { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire-Vertrag für den Gefährdungsbeurteilungs-Entwurf (ai-backend/gbu.php, Nutzerwunsch neben
|
||||
/// 4.2 "Anhänge je Stunde"): erkennt aus Thema/Verlaufsplan der Lesson das Experiment und liefert
|
||||
/// dafür einen strukturierten Entwurf. Eigener Endpunkt statt Zusatzfeld in plan.php/explain.php,
|
||||
/// da inhaltlich unabhängig von der Unterrichtsplanung selbst.
|
||||
/// </summary>
|
||||
public class AiHazardAssessmentRequest
|
||||
{
|
||||
public AiUnitContext Unit { get; set; } = new();
|
||||
public AiLesson Lesson { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AiHazardAssessmentResponse
|
||||
{
|
||||
/// "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch" (siehe ExperimentKind).
|
||||
public string ExperimentKind { get; set; } = "";
|
||||
public string Procedure { get; set; } = "";
|
||||
public List<AiHazardSubstance> Substances { get; set; } = [];
|
||||
public List<string> Hazards { get; set; } = [];
|
||||
public List<string> ProtectiveMeasures { get; set; } = [];
|
||||
public string FirstAid { get; set; } = "";
|
||||
public string Disposal { get; set; } = "";
|
||||
}
|
||||
|
||||
public class AiHazardSubstance
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Amount { get; set; } = "";
|
||||
/// GHS-Piktogramm-Codes als Text, z.B. "GHS05" — siehe GhsPictogram für die Zuordnung.
|
||||
public List<string> GhsPictograms { get; set; } = [];
|
||||
public string HStatements { get; set; } = "";
|
||||
public string PStatements { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire-Vertrag für die Chemikalien-Recherche (ai-backend/substance.php, Nutzerwunsch neben 4.2):
|
||||
/// ein reiner Datenbank-Lookup gegen die lokale RiSU-Stoffliste, kein KI-Aufruf (siehe
|
||||
/// ai-backend/stoffliste.php) — der Name "Ai..." bleibt trotzdem bestehen, da der Aufruf über
|
||||
/// dieselbe Anmeldung/denselben Host wie die echten KI-Endpunkte läuft. Eigener, sehr schmaler
|
||||
/// Wire-Vertrag statt Wiederverwendung von AiHazardSubstance — die Recherche liefert
|
||||
/// zusätzliche, nur hier relevante Felder (CAS, Signalwort, Tätigkeitsbeschränkung, Quelle).
|
||||
/// Enthält bewusst KEIN Entsorgungsfeld — die RiSU-Stoffliste führt keine Entsorgungshinweise.
|
||||
/// </summary>
|
||||
public class AiSubstanceResearchResponse
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Cas { get; set; } = "";
|
||||
public List<string> GhsPictograms { get; set; } = [];
|
||||
public string SignalWord { get; set; } = "";
|
||||
public string HStatements { get; set; } = "";
|
||||
public string PStatements { get; set; } = "";
|
||||
public string ActivityRestriction { get; set; } = "";
|
||||
public string Source { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ public interface ISeatingPlanRepository
|
||||
List<SeatingPlan> GetByGroup(Guid groupId);
|
||||
void Save(SeatingPlan plan);
|
||||
void Delete(Guid id);
|
||||
/// <summary>Stellt einen über <see cref="Delete"/> in den Papierkorb verschobenen Sitzplan
|
||||
/// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert.</summary>
|
||||
void Restore(Guid trashId);
|
||||
}
|
||||
public interface IGroupMembershipRepository
|
||||
{
|
||||
@@ -57,6 +60,8 @@ public interface IGradingKeyTemplateRepository
|
||||
GradingKeyTemplate? GetById(Guid id);
|
||||
void Save(GradingKeyTemplate template);
|
||||
void Delete(Guid id);
|
||||
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||
void Restore(Guid trashId);
|
||||
}
|
||||
public interface IGradeRepository
|
||||
{
|
||||
@@ -64,6 +69,8 @@ public interface IGradeRepository
|
||||
List<Grade> GetByGroup(Guid groupId);
|
||||
void Save(Grade grade);
|
||||
void Delete(Guid id);
|
||||
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||
void Restore(Guid trashId);
|
||||
}
|
||||
public interface IGradingSchemeRepository
|
||||
{
|
||||
@@ -117,9 +124,70 @@ 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);
|
||||
}
|
||||
/// Lokaler Cache der Fehlzeiten-Berichtszeilen fürs Klassenlehrer-Feature (siehe TODO.md) —
|
||||
/// bewusst nicht synchronisiert (siehe Implementierung), jedes Gerät ruft WebUntis selbst ab.
|
||||
public interface IUntisAbsenceCacheRepository
|
||||
{
|
||||
List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
|
||||
/// Löscht vorhandene Zeilen im Bereich und fügt die übergebenen neu ein — fürs "heiße" Fenster,
|
||||
/// dessen Inhalt sich seit dem letzten Abruf geändert haben kann.
|
||||
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries);
|
||||
/// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie.
|
||||
void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries);
|
||||
}
|
||||
/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature —
|
||||
/// gleiches Muster wie <see cref="IUntisAbsenceCacheRepository"/>.
|
||||
public interface IUntisClassRegisterCacheRepository
|
||||
{
|
||||
List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
|
||||
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
|
||||
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
|
||||
}
|
||||
/// Lokaler Cache der aktuellen Klassenliste (Roster) fürs Klassenlehrer-Feature — anders als
|
||||
/// Fehlzeiten/Klassenbuch keine Historie, deshalb kein ReplaceRange/InsertRange, sondern immer der
|
||||
/// komplette Ersatz des zuletzt bekannten Standes einer Klasse.
|
||||
public interface IUntisStudentRosterCacheRepository
|
||||
{
|
||||
List<UntisStudentRosterCacheEntry> GetByClass(string className);
|
||||
void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries);
|
||||
}
|
||||
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
|
||||
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
|
||||
public interface IUntisCacheFetchStateRepository
|
||||
{
|
||||
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
||||
void Save(UntisCacheFetchState state);
|
||||
}
|
||||
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
||||
public interface IUntisSlotMappingRepository
|
||||
{
|
||||
List<UntisSlotMapping> GetAll();
|
||||
void Save(UntisSlotMapping mapping);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
/// Lokal importierter, rein informativer Schuljahresplan. Bewusst getrennt von WebUntis-
|
||||
/// Stundenplanabweichungen und nicht über den App-Sync repliziert; jedes Gerät bezieht denselben
|
||||
/// externen Feed selbst.
|
||||
public interface IAnnualPlanEventRepository
|
||||
{
|
||||
List<AnnualPlanEvent> GetAll();
|
||||
List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to);
|
||||
AnnualPlanEvent? GetByExternalId(string externalId);
|
||||
void Save(AnnualPlanEvent entry);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface IDocumentationRepository
|
||||
{
|
||||
List<Documentation> GetByStudent(Guid studentId);
|
||||
@@ -136,8 +204,12 @@ public interface IWorkTaskRepository
|
||||
{
|
||||
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
||||
List<WorkTask> GetAll();
|
||||
/// Für das "Anstehende Aufgaben"-Widget im Kurs-Dashboard (9.2/7.2).
|
||||
List<WorkTask> GetByGroup(Guid groupId);
|
||||
void Save(WorkTask task);
|
||||
void Delete(Guid id);
|
||||
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||
void Restore(Guid trashId);
|
||||
}
|
||||
public interface ITimeEntryRepository
|
||||
{
|
||||
@@ -146,6 +218,8 @@ public interface ITimeEntryRepository
|
||||
List<TimeEntry> GetByTask(Guid taskId);
|
||||
void Save(TimeEntry entry);
|
||||
void Delete(Guid id);
|
||||
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||
void Restore(Guid trashId);
|
||||
}
|
||||
public interface IParticipationSessionRepository
|
||||
{
|
||||
@@ -209,3 +283,11 @@ public interface IAlternativeLessonPathRepository
|
||||
void Save(AlternativeLessonPath path);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
/// <summary>Papierkorb (14.3) — reine Übersicht/Aufräumen; das eigentliche Wiederherstellen läuft
|
||||
/// über die jeweilige Fach-Repository (z.B. IGradeRepository.Restore), da nur die kennt, wie ein
|
||||
/// Schnappschuss ihres Modells korrekt gespeichert wird (Validierung, OnChange-Ereignis).</summary>
|
||||
public interface ITrashRepository
|
||||
{
|
||||
List<TrashedItem> GetAll();
|
||||
void PurgeOlderThan(DateTime cutoffUtc);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Ein aus dem schulweiten Jahresplan importierter Termin. Anders als <see cref="TimetableSlot"/>
|
||||
/// und <see cref="SubstitutionEntry"/> ist er reine Kalenderinformation: Er verändert weder den
|
||||
/// regulären Stundenplan noch erzeugt er Vertretungen oder Ausfälle.
|
||||
/// </summary>
|
||||
public class AnnualPlanEvent
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Stabile UID aus dem iCal-Feed; fachlicher Schlüssel für idempotente Importe.</summary>
|
||||
public string ExternalId { get; set; } = "";
|
||||
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Location { get; set; } = "";
|
||||
|
||||
/// <summary>Erster beziehungsweise letzter tatsächlich belegter lokaler Kalendertag.</summary>
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Bei Ganztagsterminen null. Zeitgebundene UTC-Werte aus dem Feed werden beim Import in die
|
||||
/// lokale Schulzeit Europe/Berlin umgerechnet.
|
||||
/// </summary>
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
public bool IsAllDay { get; set; }
|
||||
|
||||
/// <summary>ClassyPlan-Gruppe, z.B. "Lehrkräfte" oder "Öffentlich".</summary>
|
||||
public string CalendarGroup { get; set; } = "";
|
||||
public string Color { get; set; } = "";
|
||||
public string Status { get; set; } = "CONFIRMED";
|
||||
public int Sequence { get; set; }
|
||||
public DateTime? SourceLastModifiedUtc { get; set; }
|
||||
}
|
||||
@@ -13,6 +13,12 @@ public class Exam
|
||||
public DateOnly? ReturnedAt { get; set; }
|
||||
public Niveau? Niveau { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateOnly? ApprovalGrantedAt { get; set; }
|
||||
public DateOnly? AnnouncedAt { get; set; }
|
||||
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
|
||||
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
|
||||
/// (siehe ExamDialogViewModel.Save).
|
||||
public Guid? SharedExamGroupId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Gefährdungsbeurteilung zu einem Experiment (fachspezifisch, v.a. Chemie/Naturwissenschaften).
|
||||
/// Lebt NICHT als eigene LiteDB-Collection/Repository, sondern wird als JSON serialisiert und
|
||||
/// über die bestehende <see cref="DocumentAttachment"/>-Infrastruktur an eine <see cref="Lesson"/>
|
||||
/// angehängt (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2) — das reicht für Bearbeiten,
|
||||
/// Anzeigen und PDF-Export, ohne eine weitere synchronisierte Entität samt Repository/DI-Wiring
|
||||
/// einzuführen (bewusste Scope-Entscheidung, siehe TODO.md).
|
||||
/// </summary>
|
||||
public class HazardAssessment
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
/// Klassenstufe/Kurs, z.B. "10c" — vorbelegt aus der zugehörigen Lerngruppe, frei änderbar.
|
||||
public string GroupLabel { get; set; } = "";
|
||||
public DateOnly? Date { get; set; }
|
||||
public ExperimentKind Kind { get; set; } = ExperimentKind.Lehrerversuch;
|
||||
/// Kurzbeschreibung der Durchführung.
|
||||
public string Procedure { get; set; } = "";
|
||||
public List<HazardSubstance> Substances { get; set; } = [];
|
||||
public List<string> Hazards { get; set; } = [];
|
||||
public List<string> ProtectiveMeasures { get; set; } = [];
|
||||
public string FirstAid { get; set; } = "";
|
||||
public string Disposal { get; set; } = "";
|
||||
public string Notes { get; set; } = "";
|
||||
/// Ob ein KI-Entwurf beteiligt war — steuert den Rechtssicherheits-Hinweis im PDF-Export.
|
||||
public bool IsAiAssisted { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class HazardSubstance
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
/// CAS-Nummer, falls über die Chemikalien-Recherche (TODO.md 4.2-Nachtrag) ermittelt oder von
|
||||
/// Hand eingetragen — optional, nur zur eindeutigeren Wiedererkennung/Recherche.
|
||||
public string Cas { get; set; } = "";
|
||||
/// Typische Einsatzmenge, z.B. "ca. 2 g" — Freitext statt strukturierter Einheit, da der
|
||||
/// Rahmen (fest/Lösung/Gas, Konzentration ...) zu unterschiedlich für ein starres Feld ist.
|
||||
public string Amount { get; set; } = "";
|
||||
public List<GhsPictogram> GhsPictograms { get; set; } = [];
|
||||
/// "Gefahr" oder "Achtung" (GHS-Signalwort) — Freitext statt Enum, da GESTIS-Recherchen auch
|
||||
/// mal ohne eindeutiges Signalwort zurückkommen können.
|
||||
public string SignalWord { get; set; } = "";
|
||||
/// H-Sätze (Gefahrenhinweise) als Freitext, z.B. "H314, H290" oder mit Kurztext — bewusst kein
|
||||
/// eingebauter Katalog amtlicher H-/P-Satz-Texte in der App (Fehlerrisiko bei
|
||||
/// sicherheitsrelevanten Angaben), die Lehrkraft prüft/ergänzt gegen das Sicherheitsdatenblatt.
|
||||
public string HStatements { get; set; } = "";
|
||||
public string PStatements { get; set; } = "";
|
||||
/// Tätigkeitsbeschränkung laut RiSU-Stoffliste (Freigabe für alle/Schulversuche/nur
|
||||
/// Lehrkraft, Altersgrenzen, Ersatzstoffprüfung nötig, verboten, ...) — Freitext, da die
|
||||
/// tatsächlichen Formulierungen zu unterschiedlich für ein starres Feld/Enum sind.
|
||||
public string ActivityRestriction { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum ExperimentKind { Lehrerversuch, Schuelerversuch, Demonstrationsversuch }
|
||||
|
||||
/// Die neun GHS-Gefahrenpiktogramme (Global Harmonisiertes System).
|
||||
public enum GhsPictogram
|
||||
{
|
||||
Explosive, Flammable, Oxidizing, CompressedGas, Corrosive,
|
||||
Toxic, Harmful, HealthHazard, Environmental,
|
||||
}
|
||||
@@ -20,6 +20,12 @@ public class LearningGroup
|
||||
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
||||
/// </summary>
|
||||
public bool RequiresLessonPlanning { get; set; } = true;
|
||||
/// <summary>
|
||||
/// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des
|
||||
/// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss
|
||||
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
||||
/// </summary>
|
||||
public int? WebUntisLessonId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public class Unit
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
public class Lesson
|
||||
public class Lesson : IHasAttachments
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UnitId { get; set; }
|
||||
@@ -64,6 +64,10 @@ public class Lesson
|
||||
public bool HomeworkCheckDismissed { get; set; }
|
||||
public string? Reflection { get; set; }
|
||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
||||
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
||||
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
||||
/// bei <see cref="Documentation"/>.
|
||||
public List<DocumentAttachment> Attachments { get; set; } = [];
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -206,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>
|
||||
|
||||
@@ -23,6 +23,13 @@ public class SeatingPlan
|
||||
/// </summary>
|
||||
public List<double> ColumnGapWidths { get; set; } = [];
|
||||
public List<SeatAssignment> Assignments { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Plätze, für die im Raum physisch kein Tisch existiert (z. B. eine Reihe mit einer Lücke) —
|
||||
/// werden im Sitzplan ausgeblendet, bleiben aber Teil des Rasters, damit Reihen/Spalten
|
||||
/// weiterhin korrekt ausgerichtet sind. Ein ausgeblendeter Platz kann nie belegt sein, siehe
|
||||
/// SeatingPlanRepository.Save.
|
||||
/// </summary>
|
||||
public List<HiddenSeat> HiddenSeats { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -33,3 +40,9 @@ public class SeatAssignment
|
||||
public int Column { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
}
|
||||
|
||||
public class HiddenSeat
|
||||
{
|
||||
public int Row { get; set; }
|
||||
public int Column { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Papierkorb-Eintrag (14.3): ein gelöschter Datensatz, als vollständiger JSON-Schnappschuss
|
||||
/// aufbewahrt, damit er wiederhergestellt werden kann. Deckt bewusst nur Löschvorgänge OHNE
|
||||
/// Kaskade ab (z.B. eine einzelne Note, keine ganze Lerngruppe) — Details siehe
|
||||
/// <c>LiteDbContext.MoveToTrash</c>/<c>RestoreFromTrash</c> und TODO.md 14.3.
|
||||
/// </summary>
|
||||
public class TrashedItem
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
/// <summary>Modellname (<c>nameof(Grade)</c> etc.) — bestimmt, wie der Schnappschuss beim
|
||||
/// Wiederherstellen deserialisiert und welche Repository-Methode aufgerufen wird.</summary>
|
||||
public string EntityType { get; set; } = "";
|
||||
public Guid EntityId { get; set; }
|
||||
/// <summary>Vollständiger JSON-Schnappschuss der gelöschten Entität.</summary>
|
||||
public string Snapshot { get; set; } = "";
|
||||
/// <summary>Kurze, für die Papierkorb-Liste lesbare Beschreibung (z.B. "Note 2 (12.09.2026)").</summary>
|
||||
public string Summary { get; set; } = "";
|
||||
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lokal zwischengespeicherte Zeile aus dem WebUntis-Bericht "Fehlzeiten pro Schüler*in"
|
||||
/// (Klassenlehrer-Feature, siehe TODO.md) — 1:1-Abbildung von `UntisClassAbsenceEntryDto`
|
||||
/// (LehrerApp.Desktop), damit Fehlzeiten nicht bei jedem Öffnen der Ansicht neu abgerufen werden
|
||||
/// müssen. <c>Date</c> bewusst als <c>int</c> (yyyyMMdd) wie die DTOs statt <c>DateOnly</c>/
|
||||
/// <c>DateTime</c> — vermeidet die LiteDB-<c>DateTime</c>-Kind-Tücke (siehe CLAUDE.md) und ist
|
||||
/// direkt für Bereichs-Queries nutzbar.
|
||||
/// </summary>
|
||||
public class UntisAbsenceCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public string StudentName { get; set; } = "";
|
||||
public int? ExternKey { get; set; }
|
||||
public int Date { get; set; }
|
||||
public int AbsentPeriods { get; set; }
|
||||
public int AbsentMinutes { get; set; }
|
||||
public string? TeacherUsernames { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string? AbsenceReason { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public int? EntryId { get; set; }
|
||||
public string? HandledOn { get; set; }
|
||||
public bool Counts { get; set; }
|
||||
public string? ExcuseNote { get; set; }
|
||||
public int? PeriodNumber { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool CountsAsFullDay { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf
|
||||
/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von
|
||||
/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop).</summary>
|
||||
public class UntisClassRegisterCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public int Date { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string StudentName { get; set; } = "";
|
||||
public string? TeacherUsername { get; set; }
|
||||
public string? CategoryName { get; set; }
|
||||
public string? CategoryGroup { get; set; }
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Lokal zwischengespeicherte Zeile aus dem WebUntis-Schülerreport, eingeschränkt auf die
|
||||
/// für die Klassenlehrer-Übersicht nötigen Felder (Roster-Namen) — anders als Fehlzeiten/
|
||||
/// Klassenbuch keine Historie, sondern immer nur der zuletzt abgerufene Stand einer Klasse
|
||||
/// (siehe <see cref="UntisClassRegisterCacheEntry"/> für den Grund, warum es trotzdem ein eigenes
|
||||
/// Modell statt Wiederverwendung des vollen Schülerreport-DTOs ist: nur diese zwei Felder werden
|
||||
/// für den Roster-Abgleich tatsächlich gebraucht).</summary>
|
||||
public class UntisStudentRosterCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public int? ExternKey { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum UntisCacheKind { Absences, ClassRegister, Roster }
|
||||
|
||||
/// <summary>
|
||||
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
|
||||
/// (die letzten Tage, deren Status sich noch ändern kann) zuletzt aufgefrischt wurde, und wie weit
|
||||
/// die "kalte", als endgültig angenommene Historie bereits lückenlos zurückreicht. Getrennt von den
|
||||
/// Cache-Zeilen selbst, weil ein Fenster ohne Treffer (z.B. eine ereignislose Woche) sonst keine
|
||||
/// Zeile hinterließe, aus der sich "zuletzt abgerufen am…" ableiten ließe.
|
||||
/// </summary>
|
||||
public class UntisCacheFetchState
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public UntisCacheKind Kind { get; set; }
|
||||
public DateTime? HotWindowFetchedAt { get; set; }
|
||||
public int? ColdCoverageStartDate { get; set; }
|
||||
}
|
||||
@@ -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,63 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>Vom Nutzer eingegebene Schuladresse. Die Geocodierung erfolgt ausschließlich
|
||||
/// serverseitig beim expliziten Speichern, nicht während der Eingabe.</summary>
|
||||
public class SchoolLocationRequest
|
||||
{
|
||||
public string SchoolName { get; set; } = "";
|
||||
public string Street { get; set; } = "";
|
||||
public string PostalCode { get; set; } = "";
|
||||
public string City { get; set; } = "";
|
||||
public GermanState State { get; set; } = GermanState.NW;
|
||||
}
|
||||
|
||||
/// <summary>Serverseitig gespeicherter Schulstandort einschließlich des bestätigbaren
|
||||
/// Geocoding-Ergebnisses. Koordinaten werden nie an den DWD als Straßenadresse übertragen.</summary>
|
||||
public sealed class SchoolLocationProfile : SchoolLocationRequest
|
||||
{
|
||||
public double Latitude { get; set; }
|
||||
public double Longitude { get; set; }
|
||||
public string ResolvedAddress { get; set; } = "";
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WeatherSnapshot
|
||||
{
|
||||
public string StationId { get; set; } = "";
|
||||
public string StationName { get; set; } = "";
|
||||
public double StationDistanceKm { get; set; }
|
||||
public DateTime? ForecastIssuedAt { get; set; }
|
||||
public DateTime RetrievedAt { get; set; }
|
||||
/// <summary>True, wenn der DWD vorübergehend nicht erreichbar war und der Server den zuletzt
|
||||
/// erfolgreich gespeicherten Stand zurückgibt.</summary>
|
||||
public bool IsStale { get; set; }
|
||||
public List<WeatherForecastHour> Forecast { get; set; } = [];
|
||||
public List<WeatherWarning> Warnings { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class WeatherForecastHour
|
||||
{
|
||||
public DateTime ValidAt { get; set; }
|
||||
public double? TemperatureC { get; set; }
|
||||
public double? WindSpeedKmh { get; set; }
|
||||
public double? WindGustKmh { get; set; }
|
||||
public double? PrecipitationMm { get; set; }
|
||||
/// <summary>Stündliche MOSMIX-Wahrscheinlichkeit für mehr als 0,1 mm Niederschlag.</summary>
|
||||
public double? PrecipitationProbabilityPercent { get; set; }
|
||||
/// <summary>MOSMIX-Sonnenscheindauer der letzten Stunde, in Minuten.</summary>
|
||||
public double? SunshineDurationMinutes { get; set; }
|
||||
public double? CloudCoverPercent { get; set; }
|
||||
public int? WeatherCode { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WeatherWarning
|
||||
{
|
||||
public string Identifier { get; set; } = "";
|
||||
public string Event { get; set; } = "";
|
||||
public string Headline { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Instruction { get; set; } = "";
|
||||
public string Severity { get; set; } = "";
|
||||
public DateTime? Onset { get; set; }
|
||||
public DateTime? Expires { get; set; }
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
public class Documentation
|
||||
public class Documentation : IHasAttachments
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid StudentId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
/// <summary>Unterrichtssitzung, in der die Beobachtung entstanden ist.</summary>
|
||||
public Guid? ParticipationSessionId { get; set; }
|
||||
/// <summary>Optionaler Bezug auf die konkrete geplante Stunde.</summary>
|
||||
public Guid? LessonId { get; set; }
|
||||
public DocumentationType Type { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
@@ -17,7 +21,13 @@ public class Documentation
|
||||
public List<DocumentAttachment> Attachments { get; set; } = [];
|
||||
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
|
||||
public List<string> Tags { get; set; } = [];
|
||||
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
|
||||
public bool IsDraft { get; set; }
|
||||
public bool IsConfidential { get; set; }
|
||||
/// <summary>Für die pädagogische Arbeit/Leistungsbewertung gedacht, aber nicht für den
|
||||
/// WebUntis-Klassenbuch-Abgleich vorgesehen - blendet den Eintrag im Abgleichsdialog
|
||||
/// (WebUntisDocumentationComparisonViewModel) aus dem Zwischenablage-Vorschlag aus.</summary>
|
||||
public bool ExcludeFromWebUntisSync { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
// Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
|
||||
@@ -71,6 +81,17 @@ public class DocumentAttachment
|
||||
public long SizeBytes { get; set; }
|
||||
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
/// <summary>
|
||||
/// Implementiert von jedem Modell mit Datei-Anhängen (aktuell <see cref="Documentation"/> und
|
||||
/// <see cref="Lesson"/>). Anhang-Bytes reisen nicht im JSON-Sync-Ereignis mit, sondern als eigener
|
||||
/// Binärtransfer (siehe <c>SyncEventPublisher</c>/<c>EventApplier</c>/<c>AttachmentSyncer</c> in
|
||||
/// LehrerApp.Sync) — dieses Interface macht die Erkennung "hat dieses Modell Anhänge?" dort
|
||||
/// generisch statt hart auf einen einzelnen Modelltyp verdrahtet.
|
||||
/// </summary>
|
||||
public interface IHasAttachments
|
||||
{
|
||||
List<DocumentAttachment> Attachments { get; }
|
||||
}
|
||||
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
|
||||
public enum SupportStatus { Active, Completed, Paused }
|
||||
@@ -87,9 +108,34 @@ public class WorkTask
|
||||
public string? Notes { get; set; }
|
||||
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4).
|
||||
public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None;
|
||||
/// Nutzer-Feedback: "Aufgaben" wirkt wie reine Zeiterfassung, dabei braucht man auch schlichte
|
||||
/// Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse machen"). Reminder-Aufgaben
|
||||
/// laufen über dieselbe Liste/dasselbe Dashboard-Widget, werden aber nie mit einem
|
||||
/// TimeEntry verknüpft und fließen deshalb schon rein datenmodell-bedingt (WorkloadEvaluationViewModel
|
||||
/// wertet nur TimeEntry aus, nicht WorkTask) nicht in die Zeitauswertung (6.3) ein.
|
||||
public TaskKind Kind { get; set; } = TaskKind.WorkItem;
|
||||
/// Nutzer-Feedback: pädagogische Erinnerungen ("Ansage an die Klasse", "Material einsammeln")
|
||||
/// sollen sich je nach Dringlichkeit farblich absetzen können, unabhängig vom Fälligkeitsdatum.
|
||||
public TaskPriority Priority { get; set; } = TaskPriority.Normal;
|
||||
/// Optionale Abhak-Liste (Nutzer-Feedback: "Namensliste aller Kurs-Schüler" ODER freie
|
||||
/// Teil-Punkte, wahlweise). Beides läuft über denselben Listentyp — bei aus der Kursliste
|
||||
/// befüllten Einträgen ist StudentId gesetzt (Sprungziel zum Schüler), bei frei eingegebenen
|
||||
/// Einträgen bleibt es leer. Als verschachtelte LiteDB-Liste statt eigener Collection/JSON-
|
||||
/// String, siehe TODO.md-Nachtrag zu 6.1/9 — synct dadurch automatisch über den bestehenden
|
||||
/// Sync-Unterbau mit (EventPublisher serialisiert das ganze WorkTask-Dokument je Save()).
|
||||
public List<ChecklistItem> ChecklistItems { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
public class ChecklistItem
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Label { get; set; } = "";
|
||||
public bool IsDone { get; set; }
|
||||
/// Gesetzt, wenn der Eintrag aus der Kursliste befüllt wurde (Sprungziel zum Schüler);
|
||||
/// bei frei eingegebenen Einträgen null.
|
||||
public Guid? StudentId { get; set; }
|
||||
}
|
||||
public class TimeEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -106,3 +152,5 @@ public class TimeEntry
|
||||
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
|
||||
public enum WorkTaskStatus { Open, InProgress, Done }
|
||||
public enum TaskRecurrence { None, Weekly, Monthly }
|
||||
public enum TaskKind { WorkItem, Reminder }
|
||||
public enum TaskPriority { Low, Normal, High }
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen
|
||||
/// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse.
|
||||
/// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der
|
||||
/// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht.
|
||||
/// </summary>
|
||||
public static class AnnualPlanIcsParser
|
||||
{
|
||||
private static readonly TimeZoneInfo BerlinTimeZone =
|
||||
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
|
||||
private readonly record struct Property(string Head, string Value)
|
||||
{
|
||||
public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static List<AnnualPlanEvent> Parse(string icsText)
|
||||
{
|
||||
var lines = Unfold(icsText);
|
||||
if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) ||
|
||||
!lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase)))
|
||||
throw new FormatException("Kein vollständiger iCal-Kalender gefunden.");
|
||||
|
||||
var result = new List<AnnualPlanEvent>();
|
||||
Dictionary<string, Property>? current = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden.");
|
||||
current = new Dictionary<string, Property>(StringComparer.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden.");
|
||||
result.Add(BuildEvent(current, result.Count + 1));
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current is null) continue;
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon < 0) continue;
|
||||
var head = line[..colon];
|
||||
var separator = head.IndexOf(';');
|
||||
var key = separator < 0 ? head : head[..separator];
|
||||
if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]);
|
||||
}
|
||||
|
||||
if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden.");
|
||||
if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count)
|
||||
throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AnnualPlanEvent BuildEvent(Dictionary<string, Property> properties, int number)
|
||||
{
|
||||
var uid = Required(properties, "UID", number).Value.Trim();
|
||||
if (uid.Length == 0) throw Invalid(number, "UID fehlt");
|
||||
|
||||
var startProperty = Required(properties, "DTSTART", number);
|
||||
var endProperty = properties.GetValueOrDefault("DTEND");
|
||||
|
||||
DateOnly startDate;
|
||||
DateOnly endDate;
|
||||
TimeOnly? startTime;
|
||||
TimeOnly? endTime;
|
||||
var isAllDay = startProperty.IsDate;
|
||||
|
||||
if (isAllDay)
|
||||
{
|
||||
startDate = ParseDate(startProperty.Value, number, "DTSTART");
|
||||
var endExclusive = endProperty == default
|
||||
? startDate.AddDays(1)
|
||||
: endProperty.IsDate
|
||||
? ParseDate(endProperty.Value, number, "DTEND")
|
||||
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
|
||||
if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART");
|
||||
endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv.
|
||||
startTime = null;
|
||||
endTime = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var start = ParseLocalDateTime(startProperty, number, "DTSTART");
|
||||
var end = endProperty == default
|
||||
? start
|
||||
: !endProperty.IsDate
|
||||
? ParseLocalDateTime(endProperty, number, "DTEND")
|
||||
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
|
||||
if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART");
|
||||
startDate = DateOnly.FromDateTime(start);
|
||||
endDate = DateOnly.FromDateTime(end);
|
||||
startTime = TimeOnly.FromDateTime(start);
|
||||
endTime = TimeOnly.FromDateTime(end);
|
||||
}
|
||||
|
||||
return new AnnualPlanEvent
|
||||
{
|
||||
ExternalId = uid,
|
||||
Title = Text(properties, "SUMMARY"),
|
||||
Description = Text(properties, "DESCRIPTION"),
|
||||
Location = Text(properties, "LOCATION"),
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
IsAllDay = isAllDay,
|
||||
CalendarGroup = Text(properties, "X-GROUPNAME"),
|
||||
Color = Text(properties, "X-COLOR"),
|
||||
Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED",
|
||||
Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture, out var sequence) ? sequence : 0,
|
||||
SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number),
|
||||
};
|
||||
}
|
||||
|
||||
private static Property Required(Dictionary<string, Property> properties, string key, int number) =>
|
||||
properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt");
|
||||
|
||||
private static string Raw(Dictionary<string, Property> properties, string key) =>
|
||||
properties.TryGetValue(key, out var value) ? value.Value : "";
|
||||
|
||||
private static string Text(Dictionary<string, Property> properties, string key) =>
|
||||
Unescape(Raw(properties, key));
|
||||
|
||||
private static DateOnly ParseDate(string value, int number, string field)
|
||||
{
|
||||
if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date)) return date;
|
||||
throw Invalid(number, $"ungültiges {field}: {value}");
|
||||
}
|
||||
|
||||
private static DateTime ParseLocalDateTime(Property property, int number, string field)
|
||||
{
|
||||
var utc = property.Value.EndsWith('Z');
|
||||
var value = utc ? property.Value[..^1] : property.Value;
|
||||
var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
|
||||
if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var parsed))
|
||||
throw Invalid(number, $"ungültiges {field}: {property.Value}");
|
||||
|
||||
return utc
|
||||
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
|
||||
: DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
|
||||
}
|
||||
|
||||
private static DateTime? ParseOptionalUtc(Property property, int number)
|
||||
{
|
||||
if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null;
|
||||
var local = ParseLocalDateTime(property, number, "LAST-MODIFIED");
|
||||
return property.Value.EndsWith('Z')
|
||||
? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone)
|
||||
: TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone);
|
||||
}
|
||||
|
||||
private static List<string> Unfold(string text)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'))
|
||||
{
|
||||
if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0)
|
||||
result[^1] += line[1..];
|
||||
else
|
||||
result.Add(line);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Unescape(string value)
|
||||
{
|
||||
var result = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] != '\\' || i + 1 >= value.Length)
|
||||
{
|
||||
result.Append(value[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = value[++i];
|
||||
result.Append(next switch
|
||||
{
|
||||
'n' or 'N' => '\n',
|
||||
'\\' => '\\',
|
||||
',' => ',',
|
||||
';' => ';',
|
||||
_ => next,
|
||||
});
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static FormatException Invalid(int number, string message) =>
|
||||
new($"Ungültiger Jahresplan-Termin #{number}: {message}.");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen
|
||||
/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald
|
||||
/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von
|
||||
/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren-
|
||||
/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen.
|
||||
public static class ExamCorrectionCounter
|
||||
{
|
||||
public static (int Expected, int Evaluated) Count(Exam exam, List<GroupMembership> groupMemberships,
|
||||
List<ExamResult> examResults)
|
||||
{
|
||||
var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
||||
var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
||||
return (expected, Math.Min(evaluated, expected));
|
||||
}
|
||||
}
|
||||
|
||||
public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned }
|
||||
|
||||
public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score);
|
||||
|
||||
/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt
|
||||
/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft"
|
||||
/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe
|
||||
/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer
|
||||
/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der
|
||||
/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben.
|
||||
public static class ExamPriorityService
|
||||
{
|
||||
public const int StuckAfterDays = 21;
|
||||
private const int CorrectionGraceDays = 3;
|
||||
|
||||
public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today)
|
||||
{
|
||||
if (exam.Status == ExamStatus.Returned)
|
||||
return new ExamPriorityInfo(ExamListStatus.Returned, -100);
|
||||
|
||||
if (exam.Status == ExamStatus.Graded)
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||
|
||||
if (exam.Status == ExamStatus.Planned)
|
||||
{
|
||||
var daysUntil = exam.Date.DayNumber - today.DayNumber;
|
||||
return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150));
|
||||
}
|
||||
|
||||
// Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung.
|
||||
var daysSince = today.DayNumber - exam.Date.DayNumber;
|
||||
|
||||
if (evaluated <= 0)
|
||||
{
|
||||
var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince;
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score);
|
||||
}
|
||||
|
||||
if (expected > 0 && evaluated >= expected)
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||
|
||||
if (daysSince > StuckAfterDays)
|
||||
return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90);
|
||||
|
||||
return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ public sealed class GroupRolloverService(
|
||||
IsActive = true,
|
||||
IsOwnClass = source.IsOwnClass,
|
||||
IsDifferentiated = source.IsDifferentiated,
|
||||
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||
};
|
||||
|
||||
var sourceWasActive = source.IsActive;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -303,6 +303,58 @@ public sealed class RepositoryTests
|
||||
Assert.Throws<InvalidOperationException>(() => new SeatingPlanRepository(db).Save(plan));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatingPlanRepository_FiltertAusgeblendetePlaetzeAusserhalbDesRasters()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
new GroupRepository(db).Save(group);
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2,
|
||||
HiddenSeats =
|
||||
[
|
||||
new HiddenSeat { Row = 0, Column = 1 },
|
||||
new HiddenSeat { Row = 5, Column = 5 }, // außerhalb des 2x2-Rasters
|
||||
],
|
||||
};
|
||||
|
||||
new SeatingPlanRepository(db).Save(plan);
|
||||
|
||||
var saved = db.SeatingPlans.FindById(plan.Id);
|
||||
Assert.NotNull(saved);
|
||||
var hidden = Assert.Single(saved.HiddenSeats);
|
||||
Assert.Equal((0, 1), (hidden.Row, hidden.Column));
|
||||
}
|
||||
|
||||
/// Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist wertvoller
|
||||
/// als das Ausblenden. SeatingPlanTabViewModel verhindert das Belegen ausgeblendeter Plätze
|
||||
/// bereits in der UI; diese Absicherung greift für Daten, die diesen Weg umgehen (z. B. Sync
|
||||
/// von einem älteren Client).
|
||||
[Fact]
|
||||
public void SeatingPlanRepository_BlendetPlatzWiederEinWennErBelegtWird()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
new GroupRepository(db).Save(group);
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
db.Students.Insert(student);
|
||||
db.Memberships.Insert(new GroupMembership { GroupId = group.Id, StudentId = student.Id });
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2,
|
||||
HiddenSeats = [new HiddenSeat { Row = 0, Column = 0 }],
|
||||
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
|
||||
};
|
||||
|
||||
new SeatingPlanRepository(db).Save(plan);
|
||||
|
||||
var saved = db.SeatingPlans.FindById(plan.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Empty(saved.HiddenSeats);
|
||||
Assert.Equal(student.Id, saved.Assignments.Single().StudentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatingPlanRepository_AktualisiertPlanMitFehlendemOptionalenRaum()
|
||||
{
|
||||
@@ -688,6 +740,25 @@ public sealed class RepositoryTests
|
||||
Assert.Equal(2, result[2].LessonNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LessonRepository_DeleteRaeumtAnhaengeAusDerAttachmentAblageAuf()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new LessonRepository(db);
|
||||
var lesson = new Lesson
|
||||
{
|
||||
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(),
|
||||
Attachments = [new DocumentAttachment { StorageId = "gbu-1", FileName = "gbu.pdf" }],
|
||||
};
|
||||
db.Attachments.Upload("gbu-1", "gbu.pdf", new MemoryStream([1, 2, 3]));
|
||||
repo.Save(lesson);
|
||||
|
||||
repo.Delete(lesson.Id);
|
||||
|
||||
Assert.Null(db.Lessons.FindById(lesson.Id));
|
||||
Assert.False(db.Attachments.Exists("gbu-1"));
|
||||
}
|
||||
|
||||
// ── ShorthandCodeRepository ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -883,4 +954,238 @@ public sealed class RepositoryTests
|
||||
Assert.Single(result);
|
||||
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());
|
||||
}
|
||||
|
||||
// ── AnnualPlanEventRepository ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AnnualPlanEventRepository_GetByRange_FindetAuchUeberlappendeMehrtagstermine()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new AnnualPlanEventRepository(db);
|
||||
repo.Save(new AnnualPlanEvent
|
||||
{
|
||||
ExternalId = "fahrt", Title = "Klassenfahrt",
|
||||
StartDate = new DateOnly(2026, 6, 15), EndDate = new DateOnly(2026, 6, 19), IsAllDay = true,
|
||||
});
|
||||
repo.Save(new AnnualPlanEvent
|
||||
{
|
||||
ExternalId = "spaeter", Title = "Sommerfest",
|
||||
StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 7, 1), IsAllDay = true,
|
||||
});
|
||||
|
||||
var result = repo.GetByRange(new DateOnly(2026, 6, 17), new DateOnly(2026, 6, 17));
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("fahrt", result[0].ExternalId);
|
||||
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
|
||||
}
|
||||
|
||||
// ── UntisAbsenceCacheRepository / UntisClassRegisterCacheRepository / UntisCacheFetchStateRepository ──
|
||||
|
||||
[Fact]
|
||||
public void UntisAbsenceCacheRepository_GetByClassAndRange_FiltertKlasseUndDatumsbereich()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisAbsenceCacheRepository(db);
|
||||
repo.InsertRange(
|
||||
[
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260824 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260901 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10d", StudentName = "Ben", Date = 20260824 },
|
||||
]);
|
||||
|
||||
var result = repo.GetByClassAndRange("10c", 20260801, 20260831);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(20260824, result[0].Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisAbsenceCacheRepository_ReplaceRange_ErsetztNurDenAngegebenenBereich()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisAbsenceCacheRepository(db);
|
||||
repo.InsertRange(
|
||||
[
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260810 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260901 },
|
||||
]);
|
||||
|
||||
repo.ReplaceRange("10c", 20260801, 20260831,
|
||||
[new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Neu", Date = 20260815 }]);
|
||||
|
||||
var inRange = repo.GetByClassAndRange("10c", 20260801, 20260831);
|
||||
var untouched = repo.GetByClassAndRange("10c", 20260901, 20260901);
|
||||
Assert.Equal("Neu", Assert.Single(inRange).StudentName);
|
||||
Assert.Single(untouched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisClassRegisterCacheRepository_InsertUndGetByClassAndRange_RoundTrip()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisClassRegisterCacheRepository(db);
|
||||
repo.InsertRange([new UntisClassRegisterCacheEntry
|
||||
{
|
||||
ClassName = "10c", Date = 20260824, StudentName = "Max", Subject = "Deu",
|
||||
TeacherUsername = "mueller", CategoryName = "Positiv",
|
||||
}]);
|
||||
|
||||
var result = Assert.Single(repo.GetByClassAndRange("10c", 20260801, 20260831));
|
||||
Assert.Equal("mueller", result.TeacherUsername);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisStudentRosterCacheRepository_ReplaceAll_ErsetztNurDieAngegebeneKlasse()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisStudentRosterCacheRepository(db);
|
||||
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Alt" }]);
|
||||
repo.ReplaceAll("10d", [new UntisStudentRosterCacheEntry { ClassName = "10d", DisplayName = "Andere Klasse" }]);
|
||||
|
||||
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Neu", ExternKey = 42 }]);
|
||||
|
||||
var tenC = repo.GetByClass("10c");
|
||||
var tenD = repo.GetByClass("10d");
|
||||
Assert.Equal("Neu", Assert.Single(tenC).DisplayName);
|
||||
Assert.Equal(42, tenC[0].ExternKey);
|
||||
Assert.Single(tenD); // andere Klasse bleibt unberührt
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisCacheFetchStateRepository(db);
|
||||
var fetchedAt = new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc);
|
||||
repo.Save(new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = fetchedAt, ColdCoverageStartDate = 20260101,
|
||||
});
|
||||
|
||||
var result = repo.Get("10c", UntisCacheKind.Absences);
|
||||
var missing = repo.Get("10c", UntisCacheKind.ClassRegister);
|
||||
|
||||
Assert.NotNull(result);
|
||||
// LiteDB rundet Kind Utc->Local, verschiebt dabei die Ticks um denselben Zeitpunkt zu
|
||||
// erhalten (siehe CLAUDE.md) - deshalb erst beide Seiten auf UTC normalisieren.
|
||||
Assert.Equal(fetchedAt.ToUniversalTime(), result!.HotWindowFetchedAt!.Value.ToUniversalTime());
|
||||
Assert.Equal(20260101, result.ColdCoverageStartDate);
|
||||
Assert.Null(missing);
|
||||
}
|
||||
|
||||
// ── WorkTaskRepository ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WorkTaskRepository_GetByGroup_FindetNurAufgabenDieserGruppe()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new WorkTaskRepository(db);
|
||||
var groupId = Guid.NewGuid();
|
||||
repo.Save(new WorkTask { Title = "Klassenaufgabe", GroupId = groupId });
|
||||
repo.Save(new WorkTask { Title = "Andere Gruppe", GroupId = Guid.NewGuid() });
|
||||
repo.Save(new WorkTask { Title = "Ohne Gruppe", GroupId = null });
|
||||
|
||||
var result = repo.GetByGroup(groupId);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Klassenaufgabe", result[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkTaskRepository_SaveUndLoad_RundetPrioritaetUndChecklistItemsRoundTrip()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new WorkTaskRepository(db);
|
||||
var studentId = Guid.NewGuid();
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = "Material einsammeln",
|
||||
Priority = TaskPriority.High,
|
||||
ChecklistItems = [new ChecklistItem { Label = "Anna", StudentId = studentId, IsDone = true }],
|
||||
};
|
||||
|
||||
repo.Save(task);
|
||||
var loaded = repo.GetAll().Single(t => t.Id == task.Id);
|
||||
|
||||
Assert.Equal(TaskPriority.High, loaded.Priority);
|
||||
var item = Assert.Single(loaded.ChecklistItems);
|
||||
Assert.Equal("Anna", item.Label);
|
||||
Assert.Equal(studentId, item.StudentId);
|
||||
Assert.True(item.IsDone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Data.Tests;
|
||||
|
||||
public sealed class TrashTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
|
||||
[Fact]
|
||||
public void GradeRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
new GroupRepository(db).Save(group);
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
db.Students.Insert(student);
|
||||
var repo = new GradeRepository(db);
|
||||
var grade = new Grade
|
||||
{
|
||||
StudentId = student.Id, GroupId = group.Id, Category = GradeCategory.Oral,
|
||||
Value = "2", Date = new DateOnly(2026, 3, 4),
|
||||
};
|
||||
repo.Save(grade);
|
||||
|
||||
repo.Delete(grade.Id);
|
||||
|
||||
Assert.Null(db.Grades.FindById(grade.Id));
|
||||
var trashed = db.TrashedItems.FindAll().Single();
|
||||
Assert.Equal(nameof(Grade), trashed.EntityType);
|
||||
Assert.Equal("Note 2 (04.03.2026)", trashed.Summary);
|
||||
|
||||
repo.Restore(trashed.Id);
|
||||
|
||||
var restored = db.Grades.FindById(grade.Id);
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("2", restored!.Value);
|
||||
Assert.Empty(db.TrashedItems.FindAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GradeRepository_RestoreMitUnbekannterTrashIdTutNichts()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new GradeRepository(db);
|
||||
|
||||
repo.Restore(Guid.NewGuid());
|
||||
|
||||
Assert.Empty(db.Grades.FindAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkTaskRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new WorkTaskRepository(db);
|
||||
var task = new WorkTask { Title = "Elternbrief schreiben", Category = TaskCategory.Admin };
|
||||
repo.Save(task);
|
||||
|
||||
repo.Delete(task.Id);
|
||||
|
||||
Assert.Null(db.Tasks.FindById(task.Id));
|
||||
Assert.Equal("Elternbrief schreiben", db.TrashedItems.FindAll().Single().Summary);
|
||||
|
||||
repo.Restore(db.TrashedItems.FindAll().Single().Id);
|
||||
|
||||
Assert.NotNull(db.Tasks.FindById(task.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeEntryRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new TimeEntryRepository(db);
|
||||
var entry = new TimeEntry { Category = "Korrektur", Date = new DateOnly(2026, 3, 4), DurationMinutes = 45 };
|
||||
repo.Save(entry);
|
||||
|
||||
repo.Delete(entry.Id);
|
||||
|
||||
Assert.Null(db.TimeEntries.FindById(entry.Id));
|
||||
Assert.Equal("45 Min. Korrektur (04.03.2026)", db.TrashedItems.FindAll().Single().Summary);
|
||||
|
||||
repo.Restore(db.TrashedItems.FindAll().Single().Id);
|
||||
|
||||
Assert.NotNull(db.TimeEntries.FindById(entry.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatingPlanRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
new GroupRepository(db).Save(group);
|
||||
var repo = new SeatingPlanRepository(db);
|
||||
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Room = "B204", Rows = 2, Columns = 2 };
|
||||
repo.Save(plan);
|
||||
|
||||
repo.Delete(plan.Id);
|
||||
|
||||
Assert.Null(db.SeatingPlans.FindById(plan.Id));
|
||||
Assert.Equal("Standard (Raum B204)", db.TrashedItems.FindAll().Single().Summary);
|
||||
|
||||
repo.Restore(db.TrashedItems.FindAll().Single().Id);
|
||||
|
||||
Assert.NotNull(db.SeatingPlans.FindById(plan.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatingPlanRepository_DeleteOhneRaumNutztNurDenNamenAlsSummary()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
new GroupRepository(db).Save(group);
|
||||
var repo = new SeatingPlanRepository(db);
|
||||
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 };
|
||||
repo.Save(plan);
|
||||
|
||||
repo.Delete(plan.Id);
|
||||
|
||||
Assert.Equal("Standard", db.TrashedItems.FindAll().Single().Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GradingKeyTemplateRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new GradingKeyTemplateRepository(db);
|
||||
var template = new GradingKeyTemplate { Name = "Standard 1-6", GradingSystem = GradingSystem.Grades1To6 };
|
||||
repo.Save(template);
|
||||
|
||||
repo.Delete(template.Id);
|
||||
|
||||
Assert.Null(db.GradingKeyTemplates.FindById(template.Id));
|
||||
Assert.Equal("Standard 1-6", db.TrashedItems.FindAll().Single().Summary);
|
||||
|
||||
repo.Restore(db.TrashedItems.FindAll().Single().Id);
|
||||
|
||||
Assert.NotNull(db.GradingKeyTemplates.FindById(template.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrashRepository_GetAllSortiertNeuesteZuerst()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
db.TrashedItems.Insert(new TrashedItem
|
||||
{
|
||||
EntityType = nameof(WorkTask), Summary = "Älter", DeletedAt = DateTime.UtcNow.AddDays(-2),
|
||||
});
|
||||
db.TrashedItems.Insert(new TrashedItem
|
||||
{
|
||||
EntityType = nameof(WorkTask), Summary = "Neuer", DeletedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
var all = new TrashRepository(db).GetAll();
|
||||
|
||||
Assert.Equal(2, all.Count);
|
||||
Assert.Equal("Neuer", all[0].Summary);
|
||||
Assert.Equal("Älter", all[1].Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrashRepository_PurgeOlderThanEntferntNurAeltereEintraege()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
db.TrashedItems.Insert(new TrashedItem
|
||||
{
|
||||
EntityType = nameof(WorkTask), Summary = "Alt", DeletedAt = DateTime.UtcNow.AddDays(-40),
|
||||
});
|
||||
db.TrashedItems.Insert(new TrashedItem
|
||||
{
|
||||
EntityType = nameof(WorkTask), Summary = "Frisch", DeletedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
new TrashRepository(db).PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
|
||||
|
||||
var remaining = db.TrashedItems.FindAll().ToList();
|
||||
Assert.Single(remaining);
|
||||
Assert.Equal("Frisch", remaining[0].Summary);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,14 @@ 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<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
|
||||
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
|
||||
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
|
||||
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
||||
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
||||
|
||||
public void Checkpoint() => _db.Checkpoint();
|
||||
|
||||
@@ -182,6 +190,38 @@ public class LiteDbContext : IDisposable
|
||||
Documentation.Delete(id);
|
||||
}
|
||||
|
||||
// ── Papierkorb (14.3) ────────────────────────────────────────────────────
|
||||
//
|
||||
// Absichtlich hier statt in einer eigenen TrashRepository-Konstruktorabhängigkeit: jede
|
||||
// Fach-Repository hat db (LiteDbContext) bereits im Konstruktor, ein zusätzlicher Papierkorb-
|
||||
// Parameter wäre nur Boilerplate. Snapshot/Restore sind generisch über den Modelltyp, die
|
||||
// eigentliche Wiederherstellung ruft aber bewusst die jeweilige Repository.Save-Methode auf
|
||||
// (nicht direkt die LiteDB-Collection) — nur die kennt Validierung und OnChange-Ereignis für
|
||||
// ihr Modell.
|
||||
|
||||
/// <summary>Legt einen Papierkorb-Eintrag an, BEVOR die aufrufende Repository die Entität aus
|
||||
/// ihrer eigentlichen Collection löscht.</summary>
|
||||
internal void MoveToTrash<T>(string entityType, Guid entityId, T entity, string summary) =>
|
||||
TrashedItems.Insert(new TrashedItem
|
||||
{
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Snapshot = System.Text.Json.JsonSerializer.Serialize(entity),
|
||||
Summary = summary,
|
||||
});
|
||||
|
||||
/// <summary>Liefert die gelöschte Entität aus dem Papierkorb-Eintrag zurück und entfernt den
|
||||
/// Eintrag - unabhängig davon, ob die aufrufende Repository den Schnappschuss danach
|
||||
/// erfolgreich speichert (ein fehlgeschlagenes Restore soll nicht endlos wiederholbar sein,
|
||||
/// derselbe Datensatz landet notfalls einfach nicht wieder im Papierkorb).</summary>
|
||||
internal T? RestoreFromTrash<T>(Guid trashId)
|
||||
{
|
||||
var item = TrashedItems.FindById(trashId);
|
||||
if (item is null) return default;
|
||||
TrashedItems.Delete(trashId);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<T>(item.Snapshot);
|
||||
}
|
||||
|
||||
internal void ExecuteInTransaction(Action action)
|
||||
{
|
||||
_db.BeginTrans();
|
||||
@@ -496,6 +536,16 @@ public class LiteDbContext : IDisposable
|
||||
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
|
||||
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
|
||||
SubstitutionEntries.EnsureIndex(x => x.Date);
|
||||
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
|
||||
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
|
||||
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
|
||||
UntisAbsenceCache.EnsureIndex(x => x.ClassName);
|
||||
UntisAbsenceCache.EnsureIndex(x => x.Date);
|
||||
UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
|
||||
UntisClassRegisterCache.EnsureIndex(x => x.Date);
|
||||
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
|
||||
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
|
||||
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
@@ -108,6 +108,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
||||
plan.Room = plan.Room?.Trim() ?? "";
|
||||
plan.Assignments ??= [];
|
||||
plan.ColumnGapWidths ??= [];
|
||||
plan.HiddenSeats ??= [];
|
||||
if (plan.Name.Length == 0)
|
||||
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
|
||||
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
|
||||
@@ -141,6 +142,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
||||
if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId)))
|
||||
throw new InvalidOperationException("Der Sitzplan enthält einen Schüler, der nicht zur Lerngruppe gehört.");
|
||||
|
||||
// Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist
|
||||
// wertvoller als das Ausblenden, deshalb wird hier stillschweigend wieder eingeblendet
|
||||
// statt die Zuordnung zu verwerfen. Sollte über die UI (SeatingPlanTabViewModel verhindert
|
||||
// das Belegen ausgeblendeter Plätze) nie vorkommen - reine Absicherung bei fehlerhaften
|
||||
// oder von einem älteren Client synchronisierten Daten.
|
||||
var assignedPositions = plan.Assignments.Select(a => (a.Row, a.Column)).ToHashSet();
|
||||
plan.HiddenSeats = plan.HiddenSeats
|
||||
.Where(h => h.Row >= 0 && h.Row < plan.Rows && h.Column >= 0 && h.Column < plan.Columns)
|
||||
.Where(h => !assignedPositions.Contains((h.Row, h.Column)))
|
||||
.DistinctBy(h => (h.Row, h.Column))
|
||||
.ToList();
|
||||
|
||||
plan.UpdatedAt = DateTime.UtcNow;
|
||||
db.SeatingPlans.Upsert(plan);
|
||||
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
|
||||
@@ -148,11 +161,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
||||
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.SeatingPlans.FindById(id) is { } plan)
|
||||
if (db.SeatingPlans.FindById(id) is not { } plan) return;
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
||||
db.MoveToTrash(nameof(SeatingPlan), id, plan,
|
||||
string.IsNullOrWhiteSpace(plan.Room) ? plan.Name : $"{plan.Name} (Raum {plan.Room})");
|
||||
db.SeatingPlans.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null);
|
||||
}
|
||||
|
||||
public void Restore(Guid trashId)
|
||||
{
|
||||
if (db.RestoreFromTrash<SeatingPlan>(trashId) is { } plan) Save(plan);
|
||||
}
|
||||
}
|
||||
|
||||
public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository
|
||||
@@ -247,9 +267,16 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.GradingKeyTemplates.FindById(id) is { } template)
|
||||
db.MoveToTrash(nameof(GradingKeyTemplate), id, template, template.Name);
|
||||
db.GradingKeyTemplates.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null);
|
||||
}
|
||||
|
||||
public void Restore(Guid trashId)
|
||||
{
|
||||
if (db.RestoreFromTrash<GradingKeyTemplate>(trashId) is { } template) Save(template);
|
||||
}
|
||||
}
|
||||
|
||||
public class GradeRepository(LiteDbContext db) : IGradeRepository
|
||||
@@ -266,11 +293,18 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Grades.FindById(id) is { } grade)
|
||||
if (db.Grades.FindById(id) is not { } grade) return;
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
||||
db.MoveToTrash(nameof(Grade), id, grade,
|
||||
$"Note {grade.Value} ({grade.Date:dd.MM.yyyy})");
|
||||
db.Grades.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null);
|
||||
}
|
||||
|
||||
public void Restore(Guid trashId)
|
||||
{
|
||||
if (db.RestoreFromTrash<Grade>(trashId) is { } grade) Save(grade);
|
||||
}
|
||||
}
|
||||
|
||||
public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository
|
||||
@@ -355,7 +389,10 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Lessons.FindById(id) is { } lesson)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
|
||||
foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId);
|
||||
}
|
||||
db.Lessons.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
|
||||
}
|
||||
@@ -400,6 +437,8 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
|
||||
public List<WorkTask> GetAll() =>
|
||||
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
|
||||
public List<WorkTask> GetByGroup(Guid groupId) =>
|
||||
db.Tasks.Find(t => t.GroupId == groupId).OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
|
||||
public void Save(WorkTask t)
|
||||
{
|
||||
t.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -408,9 +447,16 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Tasks.FindById(id) is { } task)
|
||||
db.MoveToTrash(nameof(WorkTask), id, task, task.Title);
|
||||
db.Tasks.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null);
|
||||
}
|
||||
|
||||
public void Restore(Guid trashId)
|
||||
{
|
||||
if (db.RestoreFromTrash<WorkTask>(trashId) is { } task) Save(task);
|
||||
}
|
||||
}
|
||||
|
||||
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
|
||||
@@ -428,9 +474,17 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.TimeEntries.FindById(id) is { } entry)
|
||||
db.MoveToTrash(nameof(TimeEntry), id, entry,
|
||||
$"{entry.DurationMinutes} Min. {entry.Category} ({entry.Date:dd.MM.yyyy})");
|
||||
db.TimeEntries.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null);
|
||||
}
|
||||
|
||||
public void Restore(Guid trashId)
|
||||
{
|
||||
if (db.RestoreFromTrash<TimeEntry>(trashId) is { } entry) Save(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
|
||||
@@ -688,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);
|
||||
@@ -700,6 +756,103 @@ 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);
|
||||
}
|
||||
|
||||
// Wie UntisSnapshotEntry rein lokal: Der Jahresplan ist ein wiederherstellbarer Cache eines
|
||||
// externen Feeds. db.OnChange würde bei jedem vollständigen Abruf hunderte Sync-Ereignisse erzeugen.
|
||||
public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepository
|
||||
{
|
||||
public List<AnnualPlanEvent> GetAll() =>
|
||||
db.AnnualPlanEvents.FindAll().OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
|
||||
|
||||
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
|
||||
db.AnnualPlanEvents.Find(e => e.StartDate <= to && e.EndDate >= from)
|
||||
.OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
|
||||
|
||||
public AnnualPlanEvent? GetByExternalId(string externalId) =>
|
||||
db.AnnualPlanEvents.FindOne(e => e.ExternalId == externalId);
|
||||
|
||||
public void Save(AnnualPlanEvent entry) => db.AnnualPlanEvents.Upsert(entry);
|
||||
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
|
||||
}
|
||||
|
||||
// Wie UntisSnapshotEntry/AnnualPlanEvent rein lokal, bewusst kein db.OnChange: Fehlzeiten- und
|
||||
// Klassenbuch-Cache fürs Klassenlehrer-Feature (siehe TODO.md) wachsen über ein Schuljahr auf viele
|
||||
// hundert Zeilen an - über Sync würde das nur Rauschen erzeugen, und jedes Gerät ruft WebUntis
|
||||
// ohnehin selbst ab (siehe UntisReportCacheService).
|
||||
public class UntisAbsenceCacheRepository(LiteDbContext db) : IUntisAbsenceCacheRepository
|
||||
{
|
||||
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
db.UntisAbsenceCache
|
||||
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
|
||||
.ToList();
|
||||
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
|
||||
{
|
||||
db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
db.UntisAbsenceCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => db.UntisAbsenceCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository
|
||||
{
|
||||
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
db.UntisClassRegisterCache
|
||||
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
|
||||
.ToList();
|
||||
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
|
||||
{
|
||||
db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
db.UntisClassRegisterCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
// Gleiches "bewusst kein db.OnChange"-Prinzip wie UntisAbsenceCacheRepository oben, auch wenn der
|
||||
// Roster selbst klein bleibt (aktuelle Klassenliste, keine Historie) - jedes Gerät ruft ihn ohnehin
|
||||
// selbst ab.
|
||||
public class UntisStudentRosterCacheRepository(LiteDbContext db) : IUntisStudentRosterCacheRepository
|
||||
{
|
||||
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
|
||||
db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList();
|
||||
|
||||
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
|
||||
{
|
||||
db.UntisStudentRosterCache.DeleteMany(e => e.ClassName == className);
|
||||
db.UntisStudentRosterCache.InsertBulk(entries);
|
||||
}
|
||||
}
|
||||
|
||||
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
|
||||
{
|
||||
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
|
||||
db.UntisCacheFetchStates.FindOne(s => s.ClassName == className && s.Kind == kind);
|
||||
|
||||
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
||||
}
|
||||
|
||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||
{
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
@@ -751,3 +904,18 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Papierkorb (14.3) — bewusst nicht sync-fähig: TrashedItem wird nie über
|
||||
/// db.OnChange gemeldet, bleibt also rein lokal auf dem Gerät, auf dem gelöscht wurde. Ein
|
||||
/// "Fehlklick sofort rückgängig machen"-Werkzeug, kein geräteübergreifendes Archiv.</summary>
|
||||
public class TrashRepository(LiteDbContext db) : ITrashRepository
|
||||
{
|
||||
public List<TrashedItem> GetAll() =>
|
||||
db.TrashedItems.FindAll().OrderByDescending(t => t.DeletedAt).ToList();
|
||||
|
||||
public void PurgeOlderThan(DateTime cutoffUtc)
|
||||
{
|
||||
foreach (var item in db.TrashedItems.Find(t => t.DeletedAt < cutoffUtc).ToList())
|
||||
db.TrashedItems.Delete(item.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class AnnualPlanSettingsServiceTests
|
||||
{
|
||||
private const string SampleUrl = "https://example.org/export.php?type=ics&token=intern";
|
||||
|
||||
private static string TempPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettingssvc-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IcalUrl_WirdVerschluesseltPersistiertUndWiederGeladen()
|
||||
{
|
||||
var path = TempPath();
|
||||
var service = new AnnualPlanSettingsService(path);
|
||||
service.SetIcalUrl(SampleUrl);
|
||||
service.SetEnabled(true);
|
||||
|
||||
var raw = File.ReadAllText(Path.Combine(path, "annual-plan-settings.json"));
|
||||
var reloaded = new AnnualPlanSettingsService(path);
|
||||
|
||||
Assert.DoesNotContain("token=intern", raw);
|
||||
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
|
||||
Assert.True(reloaded.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearIcalUrl_EntferntUrlUndDeaktiviertDenImport()
|
||||
{
|
||||
var service = new AnnualPlanSettingsService(TempPath());
|
||||
service.SetIcalUrl(SampleUrl);
|
||||
service.SetEnabled(true);
|
||||
|
||||
service.ClearIcalUrl();
|
||||
|
||||
Assert.False(service.IsConfigured);
|
||||
Assert.False(service.Enabled);
|
||||
Assert.Null(service.GetIcalUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class AnnualPlanSyncServiceTests
|
||||
{
|
||||
private static string Calendar(params string[] events) =>
|
||||
"BEGIN:VCALENDAR\nVERSION:2.0\n" + string.Join("", events) + "END:VCALENDAR\n";
|
||||
|
||||
private static string Event(string uid, string title, string date = "20260823") =>
|
||||
$"BEGIN:VEVENT\nUID:{uid}\nDTSTART;VALUE=DATE:{date}\nSUMMARY:{title}\nEND:VEVENT\n";
|
||||
|
||||
private static AnnualPlanSyncService Build(FakeAnnualPlanEvents events) =>
|
||||
new(new HttpClient(), TestSupport.BuildAnnualPlanSettingsService(), events);
|
||||
|
||||
[Fact]
|
||||
public void ProcessIcsText_LegtAn_AktualisiertIdempotent_UndEntferntVerschwundeneTermine()
|
||||
{
|
||||
var events = new FakeAnnualPlanEvents();
|
||||
using var service = Build(events);
|
||||
|
||||
var first = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
|
||||
var originalId = events.GetByExternalId("1")!.Id;
|
||||
var unchanged = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
|
||||
var changed = service.ProcessIcsText(Calendar(Event("1", "Konferenz verschoben", "20260824")));
|
||||
|
||||
Assert.Equal((2, 0, 0), (first.Added, first.Updated, first.Deleted));
|
||||
Assert.Equal(0, unchanged.ChangeCount);
|
||||
Assert.Equal((0, 1, 1), (changed.Added, changed.Updated, changed.Deleted));
|
||||
Assert.Equal(originalId, events.GetByExternalId("1")!.Id);
|
||||
Assert.Null(events.GetByExternalId("2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessIcsText_DefekterTeilimport_VeraendertBestehendenBestandNicht()
|
||||
{
|
||||
var events = new FakeAnnualPlanEvents();
|
||||
events.Add(new AnnualPlanEvent
|
||||
{
|
||||
ExternalId = "bestand", Title = "Bestehend",
|
||||
StartDate = new DateOnly(2026, 8, 23), EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
|
||||
});
|
||||
using var service = Build(events);
|
||||
var broken = Calendar(Event("neu", "Neu"),
|
||||
"BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n");
|
||||
|
||||
Assert.Throws<FormatException>(() => service.ProcessIcsText(broken));
|
||||
|
||||
var remaining = Assert.Single(events.GetAll());
|
||||
Assert.Equal("bestand", remaining.ExternalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_EntferntDenLokalenFeedCache()
|
||||
{
|
||||
var events = new FakeAnnualPlanEvents();
|
||||
events.Add(new AnnualPlanEvent
|
||||
{
|
||||
ExternalId = "1", StartDate = new DateOnly(2026, 8, 23),
|
||||
EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
|
||||
});
|
||||
using var service = Build(events);
|
||||
var notified = false;
|
||||
service.DataChanged += () => notified = true;
|
||||
|
||||
service.Clear();
|
||||
|
||||
Assert.Empty(events.GetAll());
|
||||
Assert.True(notified);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class AppearanceSettingsServiceTests
|
||||
{
|
||||
private static string BuildTempPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_OhneVorhandeneDatei_LiefertSystemvorgabe() =>
|
||||
Assert.Equal(AppTheme.System, new AppearanceSettingsService(BuildTempPath()).Load());
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
[InlineData(AppTheme.System)]
|
||||
public void SaveUndLoad_PersistiertUeberNeueInstanz(AppTheme theme)
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
new AppearanceSettingsService(path).Save(theme);
|
||||
|
||||
var reloaded = new AppearanceSettingsService(path).Load();
|
||||
|
||||
Assert.Equal(theme, reloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_BeschaedigteDatei_FaelltAufSystemvorgabeZurueck()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
File.WriteAllText(Path.Combine(path, "appearancesettings.json"), "{ kein json");
|
||||
|
||||
Assert.Equal(AppTheme.System, new AppearanceSettingsService(path).Load());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AppThemeDisplayTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AppTheme.System, "Systemvorgabe")]
|
||||
[InlineData(AppTheme.Light, "Hell")]
|
||||
[InlineData(AppTheme.Dark, "Dunkel")]
|
||||
public void Label_LiefertDeutscheBeschriftung(AppTheme theme, string expected) =>
|
||||
Assert.Equal(expected, AppThemeDisplay.Label(theme));
|
||||
|
||||
[Fact]
|
||||
public void FromLabel_UndLabel_SindZueinanderInvers()
|
||||
{
|
||||
foreach (var theme in Enum.GetValues<AppTheme>())
|
||||
Assert.Equal(theme, AppThemeDisplay.FromLabel(AppThemeDisplay.Label(theme)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromLabel_UnbekanntesLabel_LiefertSystemvorgabe() =>
|
||||
Assert.Equal(AppTheme.System, AppThemeDisplay.FromLabel("Nonsens"));
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class ClassTeacherViewModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
|
||||
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
|
||||
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
|
||||
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
|
||||
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
|
||||
};
|
||||
|
||||
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
var max = rows[1];
|
||||
Assert.Equal("Muster Max", max.StudentName);
|
||||
Assert.Equal(2, max.TotalAbsentPeriods);
|
||||
Assert.Equal(90, max.TotalAbsentMinutes);
|
||||
Assert.Equal(["Che", "Kunst"], max.Subjects);
|
||||
Assert.Equal([3, 5], max.PeriodNumbers.Order());
|
||||
Assert.Equal(["nicht entsch."], max.Statuses);
|
||||
Assert.Equal(["Absent"], max.AbsenceReasons);
|
||||
Assert.Equal("Krank", max.Note);
|
||||
Assert.Null(max.ExcuseNote);
|
||||
Assert.True(max.CountsAsFullDay);
|
||||
|
||||
var andere = rows[0];
|
||||
Assert.Equal("Andere Schuelerin", andere.StudentName);
|
||||
Assert.Equal(1, andere.TotalAbsentPeriods);
|
||||
Assert.Equal(20, andere.TotalAbsentMinutes);
|
||||
Assert.Equal(["Deu"], andere.Subjects);
|
||||
Assert.Null(andere.Note);
|
||||
Assert.Equal("Zug verpasst", andere.ExcuseNote);
|
||||
Assert.False(andere.CountsAsFullDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
|
||||
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
|
||||
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
|
||||
};
|
||||
|
||||
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
|
||||
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu()
|
||||
{
|
||||
var students = new[]
|
||||
{
|
||||
Student(1001, "Ada Müller"),
|
||||
Student(1002, "Ben Schmidt"),
|
||||
Student(1003, "Cem Yilmaz"),
|
||||
};
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90,
|
||||
["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var recentClassRegister = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller",
|
||||
"Vergessen", "Organisation", "Buch vergessen"),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
|
||||
|
||||
Assert.Equal(3, roster.Count);
|
||||
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
|
||||
Assert.True(ada.HasAbsenceToday);
|
||||
Assert.Contains("nicht entsch.", ada.AbsenceTooltip);
|
||||
Assert.False(ada.HasRecentClassRegisterEntry);
|
||||
|
||||
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
|
||||
Assert.False(ben.HasAbsenceToday);
|
||||
Assert.True(ben.HasRecentClassRegisterEntry);
|
||||
|
||||
var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz");
|
||||
Assert.False(cem.HasAbsenceToday);
|
||||
Assert.False(cem.HasRecentClassRegisterEntry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge()
|
||||
{
|
||||
// WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/
|
||||
// Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich
|
||||
// muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug).
|
||||
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45,
|
||||
["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var recentClassRegister = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller",
|
||||
"Vergessen", "Organisation", "Buch vergessen"),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
|
||||
|
||||
var ben = Assert.Single(roster);
|
||||
Assert.True(ben.HasAbsenceToday);
|
||||
Assert.True(ben.HasRecentClassRegisterEntry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20,
|
||||
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [],
|
||||
new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.True(row.IsLate);
|
||||
Assert.True(row.IsUnexcused);
|
||||
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern()
|
||||
{
|
||||
var students = new[]
|
||||
{
|
||||
Student(1001, "Zora Unauffällig"),
|
||||
Student(1002, "Ada Fehlzeit"),
|
||||
Student(1003, "Ben Klassenbuch"),
|
||||
};
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90,
|
||||
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var register = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test",
|
||||
"Fehlende HA", "Negativ", "Hausaufgaben fehlen"),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26));
|
||||
|
||||
Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"],
|
||||
rows.Select(r => r.StudentName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
|
||||
["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
|
||||
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||
Assert.Equal(1, row.YearUnexcusedDayCount);
|
||||
Assert.Equal(10, row.YearAbsenceRatePercent);
|
||||
Assert.Contains("10 %", row.YearSummaryLabel);
|
||||
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
||||
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.False(row.HasYearSummary);
|
||||
Assert.Equal("", row.YearSummaryLabel);
|
||||
Assert.Null(row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
||||
{
|
||||
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
|
||||
yearAbsences, schoolDaysElapsed: 10));
|
||||
|
||||
Assert.Equal(1, row.YearAbsenceDayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendDay_AnteileSummierenSichZurGesamtquote()
|
||||
{
|
||||
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d);
|
||||
|
||||
Assert.Equal(1.0d, day.TotalFraction, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") };
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26));
|
||||
|
||||
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind);
|
||||
Assert.StartsWith("✕ ", ada.StatusTextWithGlyph);
|
||||
|
||||
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
|
||||
Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind);
|
||||
Assert.StartsWith("△ ", ben.StatusTextWithGlyph);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol()
|
||||
{
|
||||
var students = new[] { Student(1001, "Zora Unauffällig") };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
|
||||
}
|
||||
|
||||
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
|
||||
|
||||
// StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname",
|
||||
// hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow).
|
||||
Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName));
|
||||
Assert.Equal(6, rows[0].DaysOpen);
|
||||
Assert.True(rows[0].IsOverdue);
|
||||
Assert.Equal(1, rows[1].DaysOpen);
|
||||
Assert.False(rows[1].IsOverdue);
|
||||
}
|
||||
|
||||
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test",
|
||||
"Lob", "Positiv", "Gute Mitarbeit"),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherCategoryAggregateRow.Build(entries);
|
||||
|
||||
Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName));
|
||||
var homework = rows[0];
|
||||
Assert.Equal(3, homework.Count);
|
||||
Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel);
|
||||
Assert.Contains("1× Ada Müller", homework.TopStudentsLabel);
|
||||
}
|
||||
|
||||
// ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ──────
|
||||
|
||||
[Fact]
|
||||
public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag()
|
||||
{
|
||||
// 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage.
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||||
absences, names, new HashSet<string>());
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Equal("Mona Muster", notice.StudentName);
|
||||
Assert.Contains("Montag", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||||
absences, new Dictionary<string, string>(), new HashSet<string>());
|
||||
|
||||
Assert.Empty(notices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||||
absences, names, new HashSet<string> { "Mona Muster" });
|
||||
|
||||
Assert.Empty(notices);
|
||||
}
|
||||
|
||||
// ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ─────────────
|
||||
|
||||
[Fact]
|
||||
public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName()
|
||||
{
|
||||
// Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde
|
||||
// UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent
|
||||
// muss deshalb FirstName/LastName getrennt verwenden, nicht FullName.
|
||||
var students = new List<Student>
|
||||
{
|
||||
new() { FirstName = "Ada", LastName = "Müller" },
|
||||
new() { FirstName = "Ben", LastName = "Schmidt" },
|
||||
};
|
||||
|
||||
var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal("Ada", match!.FirstName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchStudent_LiefertNullOhneTreffer()
|
||||
{
|
||||
var students = new List<Student> { new() { FirstName = "Ada", LastName = "Müller" } };
|
||||
|
||||
Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students));
|
||||
}
|
||||
|
||||
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
||||
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
|
||||
}
|
||||
@@ -6,10 +6,27 @@ using Xunit;
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer
|
||||
/// Stunde). Deckt nur die neue Zeit-/Raum-Auflösung ab, nicht die übrigen, unveränderten
|
||||
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...).
|
||||
/// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
|
||||
/// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
|
||||
/// (offene Aufgaben, ...).
|
||||
public sealed class DashboardViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||
|
||||
Assert.False(vm.ExcusesCard.EffectiveIsVisible);
|
||||
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
|
||||
Assert.False(vm.AlertsCard.EffectiveIsVisible);
|
||||
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
|
||||
Assert.True(vm.TodayCard.EffectiveIsVisible);
|
||||
Assert.True(vm.CalendarCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
private static PeriodScheduleService NewPeriodSchedule()
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
@@ -40,7 +57,9 @@ public sealed class DashboardViewModelTests
|
||||
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
|
||||
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null)
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null)
|
||||
{
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
@@ -48,12 +67,12 @@ public sealed class DashboardViewModelTests
|
||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
|
||||
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||
substitutions ?? new FakeSubstitutionEntries());
|
||||
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -223,6 +242,95 @@ public sealed class DashboardViewModelTests
|
||||
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var sessionDate = today.AddDays(3);
|
||||
var session = new ParticipationSession { GroupId = group.Id, Date = sessionDate, Comment = "Aufsatz" };
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
sessions: new FakeSessions([session]));
|
||||
|
||||
var day = vm.CalendarDays.Single(d => d.Date == sessionDate);
|
||||
Assert.True(day.HasSession);
|
||||
|
||||
vm.SelectCalendarDayCommand.Execute(day);
|
||||
|
||||
Assert.Contains(vm.SelectedDayEvents,
|
||||
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_ZeigtMehrtaegigenJahresplanParallelZumUnterrichtAn()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var annualPlan = new FakeAnnualPlanEvents();
|
||||
annualPlan.Add(new AnnualPlanEvent
|
||||
{
|
||||
ExternalId = "fahrt", Title = "Klassenfahrt 6a",
|
||||
Description = "Aushang beachten", Location = "Jugendherberge",
|
||||
CalendarGroup = "Lehrkräfte", StartDate = today, EndDate = today.AddDays(2),
|
||||
StartTime = new TimeOnly(13, 30), EndTime = new TimeOnly(15, 0), IsAllDay = false,
|
||||
});
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" },
|
||||
annualPlanEvents: annualPlan);
|
||||
|
||||
var day = vm.CalendarDays.Single(d => d.Date == today);
|
||||
Assert.True(day.HasLesson);
|
||||
Assert.True(day.HasAnnualPlanEvent);
|
||||
|
||||
vm.SelectCalendarDayCommand.Execute(day);
|
||||
|
||||
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Lesson);
|
||||
var annual = Assert.Single(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.AnnualPlan);
|
||||
Assert.Equal("Klassenfahrt 6a", annual.Title);
|
||||
Assert.Equal("Aushang beachten", annual.Description);
|
||||
Assert.Contains("13:30", annual.Subtitle);
|
||||
Assert.Null(annual.GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag()
|
||||
{
|
||||
// Regressionstest (Nutzer-Feedback): "Sitzung erzeugen" (3.3.1) verknüpft die Sitzung über
|
||||
// LessonId mit der Stunde — dieselbe Unterrichtsstunde darf im Kalender nicht zusätzlich
|
||||
// als eigener Sitzungstermin auftauchen.
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Brechung" };
|
||||
var session = new ParticipationSession { GroupId = group.Id, Date = today, LessonId = lesson.Id, Comment = "Brechung" };
|
||||
var vm = BuildVm(group, lesson, sessions: new FakeSessions([session]));
|
||||
|
||||
var day = vm.CalendarDays.Single(d => d.Date == today);
|
||||
Assert.True(day.HasLesson);
|
||||
Assert.False(day.HasSession);
|
||||
|
||||
vm.SelectCalendarDayCommand.Execute(day);
|
||||
|
||||
Assert.Single(vm.SelectedDayEvents);
|
||||
Assert.DoesNotContain(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_EigeneKlasseAmStundenplanTagOhneLessonZeigtDenRingTrotzdem()
|
||||
{
|
||||
// Regressionstest (Nutzer-Feedback): der "Meine Klasse"-Ring soll schon laut Stundenplan
|
||||
// gelten, nicht erst, sobald für den Tag eine Lesson angelegt wurde.
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var tomorrow = today.AddDays(1);
|
||||
var group = new LearningGroup { Name = "9c", IsOwnClass = true };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = tomorrow.DayOfWeek, PeriodNumber = 3 });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots);
|
||||
|
||||
var day = vm.CalendarDays.Single(d => d.Date == tomorrow);
|
||||
Assert.True(day.IsOwnClassDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
|
||||
{
|
||||
@@ -353,4 +461,100 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttendanceWarnings_KleineStichprobeMitHoherQuoteWirdNichtGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
|
||||
// Nur 3 erfasste Termine, alle unentschuldigt (100 %) — Stichprobe zu klein für eine Meldung.
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
|
||||
sessions.Save(session);
|
||||
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
|
||||
}
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||
|
||||
Assert.Empty(vm.AttendanceWarnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttendanceWarnings_MeldetHoheQuoteAbMindeststichprobe()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
|
||||
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — genug Stichprobe.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
|
||||
sessions.Save(session);
|
||||
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
|
||||
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
|
||||
}
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||
|
||||
var item = Assert.Single(vm.AttendanceWarnings);
|
||||
Assert.Equal(student.FullName, item.StudentName);
|
||||
Assert.Equal(30.0, item.AbsenceRatePercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var tasks = new FakeWorkTasks();
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
|
||||
vm.OnAddTask = startAsReminder => Task.FromResult<WorkTask?>(
|
||||
new WorkTask { Title = "Ansage an die Klasse", Kind = startAsReminder ? TaskKind.Reminder : TaskKind.WorkItem });
|
||||
|
||||
await vm.AddReminderCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Single(tasks.GetAll());
|
||||
Assert.Contains(vm.OpenTasks, t => t.Title == "Ansage an die Klasse" && t.IsReminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_OhneDelegatMachtNichts()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var tasks = new FakeWorkTasks();
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
|
||||
|
||||
await vm.AddTaskCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Empty(tasks.GetAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeatherWarningItem_BereitetAmtlicheWarnungFuerDashboardAuf()
|
||||
{
|
||||
var warning = new WeatherWarning
|
||||
{
|
||||
Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
|
||||
Description = "Es treten Gewitter auf.", Instruction = "Gebäude aufsuchen.",
|
||||
Severity = "Severe", Onset = new DateTime(2026, 8, 23, 16, 0, 0, DateTimeKind.Utc),
|
||||
Expires = new DateTime(2026, 8, 23, 18, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
var item = new DashboardWeatherWarningItem(warning);
|
||||
|
||||
Assert.Equal("Amtliche Warnung vor Gewitter", item.Headline);
|
||||
Assert.Equal("#D32F2F", item.SeverityColor);
|
||||
Assert.Contains("Uhr", item.PeriodDisplay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,24 @@ public sealed class DocumentationDialogViewModelTests
|
||||
Assert.Equal("Gut verlaufen", vm.Result.ParentCallData.Impressions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_SitzplanEntwurf_WirdAbgeschlossenUndBehaeltStundenbezug()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var existing = new Documentation
|
||||
{
|
||||
StudentId = Guid.NewGuid(), Type = DocumentationType.Incident,
|
||||
Date = DateOnly.FromDateTime(DateTime.Today), Title = "Streit/Konflikt",
|
||||
IsDraft = true, ParticipationSessionId = sessionId, Tags = ["Streit/Konflikt"],
|
||||
};
|
||||
var vm = new DocumentationDialogViewModel(existing.StudentId, existing, new FakeAttachmentStorage());
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.Result!.IsDraft);
|
||||
Assert.Equal(sessionId, vm.Result.ParticipationSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass
|
||||
/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen.
|
||||
public sealed class ExamGradingDialogViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" };
|
||||
var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||
var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" };
|
||||
var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]);
|
||||
var memberships = new FakeMemberships(
|
||||
[
|
||||
new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id },
|
||||
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id },
|
||||
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id },
|
||||
]);
|
||||
var exam = new Exam
|
||||
{
|
||||
GroupId = group.Id,
|
||||
Title = "Klausur 1",
|
||||
Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }],
|
||||
GradingKey = GradingService.DefaultKey1To6(),
|
||||
};
|
||||
var results = new FakeResults();
|
||||
|
||||
var vm = new ExamGradingDialogViewModel(results, students, memberships,
|
||||
new GradingService(), exam, group.Id);
|
||||
|
||||
var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id);
|
||||
rowWithPoints.Cells[0].TrySetValue(7);
|
||||
|
||||
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||
|
||||
Assert.False(rowWithPoints.Absent);
|
||||
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent);
|
||||
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||
var students = new FakeStudents([student]);
|
||||
var memberships = new FakeMemberships(
|
||||
[new GroupMembership { GroupId = group.Id, StudentId = student.Id }]);
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() };
|
||||
var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships,
|
||||
new GradingService(), exam, group.Id);
|
||||
|
||||
vm.Rows[0].Absent = true;
|
||||
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Rows[0].Absent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht,
|
||||
/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5).
|
||||
public sealed class ExamsOverviewViewModelTests
|
||||
{
|
||||
private static ExamsOverviewViewModel BuildVm(List<LearningGroup> groups, List<Exam> exams,
|
||||
FakeMemberships? memberships = null, FakeResults? results = null) =>
|
||||
new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups),
|
||||
memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService());
|
||||
|
||||
[Fact]
|
||||
public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) };
|
||||
var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) };
|
||||
|
||||
var vm = BuildVm([group], [overdue, planned]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Alte Klausur", vm.Rows[0].Title);
|
||||
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||
{
|
||||
var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear };
|
||||
var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) };
|
||||
var duplicate = new Exam
|
||||
{
|
||||
GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned,
|
||||
Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id,
|
||||
};
|
||||
|
||||
var vm = BuildVm([groupA, groupB], [original, duplicate]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id);
|
||||
Assert.True(vm.SelectedRow.HasSibling);
|
||||
Assert.Single(vm.Siblings);
|
||||
Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleApproval_SetztUndLöschtDatum()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) };
|
||||
var vm = BuildVm([group], [exam]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
vm.SelectedRow = vm.Rows[0];
|
||||
|
||||
vm.ToggleApprovalCommand.Execute(null);
|
||||
Assert.NotNull(exam.ApprovalGrantedAt);
|
||||
Assert.StartsWith("Genehmigt am", vm.ApprovalLabel);
|
||||
|
||||
vm.ToggleApprovalCommand.Execute(null);
|
||||
Assert.Null(exam.ApprovalGrantedAt);
|
||||
Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) };
|
||||
var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) };
|
||||
var vm = BuildVm([group], [conducted, returned]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id);
|
||||
Assert.True(vm.ShowCorrectionProgress);
|
||||
Assert.False(vm.ShowGradeSummary);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id);
|
||||
Assert.False(vm.ShowCorrectionProgress);
|
||||
Assert.True(vm.ShowGradeSummary);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
@@ -29,6 +31,22 @@ 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 den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
|
||||
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettings-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new AnnualPlanSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static SyncSettingsService BuildSyncSettingsService()
|
||||
{
|
||||
@@ -64,6 +82,31 @@ public static class TestSupport
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new AppLogger(tempPath);
|
||||
}
|
||||
|
||||
/// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen.
|
||||
public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated);
|
||||
|
||||
/// Neue, leere Fakes je Aufruf - für Tests, die nicht speziell TrashViewModel-Verhalten prüfen.
|
||||
public static TrashViewModel BuildTrashViewModel() => new(
|
||||
new FakeTrash(), new FakeGrades(), new FakeWorkTasks(), new FakeTimeEntries(),
|
||||
new FakeSeatingPlans(), new FakeGradingKeyTemplates());
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static AppearanceSettingsService BuildAppearanceSettingsService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new AppearanceSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe
|
||||
/// sync.key stören.
|
||||
public static SyncKeyRecoveryService BuildSyncKeyRecoveryService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-synckeyrecovery-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), Path.Combine(tempPath, "sync.key"));
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||
@@ -110,6 +153,17 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
|
||||
_all.Add(plan);
|
||||
}
|
||||
public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id);
|
||||
// Trash-Mechanismus (14.3) lebt real in LiteDbContext.MoveToTrash/RestoreFromTrash - Fakes
|
||||
// bilden ihn bewusst nicht nach, ViewModel-Tests brauchen das nicht.
|
||||
public void Restore(Guid trashId) { }
|
||||
}
|
||||
|
||||
public class FakeTrash(List<TrashedItem>? initial = null) : ITrashRepository
|
||||
{
|
||||
private readonly List<TrashedItem> _all = initial ?? [];
|
||||
public void Add(TrashedItem item) => _all.Add(item);
|
||||
public List<TrashedItem> GetAll() => _all.OrderByDescending(t => t.DeletedAt).ToList();
|
||||
public void PurgeOlderThan(DateTime cutoffUtc) => _all.RemoveAll(t => t.DeletedAt < cutoffUtc);
|
||||
}
|
||||
|
||||
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
|
||||
@@ -177,6 +231,7 @@ public class FakeGrades : IGradeRepository
|
||||
_all.Add(grade);
|
||||
}
|
||||
public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
|
||||
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
|
||||
}
|
||||
|
||||
public class FakeExams(List<Exam> all) : IExamRepository
|
||||
@@ -214,6 +269,7 @@ public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
|
||||
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id);
|
||||
public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); }
|
||||
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
|
||||
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
|
||||
}
|
||||
|
||||
public class FakeSchemes : IGradingSchemeRepository
|
||||
@@ -382,18 +438,102 @@ 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 FakeAnnualPlanEvents : IAnnualPlanEventRepository
|
||||
{
|
||||
private readonly List<AnnualPlanEvent> _all = [];
|
||||
public void Add(AnnualPlanEvent e) => _all.Add(e);
|
||||
public List<AnnualPlanEvent> GetAll() => _all.ToList();
|
||||
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
|
||||
_all.Where(e => e.StartDate <= to && e.EndDate >= from).ToList();
|
||||
public AnnualPlanEvent? GetByExternalId(string externalId) =>
|
||||
_all.FirstOrDefault(e => e.ExternalId == externalId);
|
||||
public void Save(AnnualPlanEvent entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
|
||||
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 FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository
|
||||
{
|
||||
private readonly List<UntisStudentRosterCacheEntry> _all = [];
|
||||
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
|
||||
_all.Where(e => e.ClassName == className).ToList();
|
||||
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className);
|
||||
_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 = [];
|
||||
public void Add(WorkTask t) => _all.Add(t);
|
||||
public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList();
|
||||
public List<WorkTask> GetAll() => _all.ToList();
|
||||
public List<WorkTask> GetByGroup(Guid groupId) => _all.Where(t => t.GroupId == groupId).ToList();
|
||||
public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); }
|
||||
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
|
||||
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
|
||||
}
|
||||
|
||||
public class FakeTimeEntries : ITimeEntryRepository
|
||||
@@ -406,6 +546,7 @@ public class FakeTimeEntries : ITimeEntryRepository
|
||||
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList();
|
||||
public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
|
||||
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
|
||||
}
|
||||
|
||||
public class FakeReportGrades : IReportGradeRepository
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Desktop.Converters;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class FractionWidthConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Convert_MultipliziertTrackbreiteMitAnteil()
|
||||
{
|
||||
var result = FractionWidthConverter.Instance.Convert(
|
||||
[200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Equal(50d, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_LiefertNullBreiteBeiUngueltigenWerten()
|
||||
{
|
||||
// Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) —
|
||||
// darf nicht zu einer negativen oder undefinierten Breite führen.
|
||||
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||
[double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||
["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_ClamptNegativeErgebnisseAufNull()
|
||||
{
|
||||
var result = FractionWidthConverter.Instance.Convert(
|
||||
[100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Equal(0d, result);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer()
|
||||
public void Save_Doppelstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
@@ -98,9 +98,50 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal(1, vm.Result!.Created);
|
||||
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
|
||||
Assert.Equal([3], created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_Dreifachstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var from = new DateOnly(2025, 9, 1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 4 });
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 5 });
|
||||
var lessons = new FakeLessons();
|
||||
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal(1, vm.Result!.Created);
|
||||
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
|
||||
Assert.Equal([3], created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_ZweiGetrennteEinzelstundenAmSelbenTagBleibenGetrennt()
|
||||
{
|
||||
// Perioden 1 und 3 sind NICHT aufeinanderfolgend (Periode 2 fehlt) — keine Doppelstunde,
|
||||
// beide bekommen eine eigene Lesson.
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var from = new DateOnly(2025, 9, 1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 });
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
|
||||
var lessons = new FakeLessons();
|
||||
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal(2, vm.Result!.Created);
|
||||
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList();
|
||||
Assert.Equal([3, 4], created);
|
||||
Assert.Equal([1, 3], created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class GlobalSearchViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LeereSuche_ZeigtSchnellaktionen()
|
||||
{
|
||||
var vm = BuildVm();
|
||||
|
||||
Assert.Collection(vm.Results,
|
||||
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action));
|
||||
Assert.Same(vm.Results[0], vm.SelectedResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_FindetSchuelerGruppeKlausurUndAufgabe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9 Chemie", SchoolYear = "2026/27", GradeLevel = 9 };
|
||||
var student = new Student { FirstName = "Mia", LastName = "Chemie" };
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = new DateOnly(2026, 9, 1) };
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Chemie-Klausur korrigieren" });
|
||||
var vm = BuildVm([student], [group], [exam], tasks);
|
||||
|
||||
vm.Query = "Chemie";
|
||||
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Student && x.EntityId == student.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Group && x.EntityId == group.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Exam && x.EntityId == exam.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Task && x.Title.Contains("korrigieren"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrefferAusfuehren_NavigiertUndSchliesstPalette()
|
||||
{
|
||||
var student = new Student { FirstName = "Mia", LastName = "Muster" };
|
||||
var vm = BuildVm([student]);
|
||||
vm.Query = "Muster";
|
||||
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Student);
|
||||
GlobalSearchResult? navigated = null;
|
||||
var closed = false;
|
||||
vm.OnNavigate = x => navigated = x;
|
||||
vm.OnClose = () => closed = true;
|
||||
|
||||
await vm.ExecuteCommand.ExecuteAsync(result);
|
||||
|
||||
Assert.Same(result, navigated);
|
||||
Assert.True(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErinnerungSchnellErfassen_StartetDialogAlsErinnerung()
|
||||
{
|
||||
var vm = BuildVm();
|
||||
bool? reminder = null;
|
||||
vm.OnQuickAddTask = value => { reminder = value; return Task.CompletedTask; };
|
||||
var action = vm.Results.Single(x => x.Action == GlobalSearchAction.NewReminder);
|
||||
|
||||
await vm.ExecuteCommand.ExecuteAsync(action);
|
||||
|
||||
Assert.True(reminder);
|
||||
}
|
||||
|
||||
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) =>
|
||||
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
||||
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||
}
|
||||
@@ -18,11 +18,13 @@ public sealed class GroupDetailViewModelTests
|
||||
var grades = new FakeGrades();
|
||||
|
||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
|
||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
students, memberships, groups, new FakeCompetencyDomains()),
|
||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()),
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
|
||||
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
|
||||
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
|
||||
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
|
||||
public sealed class GroupListViewModelTests
|
||||
{
|
||||
private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
|
||||
new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
|
||||
|
||||
[Fact]
|
||||
public void Gruppenkarte_OeffnetDirektDieUebersicht()
|
||||
{
|
||||
var group = CurrentGroup("9c");
|
||||
var vm = BuildVm(group);
|
||||
Guid? openedId = null;
|
||||
int? openedTab = null;
|
||||
vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; };
|
||||
|
||||
Assert.Single(vm.Groups).OpenCommand.Execute(null);
|
||||
|
||||
Assert.Equal(group.Id, openedId);
|
||||
Assert.Equal(0, openedTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
|
||||
{
|
||||
var first = CurrentGroup("9a");
|
||||
var second = CurrentGroup("9b");
|
||||
var vm = BuildVm(first, second);
|
||||
Guid? editedId = null;
|
||||
vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
|
||||
var secondItem = vm.Groups.Single(x => x.Id == second.Id);
|
||||
|
||||
await secondItem.EditCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(second.Id, editedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_FiltertWeiterhinNachGruppenname()
|
||||
{
|
||||
var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
|
||||
|
||||
vm.SearchText = "Chemie";
|
||||
|
||||
Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
|
||||
{
|
||||
var group = CurrentGroup("9c");
|
||||
var vm = BuildVm(group);
|
||||
var item = Assert.Single(vm.Groups);
|
||||
|
||||
item.ToggleArchiveCommand.Execute(null);
|
||||
|
||||
Assert.False(group.IsActive);
|
||||
Assert.Empty(vm.Groups);
|
||||
}
|
||||
|
||||
private static LearningGroup CurrentGroup(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
|
||||
IsActive = true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für die Kurs-Übersicht: sie liest bewusst keine eigenen Daten, sondern zieht nur
|
||||
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
|
||||
public sealed class GroupOverviewViewModelTests
|
||||
{
|
||||
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
|
||||
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
|
||||
|
||||
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
||||
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
||||
List<Lesson>? lessons = null, List<Exam>? exams = null)
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var lessonsRepo = new FakeLessons();
|
||||
foreach (var l in lessons ?? []) lessonsRepo.Add(l);
|
||||
var examsRepo = new FakeExams(exams ?? []);
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var students = new FakeStudents([]);
|
||||
|
||||
var vm = NewVm(lessonsRepo, examsRepo, sessions, entries, students);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
return (vm, lessonsRepo, examsRepo, sessions, entries, students, groupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaechsteStunde_FindetDieNaechsteGeplanteStundeInnerhalbDesZeitfensters()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(10), Topic = "Später", Status = LessonStatus.Planned });
|
||||
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(3), Topic = "Brechung", Status = LessonStatus.Planned });
|
||||
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(1), Topic = "Schon vorbei", Status = LessonStatus.Conducted });
|
||||
|
||||
var vm = NewVm(lessons: lessons);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasNextLesson);
|
||||
Assert.Contains("Brechung", vm.NextLessonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaechsteStunde_OhneGeplanteStundeIstNichtVorhanden()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||
Assert.False(vm.HasNextLesson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaechsteKlausur_FindetFruehesteKuenftigeKlausur()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var exams = new FakeExams([
|
||||
new Exam { GroupId = groupId, Date = today.AddDays(20), Title = "Später" },
|
||||
new Exam { GroupId = groupId, Date = today.AddDays(5), Title = "Klassenarbeit 2" },
|
||||
new Exam { GroupId = groupId, Date = today.AddDays(-3), Title = "Vergangen" },
|
||||
]);
|
||||
var vm = NewVm(exams: exams);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasNextExam);
|
||||
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||
Assert.Contains("Noch keine Mitarbeitssitzung", vm.ParticipationHintLabel);
|
||||
Assert.False(vm.ParticipationHintIsStale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeit_LetzteSitzungVorMehrAls14TagenGiltAlsVeraltet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessions = new FakeSessions([new ParticipationSession { GroupId = groupId, Date = today.AddDays(-20) }]);
|
||||
var vm = NewVm(sessions: sessions);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.ParticipationHintIsStale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeit_LetzteSitzungKuerzlichGiltNichtAlsVeraltet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessions = new FakeSessions([new ParticipationSession { GroupId = groupId, Date = today.AddDays(-2) }]);
|
||||
var vm = NewVm(sessions: sessions);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.ParticipationHintIsStale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffeneHausaufgabenKontrolle_ZeigtUnkontrollierteHausaufgabeDerLetztenStunde()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson
|
||||
{
|
||||
GroupId = groupId, Date = today.AddDays(-2), Homework = "S. 42 Nr. 3",
|
||||
HomeworkChecked = false, HomeworkCheckDismissed = false,
|
||||
});
|
||||
var vm = NewVm(lessons: lessons);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasOpenHomeworkCheck);
|
||||
Assert.Contains("S. 42 Nr. 3", vm.OpenHomeworkCheckLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffeneHausaufgabenKontrolle_VerschwindetSobaldKontrolliert()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson
|
||||
{
|
||||
GroupId = groupId, Date = today.AddDays(-2), Homework = "S. 42 Nr. 3",
|
||||
HomeworkChecked = true,
|
||||
});
|
||||
var vm = NewVm(lessons: lessons);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasOpenHomeworkCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffeneEntschuldigungen_ListetOffenePendenteEntschuldigungenUndErlaubtAufloesen()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-2) };
|
||||
var sessions = new FakeSessions([session]);
|
||||
var entries = new FakeEntries();
|
||||
var entry = new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.ExcusePending };
|
||||
entries.Add(entry);
|
||||
var students = new FakeStudents([student]);
|
||||
|
||||
var vm = NewVm(sessions: sessions, entries: entries, students: students);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
var item = Assert.Single(vm.OpenExcuses);
|
||||
Assert.Equal(student.FullName, item.StudentName);
|
||||
|
||||
item.MarkExcusedCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.OpenExcuses);
|
||||
Assert.Equal(AttendanceStatus.Excused, entries.GetBySessionAndStudent(session.Id, student.Id)!.Attendance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffeneDokumentationen_ZaehltNurEntwuerfeDieserGruppeOderOhneGruppe()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var otherGroupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var students = new FakeStudents([student]);
|
||||
var documentation = new FakeDocumentation();
|
||||
documentation.Add(new Documentation { StudentId = student.Id, GroupId = groupId, IsDraft = true, Title = "Beobachtung" });
|
||||
documentation.Add(new Documentation { StudentId = student.Id, GroupId = null, IsDraft = true, Title = "Ohne Gruppe" });
|
||||
documentation.Add(new Documentation { StudentId = student.Id, GroupId = otherGroupId, IsDraft = true, Title = "Anderer Kurs" });
|
||||
documentation.Add(new Documentation { StudentId = student.Id, GroupId = groupId, IsDraft = false, Title = "Fertig" });
|
||||
|
||||
var vm = NewVm(students: students, documentation: documentation);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.Equal(2, vm.DraftDocumentationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuffaelligeFehlzeiten_WirdErstAbMindeststichprobeGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var students = new FakeStudents([student]);
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
|
||||
// Nur 3 erfasste Termine, davon 3 unentschuldigt (100 %) — trotz hoher Quote noch keine
|
||||
// Meldung, weil die Stichprobe zu klein ist (Schuljahresbeginn-Problem).
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-i - 1) };
|
||||
sessions.Save(session);
|
||||
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
|
||||
}
|
||||
|
||||
var vm = NewVm(sessions: sessions, entries: entries, students: students);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasAttendanceNotices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuffaelligeFehlzeiten_MeldetHoheQuoteAbMindeststichprobe()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var students = new FakeStudents([student]);
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
|
||||
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — jetzt genug
|
||||
// Stichprobe für eine Meldung.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-i - 1) };
|
||||
sessions.Save(session);
|
||||
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
|
||||
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
|
||||
}
|
||||
|
||||
var vm = NewVm(sessions: sessions, entries: entries, students: students);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
var item = Assert.Single(vm.AttendanceNotices);
|
||||
Assert.Equal(student.FullName, item.StudentName);
|
||||
Assert.Equal(30.0, item.AbsenceRatePercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigationCommands_RufenDelegatMitPassendemTabIndexAuf()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||
var indices = new List<int>();
|
||||
vm.OnNavigateToTab = idx => indices.Add(idx);
|
||||
|
||||
vm.NavigateToPlanningCommand.Execute(null);
|
||||
vm.NavigateToExamsCommand.Execute(null);
|
||||
vm.NavigateToParticipationCommand.Execute(null);
|
||||
vm.NavigateToDocumentationCommand.Execute(null);
|
||||
|
||||
Assert.Equal([6, 4, 3, 8], indices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FehlendeHausaufgaben_ListetNurSchuelerAusDerLetztenSitzung()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentMissing = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var studentDone = new Student { FirstName = "Ben", LastName = "Muster" };
|
||||
var students = new FakeStudents([studentMissing, studentDone]);
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
|
||||
var olderSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-10) };
|
||||
sessions.Save(olderSession);
|
||||
entries.Add(new ParticipationEntry
|
||||
{
|
||||
SessionId = olderSession.Id, StudentId = studentDone.Id,
|
||||
Homework = HomeworkStatus.MissingOpen, HomeworkMissing = true,
|
||||
});
|
||||
|
||||
var lastSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-1) };
|
||||
sessions.Save(lastSession);
|
||||
entries.Add(new ParticipationEntry
|
||||
{
|
||||
SessionId = lastSession.Id, StudentId = studentMissing.Id,
|
||||
Homework = HomeworkStatus.MissingOverdue, HomeworkMissing = true,
|
||||
});
|
||||
entries.Add(new ParticipationEntry
|
||||
{
|
||||
SessionId = lastSession.Id, StudentId = studentDone.Id,
|
||||
Homework = HomeworkStatus.Completed, HomeworkMissing = false,
|
||||
});
|
||||
|
||||
var vm = NewVm(sessions: sessions, entries: entries, students: students);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
var item = Assert.Single(vm.MissingHomeworkStudents);
|
||||
Assert.Equal(studentMissing.FullName, item.StudentName);
|
||||
Assert.Contains(lastSession.Date.ToString("dd.MM.yyyy"), vm.MissingHomeworkSessionLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FehlendeHausaufgaben_OhneOffenePostenIstLeer()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||
Assert.False(vm.HasMissingHomework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnstehendeAufgaben_ZeigtNurOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var groupId = Guid.NewGuid();
|
||||
var otherGroupId = Guid.NewGuid();
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(new WorkTask { GroupId = groupId, Title = "Später fällig", DueDate = today.AddDays(10), Status = WorkTaskStatus.Open });
|
||||
tasks.Add(new WorkTask { GroupId = groupId, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
|
||||
tasks.Add(new WorkTask { GroupId = groupId, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
|
||||
tasks.Add(new WorkTask { GroupId = otherGroupId, Title = "Anderer Kurs", DueDate = today, Status = WorkTaskStatus.Open });
|
||||
|
||||
var vm = NewVm(tasks: tasks);
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasGroupTasks);
|
||||
Assert.Equal(["Bald fällig", "Später fällig"], vm.GroupTasks.Select(t => t.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnstehendeAufgaben_OhneAufgabenIstLeer()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||
Assert.False(vm.HasGroupTasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateToWorkload_RuftDelegatAuf()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||
var called = false;
|
||||
vm.OnNavigateToWorkload = () => called = true;
|
||||
|
||||
vm.NavigateToWorkloadCommand.Execute(null);
|
||||
|
||||
Assert.True(called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddGroupTask_SpeichertErgebnisMitGruppeUndAktualisiertListe()
|
||||
{
|
||||
var tasks = new FakeWorkTasks();
|
||||
var (vm, _, _, _, _, _, groupId) = BuildScenario();
|
||||
var vmWithTasks = NewVm(tasks: tasks);
|
||||
vmWithTasks.Initialize(groupId, "Testkurs");
|
||||
vmWithTasks.OnAddGroupTask = requestedGroupId =>
|
||||
Task.FromResult<WorkTask?>(new WorkTask { Title = "Material einsammeln", GroupId = requestedGroupId });
|
||||
|
||||
await vmWithTasks.AddGroupTaskCommand.ExecuteAsync(null);
|
||||
|
||||
var saved = Assert.Single(tasks.GetByGroup(groupId));
|
||||
Assert.Equal("Material einsammeln", saved.Title);
|
||||
Assert.True(vmWithTasks.HasGroupTasks);
|
||||
Assert.Equal("Material einsammeln", Assert.Single(vmWithTasks.GroupTasks).Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddGroupTask_OhneDelegatMachtNichts()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||
await vm.AddGroupTaskCommand.ExecuteAsync(null);
|
||||
Assert.False(vm.HasGroupTasks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
using LehrerApp.Core.AiPlanning;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class HazardAssessmentWizardViewModelTests
|
||||
{
|
||||
private static HazardAssessmentWizardViewModel Build(HazardAssessment? editing = null) => new(editing, "10c");
|
||||
|
||||
private static string BuildTempPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-gbuwizard-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Neu_StartetAufSchritt0OhneKiOhneEditingKennzeichnung()
|
||||
{
|
||||
var vm = Build();
|
||||
|
||||
Assert.Equal(0, vm.StepIndex);
|
||||
Assert.True(vm.IsBasicsStep);
|
||||
Assert.True(vm.IsFirstStep);
|
||||
Assert.Equal("Gefährdungsbeurteilung erstellen", vm.DialogTitle);
|
||||
Assert.False(vm.CanUseAi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_OhneTitel_BleibtAufSchritt0UndSetztFehler()
|
||||
{
|
||||
var vm = Build();
|
||||
|
||||
vm.NextCommand.Execute(null);
|
||||
|
||||
Assert.Equal(0, vm.StepIndex);
|
||||
Assert.NotEqual("", vm.TitleError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_MitTitel_GehtEinenSchrittWeiterUndAktualisiertStepFlags()
|
||||
{
|
||||
var vm = Build();
|
||||
vm.Title = "Natrium in Wasser";
|
||||
|
||||
vm.NextCommand.Execute(null);
|
||||
|
||||
Assert.Equal(1, vm.StepIndex);
|
||||
Assert.True(vm.IsSubstancesStep);
|
||||
Assert.False(vm.IsFirstStep);
|
||||
Assert.Equal("", vm.TitleError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Back_AufSchritt0_BleibtAufSchritt0()
|
||||
{
|
||||
var vm = Build();
|
||||
|
||||
vm.BackCommand.Execute(null);
|
||||
|
||||
Assert.Equal(0, vm.StepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DurchlaufAllerSchritte_LandetAufZusammenfassung()
|
||||
{
|
||||
var vm = Build();
|
||||
vm.Title = "Natrium in Wasser";
|
||||
|
||||
for (var i = 0; i < vm.StepCount - 1; i++) vm.NextCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsSummaryStep);
|
||||
Assert.True(vm.IsLastStep);
|
||||
|
||||
vm.NextCommand.Execute(null);
|
||||
Assert.True(vm.IsSummaryStep, "Weiter auf dem letzten Schritt darf nicht darüber hinausgehen.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSubstance_UndRemove_AktualisierenDieListeUndHasSubstances()
|
||||
{
|
||||
var vm = Build();
|
||||
Assert.False(vm.HasSubstances);
|
||||
|
||||
vm.AddSubstanceCommand.Execute(null);
|
||||
Assert.True(vm.HasSubstances);
|
||||
var item = vm.Substances[0];
|
||||
item.Name = "Natrium";
|
||||
item.Amount = "ca. 0,5 g";
|
||||
item.PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true;
|
||||
|
||||
item.RemoveCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.HasSubstances);
|
||||
Assert.Empty(vm.Substances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHazard_TrimmtUndLeertDasEingabefeld()
|
||||
{
|
||||
var vm = Build();
|
||||
|
||||
vm.NewHazard = " Verätzungsgefahr ";
|
||||
vm.AddHazardCommand.Execute(null);
|
||||
|
||||
Assert.Equal(["Verätzungsgefahr"], vm.Hazards);
|
||||
Assert.Equal("", vm.NewHazard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHazard_MitLeeremTextTutNichts()
|
||||
{
|
||||
var vm = Build();
|
||||
|
||||
vm.NewHazard = " ";
|
||||
vm.AddHazardCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.Hazards);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveHazard_EntferntGenauDenGenanntenEintrag()
|
||||
{
|
||||
var vm = Build();
|
||||
vm.NewHazard = "A"; vm.AddHazardCommand.Execute(null);
|
||||
vm.NewHazard = "B"; vm.AddHazardCommand.Execute(null);
|
||||
|
||||
vm.RemoveHazardCommand.Execute("A");
|
||||
|
||||
Assert.Equal(["B"], vm.Hazards);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddProtectiveMeasure_TrimmtUndLeertDasEingabefeld()
|
||||
{
|
||||
var vm = Build();
|
||||
|
||||
vm.NewProtectiveMeasure = " Schutzbrille ";
|
||||
vm.AddProtectiveMeasureCommand.Execute(null);
|
||||
|
||||
Assert.Equal(["Schutzbrille"], vm.ProtectiveMeasures);
|
||||
Assert.Equal("", vm.NewProtectiveMeasure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_OhneTitel_SetztFehlerUndSpringtZurueckAufSchritt0()
|
||||
{
|
||||
var vm = Build();
|
||||
vm.Title = "Etwas";
|
||||
vm.NextCommand.Execute(null); // Schritt 1
|
||||
vm.Title = " ";
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Null(vm.Result);
|
||||
Assert.Equal(0, vm.StepIndex);
|
||||
Assert.NotEqual("", vm.TitleError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_BautVollstaendigesErgebnisAusAllenSchritten()
|
||||
{
|
||||
var vm = Build();
|
||||
vm.Title = "Natrium in Wasser";
|
||||
vm.GroupLabel = "10c";
|
||||
vm.DateText = "12.09.2026";
|
||||
vm.KindName = "Lehrerversuch";
|
||||
vm.Procedure = "Kurzbeschreibung";
|
||||
vm.AddSubstanceCommand.Execute(null);
|
||||
vm.Substances[0].Name = "Natrium";
|
||||
vm.Substances[0].Amount = "ca. 0,5 g";
|
||||
vm.Substances[0].HStatements = "H260";
|
||||
vm.Substances[0].PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true;
|
||||
vm.NewHazard = "Verpuffung"; vm.AddHazardCommand.Execute(null);
|
||||
vm.NewProtectiveMeasure = "Schutzbrille"; vm.AddProtectiveMeasureCommand.Execute(null);
|
||||
vm.FirstAid = "Wasser spülen";
|
||||
vm.Disposal = "Abreagieren lassen";
|
||||
vm.Notes = "Hinweis";
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
var result = vm.Result!;
|
||||
Assert.Equal("Natrium in Wasser", result.Title);
|
||||
Assert.Equal("10c", result.GroupLabel);
|
||||
Assert.Equal(new DateOnly(2026, 9, 12), result.Date);
|
||||
Assert.Equal(ExperimentKind.Lehrerversuch, result.Kind);
|
||||
var substance = Assert.Single(result.Substances);
|
||||
Assert.Equal("Natrium", substance.Name);
|
||||
Assert.Equal([GhsPictogram.Flammable], substance.GhsPictograms);
|
||||
Assert.Equal(["Verpuffung"], result.Hazards);
|
||||
Assert.Equal(["Schutzbrille"], result.ProtectiveMeasures);
|
||||
Assert.Equal("Wasser spülen", result.FirstAid);
|
||||
Assert.Equal("Abreagieren lassen", result.Disposal);
|
||||
Assert.Equal("Hinweis", result.Notes);
|
||||
Assert.False(result.IsAiAssisted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_MitEditingLaedtAlleFelderVor()
|
||||
{
|
||||
var editing = new HazardAssessment
|
||||
{
|
||||
Title = "Bestehende GBU",
|
||||
GroupLabel = "9a",
|
||||
Date = new DateOnly(2026, 1, 15),
|
||||
Kind = ExperimentKind.Schuelerversuch,
|
||||
Procedure = "Ablauf",
|
||||
Substances = [new HazardSubstance { Name = "Salzsäure", GhsPictograms = [GhsPictogram.Corrosive] }],
|
||||
Hazards = ["Verätzung"],
|
||||
ProtectiveMeasures = ["Handschuhe"],
|
||||
FirstAid = "Erste Hilfe Text",
|
||||
Disposal = "Entsorgungstext",
|
||||
Notes = "Notiz",
|
||||
IsAiAssisted = true,
|
||||
};
|
||||
|
||||
var vm = Build(editing);
|
||||
|
||||
Assert.Equal("Gefährdungsbeurteilung bearbeiten", vm.DialogTitle);
|
||||
Assert.Equal("Bestehende GBU", vm.Title);
|
||||
Assert.Equal("9a", vm.GroupLabel);
|
||||
Assert.Equal("15.01.2026", vm.DateText);
|
||||
Assert.Equal("Schülerversuch", vm.KindName);
|
||||
Assert.Equal("Ablauf", vm.Procedure);
|
||||
var substance = Assert.Single(vm.Substances);
|
||||
Assert.Equal("Salzsäure", substance.Name);
|
||||
Assert.True(substance.PictogramOptions.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
|
||||
Assert.Equal(["Verätzung"], vm.Hazards);
|
||||
Assert.Equal(["Handschuhe"], vm.ProtectiveMeasures);
|
||||
Assert.Equal("Erste Hilfe Text", vm.FirstAid);
|
||||
Assert.Equal("Entsorgungstext", vm.Disposal);
|
||||
Assert.Equal("Notiz", vm.Notes);
|
||||
Assert.True(vm.IsAiAssisted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanUseAi_OhneKiAbhaengigkeiten_IstFalseUndSubstanzenKoennenNichtRecherchieren()
|
||||
{
|
||||
var vm = Build();
|
||||
vm.AddSubstanceCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.CanUseAi);
|
||||
Assert.False(vm.Substances[0].CanResearch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanUseAi_MitAngemeldeterKiUndUnitUndLesson_IstTrueUndSubstanzenKoennenRecherchieren()
|
||||
{
|
||||
var aiSettings = new AiSettingsService(BuildTempPath());
|
||||
aiSettings.SetEnabled(true);
|
||||
aiSettings.SetCredentialsAndToken("sebastian", "token-abc");
|
||||
var ai = new AiPlanningService(new HttpClient(), new FakeLessons(), new FakeGroups([]),
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
var unit = new Unit { Title = "Redoxreaktionen" };
|
||||
var lesson = new Lesson { Topic = "Natrium in Wasser" };
|
||||
|
||||
var vm = new HazardAssessmentWizardViewModel(null, "10c", ai, aiSettings, unit, lesson);
|
||||
vm.AddSubstanceCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.CanUseAi);
|
||||
Assert.True(vm.Substances[0].CanResearch);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HazardSubstanceEditItemTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Research_OhneOnResearch_TutNichtsUndBleibtOhneFehler()
|
||||
{
|
||||
var item = new HazardSubstanceEditItem { Name = "Natrium" };
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("", item.ResearchError);
|
||||
Assert.False(item.IsResearching);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Research_OhneNamen_SetztFehlerUndRuftDelegateNicht()
|
||||
{
|
||||
var called = false;
|
||||
var item = new HazardSubstanceEditItem
|
||||
{
|
||||
Name = " ",
|
||||
OnResearch = _ => { called = true; return Task.FromResult(new AiSubstanceResearchResponse()); },
|
||||
};
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(called);
|
||||
Assert.Equal("Stoffname eingeben.", item.ResearchError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Research_UebernimmtAlleFelderAusDerAntwort()
|
||||
{
|
||||
var item = new HazardSubstanceEditItem { Name = "Natrium" };
|
||||
item.OnResearch = name =>
|
||||
{
|
||||
Assert.Equal("Natrium", name);
|
||||
return Task.FromResult(new AiSubstanceResearchResponse
|
||||
{
|
||||
Name = "Natrium",
|
||||
Cas = "7440-23-5",
|
||||
SignalWord = "Gefahr",
|
||||
HStatements = "H260",
|
||||
PStatements = "P223, P231+P232",
|
||||
ActivityRestriction = "Für Schüler ab der 5. Klasse freigegeben",
|
||||
GhsPictograms = ["GHS02", "GHS05"],
|
||||
});
|
||||
};
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("Natrium", item.Name);
|
||||
Assert.Equal("7440-23-5", item.Cas);
|
||||
Assert.Equal("Gefahr", item.SignalWord);
|
||||
Assert.Equal("H260", item.HStatements);
|
||||
Assert.Equal("P223, P231+P232", item.PStatements);
|
||||
Assert.Equal("Für Schüler ab der 5. Klasse freigegeben", item.ActivityRestriction);
|
||||
Assert.True(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Flammable).IsSelected);
|
||||
Assert.True(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
|
||||
Assert.False(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Toxic).IsSelected);
|
||||
Assert.False(item.IsResearching);
|
||||
Assert.Equal("", item.ResearchError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Research_BeiFehler_SetztResearchErrorUndBehaeltAlteFelderUnveraendert()
|
||||
{
|
||||
var item = new HazardSubstanceEditItem { Name = "Unbekannt", HStatements = "H999" };
|
||||
item.OnResearch = _ => throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.", item.ResearchError);
|
||||
Assert.Equal("H999", item.HStatements);
|
||||
Assert.False(item.IsResearching);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExperimentKindDisplayTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ExperimentKind.Lehrerversuch, "Lehrerversuch")]
|
||||
[InlineData(ExperimentKind.Schuelerversuch, "Schülerversuch")]
|
||||
[InlineData(ExperimentKind.Demonstrationsversuch, "Demonstrationsversuch")]
|
||||
public void Label_UndFromLabel_SindZueinanderInvers(ExperimentKind kind, string label)
|
||||
{
|
||||
Assert.Equal(label, ExperimentKindDisplay.Label(kind));
|
||||
Assert.Equal(kind, ExperimentKindDisplay.FromLabel(label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromLabel_UnbekanntFaelltAufLehrerversuchZurueck() =>
|
||||
Assert.Equal(ExperimentKind.Lehrerversuch, ExperimentKindDisplay.FromLabel("Unbekannt"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Schuelerversuch", ExperimentKind.Schuelerversuch)]
|
||||
[InlineData("Demonstrationsversuch", ExperimentKind.Demonstrationsversuch)]
|
||||
[InlineData("etwas anderes", ExperimentKind.Lehrerversuch)]
|
||||
public void FromWireValue_LiestDenUnmarkiertenEnumNamenDerKiAntwort(string wire, ExperimentKind expected) =>
|
||||
Assert.Equal(expected, ExperimentKindDisplay.FromWireValue(wire));
|
||||
}
|
||||
|
||||
public sealed class GhsPictogramDisplayTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(GhsPictogram.Explosive, "GHS01")]
|
||||
[InlineData(GhsPictogram.Corrosive, "GHS05")]
|
||||
[InlineData(GhsPictogram.Environmental, "GHS09")]
|
||||
public void Code_LiefertDenErwartetenGhsCode(GhsPictogram value, string code) =>
|
||||
Assert.Equal(code, GhsPictogramDisplay.Code(value));
|
||||
|
||||
[Fact]
|
||||
public void FromCode_IstFallUnabhaengigUndTrimmt() =>
|
||||
Assert.Equal(GhsPictogram.Corrosive, GhsPictogramDisplay.FromCode(" ghs05 "));
|
||||
|
||||
[Fact]
|
||||
public void FromCode_UnbekannterCodeLiefertNull() =>
|
||||
Assert.Null(GhsPictogramDisplay.FromCode("GHS99"));
|
||||
|
||||
[Fact]
|
||||
public void BuildOptions_MarkiertNurDieUebergebenenAlsAusgewaehlt()
|
||||
{
|
||||
var options = GhsPictogramDisplay.BuildOptions([GhsPictogram.Toxic, GhsPictogram.Flammable]);
|
||||
|
||||
Assert.Equal(9, options.Count);
|
||||
Assert.True(options.Single(o => o.Value == GhsPictogram.Toxic).IsSelected);
|
||||
Assert.True(options.Single(o => o.Value == GhsPictogram.Flammable).IsSelected);
|
||||
Assert.False(options.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
@@ -18,7 +19,7 @@ public sealed class LessonDialogViewModelTests
|
||||
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null,
|
||||
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) =>
|
||||
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new FakeAttachmentStorage(),
|
||||
unitId, groupId, "10c", "Chemie", [], [], editing);
|
||||
|
||||
/// Nutzer-Feedback: bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere
|
||||
@@ -92,7 +93,7 @@ public sealed class LessonDialogViewModelTests
|
||||
{
|
||||
var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
|
||||
var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]),
|
||||
new FakeTimetableSlots(), NewPeriodSchedule(),
|
||||
new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(),
|
||||
Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen
|
||||
|
||||
Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
|
||||
@@ -155,7 +156,7 @@ public sealed class LessonDialogViewModelTests
|
||||
var groupId = Guid.NewGuid();
|
||||
var lessons = new FakeLessons();
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
new FakeTimetableSlots(), NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null)
|
||||
new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null)
|
||||
{
|
||||
Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45",
|
||||
};
|
||||
@@ -179,6 +180,58 @@ public sealed class LessonDialogViewModelTests
|
||||
Assert.Single(lessons.GetByUnit(unitId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttachment_LaedtHochUndSaveUebernimmtIhnInDasErgebnis()
|
||||
{
|
||||
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
|
||||
vm.Topic = "Thema"; vm.DateText = "01.09.2025";
|
||||
|
||||
vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3]));
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
var attachment = Assert.Single(vm.Result!.Attachments);
|
||||
Assert.Equal("gbu-natrium.pdf", attachment.FileName);
|
||||
Assert.Equal(3, attachment.SizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttachment_ZuGrosseDateiWirdAbgelehnt()
|
||||
{
|
||||
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
|
||||
|
||||
vm.AddAttachment("riesig.pdf", new MemoryStream(new byte[IAttachmentStorage.MaxSizeBytes + 1]));
|
||||
|
||||
Assert.Empty(vm.Attachments);
|
||||
Assert.NotEqual("", vm.AttachmentError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAttachmentCommand_EntferntDenAnhangAusDerListe()
|
||||
{
|
||||
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
|
||||
vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3]));
|
||||
var item = vm.Attachments[0];
|
||||
|
||||
vm.RemoveAttachmentCommand.Execute(item);
|
||||
|
||||
Assert.Empty(vm.Attachments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BearbeiteteStunde_LaedtVorhandeneAnhaengeVor()
|
||||
{
|
||||
var editing = new Lesson
|
||||
{
|
||||
Attachments = [new DocumentAttachment { StorageId = "abc", FileName = "gbu.pdf", SizeBytes = 42 }],
|
||||
};
|
||||
|
||||
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing);
|
||||
|
||||
var attachment = Assert.Single(vm.Attachments);
|
||||
Assert.Equal("gbu.pdf", attachment.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasHomeworkText_OhneHausaufgabe_IstFalse()
|
||||
{
|
||||
@@ -305,7 +358,7 @@ public sealed class LessonDialogViewModelTests
|
||||
};
|
||||
|
||||
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths,
|
||||
new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing);
|
||||
new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing);
|
||||
|
||||
Assert.True(vm.Phases[0].HasAlternativePath);
|
||||
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName);
|
||||
@@ -466,10 +519,11 @@ public sealed class LessonDialogViewModelTests
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
|
||||
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
// Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
|
||||
Assert.Equal("13.01.2099", vm.DateText);
|
||||
Assert.Equal(3, vm.LessonNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -484,10 +538,47 @@ public sealed class LessonDialogViewModelTests
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
|
||||
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
|
||||
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
|
||||
Assert.Equal(weekday, suggested.DayOfWeek);
|
||||
Assert.Equal(3, vm.LessonNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_Doppelstunde_SchlaegtDieErstePeriodeAlsStundennummerVor()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 });
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 4 });
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
|
||||
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
Assert.Equal(3, vm.LessonNumber); // frühere Periode, nicht 4
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_MitStundenvorschlag_UebernimmtBeginnAusStundenraster()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var weekday = DateTime.Today.DayOfWeek;
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 2 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 50), End = new TimeOnly(9, 35) }]);
|
||||
|
||||
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, periodSchedule, new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
Assert.Equal(2, vm.LessonNumber);
|
||||
Assert.Equal("08:50", vm.StartTimeText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,34 @@ public sealed class LessonViewerViewModelTests
|
||||
Assert.Equal("ab 12:00", vm.PhaseGroups[0].Phases[0].TimeDisplay);
|
||||
Assert.Equal("ab 12:00", vm.PhaseGroups[1].Phases[0].TimeDisplay);
|
||||
}
|
||||
|
||||
// ── Verzweigung aus dem Viewer (4.5.3) ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NavigateToParticipation_RuftDelegatMitTabIndexDreiAuf()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var lesson = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2025, 9, 1) };
|
||||
var vm = new LessonViewerViewModel(lesson, new FakeAlternativeLessonPaths([]));
|
||||
int? navigatedTab = null;
|
||||
vm.OnNavigateToTab = idx => navigatedTab = idx;
|
||||
|
||||
vm.NavigateToParticipationCommand.Execute(null);
|
||||
|
||||
Assert.Equal(groupId, vm.GroupId);
|
||||
Assert.Equal(3, navigatedTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateToGrades_RuftDelegatMitTabIndexFuenfAuf()
|
||||
{
|
||||
var lesson = new Lesson { GroupId = Guid.NewGuid(), Topic = "Brechung", Date = new DateOnly(2025, 9, 1) };
|
||||
var vm = new LessonViewerViewModel(lesson, new FakeAlternativeLessonPaths([]));
|
||||
int? navigatedTab = null;
|
||||
vm.OnNavigateToTab = idx => navigatedTab = idx;
|
||||
|
||||
vm.NavigateToGradesCommand.Execute(null);
|
||||
|
||||
Assert.Equal(5, navigatedTab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,49 @@ public sealed class ParticipationSessionEditingTests
|
||||
Assert.Equal("Nachbesprechung Elektrizität", sessions.GetById(session.Id)?.Comment);
|
||||
Assert.Contains("Nachbesprechung Elektrizität", vm.SelectedSessionDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoeschenBefehl_FragtBestaetigungAbUndEntferntSessionMitsamtBewertungen()
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
|
||||
var session = new ParticipationSession { GroupId = group.Id, Date = new DateOnly(2026, 8, 19) };
|
||||
var sessions = new FakeSessions([session]);
|
||||
var entries = new FakeEntries();
|
||||
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
|
||||
var vm = new ParticipationTabViewModel(
|
||||
sessions, entries, new FakeAspects(), new FakeStudents([]),
|
||||
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
|
||||
vm.Initialize(group.Id, group.SchoolYear);
|
||||
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
|
||||
|
||||
int? passedEntryCount = null;
|
||||
vm.OnConfirmDeleteSession = (_, count) =>
|
||||
{
|
||||
passedEntryCount = count;
|
||||
return Task.FromResult(true);
|
||||
};
|
||||
|
||||
await vm.DeleteSessionCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(1, passedEntryCount);
|
||||
Assert.Null(sessions.GetById(session.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoeschenBefehl_BrichtBeiAbgelehnterBestaetigungAb()
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
|
||||
var session = new ParticipationSession { GroupId = group.Id, Date = new DateOnly(2026, 8, 19) };
|
||||
var sessions = new FakeSessions([session]);
|
||||
var vm = new ParticipationTabViewModel(
|
||||
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
|
||||
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
|
||||
vm.Initialize(group.Id, group.SchoolYear);
|
||||
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
|
||||
vm.OnConfirmDeleteSession = (_, _) => Task.FromResult(false);
|
||||
|
||||
await vm.DeleteSessionCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(sessions.GetById(session.Id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class PdfExportServiceTests
|
||||
{
|
||||
private static readonly PdfExportService Service = new();
|
||||
|
||||
private static void AssertIsPdf(byte[] bytes)
|
||||
{
|
||||
Assert.True(bytes.Length > 500, "PDF ist verdächtig klein.");
|
||||
Assert.Equal("%PDF"u8.ToArray(), bytes[..4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSeatingPlanPdf_VollstaendigerPlan_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new SeatingPlanPrintData("Standard", "Raum B204 · 2 × 3 Plätze",
|
||||
Columns: 3, IsBoardAtBottom: false, ColumnGapWidths: [0, 90],
|
||||
Seats:
|
||||
[
|
||||
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
|
||||
new SeatPrintCell(0, 1, null, false),
|
||||
new SeatPrintCell(0, 2, "Muster, Ben", false),
|
||||
new SeatPrintCell(1, 0, null, false),
|
||||
new SeatPrintCell(1, 1, "Probe, Cem", false),
|
||||
new SeatPrintCell(1, 2, null, false),
|
||||
]);
|
||||
|
||||
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
|
||||
}
|
||||
|
||||
/// Ausgeblendete Plätze (kein Tisch im Raum, siehe SeatingPlan.HiddenSeats) müssen als leere
|
||||
/// Rasterposition mitgedruckt werden, ohne die Erzeugung zu stören.
|
||||
[Fact]
|
||||
public void BuildSeatingPlanPdf_MitAusgeblendetenPlaetzenUndTafelUnten_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new SeatingPlanPrintData("Physikraum", "",
|
||||
Columns: 2, IsBoardAtBottom: true, ColumnGapWidths: [],
|
||||
Seats:
|
||||
[
|
||||
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
|
||||
new SeatPrintCell(0, 1, null, true),
|
||||
new SeatPrintCell(1, 0, null, true),
|
||||
new SeatPrintCell(1, 1, null, false),
|
||||
]);
|
||||
|
||||
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGradeListPdf_MatrixMitMehrerenSpalten_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new GradeListPrintData("8a Chemie", "2026/27", "1. Halbjahr",
|
||||
ColumnHeaders: ["12.09. Klausur 1", "Mündlich 20.09.", "Hausaufgaben 01.10."],
|
||||
Rows:
|
||||
[
|
||||
new GradeListPrintRow("Beispiel, Anna", ["2", "1", ""], "1.67"),
|
||||
new GradeListPrintRow("Muster, Ben", ["abwesend", "3", "2"], "2.50"),
|
||||
new GradeListPrintRow("Probe, Cem", ["", "", ""], "–"),
|
||||
]);
|
||||
|
||||
AssertIsPdf(Service.BuildGradeListPdf(data));
|
||||
}
|
||||
|
||||
/// Ein Kurs kann übers Jahr viele Notenspalten ansammeln - die Tabelle muss auch dann noch
|
||||
/// erzeugbar sein (QuestPDF staucht die Spalten, statt zu werfen).
|
||||
[Fact]
|
||||
public void BuildGradeListPdf_VieleSpalten_LiefertGueltigesPdf()
|
||||
{
|
||||
var headers = Enumerable.Range(1, 20).Select(i => $"Note {i}").ToList();
|
||||
var rows = Enumerable.Range(1, 30).Select(i => new GradeListPrintRow(
|
||||
$"Schüler {i}", [.. Enumerable.Repeat("2", 20)], "2.00")).ToList();
|
||||
|
||||
AssertIsPdf(Service.BuildGradeListPdf(new GradeListPrintData("LK Chemie", "2026/27",
|
||||
"Gesamtes Schuljahr", headers, rows)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExamStatisticsPdf_MitVerteilungUndAufgabenanalyse_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new ExamStatisticsPrintData("Klausur 1 - Säuren und Basen", "12.09.2026", "E",
|
||||
Stats:
|
||||
[
|
||||
new LabelValue("Bewertet", "24"),
|
||||
new LabelValue("Abwesend", "2"),
|
||||
new LabelValue("Durchschnitt", "2.71"),
|
||||
new LabelValue("Median", "3"),
|
||||
new LabelValue("Anteil nicht ausreichend (Note 5/6)", "8.3 %"),
|
||||
],
|
||||
Distribution:
|
||||
[
|
||||
new GradeDistributionPrintRow("1", 3, 12.5),
|
||||
new GradeDistributionPrintRow("2", 8, 33.3),
|
||||
new GradeDistributionPrintRow("3", 7, 29.2),
|
||||
new GradeDistributionPrintRow("4", 4, 16.7),
|
||||
new GradeDistributionPrintRow("5", 2, 8.3),
|
||||
new GradeDistributionPrintRow("6", 0, 0),
|
||||
],
|
||||
TaskAnalysis:
|
||||
[
|
||||
new TaskAnalysisPrintRow("1. Titration", 78.4, false),
|
||||
new TaskAnalysisPrintRow("2. pH-Berechnung", 42.1, true),
|
||||
]);
|
||||
|
||||
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
|
||||
}
|
||||
|
||||
/// Auch ohne Aufgabenstruktur (Klausur ohne Teilaufgaben) und ohne Niveau muss der
|
||||
/// Notenspiegel druckbar sein - der Abschnitt Aufgabenanalyse entfällt dann einfach.
|
||||
[Fact]
|
||||
public void BuildExamStatisticsPdf_OhneAufgabenUndNiveau_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new ExamStatisticsPrintData("Test", "01.10.2026", "",
|
||||
[new LabelValue("Bewertet", "0")], [], []);
|
||||
|
||||
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCompetencyReportPdf_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new CompetencyReportPrintData("8a Chemie", "Beispiel, Anna",
|
||||
"3 von 5 behandelt · 2 geprüft · 2 mit Ergebnisdaten", "Wiederholungsbedarf unter 60 %",
|
||||
[
|
||||
new CompetencyReportPrintRow("Stoff-Teilchen (ST)", "ST1",
|
||||
"Aggregatzustände auf Teilchenebene erklären", "2× behandelt", "1× geprüft",
|
||||
"72.5 % · 24 Schüler · 3 Aufgabenwerte", "85 % · 3 Aufgabenwerte", "Stand solide"),
|
||||
new CompetencyReportPrintRow("Chemische Reaktion (CR)", "CR2",
|
||||
"Reaktionsgleichungen aufstellen", "1× behandelt · 1× geplant", "1× geprüft",
|
||||
"48.3 % · 24 Schüler · 2 Aufgabenwerte", "40 % · 2 Aufgabenwerte", "Wiederholungsbedarf"),
|
||||
new CompetencyReportPrintRow("Energie (EN)", "–", "Energiediagramme deuten",
|
||||
"–", "1× geplant", "–", "–", "Noch keine Bewertung"),
|
||||
]);
|
||||
|
||||
AssertIsPdf(Service.BuildCompetencyReportPdf(data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStudentDocumentationPdf_MitVertraulichenUndEntwurfsEintraegen_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new StudentDocumentationPrintData("Beispiel, Anna",
|
||||
[
|
||||
new DocumentationPrintEntry("12.09.2026", "Beobachtung", "Unterricht gestört",
|
||||
"Wiederholt dazwischengerufen, nach Ermahnung gebessert.",
|
||||
["Kritisch"], IsConfidential: false, IsDraft: false),
|
||||
new DocumentationPrintEntry("20.09.2026", "Elterngespräch", "Telefonat Mutter",
|
||||
"Absprache: wöchentliche Rückmeldung im Hausaufgabenheft.",
|
||||
[], IsConfidential: true, IsDraft: false),
|
||||
new DocumentationPrintEntry("01.10.2026", "Beobachtung", "Schnellnotiz",
|
||||
"", ["Nacharbeiten"], IsConfidential: false, IsDraft: true),
|
||||
]);
|
||||
|
||||
AssertIsPdf(Service.BuildStudentDocumentationPdf(data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHazardAssessmentPdf_MitStoffenUndKiHinweis_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new HazardAssessmentPrintData(
|
||||
"Natrium in Wasser", "10c", "12.09.2026", "Lehrerversuch",
|
||||
"Ein kleines Stück Natrium wird in eine Schale mit Wasser gegeben.",
|
||||
[
|
||||
new HazardSubstancePrintRow("Natrium", "7440-23-5", "ca. 0,5 g", "GHS02, GHS05", "Gefahr",
|
||||
"H260 - Reagiert mit Wasser unter Bildung entzündbarer Gase", "P223, P231+P232, P370+P378",
|
||||
"Abgabe an unter 18-Jährige verboten"),
|
||||
],
|
||||
["Wasserstoffbildung, Verpuffungsgefahr", "Verätzungsgefahr durch Natronlauge"],
|
||||
["Schutzbrille", "Sicherheitsabstand einhalten", "Abzug/Freiluftversuch"],
|
||||
"Betroffene Stellen mit viel Wasser spülen, Arzt hinzuziehen.",
|
||||
"Reste unter Aufsicht vollständig abreagieren lassen.",
|
||||
"", IsAiAssisted: true);
|
||||
|
||||
AssertIsPdf(Service.BuildHazardAssessmentPdf(data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHazardAssessmentPdf_OhneStoffeUndOhneKiHinweis_LiefertGueltigesPdf()
|
||||
{
|
||||
var data = new HazardAssessmentPrintData(
|
||||
"Papierchromatographie", "8b", "", "Schülerversuch", "", [], [], [], "", "", "", IsAiAssisted: false);
|
||||
|
||||
AssertIsPdf(Service.BuildHazardAssessmentPdf(data));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public class PlanningTabViewModelTests
|
||||
var lessons = new FakeLessons();
|
||||
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
|
||||
TestSupport.BuildAiSettingsService());
|
||||
TestSupport.BuildAiSettingsService(), new FakeSessions([]));
|
||||
vm.Initialize(groupId);
|
||||
|
||||
return (vm, units, lessons, groupId);
|
||||
@@ -227,4 +227,71 @@ public class PlanningTabViewModelTests
|
||||
|
||||
Assert.Equal(["L->AB01", "Plenum"], vm.KnownShorthands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateParticipationSession_LegtSitzungMitDatumUndThemaDerStundeAn()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
|
||||
var groups = new FakeGroups([group]);
|
||||
var units = new FakeUnits();
|
||||
var lessons = new FakeLessons();
|
||||
var sessions = new FakeSessions([]);
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
|
||||
vm.Initialize(groupId);
|
||||
|
||||
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
units.Add(unit);
|
||||
var lesson = new Lesson
|
||||
{
|
||||
UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "Brechung",
|
||||
};
|
||||
lessons.Add(lesson);
|
||||
vm.RefreshPlanning(unit.Id, lesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == lesson.Id);
|
||||
|
||||
string? notified = null;
|
||||
vm.OnNotify = m => notified = m;
|
||||
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(lesson.Id, created.LessonId);
|
||||
Assert.Equal(lesson.Date, created.Date);
|
||||
Assert.Equal("Brechung", created.Comment);
|
||||
Assert.Equal("Sitzung aus der Stunde erstellt.", notified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateParticipationSession_ZweiterAufrufLegtKeineDoppelteSitzungAn()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
|
||||
var groups = new FakeGroups([group]);
|
||||
var units = new FakeUnits();
|
||||
var lessons = new FakeLessons();
|
||||
var sessions = new FakeSessions([]);
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
|
||||
vm.Initialize(groupId);
|
||||
|
||||
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
units.Add(unit);
|
||||
var lesson = new Lesson
|
||||
{
|
||||
UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "Brechung",
|
||||
};
|
||||
lessons.Add(lesson);
|
||||
vm.RefreshPlanning(unit.Id, lesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == lesson.Id);
|
||||
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
string? notified = null;
|
||||
vm.OnNotify = m => notified = m;
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class SchoolDayWeatherSummaryTests
|
||||
{
|
||||
private static readonly DateOnly Day = new(2026, 8, 24);
|
||||
|
||||
[Fact]
|
||||
public void From_GewitterHatVorrangVorSturmUndRegen()
|
||||
{
|
||||
var snapshot = Snapshot(
|
||||
Hour(8, code: 61, rain: 2, gust: 70),
|
||||
Hour(10, code: 95, rain: 1, gust: 80));
|
||||
|
||||
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||
|
||||
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
|
||||
Assert.Equal("⛈️", summary.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_BoeAbBeaufortAcht_WirdAlsSturmAngezeigt()
|
||||
{
|
||||
var summary = SchoolDayWeatherSummary.From(Snapshot(Hour(10, gust: 62)), Day);
|
||||
|
||||
Assert.Equal(SchoolDayWeatherKind.Storm, summary!.Kind);
|
||||
Assert.Equal("💨", summary.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_NaechtlicheBoeBeeinflusstSchultagNicht()
|
||||
{
|
||||
var snapshot = Snapshot(Hour(10, cloud: 10), Hour(23, gust: 90));
|
||||
|
||||
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||
|
||||
Assert.Equal(20, summary!.MaxWindGustKmh);
|
||||
Assert.Equal(SchoolDayWeatherKind.Sunny, summary!.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_VerwendetFuerKennzahlenNurAchtBisSechzehnUhr()
|
||||
{
|
||||
var snapshot = Snapshot(
|
||||
Hour(7, temperature: 5, rain: 3, probability: 95, sunshineMinutes: 0),
|
||||
Hour(8, temperature: 12, probability: 20, sunshineMinutes: 30),
|
||||
Hour(16, temperature: 17, probability: 65, sunshineMinutes: 45),
|
||||
Hour(17, temperature: 25, rain: 4, probability: 99, sunshineMinutes: 60));
|
||||
|
||||
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||
|
||||
Assert.Equal(12, summary!.MinTemperatureC);
|
||||
Assert.Equal(17, summary.MaxTemperatureC);
|
||||
Assert.Equal(0, summary.PrecipitationMm);
|
||||
Assert.Equal(65, summary.MaxHourlyPrecipitationProbabilityPercent);
|
||||
Assert.Equal(1.3, summary.SunshineDurationHours);
|
||||
Assert.Contains("08–16 Uhr", summary.Tooltip);
|
||||
Assert.Contains("Regenwahrscheinlichkeit: bis 65 % (stündlich)", summary.Tooltip);
|
||||
Assert.Contains($"Sonnenschein: {summary.SunshineDurationHours:0.#} h", summary.Tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_AmtlicheGewitterwarnung_PraegtSymbolUndTooltip()
|
||||
{
|
||||
var snapshot = Snapshot(Hour(10, cloud: 20));
|
||||
snapshot.Warnings.Add(new WeatherWarning
|
||||
{
|
||||
Identifier = "w1", Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
|
||||
Instruction = "Aufenthalt im Freien vermeiden.", Severity = "Severe",
|
||||
Onset = Day.ToDateTime(new TimeOnly(12, 0)), Expires = Day.ToDateTime(new TimeOnly(16, 0)),
|
||||
});
|
||||
|
||||
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||
|
||||
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
|
||||
Assert.Contains("Amtliche Warnung", summary.Tooltip);
|
||||
Assert.Contains("Aufenthalt im Freien", summary.Tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_RegenSummiertStundenUndZeigtTemperaturspanne()
|
||||
{
|
||||
var snapshot = Snapshot(
|
||||
Hour(8, temperature: 12, rain: 0.1),
|
||||
Hour(12, temperature: 17, rain: 0.4));
|
||||
|
||||
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||
|
||||
Assert.Equal(SchoolDayWeatherKind.Rain, summary!.Kind);
|
||||
Assert.Equal(0.5, summary.PrecipitationMm);
|
||||
Assert.Contains("12–17 °C", summary.Tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarningTouchesDate_MehrtaegigeVorwarnung_TrifftAlleUeberlapptenTage()
|
||||
{
|
||||
var warning = new WeatherWarning
|
||||
{
|
||||
Onset = Day.ToDateTime(new TimeOnly(18, 0)),
|
||||
Expires = Day.AddDays(2).ToDateTime(new TimeOnly(8, 0)),
|
||||
};
|
||||
|
||||
Assert.True(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(1)));
|
||||
Assert.False(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeekCellItem_WetterOderWarnung_AktiviertGemeinsameHoverFlaeche()
|
||||
{
|
||||
var cell = WeekCellItem.WeekdayHeader(DayOfWeek.Monday, Day, false);
|
||||
Assert.False(cell.HasWeatherInformation);
|
||||
|
||||
cell.SetWeather(SchoolDayWeatherSummary.From(Snapshot(Hour(10)), Day), []);
|
||||
Assert.True(cell.HasWeatherInformation);
|
||||
|
||||
cell.SetWeather(null, [new WeatherWarning { Headline = "Amtliche Warnung" }]);
|
||||
Assert.True(cell.HasWeatherInformation);
|
||||
Assert.Contains("Amtliche Warnung", cell.WeatherTooltip);
|
||||
}
|
||||
|
||||
private static WeatherSnapshot Snapshot(params WeatherForecastHour[] hours) => new()
|
||||
{
|
||||
RetrievedAt = DateTime.UtcNow,
|
||||
Forecast = hours.ToList(),
|
||||
};
|
||||
|
||||
private static WeatherForecastHour Hour(int hour, int? code = 0, double? temperature = 20,
|
||||
double? rain = 0, double? gust = 20, double? cloud = 20,
|
||||
double? probability = null, double? sunshineMinutes = null) => new()
|
||||
{
|
||||
ValidAt = Day.ToDateTime(new TimeOnly(hour, 0)), WeatherCode = code,
|
||||
TemperatureC = temperature, PrecipitationMm = rain, WindGustKmh = gust,
|
||||
CloudCoverPercent = cloud, PrecipitationProbabilityPercent = probability,
|
||||
SunshineDurationMinutes = sunshineMinutes,
|
||||
};
|
||||
}
|
||||
@@ -115,6 +115,218 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Equal(2, plans.GetById(plan.Id)!.Assignments.Count);
|
||||
}
|
||||
|
||||
// ── Ausgeblendete Plätze (fehlender Tisch in einer Reihe) ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void LoadPlan_LaedtAusgeblendetePlaetzeAusDemPlan()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
|
||||
HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }],
|
||||
};
|
||||
var vm = new SeatingPlanTabViewModel(
|
||||
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
|
||||
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
Assert.False(vm.Seats[0].IsHidden);
|
||||
Assert.True(vm.Seats[1].IsHidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleSeatHidden_LeererPlatz_BlendetAusUndSpeichertUndKannWiederEingeblendetWerden()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2 };
|
||||
var plans = new FakeSeatingPlans([plan]);
|
||||
var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([]), new FakeMemberships([]),
|
||||
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
vm.IsEditMode = true;
|
||||
|
||||
vm.ToggleSeatHidden(vm.Seats[1]);
|
||||
|
||||
Assert.True(vm.Seats[1].IsHidden);
|
||||
var hidden = Assert.Single(plans.GetById(plan.Id)!.HiddenSeats);
|
||||
Assert.Equal((0, 1), (hidden.Row, hidden.Column));
|
||||
|
||||
vm.ToggleSeatHidden(vm.Seats[1]);
|
||||
|
||||
Assert.False(vm.Seats[1].IsHidden);
|
||||
Assert.Empty(plans.GetById(plan.Id)!.HiddenSeats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleSeatHidden_BelegterPlatz_TutNichts()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var memberships = new FakeMemberships([
|
||||
new GroupMembership { GroupId = groupId, StudentId = student.Id },
|
||||
]);
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
|
||||
};
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
memberships, new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
vm.IsEditMode = true;
|
||||
|
||||
vm.ToggleSeatHidden(vm.Seats[0]);
|
||||
|
||||
Assert.False(vm.Seats[0].IsHidden);
|
||||
}
|
||||
|
||||
/// Ein ausgeblendeter Platz darf nie ein Ziel für Drag&Drop/Zuordnung sein - sonst hätte man
|
||||
/// gleichzeitig "kein Tisch hier" und einen dort sitzenden Schüler.
|
||||
[Fact]
|
||||
public void AssignStudent_AufAusgeblendetenPlatz_WirdIgnoriert()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var memberships = new FakeMemberships([
|
||||
new GroupMembership { GroupId = groupId, StudentId = student.Id },
|
||||
]);
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
HiddenSeats = [new HiddenSeat { Row = 0, Column = 0 }],
|
||||
};
|
||||
var plans = new FakeSeatingPlans([plan]);
|
||||
var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([student]), memberships,
|
||||
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
vm.IsEditMode = true;
|
||||
|
||||
vm.AssignStudent(vm.StudentOptions.Single(o => o.StudentId == student.Id), vm.Seats[0]);
|
||||
|
||||
Assert.False(vm.Seats[0].IsOccupied);
|
||||
Assert.Empty(plans.GetById(plan.Id)!.Assignments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveSeat_AufAusgeblendetenPlatz_WirdIgnoriert()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var memberships = new FakeMemberships([
|
||||
new GroupMembership { GroupId = groupId, StudentId = student.Id },
|
||||
]);
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
|
||||
HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }],
|
||||
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
|
||||
};
|
||||
var plans = new FakeSeatingPlans([plan]);
|
||||
var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([student]), memberships,
|
||||
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
vm.IsEditMode = true;
|
||||
|
||||
vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
|
||||
|
||||
Assert.Equal(student.Id, vm.Seats[0].SelectedOption.StudentId);
|
||||
Assert.False(vm.Seats[1].IsOccupied);
|
||||
}
|
||||
|
||||
// ── Unterrichtsmodus (14.x): Sitzung mit einer konkreten Stunde verknüpfen ────────────────
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_BereitsVerknuepfteSitzungVorhanden_WaehltSieAus()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var lesson = new Lesson { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var existingSession = new ParticipationSession
|
||||
{ GroupId = groupId, Date = lesson.Date, LessonId = lesson.Id, Comment = "Redox" };
|
||||
var sessions = new FakeSessions([existingSession]);
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
|
||||
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
vm.SelectOrCreateSessionForLesson(lesson);
|
||||
|
||||
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var lesson = new Lesson
|
||||
{ GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var sessions = new FakeSessions([]);
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
|
||||
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
vm.SelectOrCreateSessionForLesson(lesson);
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(lesson.Id, created.LessonId);
|
||||
Assert.Equal(lesson.Topic, created.Comment);
|
||||
Assert.Equal(vm.SelectedSession?.Id, created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_ArchivierteGruppe_LegtKeineSitzungAn()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var lesson = new Lesson
|
||||
{ GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var sessions = new FakeSessions([]);
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
|
||||
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: true);
|
||||
|
||||
vm.SelectOrCreateSessionForLesson(lesson);
|
||||
|
||||
Assert.Empty(sessions.GetByGroup(groupId));
|
||||
Assert.Null(vm.SelectedSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowSeatUndCanToggleHidden_HaengenVonBearbeitungsmodusUndBelegungAb()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var memberships = new FakeMemberships([
|
||||
new GroupMembership { GroupId = groupId, StudentId = student.Id },
|
||||
]);
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
|
||||
HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }],
|
||||
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
|
||||
};
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
memberships, new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
// Ansichtsmodus: der ausgeblendete Platz wird nicht gerendert, belegte Plätze können
|
||||
// ohnehin nicht ausgeblendet werden.
|
||||
Assert.False(vm.Seats[1].ShowSeat);
|
||||
Assert.False(vm.Seats[0].CanToggleHidden);
|
||||
Assert.False(vm.Seats[1].CanToggleHidden);
|
||||
|
||||
vm.IsEditMode = true;
|
||||
|
||||
// Bearbeitungsmodus: der ausgeblendete Platz bleibt sichtbar (abgeblendet), um ihn wieder
|
||||
// einblenden zu können - der belegte Platz kann weiterhin nicht ausgeblendet werden.
|
||||
Assert.True(vm.Seats[1].ShowSeat);
|
||||
Assert.False(vm.Seats[0].CanToggleHidden);
|
||||
Assert.True(vm.Seats[1].CanToggleHidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ansichtsmodus_VerhindertVerschiebenUndBlendetBearbeitungsbefehleAus()
|
||||
{
|
||||
@@ -146,6 +358,66 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.All(vm.Seats, seat => Assert.True(seat.CanEdit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ansichtsmodus_ZeigtAnwesenheitUndHausaufgabeUndBlendetAbwesendeAb()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var session = new ParticipationSession { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today) };
|
||||
var entries = new FakeEntries();
|
||||
entries.Add(new ParticipationEntry
|
||||
{
|
||||
SessionId = session.Id, StudentId = student.Id,
|
||||
Attendance = AttendanceStatus.Excused, Homework = HomeworkStatus.MissingOpen,
|
||||
});
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||
new FakeSessions([session]), entries, new FakeAspects());
|
||||
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
var seat = Assert.Single(vm.Seats);
|
||||
Assert.True(seat.HasAttendanceBadge);
|
||||
Assert.True(seat.HasHomeworkBadge);
|
||||
Assert.Equal(0.42, seat.LessonOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SituationsTags_WerdenProSchuelerUndSitzungInEinemEntwurfZusammengefuehrt()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var session = new ParticipationSession
|
||||
{
|
||||
GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), LessonId = Guid.NewGuid(),
|
||||
};
|
||||
var docs = new FakeDocumentation();
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||
new FakeSessions([session]), new FakeEntries(), new FakeAspects(), docs);
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
var seat = Assert.Single(vm.Seats);
|
||||
|
||||
seat.SituationTags.Single(t => t.Text == "Mitarbeit verweigert").ToggleCommand.Execute(null);
|
||||
seat.SituationTags.Single(t => t.Text == "Material vergessen").ToggleCommand.Execute(null);
|
||||
|
||||
var draft = Assert.Single(docs.GetByStudent(student.Id));
|
||||
Assert.True(draft.IsDraft);
|
||||
Assert.Equal(session.Id, draft.ParticipationSessionId);
|
||||
Assert.Equal(session.LessonId, draft.LessonId);
|
||||
Assert.Equal(["Mitarbeit verweigert", "Material vergessen"], draft.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tischabstaende_WerdenBeimBearbeitenProSpaltengrenzeGespeichert()
|
||||
{
|
||||
@@ -224,4 +496,159 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.False(vm.CanEdit);
|
||||
Assert.Empty(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_LegtOhneVorhandeneSitzungKeineGeistersitzungAn()
|
||||
{
|
||||
// Regressionstest: Initialize() läuft schon beim bloßen Navigieren zur Kursübersicht
|
||||
// (GroupDetailViewModel.LoadGroup ruft SeatingPlanTab.Initialize für jede Gruppe auf),
|
||||
// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Vorher legte
|
||||
// LoadTodaySessions() dabei sofort eine "Sitzplan"-Sitzung für heute an.
|
||||
var groupId = Guid.NewGuid();
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var sessions = new FakeSessions([]);
|
||||
var vm = new SeatingPlanTabViewModel(
|
||||
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
|
||||
sessions, new FakeEntries(), new FakeAspects());
|
||||
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
Assert.Empty(sessions.GetByGroup(groupId));
|
||||
Assert.Null(vm.SelectedSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var sessions = new FakeSessions([]);
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||
sessions, new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
vm.OnAssessStudent = _ => Task.CompletedTask;
|
||||
Assert.Empty(sessions.GetByGroup(groupId));
|
||||
|
||||
await vm.AssessStudent(vm.Seats.Single());
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||
Assert.Equal("Sitzplan", session.Comment);
|
||||
Assert.Equal(session.Id, vm.SelectedSession?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssessStudent_SitzungBereitsUeberAndereInstanzVerknuepft_LegtKeineZweiteAn()
|
||||
{
|
||||
// Regressionstest: Der Unterrichtsmodus läuft auf einer eigenen, per DI frisch aufgelösten
|
||||
// SeatingPlanTabViewModel-Instanz (TimetableView.ShowTeachingMode), getrennt von der
|
||||
// Instanz im Sitzplan-Tab der Kursübersicht — beide teilen sich nur das Repository. Wurde
|
||||
// (wie hier simuliert) die Sitzung bereits über den Unterrichtsmodus mit der Stunde
|
||||
// verknüpft, bevor im echten Sitzplan-Tab bewertet wird, darf letzterer keine zweite,
|
||||
// unverknüpfte "Sitzplan"-Sitzung für denselben Tag anlegen.
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var sessions = new FakeSessions([]);
|
||||
var memberships = new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]);
|
||||
|
||||
// Instanz A: Sitzplan-Tab der Kursübersicht, bereits initialisiert (kein SelectedSession).
|
||||
var tabVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
memberships, sessions, new FakeEntries(), new FakeAspects());
|
||||
tabVm.Initialize(groupId, isReadOnly: false);
|
||||
tabVm.OnAssessStudent = _ => Task.CompletedTask;
|
||||
|
||||
// Instanz B: eigene, frische Instanz wie im Unterrichtsmodus — verknüpft die Sitzung mit
|
||||
// der Stunde, ohne dass Instanz A davon erfährt.
|
||||
var lesson = new Lesson { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var teachingModeVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
memberships, sessions, new FakeEntries(), new FakeAspects());
|
||||
teachingModeVm.Initialize(groupId, isReadOnly: false);
|
||||
teachingModeVm.SelectOrCreateSessionForLesson(lesson);
|
||||
|
||||
var linked = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(lesson.Id, linked.LessonId);
|
||||
|
||||
await tabVm.AssessStudent(tabVm.Seats.Single());
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(linked.Id, session.Id);
|
||||
Assert.Equal(lesson.Id, session.LessonId);
|
||||
Assert.Equal(session.Id, tabVm.SelectedSession?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var anna = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var ben = new Student { FirstName = "Ben", LastName = "B" };
|
||||
var cara = new Student { FirstName = "Cara", LastName = "C" };
|
||||
var students = new FakeStudents([anna, ben, cara]);
|
||||
var memberships = new FakeMemberships([
|
||||
new GroupMembership { GroupId = groupId, StudentId = anna.Id },
|
||||
new GroupMembership { GroupId = groupId, StudentId = ben.Id },
|
||||
new GroupMembership { GroupId = groupId, StudentId = cara.Id },
|
||||
]);
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Klausur", Room = "B204", Rows = 1, Columns = 3,
|
||||
Assignments =
|
||||
[
|
||||
new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id },
|
||||
new SeatAssignment { Row = 0, Column = 1, StudentId = ben.Id },
|
||||
new SeatAssignment { Row = 0, Column = 2, StudentId = cara.Id },
|
||||
],
|
||||
};
|
||||
var plans = new FakeSeatingPlans([plan]);
|
||||
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
|
||||
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
vm.IsEditMode = true;
|
||||
|
||||
vm.ShuffleSeatsCommand.Execute(null);
|
||||
|
||||
var all = plans.GetByGroup(groupId);
|
||||
Assert.Equal(2, all.Count);
|
||||
var shuffled = Assert.Single(all, p => p.Id != plan.Id);
|
||||
Assert.Equal("B204", shuffled.Room);
|
||||
Assert.Equal(1, shuffled.Rows);
|
||||
Assert.Equal(3, shuffled.Columns);
|
||||
// Dieselben drei Schüler auf denselben drei Koordinaten, nur die Zuordnung untereinander
|
||||
// wurde neu gewürfelt.
|
||||
Assert.Equal(new[] { anna.Id, ben.Id, cara.Id }.OrderBy(id => id),
|
||||
shuffled.Assignments.Select(a => a.StudentId).OrderBy(id => id));
|
||||
Assert.Equal(new[] { (0, 0), (0, 1), (0, 2) },
|
||||
shuffled.Assignments.Select(a => (a.Row, a.Column)).OrderBy(t => t.Item2));
|
||||
// Ursprungsplan bleibt unverändert erhalten.
|
||||
Assert.Equal(3, plans.GetById(plan.Id)!.Assignments.Count);
|
||||
Assert.Equal(vm.SelectedPlan?.Id, shuffled.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShuffleSeatsCommand_OhneBearbeitungsmodus_NichtAusfuehrbar()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var vm = new SeatingPlanTabViewModel(
|
||||
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
|
||||
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
Assert.False(vm.ShuffleSeatsCommand.CanExecute(null));
|
||||
|
||||
vm.IsEditMode = true;
|
||||
|
||||
Assert.True(vm.ShuffleSeatsCommand.CanExecute(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
@@ -14,7 +15,9 @@ public sealed class SettingsViewModelTests
|
||||
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
||||
FakeSupervisionDuties? supervisionDuties = null,
|
||||
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
|
||||
EventQueue? eventQueue = null)
|
||||
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null,
|
||||
SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null,
|
||||
TrashViewModel? trashTab = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
@@ -31,8 +34,148 @@ 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.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger());
|
||||
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
|
||||
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
|
||||
syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService(),
|
||||
appearance ?? TestSupport.BuildAppearanceSettingsService(),
|
||||
trashTab ?? TestSupport.BuildTrashViewModel());
|
||||
}
|
||||
|
||||
// ── Darstellung (12.4) ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SelectedTheme_SpiegeltDenGespeichertenStandBeimStartWider()
|
||||
{
|
||||
var appearance = TestSupport.BuildAppearanceSettingsService();
|
||||
appearance.Save(AppTheme.Dark);
|
||||
|
||||
var vm = BuildViewModel(appearance: appearance);
|
||||
|
||||
Assert.Equal(AppTheme.Dark, vm.SelectedTheme);
|
||||
Assert.Equal("Dunkel", vm.SelectedThemeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedThemeName_Aendern_PersistiertUndBenachrichtigtDenCodeBehind()
|
||||
{
|
||||
var appearance = TestSupport.BuildAppearanceSettingsService();
|
||||
var vm = BuildViewModel(appearance: appearance);
|
||||
AppTheme? notified = null;
|
||||
vm.OnThemeChanged = t => notified = t;
|
||||
|
||||
vm.SelectedThemeName = "Dunkel";
|
||||
|
||||
Assert.Equal(AppTheme.Dark, notified);
|
||||
Assert.Equal(AppTheme.Dark, appearance.Load());
|
||||
}
|
||||
|
||||
// ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ────────────────────────────
|
||||
//
|
||||
// Der "glückliche" Redeem-Pfad (gültiger Code + Datei + Bestätigung) endet in
|
||||
// AppBootstrapper.RestartApplication(), das Environment.Exit(0) aufruft - das darf ein Test
|
||||
// niemals auslösen (würde den Testlauf beenden). Deshalb sind hier nur die Zweige getestet,
|
||||
// die VOR diesem Aufruf zurückkehren; genau dasselbe Muster gilt bereits für
|
||||
// RedeemPairingCode, das aus demselben Grund ebenfalls keinen Erfolgstest hat. Der
|
||||
// eigentliche Kryptografie-/Datei-Roundtrip ist in SyncKeyRecoveryServiceTests abgedeckt.
|
||||
|
||||
[Fact]
|
||||
public void SyncKeyWasRegenerated_SpiegeltDenBeimStartErkanntenStatusWider()
|
||||
{
|
||||
var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: true));
|
||||
|
||||
Assert.True(vm.SyncKeyWasRegenerated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncKeyWasRegenerated_NormalerStart_IstFalse()
|
||||
{
|
||||
var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: false));
|
||||
|
||||
Assert.False(vm.SyncKeyWasRegenerated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRecoveryCode_SpeichernErfolgreich_ZeigtCodeUndUebergibtDateiinhalt()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
string? savedContent = null;
|
||||
vm.OnSaveRecoveryFile = content => { savedContent = content; return Task.FromResult(true); };
|
||||
|
||||
await vm.CreateRecoveryCodeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotEmpty(vm.RecoveryCode);
|
||||
Assert.NotNull(savedContent);
|
||||
Assert.Contains("notieren", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRecoveryCode_AbbruchBeimSpeichern_ZeigtKeinenCode()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
vm.OnSaveRecoveryFile = _ => Task.FromResult(false);
|
||||
|
||||
await vm.CreateRecoveryCodeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Empty(vm.RecoveryCode);
|
||||
Assert.Equal("Abgebrochen.", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_LeererCode_FragtNieNachEinerDatei()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
var filePickerCalled = false;
|
||||
vm.OnPickRecoveryFile = () => { filePickerCalled = true; return Task.FromResult<string?>(null); };
|
||||
vm.RecoveryCodeInput = " ";
|
||||
|
||||
await vm.RedeemRecoveryCodeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(filePickerCalled);
|
||||
Assert.Equal("Bitte Code eingeben.", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_DateiauswahlAbgebrochen_TutNichts()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(null);
|
||||
vm.RecoveryCodeInput = "IRGENDEIN-CODE";
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_FalscherCode_ZeigtFehlerUndWirftNicht()
|
||||
{
|
||||
var recovery = TestSupport.BuildSyncKeyRecoveryService();
|
||||
var vm = BuildViewModel(syncKeyRecovery: recovery);
|
||||
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(recovery.CreateRecoveryFile("RICHTIGER-CODE"));
|
||||
vm.OnConfirmRecoveryRestore = () => Task.FromResult(true);
|
||||
vm.RecoveryCodeInput = "FALSCHER-CODE";
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Contains("passen nicht zusammen", vm.RecoveryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedeemRecoveryCode_BestaetigungAbgelehnt_TutNichts()
|
||||
{
|
||||
var recovery = TestSupport.BuildSyncKeyRecoveryService();
|
||||
var vm = BuildViewModel(syncKeyRecovery: recovery);
|
||||
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(recovery.CreateRecoveryFile("EIN-CODE"));
|
||||
vm.OnConfirmRecoveryRestore = () => Task.FromResult(false);
|
||||
vm.RecoveryCodeInput = "EIN-CODE";
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,6 +275,34 @@ public sealed class SettingsViewModelTests
|
||||
Assert.Empty(vm.SchoolHolidayEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSchoolLocation_FehlendeFelder_ZeigtAlleFeldfehler()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
|
||||
await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotEmpty(vm.SchoolNameError);
|
||||
Assert.NotEmpty(vm.SchoolStreetError);
|
||||
Assert.NotEmpty(vm.SchoolPostalCodeError);
|
||||
Assert.NotEmpty(vm.SchoolCityError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSchoolLocation_UngueltigePlz_RuftKeinenServerAuf()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
vm.SchoolName = "Testschule";
|
||||
vm.SchoolStreet = "Schulweg 1";
|
||||
vm.SchoolPostalCode = "1234";
|
||||
vm.SchoolCity = "Teststadt";
|
||||
|
||||
await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Contains("fünfstellige", vm.SchoolPostalCodeError);
|
||||
Assert.Empty(vm.SchoolLocationStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
|
||||
{
|
||||
@@ -148,8 +319,11 @@ 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.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger());
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||
|
||||
vm.SelectedStateName = "Bayern";
|
||||
|
||||
@@ -172,8 +346,11 @@ 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.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger());
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:00";
|
||||
vm.PeriodTimes[0].EndText = "08:45";
|
||||
@@ -200,8 +377,11 @@ 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.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
TestSupport.BuildAppLogger());
|
||||
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:45";
|
||||
vm.PeriodTimes[0].EndText = "08:00";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Net;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Sync;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class SyncAuthServiceProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_InkompatibleVersion_WirdNichtAlsOfflineGemeldet()
|
||||
{
|
||||
HttpRequestMessage? sent = null;
|
||||
var service = new SyncAuthService(new HttpClient(new Handler(request =>
|
||||
{
|
||||
sent = request;
|
||||
return new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
|
||||
})));
|
||||
|
||||
var result = await service.TestConnectionAsync("https://sync.example.invalid", "token");
|
||||
|
||||
Assert.Equal(SyncConnectionTestResult.IncompatibleVersion, result);
|
||||
Assert.Equal(SyncProtocol.CurrentVersion,
|
||||
sent!.Headers.GetValues(SyncProtocol.VersionHeaderName).Single());
|
||||
}
|
||||
|
||||
private sealed class Handler(Func<HttpRequestMessage, HttpResponseMessage> response) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(response(request));
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,19 @@ public sealed class SyncStatusViewModelTests
|
||||
Assert.False(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InkompatibleSyncVersion_ZeigtKlareUpdateMeldung()
|
||||
{
|
||||
using var temp = new TempSyncEngine(new IncompatibleVersionStubHandler());
|
||||
var vm = new SyncStatusViewModel(temp.Engine);
|
||||
|
||||
await temp.Engine.SyncNowAsync();
|
||||
|
||||
Assert.True(vm.IsIncompatibleVersion);
|
||||
Assert.Equal("Update erforderlich", vm.StatusText);
|
||||
Assert.Contains("Bitte aktualisiere die LehrerApp", SyncStatusViewModel.IncompatibleVersionMessage);
|
||||
}
|
||||
|
||||
private sealed class PullEventStubHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
@@ -100,6 +113,17 @@ public sealed class SyncStatusViewModelTests
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class IncompatibleVersionStubHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
|
||||
response.Headers.Add(SyncProtocol.VersionHeaderName, "2");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TempSyncEngine : IDisposable
|
||||
{
|
||||
private readonly string _queuePath = Path.Combine(
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class TeachingModeViewModelTests
|
||||
{
|
||||
private static SeatingPlanTabViewModel BuildSeatingPlan(Guid groupId, FakeSessions? sessions = null,
|
||||
List<Student>? students = null, List<GroupMembership>? memberships = null,
|
||||
List<SeatingPlan>? plans = null) =>
|
||||
new(new FakeSeatingPlans(plans ?? [new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 }]),
|
||||
new FakeStudents(students ?? []), new FakeMemberships(memberships ?? []),
|
||||
sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
|
||||
/// Dieselben Fakes wie <see cref="BuildSeatingPlan"/>, aber für ParticipationTabViewModel —
|
||||
/// beide brauchen eigene IParticipationSessionRepository/IParticipationRepository-Instanzen
|
||||
/// im Produktivcode (siehe TeachingModeWindow-Kommentar zu ReloadSeatBadgesFromRepository),
|
||||
/// hier reicht für die Tests aber eine gemeinsame FakeSessions-Instanz, damit beide
|
||||
/// ViewModels dieselbe verknüpfte Sitzung sehen.
|
||||
private static ParticipationTabViewModel BuildParticipation(LearningGroup group,
|
||||
FakeSessions? sessions = null) =>
|
||||
new(sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
new FakeStudents([]), new FakeMemberships([]), new FakeGroups([group]),
|
||||
new FakeCompetencyDomains());
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox",
|
||||
Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Activity = "Impuls" }],
|
||||
};
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.Equal("Q1 Chemie", vm.GroupName);
|
||||
Assert.Equal("Redox", vm.LessonInfo.Topic);
|
||||
Assert.Single(vm.LessonInfo.PhaseGroups.Single().Phases);
|
||||
Assert.True(vm.SeatingPlan.HasSelectedPlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_VerknuepftDieSitzungMitDieserStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var sessions = new FakeSessions([]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(group.Id));
|
||||
Assert.Equal(lesson.Id, created.LessonId);
|
||||
Assert.Equal(vm.SeatingPlan.SelectedSession?.Id, created.Id);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: Schnellbewertungs-Dialoge (Mitarbeit, Anwesenheit/Hausaufgabe) sollen sich
|
||||
/// im Unterrichtsmodus direkt öffnen lassen — das setzt voraus, dass die dafür verwendete
|
||||
/// ParticipationTabViewModel-Instanz von Anfang an auf der zu dieser Stunde gehörenden
|
||||
/// Sitzung steht, nicht auf einer beliebigen anderen Sitzung der Gruppe.
|
||||
[Fact]
|
||||
public void Konstruktor_SelektiertDieselbeSitzungAuchInDerParticipationViewModel()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var otherSession = new ParticipationSession
|
||||
{ GroupId = group.Id, Date = lesson.Date.AddDays(-1), Comment = "Andere Stunde" };
|
||||
var sessions = new FakeSessions([otherSession]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
Assert.NotNull(vm.SeatingPlan.SelectedSession);
|
||||
Assert.Equal(vm.SeatingPlan.SelectedSession!.Id, vm.Participation.SelectedSession?.Id);
|
||||
Assert.NotEqual(otherSession.Id, vm.Participation.SelectedSession?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var existing = new ParticipationSession
|
||||
{ GroupId = group.Id, Date = lesson.Date, LessonId = lesson.Id, Comment = "Redox" };
|
||||
var sessions = new FakeSessions([existing]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
Assert.Single(sessions.GetByGroup(group.Id));
|
||||
Assert.Equal(existing.Id, vm.SeatingPlan.SelectedSession?.Id);
|
||||
}
|
||||
|
||||
/// Eine archivierte Gruppe darf im Unterrichtsmodus nicht bearbeitbar sein - genau wie in der
|
||||
/// normalen Gruppenansicht (GroupDetailViewModel.IsReadOnly).
|
||||
[Fact]
|
||||
public void Konstruktor_ArchivierteGruppe_InitialisiertSitzplanSchreibgeschuetzt()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", IsActive = false };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.False(vm.SeatingPlan.IsEditable);
|
||||
}
|
||||
|
||||
// ── Hausaufgabe: letzte Stunde ansehen/abhaken, aktuelle einsehen/ändern ─────
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_FindetHausaufgabeDerVorherigenStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren",
|
||||
Homework = "S. 42, Aufgabe 3", HomeworkChecked = false,
|
||||
};
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.True(vm.Homework.HasPreviousHomework);
|
||||
Assert.Equal("S. 42, Aufgabe 3", vm.Homework.PreviousHomeworkText);
|
||||
Assert.Contains("Säuren", vm.Homework.PreviousLessonLabel);
|
||||
Assert.False(vm.Homework.PreviousHomeworkChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_IgnoriertVorherigeStundeOhneHausaufgabe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson { GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren" };
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.False(vm.Homework.HasPreviousHomework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviousHomeworkChecked_SpeichertSofortAnDerVorherigenStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson
|
||||
{ GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren", Homework = "S. 42" };
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
vm.Homework.PreviousHomeworkChecked = true;
|
||||
|
||||
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, today.AddDays(-120), today.AddDays(-1)));
|
||||
Assert.True(saved.HomeworkChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentHomework_SchreibtAufDieAktuelleStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
vm.Homework.CurrentHomework = "AB 7 fertigstellen";
|
||||
vm.Homework.SaveCurrentHomeworkCommand.Execute(null);
|
||||
|
||||
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, lesson.Date, lesson.Date));
|
||||
Assert.Equal("AB 7 fertigstellen", saved.Homework);
|
||||
Assert.Equal("Gespeichert.", vm.Homework.SaveStatus);
|
||||
}
|
||||
}
|
||||
@@ -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,10 +13,12 @@ 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,
|
||||
PeriodScheduleService? periodSchedule = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
|
||||
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
|
||||
// nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
@@ -25,7 +28,9 @@ 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(),
|
||||
periodSchedule ?? new PeriodScheduleService(tempPath));
|
||||
}
|
||||
|
||||
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
|
||||
@@ -230,6 +235,191 @@ public sealed class TimetableViewModelTests
|
||||
Assert.Equal(group.Id, navigatedTo);
|
||||
}
|
||||
|
||||
// ── Direktsprung in den Verlaufsplan-Viewer (4.5.2) ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task OpenTodayLesson_MitVorhandenerLesson_OeffnetDenViewerStattZurGruppeZuNavigieren()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
Lesson? openedLesson = null;
|
||||
Guid? navigatedTo = null;
|
||||
vm.OnOpenLessonViewer = l => { openedLesson = l; return Task.CompletedTask; };
|
||||
vm.OnNavigateToGroup = id => navigatedTo = id;
|
||||
|
||||
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||
|
||||
Assert.Equal(lesson.Id, openedLesson?.Id);
|
||||
Assert.Null(navigatedTo);
|
||||
Assert.Equal("Verlaufsplan ansehen", vm.TodayItems[0].OpenButtonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenTodayLesson_OhneVorhandeneLesson_NavigiertWieBisherZurGruppe()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
Guid? navigatedTo = null;
|
||||
var viewerOpened = false;
|
||||
vm.OnOpenLessonViewer = _ => { viewerOpened = true; return Task.CompletedTask; };
|
||||
vm.OnNavigateToGroup = id => navigatedTo = id;
|
||||
|
||||
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||
|
||||
Assert.Equal(group.Id, navigatedTo);
|
||||
Assert.False(viewerOpened);
|
||||
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel);
|
||||
}
|
||||
|
||||
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StartTeachingMode_MitVorhandenerLesson_OeffnetDenUnterrichtsmodus()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
Lesson? openedLesson = null;
|
||||
vm.OnOpenTeachingMode = l => { openedLesson = l; return Task.CompletedTask; };
|
||||
|
||||
await vm.StartTeachingModeCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||
|
||||
Assert.Equal(lesson.Id, openedLesson?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTeachingMode_OhneVorhandeneLesson_TutNichts()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
var opened = false;
|
||||
vm.OnOpenTeachingMode = _ => { opened = true; return Task.CompletedTask; };
|
||||
|
||||
await vm.StartTeachingModeCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||
|
||||
Assert.False(opened);
|
||||
Assert.False(vm.TodayItems[0].HasLesson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWeekCell_MitVorhandenerLesson_OeffnetDenViewer()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = date, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
Lesson? openedLesson = null;
|
||||
vm.OnOpenLessonViewer = l => { openedLesson = l; return Task.CompletedTask; };
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
|
||||
|
||||
Assert.Equal(lesson.Id, openedLesson?.Id);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
|
||||
/// "einheitlicher" springen — bei einer Lesson HEUTE, während gerade Unterrichtszeit ist,
|
||||
/// direkt in den Unterrichtsmodus statt in den (schreibgeschützten) Planungsviewer.
|
||||
[Fact]
|
||||
public async Task OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
// Bewusst nicht der reale DateTime.Today-Wochentag: an einem Sa/So gäbe es dafür keine
|
||||
// Kachel im Mo-Fr-Raster. Stattdessen ein beliebiger Wochentag der laufenden Kalenderwoche
|
||||
// (Kachel existiert immer), dessen Datum per VM.Clock als "heute" simuliert wird (siehe
|
||||
// TimetableViewModel.Clock - genau für solche Tests eingeführt).
|
||||
var weekday = DayOfWeek.Monday;
|
||||
var lessonDate = DateInCurrentWeek(weekday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = lessonDate, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var periodSchedule = BuildPeriodSchedule(1);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
|
||||
// Gleiche Uhrzeit wie BuildPeriodSchedule (reales DateTime.Now), aber am simulierten
|
||||
// "heutigen" Datum - die Stundenzeit-Prüfung (Uhrzeit) bleibt so unabhängig vom Datum.
|
||||
vm.Clock = () => lessonDate.ToDateTime(TimeOnly.FromDateTime(DateTime.Now));
|
||||
Lesson? teachingModeLesson = null;
|
||||
Lesson? viewerLesson = null;
|
||||
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
|
||||
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == weekday && c.PeriodNumber == 1);
|
||||
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
|
||||
|
||||
Assert.Equal(lesson.Id, teachingModeLesson?.Id);
|
||||
Assert.Null(viewerLesson);
|
||||
}
|
||||
|
||||
/// Dieselbe Stundenzeit passt, die Lesson liegt aber nicht heute — bleibt beim Viewer.
|
||||
[Fact]
|
||||
public async Task OpenWeekCell_LessonNichtHeuteTrotzPassenderUhrzeit_OeffnetViewer()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var otherWeekday = today.DayOfWeek == DayOfWeek.Monday ? DayOfWeek.Tuesday : DayOfWeek.Monday;
|
||||
var otherDate = DateInCurrentWeek(otherWeekday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = otherWeekday, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = otherDate, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var periodSchedule = BuildPeriodSchedule(1);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
|
||||
Lesson? teachingModeLesson = null;
|
||||
Lesson? viewerLesson = null;
|
||||
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
|
||||
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == otherWeekday && c.PeriodNumber == 1);
|
||||
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
|
||||
|
||||
Assert.Equal(lesson.Id, viewerLesson?.Id);
|
||||
Assert.Null(teachingModeLesson);
|
||||
}
|
||||
|
||||
/// Weites Zeitfenster um <paramref name="around"/> herum (±2 Stunden), damit der Test nicht an
|
||||
/// die exakte Toleranz von TimetableViewModel.TeachingTimeTolerance gebunden ist.
|
||||
/// Start UND Ende exakt auf "jetzt" statt eines künstlich breiteren Fensters (z.B. ganzer Tag
|
||||
/// via TimeOnly.MinValue/MaxValue): TimetableViewModel.IsAroundTeachingTime addiert selbst
|
||||
/// noch ±10 Minuten Toleranz (TeachingTimeTolerance) auf Start/Ende — ein zusätzliches, vom
|
||||
/// Test vorgegebenes Fenster würde nahe Mitternacht durch die TimeOnly-Arithmetik (wrappt bei
|
||||
/// 24 Uhr) selbst kippen. "Jetzt" als Start/Ende bleibt nur dann riskant, wenn der Test
|
||||
/// zufällig innerhalb der letzten/ersten 10 Minuten des Tages läuft — dasselbe inhärente
|
||||
/// Randproblem hätte dann auch die Produktion, kein Testartefakt.
|
||||
private static PeriodScheduleService BuildPeriodSchedule(int periodNumber)
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
var service = new PeriodScheduleService(tempPath);
|
||||
var now = TimeOnly.FromDateTime(DateTime.Now);
|
||||
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = periodNumber, Start = now, End = now }]);
|
||||
return service;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenSettings_RuftOnNavigateToSettingsAuf()
|
||||
{
|
||||
@@ -624,6 +814,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()
|
||||
{
|
||||
@@ -827,6 +1042,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()
|
||||
{
|
||||
@@ -938,4 +1185,119 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde): das Popup-Menü je Wochenraster-Kachel (TimetableView.axaml,
|
||||
/// MenuFlyout) blendet "Unterrichtsansicht"/"Planungsviewer" per IsVisible="{Binding HasLesson}"
|
||||
/// aus, wenn für den Slot noch keine Lesson existiert, und den ganzen Menü-Trigger per
|
||||
/// IsVisible="{Binding HasGroupId}", wenn die Kachel gar keiner Gruppe zugeordnet ist (z.B. eine
|
||||
/// GroupId-lose Vertretung). Diese beiden Properties sind die Grundlage dafür.
|
||||
public sealed class WeekCellItemMenuVisibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForSlot_MitLesson_HatHasLessonUndHasGroupId()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Redox" };
|
||||
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
|
||||
"R204", "Redox", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
|
||||
hasExperiment: false, Guid.NewGuid(), isHoliday: false, lesson: lesson);
|
||||
|
||||
Assert.True(cell.HasLesson);
|
||||
Assert.True(cell.HasGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSlot_OhneLesson_HatKeinHasLesson()
|
||||
{
|
||||
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
|
||||
"R204", "", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
|
||||
hasExperiment: false, Guid.NewGuid(), isHoliday: false);
|
||||
|
||||
Assert.False(cell.HasLesson);
|
||||
Assert.True(cell.HasGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSubstitutionLesson_OhneGroupId_HatKeinHasGroupId()
|
||||
{
|
||||
var cell = WeekCellItem.ForSubstitutionLesson(DayOfWeek.Monday, 3, isToday: true,
|
||||
new SubstitutionEntry { GroupId = null, GroupLabel = "Fremde Klasse", Description = "Vertretung" });
|
||||
|
||||
Assert.False(cell.HasGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class TrashViewModelTests
|
||||
{
|
||||
private static TrashViewModel BuildViewModel(LiteDbContext db) => new(
|
||||
new TrashRepository(db), new GradeRepository(db), new WorkTaskRepository(db),
|
||||
new TimeEntryRepository(db), new SeatingPlanRepository(db), new GradingKeyTemplateRepository(db));
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_LaedtVorhandenePapierkorbEintraege()
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
var taskRepo = new WorkTaskRepository(db);
|
||||
var task = new WorkTask { Title = "Elternbrief schreiben" };
|
||||
taskRepo.Save(task);
|
||||
taskRepo.Delete(task.Id);
|
||||
|
||||
var vm = BuildViewModel(db);
|
||||
|
||||
Assert.True(vm.HasItems);
|
||||
var item = Assert.Single(vm.Items);
|
||||
Assert.Equal("Aufgabe", item.TypeLabel);
|
||||
Assert.Equal("Elternbrief schreiben", item.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeererPapierkorb_HasItemsIstFalsch()
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
|
||||
var vm = BuildViewModel(db);
|
||||
|
||||
Assert.False(vm.HasItems);
|
||||
Assert.Empty(vm.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreCommand_StelltAufgabeWiederHerUndAktualisiertDieListe()
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
var taskRepo = new WorkTaskRepository(db);
|
||||
var task = new WorkTask { Title = "Klausuren korrigieren" };
|
||||
taskRepo.Save(task);
|
||||
taskRepo.Delete(task.Id);
|
||||
var vm = BuildViewModel(db);
|
||||
var item = Assert.Single(vm.Items);
|
||||
|
||||
vm.RestoreCommand.Execute(item);
|
||||
|
||||
Assert.NotNull(db.Tasks.FindById(task.Id));
|
||||
Assert.False(vm.HasItems);
|
||||
Assert.Contains("Klausuren korrigieren", vm.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreCommand_MitNullTutNichts()
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
var vm = BuildViewModel(db);
|
||||
|
||||
vm.RestoreCommand.Execute(null);
|
||||
|
||||
Assert.Equal("", vm.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(Grade), "Note")]
|
||||
[InlineData(nameof(WorkTask), "Aufgabe")]
|
||||
[InlineData(nameof(TimeEntry), "Zeiteintrag")]
|
||||
[InlineData(nameof(SeatingPlan), "Sitzplan")]
|
||||
[InlineData(nameof(GradingKeyTemplate), "Notenschlüssel-Vorlage")]
|
||||
public void TypeLabel_UebersetztDenEntityTypeInsDeutsche(string entityType, string expectedLabel)
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
db.TrashedItems.Insert(new TrashedItem { EntityType = entityType, Summary = "Testeintrag" });
|
||||
|
||||
var vm = BuildViewModel(db);
|
||||
|
||||
var item = Assert.Single(vm.Items);
|
||||
Assert.Equal(expectedLabel, item.TypeLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypeLabel_UnbekannterEntityTypeFaelltAufDenRohenNamenZurueck()
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
db.TrashedItems.Insert(new TrashedItem { EntityType = "Sonstiges", Summary = "Testeintrag" });
|
||||
|
||||
var vm = BuildViewModel(db);
|
||||
|
||||
Assert.Equal("Sonstiges", Assert.Single(vm.Items).TypeLabel);
|
||||
}
|
||||
}
|
||||
@@ -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,21 @@
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisNameMatchingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Ben Schmidt", "Schmidt Ben")]
|
||||
[InlineData("Ada Müller", "MÜLLER ADA")]
|
||||
[InlineData("Max Mustermann", "mustermann max")]
|
||||
public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) =>
|
||||
Assert.True(UntisNameMatching.NamesMatch(a, b));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ben Schmidt", "Ben Müller")]
|
||||
[InlineData(null, "Ben Schmidt")]
|
||||
[InlineData("Ben Schmidt", "")]
|
||||
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
||||
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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 rosterCache = new FakeUntisStudentRosterCache();
|
||||
var fetchStates = new FakeUntisCacheFetchStates();
|
||||
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, rosterCache, 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
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStudentRosterAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"id\texternKey\tklasse.name\tname\tlongName\tforeName\r\n" +
|
||||
"1\t9001\t10c\tMUST\tMustermann\tMax\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=Student&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 cache = new UntisReportCacheService(untis, new FakeUntisAbsenceCache(), new FakeUntisClassRegisterCache(),
|
||||
new FakeUntisStudentRosterCache(), new FakeUntisCacheFetchStates());
|
||||
|
||||
var first = await cache.GetStudentRosterAsync("10c");
|
||||
Assert.Single(first);
|
||||
Assert.Equal("Max Mustermann", first[0].DisplayName);
|
||||
Assert.Equal(4, handler.Requests.Count);
|
||||
|
||||
var second = await cache.GetStudentRosterAsync("10c");
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,88 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class WebUntisIntegrationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectUndAbruf_GehenDirektAnWebUntisUndVerwendenDieselbeSession()
|
||||
{
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"result\":[{\"id\":42,\"name\":\"KUE\",\"longName\":\"Kürbis\",\"active\":true}]}"),
|
||||
Json("{\"result\":{}}"));
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
|
||||
await service.ConnectAsync(new WebUntisCredentials(
|
||||
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
var teachers = await service.GetTeachersAsync();
|
||||
|
||||
Assert.Equal("KUE", Assert.Single(teachers).Name);
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
Assert.All(handler.Requests, request =>
|
||||
Assert.StartsWith("https://arche.webuntis.com/WebUntis/", request.Uri));
|
||||
Assert.DoesNotContain(handler.Requests, request => request.Uri.Contains("/api/webuntis"));
|
||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
|
||||
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
|
||||
"Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
|
||||
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
|
||||
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
|
||||
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
|
||||
Json("{\"result\":{}}")); // logout (Dispose)
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
|
||||
await service.ConnectAsync(new WebUntisCredentials(
|
||||
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
var entries = await service.GetClassAbsencesAsync("10c",
|
||||
new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
|
||||
|
||||
var entry = Assert.Single(entries);
|
||||
Assert.Equal("Muster Max", entry.StudentName);
|
||||
Assert.Equal("nicht entsch.", entry.Status);
|
||||
// Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
|
||||
Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
|
||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
|
||||
Assert.Single(handler.Requests, request =>
|
||||
request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
|
||||
request.Uri.Contains("klasseOrStudentgroupId=KL874"));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||
public List<CapturedRequest> Requests { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(new(request.RequestUri?.ToString() ?? "",
|
||||
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken)));
|
||||
return _responses.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedRequest(string Uri, string Body);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiCredentials_SindVerschluesseltUndPersistieren()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
var credentials = new WebUntisCredentials("schule", "mese.webuntis.com", "lehrkraft", "sehr geheim");
|
||||
var service = new WebUntisSettingsService(path);
|
||||
|
||||
service.SetApiCredentials(credentials);
|
||||
service.SetTeacherUntisId(4711);
|
||||
|
||||
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
|
||||
var reloaded = new WebUntisSettingsService(path);
|
||||
Assert.DoesNotContain("sehr geheim", raw);
|
||||
Assert.True(reloaded.ApiIsConfigured);
|
||||
Assert.Equal(credentials, reloaded.GetApiCredentials());
|
||||
Assert.Equal(4711, reloaded.TeacherUntisId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearApiCredentials_LaesstIcalKonfigurationUnveraendert()
|
||||
{
|
||||
var service = new WebUntisSettingsService(BuildTempPath());
|
||||
service.SetIcalUrl(SampleUrl);
|
||||
service.SetApiCredentials(new("schule", "", "user", "password"));
|
||||
service.SetTeacherUntisId(12);
|
||||
|
||||
service.ClearApiCredentials();
|
||||
|
||||
Assert.False(service.ApiIsConfigured);
|
||||
Assert.Null(service.GetApiCredentials());
|
||||
Assert.Null(service.TeacherUntisId);
|
||||
Assert.True(service.IsConfigured);
|
||||
Assert.Equal(SampleUrl, service.GetIcalUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class WindowSettingsServiceTests
|
||||
{
|
||||
private static string BuildTempPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-windowsettings-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_OhneVorhandeneDatei_LiefertStandardgroesse()
|
||||
{
|
||||
var service = new WindowSettingsService(BuildTempPath());
|
||||
|
||||
var settings = service.Load();
|
||||
|
||||
Assert.Equal(1280, settings.Width);
|
||||
Assert.Equal(800, settings.Height);
|
||||
Assert.False(settings.IsMaximized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveUndLoad_PersistiertUeberNeueInstanz()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
new WindowSettingsService(path).Save(new WindowSettings { Width = 1600, Height = 950, IsMaximized = true });
|
||||
|
||||
var reloaded = new WindowSettingsService(path).Load();
|
||||
|
||||
Assert.Equal(1600, reloaded.Width);
|
||||
Assert.Equal(950, reloaded.Height);
|
||||
Assert.True(reloaded.IsMaximized);
|
||||
}
|
||||
|
||||
/// Eine winzige oder negative Größe (z.B. durch eine beschädigte/manuell editierte Datei)
|
||||
/// darf das Fenster beim nächsten Start nicht unbenutzbar klein machen.
|
||||
[Fact]
|
||||
public void Load_UnplausibelKleineGroesse_FaelltAufStandardgroesseZurueck()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
new WindowSettingsService(path).Save(new WindowSettings { Width = 5, Height = 5 });
|
||||
|
||||
var reloaded = new WindowSettingsService(path).Load();
|
||||
|
||||
Assert.Equal(1280, reloaded.Width);
|
||||
Assert.Equal(800, reloaded.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_BeschaedigteDatei_FaelltAufStandardgroesseZurueck()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
File.WriteAllText(Path.Combine(path, "windowsettings.json"), "{ kein json");
|
||||
|
||||
var settings = new WindowSettingsService(path).Load();
|
||||
|
||||
Assert.Equal(1280, settings.Width);
|
||||
Assert.Equal(800, settings.Height);
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ public sealed class WorkTaskListViewModelTests
|
||||
{
|
||||
var tasks = new FakeWorkTasks();
|
||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
||||
vm.OnEditTask = _ => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
|
||||
vm.OnEditTask = (_, _) => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
|
||||
|
||||
await vm.AddTaskCommand.ExecuteAsync(null);
|
||||
|
||||
@@ -179,6 +179,26 @@ public sealed class WorkTaskListViewModelTests
|
||||
Assert.Equal("Neu", vm.Tasks[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddReminder_OeffnetDialogVorbelegtAlsErinnerungUndSpeichertErgebnis()
|
||||
{
|
||||
var tasks = new FakeWorkTasks();
|
||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
||||
bool? passedStartAsReminder = null;
|
||||
vm.OnEditTask = (_, startAsReminder) =>
|
||||
{
|
||||
passedStartAsReminder = startAsReminder;
|
||||
return Task.FromResult<WorkTask?>(new WorkTask { Title = "Ansage machen", Kind = TaskKind.Reminder });
|
||||
};
|
||||
|
||||
await vm.AddReminderCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(passedStartAsReminder);
|
||||
var item = Assert.Single(vm.Tasks);
|
||||
Assert.True(item.IsReminder);
|
||||
Assert.Equal("Erinnerung", item.CategoryLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde()
|
||||
{
|
||||
@@ -285,6 +305,122 @@ public sealed class AddEditWorkTaskDialogViewModelTests
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_AlsErinnerung_SetztKindUndVerwirftGeschaetzteDauer()
|
||||
{
|
||||
var vm = new AddEditWorkTaskDialogViewModel(null, [])
|
||||
{
|
||||
Title = "Ansage an die Klasse machen",
|
||||
IsReminder = true,
|
||||
EstimatedMinutesText = "90", // sollte ignoriert werden
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(TaskKind.Reminder, vm.Result!.Kind);
|
||||
Assert.Null(vm.Result.EstimatedMinutes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_StartAsReminder_SetztIsReminderVorbelegt()
|
||||
{
|
||||
var vm = new AddEditWorkTaskDialogViewModel(null, [], startAsReminder: true);
|
||||
Assert.True(vm.IsReminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_BestehendeErinnerung_ErkenntKindWieder()
|
||||
{
|
||||
var source = new WorkTask { Title = "Alt", Kind = TaskKind.Reminder };
|
||||
var vm = new AddEditWorkTaskDialogViewModel(source, []);
|
||||
Assert.True(vm.IsReminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_MitPrioritaet_UebernimmtSieInsResult()
|
||||
{
|
||||
var vm = new AddEditWorkTaskDialogViewModel(null, [])
|
||||
{
|
||||
Title = "Wichtig",
|
||||
SelectedPriority = TaskPriorityDisplay.Label(TaskPriority.High),
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal(TaskPriority.High, vm.Result!.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_OhneQuelle_StartetMitNormalerPrioritaet()
|
||||
{
|
||||
var vm = new AddEditWorkTaskDialogViewModel(null, []);
|
||||
Assert.Equal(TaskPriorityDisplay.Label(TaskPriority.Normal), vm.SelectedPriority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChecklistItems_HinzufuegenUndEntfernen_WirdInResultUebernommen()
|
||||
{
|
||||
var vm = new AddEditWorkTaskDialogViewModel(null, []) { Title = "Material einsammeln" };
|
||||
vm.NewChecklistItemText = "Erste Position";
|
||||
vm.AddChecklistItemCommand.Execute(null);
|
||||
vm.NewChecklistItemText = "Zweite Position";
|
||||
vm.AddChecklistItemCommand.Execute(null);
|
||||
|
||||
Assert.Equal(["Erste Position", "Zweite Position"], vm.ChecklistItems.Select(c => c.Label));
|
||||
Assert.Equal("", vm.NewChecklistItemText);
|
||||
|
||||
vm.RemoveChecklistItemCommand.Execute(vm.ChecklistItems[0]);
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
var item = Assert.Single(vm.Result!.ChecklistItems);
|
||||
Assert.Equal("Zweite Position", item.Label);
|
||||
Assert.Null(item.StudentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_BestehendeAufgabeMitChecklist_UebernimmtEintraege()
|
||||
{
|
||||
var source = new WorkTask
|
||||
{
|
||||
Title = "Alt",
|
||||
ChecklistItems = [new ChecklistItem { Label = "Schon da", IsDone = true }],
|
||||
};
|
||||
var vm = new AddEditWorkTaskDialogViewModel(source, []);
|
||||
|
||||
var row = Assert.Single(vm.ChecklistItems);
|
||||
Assert.Equal("Schon da", row.Label);
|
||||
Assert.True(row.IsDone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillFromRoster_OhneGruppeOderLader_MachtNichts()
|
||||
{
|
||||
var vm = new AddEditWorkTaskDialogViewModel(null, []);
|
||||
vm.FillFromRosterCommand.Execute(null);
|
||||
Assert.Empty(vm.ChecklistItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillFromRoster_BefuelltAusRosterUndUeberspringtDoppelteSchueler()
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a" };
|
||||
var studentId = Guid.NewGuid();
|
||||
var otherStudentId = Guid.NewGuid();
|
||||
var vm = new AddEditWorkTaskDialogViewModel(null, [group], loadRoster: _ =>
|
||||
[(studentId, "Anna Beispiel"), (otherStudentId, "Ben Muster")])
|
||||
{
|
||||
SelectedGroup = group,
|
||||
};
|
||||
|
||||
Assert.True(vm.CanFillFromRoster);
|
||||
vm.FillFromRosterCommand.Execute(null);
|
||||
Assert.Equal(2, vm.ChecklistItems.Count);
|
||||
|
||||
vm.FillFromRosterCommand.Execute(null);
|
||||
Assert.Equal(2, vm.ChecklistItems.Count); // kein Duplikat beim zweiten Aufruf
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TimeTrackingViewModelTests
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LehrerApp.Desktop.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
@@ -8,7 +10,10 @@ using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.ViewModels.Workload;
|
||||
using LehrerApp.Desktop.Views;
|
||||
using LehrerApp.Desktop.Views.Workload;
|
||||
using LehrerApp.Sync;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop;
|
||||
@@ -23,6 +28,13 @@ public class App : Application
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
// Vor jedem Fenster (auch dem DB-Passwort-Prompt) anwenden, damit kein kurzes Aufblitzen
|
||||
// der Systemvorgabe zu sehen ist, falls Hell/Dunkel manuell erzwungen wurde. Eigenständige
|
||||
// Instanz statt über den DI-Container (der erst in BuildServices() entsteht, die den
|
||||
// Passwort-Prompt bereits verzögert) - dieselbe appData-Datei wie später der
|
||||
// DI-Singleton, rein lesend also unproblematisch doppelt konstruiert.
|
||||
ApplyTheme(new AppearanceSettingsService(AppBootstrapper.ResolveAppDataPath()).Load());
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
||||
@@ -56,6 +68,10 @@ public class App : Application
|
||||
Services = _serviceProvider;
|
||||
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
|
||||
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
|
||||
// soll, kein dauerhaftes Archiv.
|
||||
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
|
||||
|
||||
if (!_exitHandlerAttached)
|
||||
{
|
||||
@@ -65,18 +81,42 @@ public class App : Application
|
||||
|
||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
WireCallbacks(mainVm);
|
||||
|
||||
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite
|
||||
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
|
||||
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
|
||||
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
|
||||
// übernimmt sein stündlicher Timer.
|
||||
if (Services.GetService<UntisSyncService>() is { } untisSync)
|
||||
_ = untisSync.PollAsync();
|
||||
|
||||
var main = new MainWindow { DataContext = mainVm };
|
||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||
main.EnableFinalSync(syncEngine);
|
||||
desktop.MainWindow = main;
|
||||
if (showImmediately) main.Show();
|
||||
}
|
||||
|
||||
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||
/// gesamten sichtbaren Baum automatisch neu, sobald sich <see cref="ThemeVariant"/>
|
||||
/// ändert.</summary>
|
||||
public static void ApplyTheme(AppTheme theme) =>
|
||||
Current!.RequestedThemeVariant = theme switch
|
||||
{
|
||||
AppTheme.Light => ThemeVariant.Light,
|
||||
AppTheme.Dark => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Default,
|
||||
};
|
||||
|
||||
private static void DisposeServices()
|
||||
{
|
||||
if (_serviceProvider is null) return;
|
||||
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
|
||||
if (serviceProvider is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -84,8 +124,19 @@ public class App : Application
|
||||
}
|
||||
finally
|
||||
{
|
||||
_serviceProvider.Dispose();
|
||||
_serviceProvider = null;
|
||||
// Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
|
||||
// Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
|
||||
// WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
|
||||
try
|
||||
{
|
||||
Task.Run(async () =>
|
||||
await serviceProvider.DisposeAsync().ConfigureAwait(false))
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +154,23 @@ public class App : Application
|
||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||
|
||||
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
||||
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
||||
var search = Services.GetRequiredService<GlobalSearchViewModel>();
|
||||
search.OnNavigate = result =>
|
||||
{
|
||||
if (result.Kind == GlobalSearchResultKind.Student && result.EntityId is { } studentId)
|
||||
main.NavigateToStudent(studentId);
|
||||
else if (result.Kind == GlobalSearchResultKind.Group && result.GroupId is { } groupId)
|
||||
main.NavigateToGroupDetail(groupId);
|
||||
else if (result.Kind == GlobalSearchResultKind.Exam && result.GroupId is { } examGroupId)
|
||||
main.NavigateToGroupDetail(examGroupId, 4);
|
||||
else if (result.Kind == GlobalSearchResultKind.Task)
|
||||
main.NavigateToWorkload();
|
||||
};
|
||||
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
||||
search.OnQuickAddStudent = ShowAddStudentDialog;
|
||||
|
||||
// StudentList → StudentDetail + Anlegen
|
||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
|
||||
@@ -122,4 +190,17 @@ public class App : Application
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
private static async Task ShowQuickTaskDialog(bool startAsReminder, DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var result = await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
||||
if (result is null) return;
|
||||
|
||||
Services.GetRequiredService<IWorkTaskRepository>().Save(result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using LehrerApp.Data;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
@@ -105,12 +107,16 @@ public static class AppBootstrapper
|
||||
ResolveDbPath();
|
||||
var queuePath = Path.Combine(appData, "syncqueue.db");
|
||||
var keyPath = Path.Combine(appData, "sync.key");
|
||||
// Muss VOR jedem möglichen Zugriff auf keyPath erfasst werden (siehe SyncKeyStatus unten) -
|
||||
// File.Exists ist hier synchron und unabhängig von der Lazy-Auflösung der DI-Factories.
|
||||
var keyExistedBefore = File.Exists(keyPath);
|
||||
|
||||
// ── Logging & Benachrichtigungen ─────────────────────────────────────────
|
||||
EnsureLogger();
|
||||
services.AddSingleton(Logger);
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
services.AddSingleton<PdfExportService>();
|
||||
|
||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||
@@ -135,6 +141,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IStudentRepository, StudentRepository>();
|
||||
services.AddSingleton<IGroupRepository, GroupRepository>();
|
||||
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
|
||||
services.AddSingleton<ITrashRepository, TrashRepository>();
|
||||
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
|
||||
services.AddSingleton<IExamRepository, ExamRepository>();
|
||||
services.AddSingleton<IExamResultRepository, ExamResultRepository>();
|
||||
@@ -160,6 +167,13 @@ 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.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
||||
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
|
||||
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -171,6 +185,8 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
||||
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||
services.AddSingleton(_ => new LetterTemplateService(appData));
|
||||
services.AddSingleton<PlanningExchangeService>();
|
||||
|
||||
@@ -179,10 +195,48 @@ 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>()));
|
||||
}
|
||||
|
||||
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
|
||||
var annualPlanSettings = new AnnualPlanSettingsService(appData);
|
||||
services.AddSingleton(annualPlanSettings);
|
||||
if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl()))
|
||||
{
|
||||
services.AddSingleton(sp => new AnnualPlanSyncService(
|
||||
new HttpClient(), annualPlanSettings,
|
||||
sp.GetRequiredService<IAnnualPlanEventRepository>(),
|
||||
sp.GetRequiredService<AppLogger>()));
|
||||
}
|
||||
|
||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||
var syncSettings = new SyncSettingsService(appData);
|
||||
services.AddSingleton(syncSettings);
|
||||
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
|
||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
||||
services.AddSingleton<UntisReportCacheService>();
|
||||
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
||||
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
|
||||
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
||||
// Bei einem Gerät, das noch nie eingeloggt war, ist ein fehlender Schlüssel dagegen der
|
||||
// normale Erstlauf. Siehe SettingsViewModel (Warnbanner) und SyncKeyRecoveryService.
|
||||
services.AddSingleton(new SyncKeyStatus(!keyExistedBefore && syncSettings.IsLoggedIn));
|
||||
|
||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
||||
@@ -192,6 +246,9 @@ public static class AppBootstrapper
|
||||
SyncCrypto.SaveKey(key, keyPath);
|
||||
return key;
|
||||
});
|
||||
// Rein lokal/offline (kein Server-Zugriff nötig), deshalb unconditional registriert - im
|
||||
// Unterschied zu SnapshotService unten, das eine konfigurierte Server-URL voraussetzt.
|
||||
services.AddSingleton(sp => new SyncKeyRecoveryService(sp.GetRequiredService<byte[]>(), keyPath));
|
||||
|
||||
var serverUrl = syncSettings.ServerUrl;
|
||||
var deviceId = LoadOrCreateDeviceId(appData);
|
||||
@@ -235,9 +292,12 @@ public static class AppBootstrapper
|
||||
// Singleton: einmal erstellt, überall dieselbe Instanz
|
||||
services.AddSingleton<AppLockViewModel>();
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
services.AddSingleton<GlobalSearchViewModel>();
|
||||
services.AddSingleton<DashboardViewModel>();
|
||||
services.AddSingleton(sp =>
|
||||
new SyncStatusViewModel(sp.GetService<SyncEngine>()));
|
||||
new SyncStatusViewModel(
|
||||
sp.GetService<SyncEngine>(),
|
||||
sp.GetRequiredService<NotificationService>()));
|
||||
services.AddSingleton<GroupListViewModel>();
|
||||
services.AddSingleton<StudentListViewModel>();
|
||||
services.AddSingleton<TimetableViewModel>();
|
||||
@@ -245,10 +305,14 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<TimeTrackingViewModel>();
|
||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||
services.AddSingleton<WorkloadViewModel>();
|
||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||
services.AddSingleton<ExamsOverviewViewModel>();
|
||||
|
||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||
services.AddTransient<GroupDetailViewModel>();
|
||||
services.AddTransient<StudentDetailViewModel>();
|
||||
services.AddTransient<GroupOverviewViewModel>();
|
||||
services.AddTransient<ParticipationTabViewModel>();
|
||||
services.AddTransient<GradeOverviewTabViewModel>();
|
||||
services.AddTransient<PlanningTabViewModel>();
|
||||
@@ -256,6 +320,7 @@ public static class AppBootstrapper
|
||||
services.AddTransient<SeatingPlanTabViewModel>();
|
||||
services.AddTransient<GroupDocumentationTabViewModel>();
|
||||
services.AddTransient<AddGroupDialogViewModel>();
|
||||
services.AddTransient<TrashViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace LehrerApp.Desktop.Converters;
|
||||
|
||||
/// <summary>Multipliziert die gerenderte Breite eines Referenzelements (typischerweise die neutrale
|
||||
/// Track-Leiste eines gestapelten Balkens) mit einem Anteil 0…1. Ersetzt eine im ViewModel hart
|
||||
/// codierte Pixelkonstante (Quick-Win, Nutzer-Feedback) — die Segmentbreite bleibt damit auch
|
||||
/// korrekt, wenn sich die Breite der Seitenspalte in XAML mal ändert, weil sie sich aus der
|
||||
/// tatsächlichen <c>Bounds.Width</c> des Track-Elements ergibt statt aus einer angenommenen
|
||||
/// festen Breite. `Grid.ColumnDefinitions` ließ sich dafür nicht binden (Avalonia bietet dort bei
|
||||
/// kompilierten Bindings keinen Setter — <c>AVLN3000</c>), deshalb MultiBinding auf Pixelbreite
|
||||
/// statt Grid-Sternspalten.</summary>
|
||||
public sealed class FractionWidthConverter : IMultiValueConverter
|
||||
{
|
||||
public static readonly FractionWidthConverter Instance = new();
|
||||
|
||||
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (values is not [double trackWidth, double fraction, ..] || double.IsNaN(trackWidth)) return 0d;
|
||||
return Math.Max(0d, trackWidth * fraction);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
@@ -18,6 +19,7 @@
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="QuestPDF" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
||||
@@ -76,6 +76,29 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Baut eine nutzergerichtete Fehlermeldung für eine fehlgeschlagene Backend-Antwort, die
|
||||
/// weder 401 noch 402 war (die haben eigene, feste Meldungen). Liest den vom Backend
|
||||
/// gesendeten Klartext-Grund (<c>ai_backend_fail</c> liefert immer <c>{"error": "..."}</c>)
|
||||
/// mit, statt nur eine pauschale Meldung zu zeigen — ohne das war z.B. beim Fehlschlagen der
|
||||
/// Websuche in substance.php (falscher Tool-Bezeichner, fehlender Beta-Header o.ä.) der
|
||||
/// eigentliche Grund nirgends sichtbar, nur "Die Anfrage ist fehlgeschlagen".
|
||||
/// </summary>
|
||||
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||
{
|
||||
string? backendReason = null;
|
||||
try
|
||||
{
|
||||
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
|
||||
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
||||
}
|
||||
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||||
|
||||
return new AiBackendException(backendReason is null
|
||||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
|
||||
}
|
||||
|
||||
public async Task<string> LoginAsync(string username, string password)
|
||||
{
|
||||
HttpResponseMessage resp;
|
||||
@@ -327,7 +350,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -368,7 +391,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -381,6 +404,87 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser Lesson ab (Nutzerwunsch
|
||||
/// neben 4.2 "Anhänge je Stunde"): die KI erkennt aus Thema/Verlaufsplan das Experiment und
|
||||
/// liefert Stoffe, Gefährdungen, Schutzmaßnahmen etc. als Entwurf zur Weiterbearbeitung im
|
||||
/// Gefährdungsbeurteilungs-Assistenten. Ändert nichts an der Lesson selbst.
|
||||
/// </summary>
|
||||
public async Task<AiHazardAssessmentResponse> RequestHazardAssessmentDraftAsync(Unit unit, AiLesson lesson, string token)
|
||||
{
|
||||
var request = new AiHazardAssessmentRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "gbu.php")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await resp.Content.ReadFromJsonAsync<AiHazardAssessmentResponse>(JsonOptions);
|
||||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt sicherheitsrelevante Daten zu einer Chemikalie ab (Nutzerwunsch neben 4.2): eigener
|
||||
/// Endpunkt <c>ai-backend/substance.php</c>, serverseitig dateibasiert gecacht (Name/CAS,
|
||||
/// Ablauffrist je nach Einstufung) — wiederholte Anfragen für denselben Stoff verursachen
|
||||
/// nach dem ersten Mal keine weiteren Kosten mehr. Kein Unit-/Guppen-Kontext nötig, nur der
|
||||
/// eingetippte Stoffname.
|
||||
/// </summary>
|
||||
public async Task<AiSubstanceResearchResponse> RequestSubstanceResearchAsync(string substanceName, string token)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "substance.php")
|
||||
{
|
||||
Content = JsonContent.Create(new { name = substanceName }, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await resp.Content.ReadFromJsonAsync<AiSubstanceResearchResponse>(JsonOptions);
|
||||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
|
||||
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
|
||||
@@ -437,4 +541,5 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
|
||||
private class LoginResult { public string Token { get; set; } = ""; }
|
||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||
private class BackendErrorResult { public string? Error { get; set; } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal sealed class AnnualPlanSettingsConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? EncryptedIcalUrl { get; set; }
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
public string LastSyncStatus { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gerätebezogene Einstellungen für den externen Schuljahresplan. Der eingebettete Feed-Schlüssel
|
||||
/// wird wie die WebUntis-URL verschlüsselt in einer separaten Datei gespeichert.
|
||||
/// </summary>
|
||||
public sealed class AnnualPlanSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private readonly byte[] _urlKey;
|
||||
private AnnualPlanSettingsConfig _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 AnnualPlanSettingsService(string appDataPath)
|
||||
{
|
||||
_configPath = Path.Combine(appDataPath, "annual-plan-settings.json");
|
||||
var keyPath = Path.Combine(appDataPath, "annual-plan-url.key");
|
||||
_urlKey = SyncCrypto.LoadKey(keyPath) ?? GenerateAndSaveKey(keyPath);
|
||||
_config = Load();
|
||||
}
|
||||
|
||||
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 SetEnabled(bool enabled)
|
||||
{
|
||||
_config.Enabled = enabled;
|
||||
Save();
|
||||
}
|
||||
|
||||
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(string keyPath)
|
||||
{
|
||||
var key = SyncCrypto.GenerateKey();
|
||||
SyncCrypto.SaveKey(key, keyPath);
|
||||
return key;
|
||||
}
|
||||
|
||||
private AnnualPlanSettingsConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<AnnualPlanSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?? new AnnualPlanSettingsConfig();
|
||||
}
|
||||
catch { /* beschädigte lokale Konfiguration -> Standardwerte */ }
|
||||
return new AnnualPlanSettingsConfig();
|
||||
}
|
||||
|
||||
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted)
|
||||
{
|
||||
public int ChangeCount => Added + Updated + Deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei
|
||||
/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories.
|
||||
/// </summary>
|
||||
public sealed class AnnualPlanSyncService : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly AnnualPlanSettingsService _settings;
|
||||
private readonly IAnnualPlanEventRepository _events;
|
||||
private readonly AppLogger? _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly Timer _timer;
|
||||
|
||||
public event Action? DataChanged;
|
||||
|
||||
public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings,
|
||||
IAnnualPlanEventRepository events, AppLogger? logger = null)
|
||||
{
|
||||
_http = http;
|
||||
_settings = settings;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
|
||||
}
|
||||
|
||||
public async Task PollAsync()
|
||||
{
|
||||
if (!await _gate.WaitAsync(0)) return;
|
||||
try
|
||||
{
|
||||
var url = _settings.GetIcalUrl();
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex);
|
||||
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
AnnualPlanPollResult result;
|
||||
try
|
||||
{
|
||||
result = ProcessIcsText(icsText);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex);
|
||||
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.SetLastSync(DateTime.UtcNow,
|
||||
$"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt.");
|
||||
_logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en).");
|
||||
if (result.ChangeCount > 0) DataChanged?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>HTTP-freier, vollständig testbarer Snapshot-Abgleich.</summary>
|
||||
public AnnualPlanPollResult ProcessIcsText(string icsText)
|
||||
{
|
||||
// Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der
|
||||
// strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands.
|
||||
var imported = AnnualPlanIcsParser.Parse(icsText);
|
||||
var existing = _events.GetAll();
|
||||
var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal);
|
||||
var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal);
|
||||
var added = 0;
|
||||
var updated = 0;
|
||||
|
||||
foreach (var entry in imported)
|
||||
{
|
||||
if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous))
|
||||
{
|
||||
_events.Save(entry);
|
||||
added++;
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.Id = previous.Id;
|
||||
if (SameContent(previous, entry)) continue;
|
||||
_events.Save(entry);
|
||||
updated++;
|
||||
}
|
||||
|
||||
var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList();
|
||||
foreach (var entry in stale) _events.Delete(entry.Id);
|
||||
|
||||
return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count);
|
||||
}
|
||||
|
||||
private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) =>
|
||||
left.ExternalId == right.ExternalId &&
|
||||
left.Title == right.Title &&
|
||||
left.Description == right.Description &&
|
||||
left.Location == right.Location &&
|
||||
left.StartDate == right.StartDate &&
|
||||
left.EndDate == right.EndDate &&
|
||||
left.StartTime == right.StartTime &&
|
||||
left.EndTime == right.EndTime &&
|
||||
left.IsAllDay == right.IsAllDay &&
|
||||
left.CalendarGroup == right.CalendarGroup &&
|
||||
left.Color == right.Color &&
|
||||
left.Status == right.Status &&
|
||||
left.Sequence == right.Sequence &&
|
||||
SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc);
|
||||
|
||||
private static bool SameInstant(DateTime? left, DateTime? right) =>
|
||||
left is null && right is null ||
|
||||
left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime();
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
var existing = _events.GetAll();
|
||||
foreach (var entry in existing) _events.Delete(entry.Id);
|
||||
if (existing.Count > 0) DataChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Systemvorgabe folgt <see cref="Avalonia.Styling.ThemeVariant.Default"/> (bisheriges,
|
||||
/// unverändertes Verhalten); Hell/Dunkel erzwingen die jeweilige Variante unabhängig vom
|
||||
/// Betriebssystem.</summary>
|
||||
public enum AppTheme { System, Light, Dark }
|
||||
|
||||
internal sealed class AppearanceSettingsConfig
|
||||
{
|
||||
public AppTheme Theme { get; set; } = AppTheme.System;
|
||||
}
|
||||
|
||||
/// <summary>Merkt sich die gewählte Darstellung (12.4) über Sitzungen hinweg.</summary>
|
||||
public sealed class AppearanceSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public AppearanceSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "appearancesettings.json");
|
||||
|
||||
public AppTheme Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<AppearanceSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?.Theme ?? AppTheme.System;
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Systemvorgabe */ }
|
||||
return AppTheme.System;
|
||||
}
|
||||
|
||||
public void Save(AppTheme theme) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(new AppearanceSettingsConfig { Theme = theme }));
|
||||
}
|
||||
|
||||
// ComboBox-Anzeige: deutsche Beschriftung statt des rohen Enum-Namens (etabliertes Muster, siehe
|
||||
// z.B. NiveauDisplay/GradeCategoryDisplay).
|
||||
public static class AppThemeDisplay
|
||||
{
|
||||
public static string Label(AppTheme theme) => theme switch
|
||||
{
|
||||
AppTheme.Light => "Hell",
|
||||
AppTheme.Dark => "Dunkel",
|
||||
_ => "Systemvorgabe",
|
||||
};
|
||||
|
||||
public static string[] Options { get; } = Enum.GetValues<AppTheme>().Select(Label).ToArray();
|
||||
|
||||
public static AppTheme FromLabel(string? label) =>
|
||||
Enum.GetValues<AppTheme>().FirstOrDefault(t => Label(t) == label, AppTheme.System);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public sealed record ExportFormat(string Label, string Extension, string MimeTyp
|
||||
{
|
||||
public static ExportFormat Csv { get; } = new("CSV-Dateien", ".csv", "text/csv");
|
||||
public static ExportFormat Json { get; } = new("JSON-Dateien", ".json", "application/json");
|
||||
public static ExportFormat Pdf { get; } = new("PDF-Dateien", ".pdf", "application/pdf");
|
||||
}
|
||||
|
||||
public sealed record ExportFile(
|
||||
@@ -21,6 +22,10 @@ public sealed record ExportFile(
|
||||
public static ExportFile Json(string dialogTitle, string suggestedFileName, string content) =>
|
||||
FromText(dialogTitle, suggestedFileName, ExportFormat.Json, content);
|
||||
|
||||
public static ExportFile Pdf(string dialogTitle, string suggestedFileName, byte[] content) =>
|
||||
new(dialogTitle, EnsureExtension(SanitizeFileName(suggestedFileName), ExportFormat.Pdf.Extension),
|
||||
ExportFormat.Pdf, content);
|
||||
|
||||
private static ExportFile FromText(string dialogTitle, string suggestedFileName,
|
||||
ExportFormat format, string content, bool includeUtf8Bom = false)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
// ── Druckdaten (bewusst schlanke DTOs statt ViewModels: die PDF-Erzeugung bleibt dadurch ohne
|
||||
// Avalonia-/MVVM-Bezug testbar, und die Views entscheiden selbst, was gedruckt wird) ────────────
|
||||
|
||||
/// <param name="Seats">Alle Plätze des Rasters in Zeilen-Hauptordnung, auch ausgeblendete
|
||||
/// (IsHidden) - sie belegen ihre Rasterposition weiterhin, werden aber leer gedruckt
|
||||
/// (gleiche Logik wie SeatingPlanPanel/SeatCellViewModel.ShowSeat).</param>
|
||||
public sealed record SeatingPlanPrintData(
|
||||
string PlanTitle,
|
||||
string PlanSubtitle,
|
||||
int Columns,
|
||||
bool IsBoardAtBottom,
|
||||
IReadOnlyList<double> ColumnGapWidths,
|
||||
IReadOnlyList<SeatPrintCell> Seats);
|
||||
|
||||
public sealed record SeatPrintCell(int Row, int Column, string? StudentName, bool IsHidden);
|
||||
|
||||
public sealed record GradeListPrintData(
|
||||
string GroupLabel,
|
||||
string SchoolYear,
|
||||
string PeriodLabel,
|
||||
IReadOnlyList<string> ColumnHeaders,
|
||||
IReadOnlyList<GradeListPrintRow> Rows);
|
||||
|
||||
public sealed record GradeListPrintRow(string Name, IReadOnlyList<string> Cells, string Total);
|
||||
|
||||
public sealed record LabelValue(string Label, string Value);
|
||||
|
||||
public sealed record ExamStatisticsPrintData(
|
||||
string ExamTitle,
|
||||
string DateLabel,
|
||||
string NiveauLabel,
|
||||
IReadOnlyList<LabelValue> Stats,
|
||||
IReadOnlyList<GradeDistributionPrintRow> Distribution,
|
||||
IReadOnlyList<TaskAnalysisPrintRow> TaskAnalysis);
|
||||
|
||||
public sealed record GradeDistributionPrintRow(string Grade, int Count, double Percent);
|
||||
|
||||
public sealed record TaskAnalysisPrintRow(string Label, double AvgPercent, bool IsWeak);
|
||||
|
||||
public sealed record CompetencyReportPrintData(
|
||||
string GroupLabel,
|
||||
string StudentName,
|
||||
string Summary,
|
||||
string ThresholdLabel,
|
||||
IReadOnlyList<CompetencyReportPrintRow> Rows);
|
||||
|
||||
public sealed record CompetencyReportPrintRow(
|
||||
string Domain,
|
||||
string Code,
|
||||
string Description,
|
||||
string InstructionCoverage,
|
||||
string ExamCoverage,
|
||||
string GroupResult,
|
||||
string StudentResult,
|
||||
string Recommendation);
|
||||
|
||||
public sealed record StudentDocumentationPrintData(
|
||||
string StudentName,
|
||||
IReadOnlyList<DocumentationPrintEntry> Entries);
|
||||
|
||||
public sealed record DocumentationPrintEntry(
|
||||
string DateDisplay,
|
||||
string TypeLabel,
|
||||
string Title,
|
||||
string Content,
|
||||
IReadOnlyList<string> Tags,
|
||||
bool IsConfidential,
|
||||
bool IsDraft);
|
||||
|
||||
public sealed record HazardAssessmentPrintData(
|
||||
string Title,
|
||||
string GroupLabel,
|
||||
string DateDisplay,
|
||||
string KindLabel,
|
||||
string Procedure,
|
||||
IReadOnlyList<HazardSubstancePrintRow> Substances,
|
||||
IReadOnlyList<string> Hazards,
|
||||
IReadOnlyList<string> ProtectiveMeasures,
|
||||
string FirstAid,
|
||||
string Disposal,
|
||||
string Notes,
|
||||
bool IsAiAssisted);
|
||||
|
||||
public sealed record HazardSubstancePrintRow(
|
||||
string Name, string Cas, string Amount, string Pictograms, string SignalWord,
|
||||
string HStatements, string PStatements, string ActivityRestriction);
|
||||
|
||||
/// <summary>
|
||||
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
|
||||
/// allen Exporten über <see cref="ExportService"/>. Lebt bewusst in LehrerApp.Desktop statt Core:
|
||||
/// QuestPDF zieht SkiaSharp-Native-Binaries mit, die im Server-Image (LehrerApp.Api, referenziert
|
||||
/// Core transitiv über Sync) nichts verloren haben.
|
||||
/// </summary>
|
||||
public sealed class PdfExportService
|
||||
{
|
||||
static PdfExportService() => QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
// Bildschirm-Pixel (Tischabstände, 0-300) → Druckpunkte. 0.4 hält auch den Maximalabstand
|
||||
// (120pt ≈ 4cm) auf einer A4-Querseite praktikabel.
|
||||
private const float GapPixelToPoint = 0.4f;
|
||||
|
||||
public byte[] BuildSeatingPlanPdf(SeatingPlanPrintData data) =>
|
||||
Document.Create(container => container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4.Landscape());
|
||||
page.Margin(36);
|
||||
page.DefaultTextStyle(style => style.FontSize(10));
|
||||
|
||||
page.Header().Element(header => Header(header, data.PlanTitle, data.PlanSubtitle));
|
||||
|
||||
page.Content().PaddingVertical(10).Column(column =>
|
||||
{
|
||||
column.Spacing(10);
|
||||
if (!data.IsBoardAtBottom) column.Item().Element(BoardBanner);
|
||||
column.Item().Element(grid => SeatGrid(grid, data));
|
||||
if (data.IsBoardAtBottom) column.Item().Element(BoardBanner);
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
public byte[] BuildGradeListPdf(GradeListPrintData data) =>
|
||||
Document.Create(container => container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4.Landscape());
|
||||
page.Margin(36);
|
||||
page.DefaultTextStyle(style => style.FontSize(8));
|
||||
|
||||
page.Header().Element(header => Header(header,
|
||||
$"Notenliste {data.GroupLabel}",
|
||||
$"{data.SchoolYear} · {data.PeriodLabel}"));
|
||||
|
||||
page.Content().PaddingVertical(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2.4f);
|
||||
foreach (var _ in data.ColumnHeaders) columns.RelativeColumn();
|
||||
columns.RelativeColumn(0.8f);
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(HeaderCell).Text("Name").SemiBold();
|
||||
foreach (var columnHeader in data.ColumnHeaders)
|
||||
header.Cell().Element(HeaderCell).Text(columnHeader).SemiBold();
|
||||
header.Cell().Element(HeaderCell).AlignRight().Text("Schnitt").SemiBold();
|
||||
});
|
||||
|
||||
foreach (var row in data.Rows)
|
||||
{
|
||||
table.Cell().Element(BodyCell).Text(row.Name);
|
||||
foreach (var cell in row.Cells)
|
||||
table.Cell().Element(BodyCell).AlignCenter().Text(cell);
|
||||
table.Cell().Element(BodyCell).AlignRight().Text(row.Total).SemiBold();
|
||||
}
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
public byte[] BuildExamStatisticsPdf(ExamStatisticsPrintData data) =>
|
||||
Document.Create(container => container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(36);
|
||||
page.DefaultTextStyle(style => style.FontSize(10));
|
||||
|
||||
var subtitle = string.IsNullOrWhiteSpace(data.NiveauLabel)
|
||||
? data.DateLabel
|
||||
: $"{data.DateLabel} · Niveau {data.NiveauLabel}";
|
||||
page.Header().Element(header => Header(header, data.ExamTitle, subtitle));
|
||||
|
||||
page.Content().PaddingVertical(10).Column(column =>
|
||||
{
|
||||
column.Spacing(14);
|
||||
|
||||
column.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn();
|
||||
});
|
||||
foreach (var stat in data.Stats)
|
||||
{
|
||||
table.Cell().Element(BodyCell).Text(stat.Label);
|
||||
table.Cell().Element(BodyCell).AlignRight().Text(stat.Value).SemiBold();
|
||||
}
|
||||
});
|
||||
|
||||
column.Item().Text("Notenspiegel").FontSize(12).SemiBold();
|
||||
column.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.ConstantColumn(60);
|
||||
columns.ConstantColumn(60);
|
||||
columns.ConstantColumn(60);
|
||||
columns.RelativeColumn();
|
||||
});
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(HeaderCell).Text("Note").SemiBold();
|
||||
header.Cell().Element(HeaderCell).AlignRight().Text("Anzahl").SemiBold();
|
||||
header.Cell().Element(HeaderCell).AlignRight().Text("Anteil").SemiBold();
|
||||
header.Cell().Element(HeaderCell);
|
||||
});
|
||||
var maxCount = data.Distribution.Count == 0 ? 0 : data.Distribution.Max(d => d.Count);
|
||||
foreach (var entry in data.Distribution)
|
||||
{
|
||||
table.Cell().Element(BodyCell).Text(entry.Grade);
|
||||
table.Cell().Element(BodyCell).AlignRight().Text(entry.Count.ToString());
|
||||
table.Cell().Element(BodyCell).AlignRight().Text($"{entry.Percent:0.#} %");
|
||||
table.Cell().Element(BodyCell).AlignMiddle().Element(bar =>
|
||||
Bar(bar, maxCount == 0 ? 0 : entry.Count * 100.0 / maxCount, Colors.Blue.Medium));
|
||||
}
|
||||
});
|
||||
|
||||
if (data.TaskAnalysis.Count > 0)
|
||||
{
|
||||
column.Item().Text("Aufgabenanalyse").FontSize(12).SemiBold();
|
||||
column.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2);
|
||||
columns.ConstantColumn(80);
|
||||
columns.RelativeColumn();
|
||||
});
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(HeaderCell).Text("Aufgabe").SemiBold();
|
||||
header.Cell().Element(HeaderCell).AlignRight().Text("Ø Erfüllung").SemiBold();
|
||||
header.Cell().Element(HeaderCell);
|
||||
});
|
||||
foreach (var task in data.TaskAnalysis)
|
||||
{
|
||||
table.Cell().Element(BodyCell).Text(text =>
|
||||
{
|
||||
text.Span(task.Label);
|
||||
if (task.IsWeak) text.Span(" auffällig schwach")
|
||||
.FontColor(Colors.Red.Darken1).FontSize(8);
|
||||
});
|
||||
table.Cell().Element(BodyCell).AlignRight().Text($"{task.AvgPercent:0.#} %");
|
||||
table.Cell().Element(BodyCell).AlignMiddle().Element(bar =>
|
||||
Bar(bar, task.AvgPercent, task.IsWeak ? Colors.Red.Medium : Colors.Green.Medium));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
public byte[] BuildCompetencyReportPdf(CompetencyReportPrintData data) =>
|
||||
Document.Create(container => container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4.Landscape());
|
||||
page.Margin(36);
|
||||
page.DefaultTextStyle(style => style.FontSize(8));
|
||||
|
||||
page.Header().Element(header => Header(header,
|
||||
$"Kompetenzbericht · {data.StudentName}", $"{data.GroupLabel} · {data.Summary}"));
|
||||
|
||||
page.Content().PaddingVertical(10).Column(column =>
|
||||
{
|
||||
column.Spacing(8);
|
||||
column.Item().Text(data.ThresholdLabel).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
column.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(1.4f); // Bereich
|
||||
columns.RelativeColumn(0.5f); // Code
|
||||
columns.RelativeColumn(2.3f); // Beschreibung
|
||||
columns.RelativeColumn(1.1f); // Unterricht
|
||||
columns.RelativeColumn(1.0f); // Klausuren
|
||||
columns.RelativeColumn(1.4f); // Gruppe
|
||||
columns.RelativeColumn(1.1f); // Schüler
|
||||
// "Wiederholungsbedarf" (längster Wert) muss ohne Wortumbruch passen.
|
||||
columns.ConstantColumn(88); // Empfehlung
|
||||
});
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(HeaderCell).Text("Bereich").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Code").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Kompetenz").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Unterricht").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Klausuren").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Gruppenergebnis").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Schüler").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Empfehlung").SemiBold();
|
||||
});
|
||||
foreach (var row in data.Rows)
|
||||
{
|
||||
table.Cell().Element(BodyCell).Text(row.Domain);
|
||||
table.Cell().Element(BodyCell).Text(row.Code);
|
||||
table.Cell().Element(BodyCell).Text(row.Description);
|
||||
table.Cell().Element(BodyCell).Text(row.InstructionCoverage);
|
||||
table.Cell().Element(BodyCell).Text(row.ExamCoverage);
|
||||
table.Cell().Element(BodyCell).Text(row.GroupResult);
|
||||
table.Cell().Element(BodyCell).Text(row.StudentResult).SemiBold();
|
||||
table.Cell().Element(BodyCell).Text(row.Recommendation)
|
||||
.FontColor(row.Recommendation == "Wiederholungsbedarf"
|
||||
? Colors.Red.Darken1
|
||||
: row.Recommendation == "Stand solide"
|
||||
? Colors.Green.Darken1
|
||||
: Colors.Grey.Darken1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
public byte[] BuildStudentDocumentationPdf(StudentDocumentationPrintData data) =>
|
||||
Document.Create(container => container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(36);
|
||||
page.DefaultTextStyle(style => style.FontSize(9));
|
||||
|
||||
page.Header().Element(header => Header(header,
|
||||
$"Dokumentation · {data.StudentName}",
|
||||
data.Entries.Count == 1 ? "1 Eintrag" : $"{data.Entries.Count} Einträge"));
|
||||
|
||||
page.Content().PaddingVertical(10).Column(column =>
|
||||
{
|
||||
column.Spacing(10);
|
||||
foreach (var entry in data.Entries)
|
||||
column.Item().Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(8)
|
||||
.Column(block =>
|
||||
{
|
||||
block.Spacing(3);
|
||||
block.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text(text =>
|
||||
{
|
||||
text.Span(entry.Title).FontSize(10).SemiBold();
|
||||
if (entry.IsConfidential)
|
||||
text.Span(" vertraulich").FontSize(8).FontColor(Colors.Red.Darken1);
|
||||
if (entry.IsDraft)
|
||||
text.Span(" Entwurf").FontSize(8).FontColor(Colors.Orange.Darken2);
|
||||
});
|
||||
row.ConstantItem(130).AlignRight()
|
||||
.Text($"{entry.TypeLabel} · {entry.DateDisplay}")
|
||||
.FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(entry.Content))
|
||||
block.Item().Text(entry.Content);
|
||||
if (entry.Tags.Count > 0)
|
||||
block.Item().Text(string.Join(" · ", entry.Tags))
|
||||
.FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
public byte[] BuildHazardAssessmentPdf(HazardAssessmentPrintData data) =>
|
||||
Document.Create(container => container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(36);
|
||||
page.DefaultTextStyle(style => style.FontSize(9));
|
||||
|
||||
page.Header().Element(header => Header(header, $"Gefährdungsbeurteilung · {data.Title}",
|
||||
$"{data.GroupLabel} · {data.KindLabel} · {data.DateDisplay}"));
|
||||
|
||||
page.Content().PaddingVertical(10).Column(column =>
|
||||
{
|
||||
column.Spacing(10);
|
||||
if (data.IsAiAssisted) column.Item().Element(AiDisclaimerBanner);
|
||||
if (!string.IsNullOrWhiteSpace(data.Procedure))
|
||||
column.Item().Element(c => TextSection(c, "Durchführung", data.Procedure));
|
||||
if (data.Substances.Count > 0)
|
||||
column.Item().Element(c => SubstanceBlocks(c, data.Substances));
|
||||
if (data.Hazards.Count > 0)
|
||||
column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards));
|
||||
if (data.ProtectiveMeasures.Count > 0)
|
||||
column.Item().Element(c => BulletSection(c, "Schutzmaßnahmen", data.ProtectiveMeasures));
|
||||
if (!string.IsNullOrWhiteSpace(data.FirstAid))
|
||||
column.Item().Element(c => TextSection(c, "Erste Hilfe", data.FirstAid));
|
||||
if (!string.IsNullOrWhiteSpace(data.Disposal))
|
||||
column.Item().Element(c => TextSection(c, "Entsorgung", data.Disposal));
|
||||
if (!string.IsNullOrWhiteSpace(data.Notes))
|
||||
column.Item().Element(c => TextSection(c, "Weitere Hinweise", data.Notes));
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
// ── Bausteine ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static void AiDisclaimerBanner(IContainer container) =>
|
||||
container.Background(Colors.Orange.Lighten4).Padding(8).Text(
|
||||
"⚠️ Enthält KI-generierte Anteile — kein rechtssicheres Dokument. Vor Verwendung " +
|
||||
"eigenverantwortlich prüfen, insbesondere H-/P-Sätze und Mengenangaben gegen das " +
|
||||
"Sicherheitsdatenblatt.").FontSize(9).FontColor(Colors.Orange.Darken3).SemiBold();
|
||||
|
||||
private static void TextSection(IContainer container, string title, string text) =>
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().Text(title).FontSize(11).SemiBold();
|
||||
column.Item().Text(text);
|
||||
});
|
||||
|
||||
private static void BulletSection(IContainer container, string title, IReadOnlyList<string> items) =>
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().Text(title).FontSize(11).SemiBold();
|
||||
foreach (var item in items)
|
||||
column.Item().Text($"• {item}");
|
||||
});
|
||||
|
||||
/// Eigener Block je Gefahrstoff statt einer engen Tabelle — bei sieben Datenpunkten pro Stoff
|
||||
/// (inkl. CAS, Signalwort, Altersbeschränkung) wäre eine Tabelle auf A4-Hochformat zu schmal
|
||||
/// für lesbare H-/P-Satz-Texte.
|
||||
private static void SubstanceBlocks(IContainer container, IReadOnlyList<HazardSubstancePrintRow> rows) =>
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Spacing(6);
|
||||
column.Item().Text("Gefahrstoffe").FontSize(11).SemiBold();
|
||||
foreach (var row in rows)
|
||||
column.Item().Element(c => SubstanceBlock(c, row));
|
||||
});
|
||||
|
||||
private static void SubstanceBlock(IContainer container, HazardSubstancePrintRow row) =>
|
||||
container.Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(6).Column(block =>
|
||||
{
|
||||
block.Spacing(2);
|
||||
block.Item().Row(header =>
|
||||
{
|
||||
header.RelativeItem().Text(text =>
|
||||
{
|
||||
text.Span(row.Name).SemiBold();
|
||||
if (!string.IsNullOrWhiteSpace(row.Cas)) text.Span($" · CAS {row.Cas}").FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(row.Amount)) text.Span($" · {row.Amount}").FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(row.SignalWord))
|
||||
header.ConstantItem(70).AlignRight().Text(row.SignalWord).SemiBold()
|
||||
.FontColor(Colors.Red.Darken2);
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(row.Pictograms))
|
||||
block.Item().Text($"GHS: {row.Pictograms}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(row.HStatements))
|
||||
block.Item().Text($"H-Sätze: {row.HStatements}");
|
||||
if (!string.IsNullOrWhiteSpace(row.PStatements))
|
||||
block.Item().Text($"P-Sätze: {row.PStatements}");
|
||||
if (!string.IsNullOrWhiteSpace(row.ActivityRestriction))
|
||||
block.Item().Text($"Tätigkeitsbeschränkung: {row.ActivityRestriction}").SemiBold()
|
||||
.FontColor(Colors.Orange.Darken3);
|
||||
});
|
||||
|
||||
/// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary>
|
||||
private static void Bar(IContainer container, double percent, string color) =>
|
||||
container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3)
|
||||
.AlignLeft().Width((float)(Math.Clamp(percent, 0, 100) * 1.5)).Background(color);
|
||||
|
||||
private static void Header(IContainer container, string title, string subtitle) =>
|
||||
container.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingBottom(6).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(column =>
|
||||
{
|
||||
column.Item().Text(title).FontSize(16).SemiBold();
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
column.Item().Text(subtitle).FontSize(10).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
row.ConstantItem(110).AlignRight().AlignBottom()
|
||||
.Text($"Stand: {DateTime.Now:dd.MM.yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
|
||||
private static void Footer(IContainer container) =>
|
||||
container.AlignCenter().Text(text =>
|
||||
{
|
||||
text.DefaultTextStyle(style => style.FontSize(8).FontColor(Colors.Grey.Darken1));
|
||||
text.Span("Seite ");
|
||||
text.CurrentPageNumber();
|
||||
text.Span(" von ");
|
||||
text.TotalPages();
|
||||
});
|
||||
|
||||
private static void BoardBanner(IContainer container) =>
|
||||
container.AlignCenter().Background(Colors.Grey.Lighten3).PaddingVertical(4)
|
||||
.PaddingHorizontal(40).Text("Tafel / Vorderseite").FontSize(9).SemiBold()
|
||||
.FontColor(Colors.Grey.Darken2);
|
||||
|
||||
private static void SeatGrid(IContainer container, SeatingPlanPrintData data) =>
|
||||
container.Table(table =>
|
||||
{
|
||||
// Tischabstände als eigene, schmale Leerspalten zwischen den Sitzspalten - dieselbe
|
||||
// Idee wie SeatingPlanPanel, nur mit Tabellenspalten statt Panel-Arithmetik. Merkt
|
||||
// sich je Sitzspalte den 1-basierten Tabellenspalten-Index.
|
||||
var tableColumnOfSeatColumn = new int[Math.Max(1, data.Columns)];
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
var next = 1;
|
||||
for (var c = 0; c < data.Columns; c++)
|
||||
{
|
||||
columns.RelativeColumn();
|
||||
tableColumnOfSeatColumn[c] = next++;
|
||||
var gap = c < data.ColumnGapWidths.Count ? data.ColumnGapWidths[c] : 0;
|
||||
if (c < data.Columns - 1 && gap > 0)
|
||||
{
|
||||
columns.ConstantColumn((float)gap * GapPixelToPoint);
|
||||
next++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var seat in data.Seats)
|
||||
{
|
||||
var cell = table.Cell()
|
||||
.Row((uint)(seat.Row + 1))
|
||||
.Column((uint)tableColumnOfSeatColumn[seat.Column])
|
||||
.Padding(3);
|
||||
// Ausgeblendete Plätze (kein Tisch im Raum) bleiben als leere Rasterposition
|
||||
// erhalten, damit die übrigen Plätze der Reihe nicht verrutschen.
|
||||
if (seat.IsHidden) { cell.MinHeight(38); continue; }
|
||||
var box = cell.MinHeight(38).Border(0.8f)
|
||||
.BorderColor(seat.StudentName is null ? Colors.Grey.Lighten2 : Colors.Grey.Darken1)
|
||||
.Padding(4).AlignCenter().AlignMiddle();
|
||||
if (seat.StudentName is null)
|
||||
box.Text("frei").FontSize(8).FontColor(Colors.Grey.Lighten1);
|
||||
else
|
||||
box.Text(seat.StudentName).FontSize(9).SemiBold();
|
||||
}
|
||||
});
|
||||
|
||||
private static IContainer HeaderCell(IContainer container) => container
|
||||
.BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingVertical(3).PaddingHorizontal(2);
|
||||
|
||||
private static IContainer BodyCell(IContainer container) => container
|
||||
.BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).PaddingVertical(2.5f).PaddingHorizontal(2);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public sealed class SchoolWeatherException(string message) : Exception(message);
|
||||
|
||||
/// <summary>Client für das serverseitige Schulstandort-/Wetterprofil. Liest URL und Token je
|
||||
/// Aufruf, damit ein Login oder Serverwechsel in den Einstellungen sofort berücksichtigt wird.</summary>
|
||||
public sealed class SchoolWeatherService(HttpClient http, SyncSettingsService syncSettings)
|
||||
{
|
||||
public bool IsAvailable => !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) && syncSettings.IsLoggedIn;
|
||||
|
||||
public async Task<SchoolLocationProfile?> GetLocationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await SendAsync(HttpMethod.Get, "/api/school/location", null, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound) return null;
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<SchoolLocationProfile>(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SchoolLocationProfile> SaveLocationAsync(SchoolLocationRequest location,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await SendAsync(HttpMethod.Put, "/api/school/location", location, cancellationToken);
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<SchoolLocationProfile>(cancellationToken)
|
||||
?? throw new SchoolWeatherException("Der Server hat keine Standortdaten zurückgegeben.");
|
||||
}
|
||||
|
||||
public async Task<WeatherSnapshot?> GetWeatherAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await SendAsync(HttpMethod.Get, "/api/school/weather", null, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound) return null;
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<WeatherSnapshot>(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(syncSettings.ServerUrl) || !syncSettings.IsLoggedIn)
|
||||
throw new SchoolWeatherException("Bitte zuerst im Reiter „Synchronisation“ am Server anmelden.");
|
||||
using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
|
||||
var token = syncSettings.GetToken();
|
||||
if (token is not null)
|
||||
request.Headers.Authorization = new("Bearer", token);
|
||||
if (body is not null) request.Content = JsonContent.Create(body);
|
||||
try { return await http.SendAsync(request, cancellationToken); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new SchoolWeatherException("Der LehrerApp-Server ist nicht erreichbar.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new SchoolWeatherException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
if (document.RootElement.TryGetProperty("detail", out var detail) &&
|
||||
!string.IsNullOrWhiteSpace(detail.GetString()))
|
||||
throw new SchoolWeatherException(detail.GetString()!);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
throw new SchoolWeatherException("Der Schulstandort konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Sync;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public class SyncAuthException(string userMessage) : Exception(userMessage);
|
||||
|
||||
public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
|
||||
public enum SyncConnectionTestResult { Ok, Unauthorized, IncompatibleVersion, Unreachable }
|
||||
|
||||
/// <summary>
|
||||
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
|
||||
@@ -27,7 +28,10 @@ public class SyncAuthService(HttpClient http)
|
||||
HttpResponseMessage resp;
|
||||
try
|
||||
{
|
||||
resp = await http.PostAsJsonAsync(CombineUrl(serverUrl, "/api/auth/login"), new { username, password });
|
||||
using var req = SyncProtocol.CreateRequest(HttpMethod.Post,
|
||||
CombineUrl(serverUrl, "/api/auth/login"));
|
||||
req.Content = JsonContent.Create(new { username, password });
|
||||
resp = await http.SendAsync(req);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
@@ -49,6 +53,7 @@ public class SyncAuthService(HttpClient http)
|
||||
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status"));
|
||||
req.Headers.Add(SyncProtocol.VersionHeaderName, SyncProtocol.CurrentVersion);
|
||||
if (token is not null)
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
@@ -56,6 +61,8 @@ public class SyncAuthService(HttpClient http)
|
||||
{
|
||||
var resp = await http.SendAsync(req);
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized;
|
||||
if (resp.StatusCode == HttpStatusCode.UpgradeRequired)
|
||||
return SyncConnectionTestResult.IncompatibleVersion;
|
||||
return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable;
|
||||
}
|
||||
catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Erkennt, ob der lokale Sync-Schlüssel (sync.key) auf diesem Gerät fehlte, obwohl Sync hier
|
||||
/// bereits aktiv war — dann hat AppBootstrapper beim Start automatisch einen neuen, unabhängigen
|
||||
/// Schlüssel erzeugt, und alle bisher unter dem ALTEN Schlüssel synchronisierten Server-Daten sind
|
||||
/// für dieses Gerät nicht mehr lesbar (TODO 10.3.2). Ein fehlender Schlüssel bei einem Gerät, das
|
||||
/// noch nie eingeloggt war, ist dagegen der normale Erstlauf — kein Grund zur Warnung.
|
||||
/// </summary>
|
||||
public class SyncKeyStatus(bool keyWasRegenerated)
|
||||
{
|
||||
public bool KeyWasRegenerated { get; } = keyWasRegenerated;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Ergebnis von <see cref="UntisReportCacheService.Plan"/>: was für eine Anfrage
|
||||
/// [<c>RequestStart</c>..<c>RequestEnd</c>, nicht Teil dieses Records, siehe Aufrufer] tatsächlich
|
||||
/// live nachgeladen werden muss, und welcher Deckungsstand danach gilt. Bewusst als reine, ohne
|
||||
/// Repository/HTTP-Zugriff testbare Funktion ausgelagert (gleiches Muster wie
|
||||
/// <see cref="ClassTeacher.ClassAbsenceDaySummaryRow.GroupByStudentAndDay"/>).</summary>
|
||||
public readonly record struct UntisCacheRefreshPlan(
|
||||
bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd,
|
||||
bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd,
|
||||
int? ResultingColdCoverageStartDate);
|
||||
|
||||
/// <summary>
|
||||
/// Lokaler Cache für die beiden Klassenlehrer-Berichte (Fehlzeiten, Klassenbucheinträge anderer
|
||||
/// Lehrkräfte, siehe TODO.md) - Nutzer-Feedback: Warnungen sollen sofort da sein, ohne bei jedem
|
||||
/// Öffnen der Ansicht neu abzurufen, und der Bericht darf nicht mehrfach pro Stunde abgerufen
|
||||
/// werden (Sorge, bei WebUntis aufzufallen, wenn dieselbe Ansicht mehrfach geöffnet wird). Ein
|
||||
/// festes "heißes" Fenster der letzten <see cref="HotWindowDays"/> Tage wird höchstens stündlich
|
||||
/// aufgefrischt (dort kann sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt");
|
||||
/// alles Ältere gilt als endgültig und wird, einmal abgerufen, dauerhaft aus dem Cache bedient.
|
||||
/// Die Cache-Tabellen selbst sind bewusst nicht synchronisiert (siehe
|
||||
/// <see cref="IUntisAbsenceCacheRepository"/>-Implementierung) - jedes Gerät füllt seinen Cache
|
||||
/// über diesen Service selbst.
|
||||
/// </summary>
|
||||
public sealed class UntisReportCacheService(
|
||||
WebUntisIntegrationService untis,
|
||||
IUntisAbsenceCacheRepository absenceCache,
|
||||
IUntisClassRegisterCacheRepository classRegisterCache,
|
||||
IUntisStudentRosterCacheRepository rosterCache,
|
||||
IUntisCacheFetchStateRepository fetchState)
|
||||
{
|
||||
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
|
||||
private const int HotWindowDays = 14;
|
||||
|
||||
/// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer
|
||||
/// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch".
|
||||
/// Behebt, dass die Klassenlehrer-Übersicht bei jedem Öffnen spürbar verzögert wirkte: der
|
||||
/// Roster-Abruf lief bislang komplett am Cache vorbei live gegen WebUntis (Nutzer-Feedback).
|
||||
public async Task<IReadOnlyList<UntisStudentRosterCacheEntry>> GetStudentRosterAsync(string className,
|
||||
bool forceRefresh = false, CancellationToken token = default)
|
||||
{
|
||||
var state = fetchState.Get(className, UntisCacheKind.Roster);
|
||||
if (NeedsRefresh(state, forceRefresh, DateTime.UtcNow))
|
||||
{
|
||||
var fresh = await untis.GetStudentsAsync(className, token);
|
||||
rosterCache.ReplaceAll(className, fresh.Students.Select(s => new UntisStudentRosterCacheEntry
|
||||
{
|
||||
ClassName = className, ExternKey = s.ExternKey, DisplayName = s.DisplayName,
|
||||
}));
|
||||
fetchState.Save(new UntisCacheFetchState
|
||||
{
|
||||
Id = state?.Id ?? Guid.NewGuid(), ClassName = className, Kind = UntisCacheKind.Roster,
|
||||
HotWindowFetchedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
return rosterCache.GetByClass(className);
|
||||
}
|
||||
|
||||
private static bool NeedsRefresh(UntisCacheFetchState? state, bool forceRefresh, DateTime utcNow) =>
|
||||
forceRefresh || state?.HotWindowFetchedAt is null || utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval;
|
||||
|
||||
public async Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetAbsencesAsync(string className,
|
||||
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
|
||||
{
|
||||
var state = fetchState.Get(className, UntisCacheKind.Absences);
|
||||
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
|
||||
|
||||
if (plan.RefreshHotWindow)
|
||||
{
|
||||
var fresh = await untis.GetClassAbsencesAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
|
||||
absenceCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
|
||||
}
|
||||
if (plan.FetchColdRange)
|
||||
{
|
||||
var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
|
||||
absenceCache.InsertRange(older.Select(ToEntry));
|
||||
}
|
||||
if (plan.RefreshHotWindow || plan.FetchColdRange)
|
||||
fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan));
|
||||
|
||||
return absenceCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetClassRegisterEventsAsync(
|
||||
string className, DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
|
||||
{
|
||||
var state = fetchState.Get(className, UntisCacheKind.ClassRegister);
|
||||
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
|
||||
|
||||
if (plan.RefreshHotWindow)
|
||||
{
|
||||
var fresh = await untis.GetForeignClassRegisterEventsAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
|
||||
classRegisterCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
|
||||
}
|
||||
if (plan.FetchColdRange)
|
||||
{
|
||||
var older = await untis.GetForeignClassRegisterEventsAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
|
||||
classRegisterCache.InsertRange(older.Select(ToEntry));
|
||||
}
|
||||
if (plan.RefreshHotWindow || plan.FetchColdRange)
|
||||
fetchState.Save(NextState(state, className, UntisCacheKind.ClassRegister, plan));
|
||||
|
||||
return classRegisterCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// Reine Entscheidungslogik ohne Repository-/HTTP-Zugriff: was muss für eine Anfrage
|
||||
/// [<paramref name="requestStart"/>..<paramref name="requestEnd"/>] live nachgeladen werden,
|
||||
/// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest
|
||||
/// [<paramref name="today"/> − <see cref="HotWindowDays"/>, <paramref name="today"/>], unabhängig
|
||||
/// von der angefragten Spanne. Nach einem heißen Refresh reicht die bekannte Abdeckung mindestens
|
||||
/// bis zum Fensteranfang zurück; ein zusätzlicher kalter Abruf erweitert sie nur so weit wie
|
||||
/// für <paramref name="requestStart"/> nötig, nie weiter.
|
||||
public static UntisCacheRefreshPlan Plan(UntisCacheFetchState? state, DateOnly requestStart, DateOnly requestEnd,
|
||||
DateOnly today, bool forceRefresh, DateTime utcNow)
|
||||
{
|
||||
var hotStart = today.AddDays(-HotWindowDays);
|
||||
var refreshHot = requestEnd >= hotStart && (forceRefresh || state?.HotWindowFetchedAt is null
|
||||
|| utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval);
|
||||
|
||||
int? coverageStart = state?.ColdCoverageStartDate;
|
||||
if (refreshHot)
|
||||
coverageStart = coverageStart is { } existing ? Math.Min(existing, Int(hotStart)) : Int(hotStart);
|
||||
|
||||
var coldEnd = coverageStart is { } covered ? FromInt(covered).AddDays(-1) : hotStart.AddDays(-1);
|
||||
var fetchCold = (coverageStart is null || requestStart < FromInt(coverageStart.Value)) && requestStart <= coldEnd;
|
||||
if (fetchCold) coverageStart = Int(requestStart);
|
||||
|
||||
return new UntisCacheRefreshPlan(refreshHot, hotStart, today, fetchCold, requestStart, coldEnd, coverageStart);
|
||||
}
|
||||
|
||||
private static UntisCacheFetchState NextState(UntisCacheFetchState? previous, string className,
|
||||
UntisCacheKind kind, UntisCacheRefreshPlan plan) => new()
|
||||
{
|
||||
Id = previous?.Id ?? Guid.NewGuid(),
|
||||
ClassName = className,
|
||||
Kind = kind,
|
||||
HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt,
|
||||
ColdCoverageStartDate = plan.ResultingColdCoverageStartDate,
|
||||
};
|
||||
|
||||
private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);
|
||||
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||
private static DateOnly FromInt(int value) => new(value / 10000, value / 100 % 100, value % 100);
|
||||
|
||||
private static UntisAbsenceCacheEntry ToEntry(UntisClassAbsenceEntryDto x) => new()
|
||||
{
|
||||
ClassName = x.ClassName, StudentName = x.StudentName, ExternKey = x.ExternKey, Date = x.Date,
|
||||
AbsentPeriods = x.AbsentPeriods, AbsentMinutes = x.AbsentMinutes, TeacherUsernames = x.TeacherUsernames,
|
||||
Subject = x.Subject, AbsenceReason = x.AbsenceReason, Note = x.Note, EntryId = x.EntryId,
|
||||
HandledOn = x.HandledOn, Counts = x.Counts, ExcuseNote = x.ExcuseNote, PeriodNumber = x.PeriodNumber,
|
||||
Status = x.Status, CountsAsFullDay = x.CountsAsFullDay,
|
||||
};
|
||||
|
||||
private static UntisClassAbsenceEntryDto ToDto(UntisAbsenceCacheEntry e) => new(
|
||||
e.StudentName, e.ExternKey, e.ClassName, e.Date, e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames,
|
||||
e.Subject, e.AbsenceReason, e.Note, e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber,
|
||||
e.Status, e.CountsAsFullDay);
|
||||
|
||||
private static UntisClassRegisterCacheEntry ToEntry(UntisForeignClassRegisterEventDto x) => new()
|
||||
{
|
||||
ClassName = x.ClassName, Date = x.Date, Subject = x.Subject, StudentName = x.StudentName,
|
||||
TeacherUsername = x.TeacherUsername, CategoryName = x.CategoryName, CategoryGroup = x.CategoryGroup,
|
||||
Text = x.Text,
|
||||
};
|
||||
|
||||
private static UntisForeignClassRegisterEventDto ToDto(UntisClassRegisterCacheEntry e) => new(
|
||||
e.ClassName, e.Date, e.Subject, e.StudentName, e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text);
|
||||
}
|
||||
@@ -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,238 @@
|
||||
using LehrerApp.WebUntis;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public sealed class WebUntisIntegrationException(string message) : Exception(message);
|
||||
|
||||
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
|
||||
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
||||
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
||||
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
||||
{
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(LongName)
|
||||
? Name
|
||||
: $"{ForeName} {LongName} ({Name})".Trim();
|
||||
}
|
||||
public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey);
|
||||
public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime);
|
||||
public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList<UntisTimeUnitDto> TimeUnits);
|
||||
public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, int EndTime, string? Code,
|
||||
string? ActivityType, string? Info, string? LessonText, string? SubstitutionText, string? StudentGroup,
|
||||
IReadOnlyList<UntisEntityDto> Classes, IReadOnlyList<UntisEntityDto> Teachers,
|
||||
IReadOnlyList<UntisEntityDto> Subjects, IReadOnlyList<UntisEntityDto> Rooms);
|
||||
public sealed record UntisStudentAddressDto(string? Email, string? Mobile, string? Phone, string? City,
|
||||
string? PostCode, string? Street);
|
||||
public sealed record UntisStudentDto(int UntisId, int? ExternKey, string ClassName, string? Name, string? LongName,
|
||||
string? ForeName, string DisplayName, string? Gender, int? BirthDate, string? BirthDateRaw, int? EntryDate,
|
||||
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
|
||||
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
|
||||
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
|
||||
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
|
||||
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
|
||||
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
|
||||
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
||||
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
|
||||
public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
||||
string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
|
||||
public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date,
|
||||
int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason,
|
||||
string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber,
|
||||
string? Status, bool CountsAsFullDay);
|
||||
|
||||
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
|
||||
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
|
||||
public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _clientGate = new(1, 1);
|
||||
private WebUntisClient? _client;
|
||||
|
||||
public bool IsAvailable => settings.ApiIsConfigured;
|
||||
|
||||
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
|
||||
{
|
||||
var candidate = CreateClient(credentials);
|
||||
try
|
||||
{
|
||||
await candidate.GetSchoolYearsAsync(token);
|
||||
}
|
||||
catch (Exception exception) when (IsUntisError(exception))
|
||||
{
|
||||
await candidate.DisposeAsync();
|
||||
throw Translate(exception);
|
||||
}
|
||||
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try { previous = _client; _client = candidate; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken token = default)
|
||||
{
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try { previous = _client; _client = null; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
||||
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
||||
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisTeacherDto>)(await client.GetTeachersAsync(token))
|
||||
.Select(x => new UntisTeacherDto(x.UntisId, x.Name, x.ForeName, x.LongName, x.Title, x.Active,
|
||||
x.DepartmentUntisIds)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisTimeGridDayDto>)(await client.GetTimeGridAsync(token))
|
||||
.Select(x => new UntisTimeGridDayDto(x.Day,
|
||||
x.TimeUnits.Select(t => new UntisTimeUnitDto(t.Name, t.StartTime, t.EndTime)).ToList())).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
|
||||
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
(IReadOnlyList<UntisTimetablePeriodDto>)(await client.GetTimetableAsync(
|
||||
UntisTimetableElementType.Teacher, teacherId, Date(start), Date(end), token))
|
||||
.Select(x => new UntisTimetablePeriodDto(x.Id, x.Date, x.StartTime, x.EndTime, x.Code,
|
||||
x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup,
|
||||
Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token);
|
||||
|
||||
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
|
||||
async client =>
|
||||
{
|
||||
var report = await client.GetStudentReportAsync(className, token);
|
||||
return new UntisStudentReportDto(report.Count, report.ClassNameFilter, report.Students.Select(x =>
|
||||
new UntisStudentDto(x.UntisId, x.ExternKey, x.ClassName, x.Name, x.LongName, x.ForeName,
|
||||
x.DisplayName, x.Gender, x.BirthDate, x.BirthDateRaw, x.EntryDate, x.EntryDateRaw,
|
||||
x.ExitDate, x.ExitDateRaw, x.Text, x.MedicalReportDuty, x.Schulpflicht, x.Majority,
|
||||
new UntisStudentAddressDto(x.Address.Email, x.Address.Mobile, x.Address.Phone, x.Address.City,
|
||||
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
||||
}, token);
|
||||
|
||||
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
|
||||
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisLessonAbsenceDto>)absences.Select(x => new UntisLessonAbsenceDto(
|
||||
x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
|
||||
x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
|
||||
x.HandledOn, x.Counts)).ToList();
|
||||
}, token);
|
||||
|
||||
// "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge
|
||||
// anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht
|
||||
// gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich.
|
||||
public Task<IReadOnlyList<UntisClassRegisterEventDto>> GetOwnClassRegisterEventsAsync(DateOnly start,
|
||||
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var ownUsername = settings.GetApiCredentials()?.Username;
|
||||
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisClassRegisterEventDto>)entries
|
||||
.Where(x => ownUsername is not null
|
||||
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
|
||||
x.CategoryName, x.CategoryGroup, x.Text))
|
||||
.ToList();
|
||||
}, token);
|
||||
|
||||
// Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
|
||||
// dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
|
||||
// die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
|
||||
// sobald eine Lehrkraft mehrere Klassen unterrichtet).
|
||||
public Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetForeignClassRegisterEventsAsync(
|
||||
string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var ownUsername = settings.GetApiCredentials()?.Username;
|
||||
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisForeignClassRegisterEventDto>)entries
|
||||
.Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
|
||||
// Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
|
||||
// ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
|
||||
// (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
|
||||
&& !(ownUsername is not null
|
||||
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
|
||||
x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
|
||||
.ToList();
|
||||
}, token);
|
||||
|
||||
// Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
|
||||
// für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
|
||||
// eines Namens, siehe ResolveClassIdAsync.
|
||||
public Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetClassAbsencesAsync(string className,
|
||||
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var classId = await ResolveClassIdAsync(client, className, token);
|
||||
var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisClassAbsenceEntryDto>)entries.Select(x => new UntisClassAbsenceEntryDto(
|
||||
x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
|
||||
x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
|
||||
x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
|
||||
.ToList();
|
||||
}, token);
|
||||
|
||||
// "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
|
||||
// (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
|
||||
// aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
|
||||
// Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
|
||||
private static async Task<string> ResolveClassIdAsync(WebUntisClient client, string className,
|
||||
CancellationToken token)
|
||||
{
|
||||
var years = await client.GetSchoolYearsAsync(token);
|
||||
var today = Date(DateOnly.FromDateTime(DateTime.Today));
|
||||
var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
|
||||
?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
|
||||
?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
|
||||
var classes = await client.GetClassesAsync(year.UntisId, token);
|
||||
var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
return matches.Count switch
|
||||
{
|
||||
1 => $"KL{matches[0].UntisId}",
|
||||
0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
|
||||
_ => throw new WebUntisIntegrationException(
|
||||
$"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
||||
{
|
||||
try { return await operation(await GetClientAsync(token)); }
|
||||
catch (Exception exception) when (IsUntisError(exception)) { throw Translate(exception); }
|
||||
}
|
||||
|
||||
private async Task<WebUntisClient> GetClientAsync(CancellationToken token)
|
||||
{
|
||||
if (_client is not null) return _client;
|
||||
await _clientGate.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
if (_client is not null) return _client;
|
||||
var credentials = settings.GetApiCredentials()
|
||||
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
|
||||
return _client = CreateClient(credentials);
|
||||
}
|
||||
finally { _clientGate.Release(); }
|
||||
}
|
||||
|
||||
private WebUntisClient CreateClient(WebUntisCredentials credentials) => new(http, new WebUntisOptions
|
||||
{
|
||||
School = credentials.School, Host = credentials.Host, Username = credentials.Username,
|
||||
Password = credentials.Password, Client = "LehrerApp-Desktop", SessionIdleTimeoutMinutes = 10,
|
||||
});
|
||||
|
||||
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||
private static IReadOnlyList<UntisEntityDto> Entities(IReadOnlyList<UntisEntity> values) => values
|
||||
.Select(x => new UntisEntityDto(x.Id, x.Name, x.OriginalId, x.OriginalName, x.ExternalKey)).ToList();
|
||||
private static bool IsUntisError(Exception exception) => exception is WebUntisException
|
||||
or WebUntisConfigurationException or InvalidDataException;
|
||||
private static WebUntisIntegrationException Translate(Exception exception) =>
|
||||
new(exception.Message);
|
||||
|
||||
public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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; } = "";
|
||||
public string? EncryptedApiCredentials { get; set; }
|
||||
public int? TeacherUntisId { get; set; }
|
||||
public int? HomeroomClassUntisId { get; set; }
|
||||
public string? HomeroomClassName { get; set; }
|
||||
}
|
||||
|
||||
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
|
||||
|
||||
/// <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 bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
|
||||
public int? TeacherUntisId => _config.TeacherUntisId;
|
||||
public int? HomeroomClassUntisId => _config.HomeroomClassUntisId;
|
||||
public string? HomeroomClassName => _config.HomeroomClassName;
|
||||
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 SetApiCredentials(WebUntisCredentials credentials)
|
||||
{
|
||||
_config.EncryptedApiCredentials = SyncCrypto.EncryptObject(credentials, _urlKey);
|
||||
Save();
|
||||
}
|
||||
|
||||
public WebUntisCredentials? GetApiCredentials() => _config.EncryptedApiCredentials is null
|
||||
? null
|
||||
: SyncCrypto.DecryptObject<WebUntisCredentials>(_config.EncryptedApiCredentials, _urlKey);
|
||||
|
||||
public void ClearApiCredentials()
|
||||
{
|
||||
_config.EncryptedApiCredentials = null;
|
||||
_config.TeacherUntisId = null;
|
||||
_config.HomeroomClassUntisId = null;
|
||||
_config.HomeroomClassName = null;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void SetTeacherUntisId(int? teacherUntisId)
|
||||
{
|
||||
_config.TeacherUntisId = teacherUntisId;
|
||||
Save();
|
||||
}
|
||||
|
||||
// Die Klasse, deren Klassenlehrer/-in man ist - unabhängig von jeder LearningGroup (siehe
|
||||
// TODO.md "Klassenlehrer"-Feature): man ist es für die ganze Klasse, nicht für einen einzelnen
|
||||
// Unterricht/Kurs. HomeroomClassName ist wie TeacherUntisId unverschlüsselt, da kein Geheimnis.
|
||||
public void SetHomeroomClass(int? untisId, string? name)
|
||||
{
|
||||
_config.HomeroomClassUntisId = untisId;
|
||||
_config.HomeroomClassName = name;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public sealed class WindowSettings
|
||||
{
|
||||
public double Width { get; set; } = 1280;
|
||||
public double Height { get; set; } = 800;
|
||||
public bool IsMaximized { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkt sich Fenstergröße und Maximiert-Status über Sitzungen hinweg (14.6). Bewusst OHNE
|
||||
/// Fensterposition: ein Laptop, der mal mit, mal ohne externen Monitor läuft, könnte sonst dazu
|
||||
/// führen, dass das Fenster bei der nächsten Sitzung außerhalb jedes sichtbaren Bildschirms
|
||||
/// landet — die Größe allein ist der Teil, der täglich beim Korrigieren am Abend stört.
|
||||
/// </summary>
|
||||
public sealed class WindowSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public WindowSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "windowsettings.json");
|
||||
|
||||
public WindowSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
{
|
||||
var loaded = JsonSerializer.Deserialize<WindowSettings>(File.ReadAllText(_configPath));
|
||||
if (loaded is { Width: >= 200, Height: >= 150 }) return loaded;
|
||||
}
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardgröße */ }
|
||||
return new WindowSettings();
|
||||
}
|
||||
|
||||
public void Save(WindowSettings settings) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(settings));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Semantische Pinsel für Flächen und Statusfarben (Klassenlehrer-Bereich, perspektivisch auch
|
||||
die übrigen Ansichten). Hart codierte Hex-Werte in den Views waren auf das dunkle Theme
|
||||
zugeschnitten und blieben beim Umschalten auf "Hell" stehen — dunkle Kartenflächen auf hellem
|
||||
Fenstergrund, Statustexte ohne Kontrast. Über ThemeDictionaries tauscht Avalonia die Werte beim
|
||||
Theme-Wechsel live aus, sofern die Views sie per DynamicResource (nicht StaticResource) holen.
|
||||
-->
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<!-- Flächen -->
|
||||
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#11151B"/>
|
||||
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#2A3038"/>
|
||||
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#0E1217"/>
|
||||
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#171D25"/>
|
||||
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#262C34"/>
|
||||
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#252B33"/>
|
||||
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#1C222A"/>
|
||||
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#4A515C"/>
|
||||
<!-- Akzent -->
|
||||
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#5795F5"/>
|
||||
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#173765"/>
|
||||
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#66A3FF"/>
|
||||
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#343B45"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#173765"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#3C86F7"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#FFFFFF"/>
|
||||
<!-- Status: Nutzer-Feedback (August 2026) — Warnung (Amber) und Gefahr (Korallrot) waren
|
||||
auf dem Bildschirm kaum auseinanderzuhalten. Per scripts/validate_palette.js der
|
||||
dataviz-Skill gegen AppTrackBackgroundBrush/AppCardBackgroundBrush geprüft (adjacent
|
||||
pairs, wie sie im gestapelten Balken tatsächlich aneinanderstoßen): die alte Kombi
|
||||
lag bei ΔE nur 4.6–10.8 (protan/deutan), jetzt 10.2–24.4 — deutlich über der
|
||||
Zielschwelle von 8. Ok/Warnung mussten zusätzlich im Tagesüberblick-Balken die
|
||||
Reihenfolge tauschen (Info dazwischen), weil Grün und das validierte Dunkel-Gelb sonst
|
||||
direkt aneinanderstießen (ΔE nur 3.0 unter Protanopie) — siehe
|
||||
ClassTeacherOverviewView.axaml.
|
||||
good=#008300, warning(dunkel)=#c98500, warning(hell)=#eda100, critical=#d03b3b
|
||||
sind dieselben Stufen wie die validierte Kategorial-/Statuspalette der dataviz-Skill;
|
||||
bewusst nicht wieder frei gewählt, um nicht erneut ins selbe Problem zu laufen. -->
|
||||
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
|
||||
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#3987E5"/>
|
||||
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#C98500"/>
|
||||
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<!-- Flächen: Karten heben sich hier heller vom grauen Fenstergrund ab, nicht dunkler. -->
|
||||
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#DCE1E8"/>
|
||||
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#EDF2F9"/>
|
||||
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#E3E7ED"/>
|
||||
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#E3E7ED"/>
|
||||
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#EDF0F5"/>
|
||||
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#B7BEC9"/>
|
||||
<!-- Akzent: dunklere Töne, die Akzentfarben aus dem Dark-Theme wären auf Weiß zu blass. -->
|
||||
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#1B5FC7"/>
|
||||
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#DCE9FC"/>
|
||||
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#14509F"/>
|
||||
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#C7CDD6"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#D7E6FC"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#2C6FD8"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#10243F"/>
|
||||
<!-- Status: siehe Dark-Kommentar oben — dieselbe Validierung, helle Stufen. -->
|
||||
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
|
||||
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#2A78D6"/>
|
||||
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#EDA100"/>
|
||||
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
|
||||
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
|
||||
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.</summary>
|
||||
public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
|
||||
string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
|
||||
{
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public string StudentDisplayName => DisplayName(StudentName);
|
||||
public ClassTeacherStatusKind CategoryKind =>
|
||||
CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
|
||||
? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
|
||||
public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
|
||||
|
||||
private static string DisplayName(string value)
|
||||
{
|
||||
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
|
||||
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
|
||||
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
|
||||
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
|
||||
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
|
||||
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
|
||||
{
|
||||
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
|
||||
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
|
||||
|
||||
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
|
||||
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
|
||||
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
|
||||
.GroupBy(e => e.CategoryName!)
|
||||
.Select(g =>
|
||||
{
|
||||
var topStudents = g.GroupBy(e => e.StudentDisplayName)
|
||||
.Select(sg => (Name: sg.Key, Count: sg.Count()))
|
||||
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
|
||||
.Take(topStudentsPerCategory)
|
||||
.Select(sg => $"{sg.Count}× {sg.Name}");
|
||||
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
|
||||
})
|
||||
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
|
||||
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
|
||||
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
|
||||
/// Rohdaten). Die Rohzeilen (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
|
||||
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
|
||||
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
|
||||
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
|
||||
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
|
||||
string? Note, string? ExcuseNote, bool CountsAsFullDay)
|
||||
{
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public string StudentDisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length < 2 ? StudentName : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||||
}
|
||||
}
|
||||
public string SubjectsLabel => string.Join(", ", Subjects);
|
||||
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
|
||||
public string StatusLabel => string.Join(", ", Statuses);
|
||||
public string ReasonLabel => string.Join("; ", AbsenceReasons);
|
||||
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
|
||||
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsUnexcused => Statuses.Any(s =>
|
||||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
|
||||
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
|
||||
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
|
||||
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
|
||||
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
|
||||
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
|
||||
IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
|
||||
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
|
||||
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
|
||||
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
|
||||
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
|
||||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||
.Where(x => x.Date is not null)
|
||||
.GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
|
||||
.Select(g => new ClassAbsenceDaySummaryRow(
|
||||
g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
|
||||
g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
|
||||
Distinct(g.Select(x => x.Entry.Subject)),
|
||||
g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
|
||||
Distinct(g.Select(x => x.Entry.Status)),
|
||||
Distinct(g.Select(x => x.Entry.AbsenceReason)),
|
||||
g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
|
||||
g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
|
||||
g.Any(x => x.Entry.CountsAsFullDay)))
|
||||
.OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
|
||||
.ToList();
|
||||
|
||||
private static List<string> Distinct(IEnumerable<string?> values) =>
|
||||
values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
|
||||
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
|
||||
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der
|
||||
/// Klasse über alle Fächer — beide über <see cref="UntisReportCacheService"/> (lokal gecacht statt
|
||||
/// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in
|
||||
/// lokale Daten. Bewusst unabhängig von jeder <see cref="Core.Models.LearningGroup"/> —
|
||||
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
|
||||
/// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
|
||||
/// nicht aus einer Gruppen-ID.
|
||||
/// </summary>
|
||||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private string _className = "";
|
||||
|
||||
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||||
[ObservableProperty] private string _studentFilter = "";
|
||||
[ObservableProperty] private int _quickRangeIndex = 1;
|
||||
|
||||
public bool HasEntries => Entries.Count > 0;
|
||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||
? "Alle Schüler*innen" : StudentFilter;
|
||||
|
||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public void Initialize(string className)
|
||||
{
|
||||
_className = className;
|
||||
StudentFilter = "";
|
||||
Entries.Clear();
|
||||
AbsenceEntries.Clear();
|
||||
CategoryAggregates.Clear();
|
||||
Status = "Zeitraum wählen und laden.";
|
||||
NotifyListState();
|
||||
}
|
||||
|
||||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||||
|
||||
partial void OnQuickRangeIndexChanged(int value)
|
||||
{
|
||||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||||
EndDate = DateTimeOffset.Now;
|
||||
StartDate = DateTimeOffset.Now.AddDays(-days);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task Load() => LoadInternal(forceRefresh: false);
|
||||
|
||||
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
|
||||
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
||||
[RelayCommand]
|
||||
private Task Refresh() => LoadInternal(forceRefresh: true);
|
||||
|
||||
[RelayCommand]
|
||||
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClearStudentFilter()
|
||||
{
|
||||
StudentFilter = "";
|
||||
await LoadInternal(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async Task LoadInternal(bool forceRefresh)
|
||||
{
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
||||
|
||||
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
|
||||
try
|
||||
{
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||||
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
|
||||
await Task.WhenAll(classRegisterTask, absencesTask);
|
||||
|
||||
var ordered = classRegisterTask.Result
|
||||
.Where(MatchesStudentFilter)
|
||||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||
.Where(x => x.Date is not null)
|
||||
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||||
foreach (var (entry, date) in ordered)
|
||||
{
|
||||
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
|
||||
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
|
||||
}
|
||||
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
|
||||
|
||||
var absences = absencesTask.Result.Where(MatchesStudentFilter);
|
||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||
AbsenceEntries.Add(row);
|
||||
|
||||
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
|
||||
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
|
||||
NotifyListState();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; NotifyListState(); }
|
||||
}
|
||||
|
||||
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
||||
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||||
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
||||
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
|
||||
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||||
|
||||
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
|
||||
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
|
||||
private void NotifyListState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im
|
||||
/// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML
|
||||
/// (<c>Styles/SemanticBrushes.axaml</c>), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt —
|
||||
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
||||
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
||||
|
||||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||
{
|
||||
public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; }
|
||||
public bool HasClassRegisterToday { get; init; }
|
||||
public bool IsLate => TodayAbsence is { } a &&
|
||||
(a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason));
|
||||
public bool IsUnexcused => TodayAbsence?.Statuses.Any(s =>
|
||||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true;
|
||||
public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry;
|
||||
public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 :
|
||||
HasRecentClassRegisterEntry ? 3 : 4;
|
||||
|
||||
public string Initials
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TodayAbsence is null)
|
||||
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
|
||||
var core = IsLate
|
||||
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
|
||||
: TodayAbsence.CountsAsFullDay
|
||||
? "Ganzer Fehltag"
|
||||
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
|
||||
return IsUnexcused ? $"{core} · Unentschuldigt" : core;
|
||||
}
|
||||
}
|
||||
|
||||
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
|
||||
HasAbsenceToday ? ClassTeacherStatusKind.Warning :
|
||||
HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok;
|
||||
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
|
||||
public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info;
|
||||
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
|
||||
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
|
||||
|
||||
/// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des
|
||||
/// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr
|
||||
/// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste).
|
||||
public string StatusTextWithGlyph => StatusKind switch
|
||||
{
|
||||
ClassTeacherStatusKind.Danger => $"✕ {StatusText}",
|
||||
ClassTeacherStatusKind.Warning => $"△ {StatusText}",
|
||||
_ => StatusText,
|
||||
};
|
||||
|
||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
||||
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
||||
public int YearAbsenceDayCount { get; init; }
|
||||
public int YearUnexcusedDayCount { get; init; }
|
||||
public int SchoolDaysElapsed { get; init; }
|
||||
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
||||
public int YearAbsenceRatePercent =>
|
||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||
public string YearSummaryLabel => HasYearSummary
|
||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||
DateOnly? today = null,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||
int schoolDaysElapsed = 0)
|
||||
{
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||||
var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
var yearRows = yearAbsences ?? [];
|
||||
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||
var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
return students.Select(student =>
|
||||
{
|
||||
var nameKey = UntisNameMatching.NameKey(student.DisplayName);
|
||||
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
||||
?? absenceByName.GetValueOrDefault(nameKey);
|
||||
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
||||
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
||||
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||
registerEntries.Count > 0)
|
||||
{
|
||||
TodayAbsence = absence,
|
||||
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
||||
YearAbsenceDayCount = yearEntries.Count,
|
||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||
SchoolDaysElapsed = schoolDaysElapsed,
|
||||
};
|
||||
})
|
||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||
}
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
|
||||
/// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige
|
||||
/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
|
||||
/// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
|
||||
/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
|
||||
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>).
|
||||
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
|
||||
int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction,
|
||||
double ExcusedFraction)
|
||||
{
|
||||
/// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten
|
||||
/// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart
|
||||
/// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML
|
||||
/// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding
|
||||
/// (<see cref="LehrerApp.Desktop.Converters.FractionWidthConverter"/>) mit der tatsächlich gerenderten Breite
|
||||
/// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere
|
||||
/// Breite bekommt. <c>Grid.ColumnDefinitions</c> ließ sich dafür nicht binden (kein Setter bei
|
||||
/// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten.
|
||||
public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction;
|
||||
public string DetailTooltip =>
|
||||
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||||
}
|
||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||||
ClassTeacherStatusKind Kind)
|
||||
{
|
||||
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
||||
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
|
||||
}
|
||||
|
||||
/// <summary>Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird —
|
||||
/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend,
|
||||
/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin
|
||||
/// für die Jahresfehlquote geladenen Daten (<see cref="ClassTeacherOverviewViewModel.Load"/>),
|
||||
/// kein zusätzlicher Abruf.</summary>
|
||||
public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen)
|
||||
{
|
||||
/// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen"
|
||||
/// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische
|
||||
/// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche
|
||||
/// Regelung kann je nach Bundesland/Schulordnung abweichen.
|
||||
public const int DeadlineDays = 3;
|
||||
public string DateLabel => Date.ToString("dd.MM.");
|
||||
public bool IsOverdue => DaysOpen > DeadlineDays;
|
||||
public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt";
|
||||
|
||||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||||
absenceDays.Where(a => a.IsUnexcused)
|
||||
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||||
today.DayNumber - a.Date.DayNumber))
|
||||
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly IWorkTaskRepository _workTasks;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||||
|
||||
[ObservableProperty] private string? _homeroomClassName;
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
[ObservableProperty] private string _status = "";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private int _selectedRosterFilter;
|
||||
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
|
||||
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
||||
[ObservableProperty] private int _studentCount;
|
||||
[ObservableProperty] private int _todayAlertCount;
|
||||
[ObservableProperty] private int _todayUnexcusedCount;
|
||||
[ObservableProperty] private int _recentClassRegisterCount;
|
||||
[ObservableProperty] private int _presentCount;
|
||||
[ObservableProperty] private int _lateCount;
|
||||
[ObservableProperty] private int _excusedAbsenceCount;
|
||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||
[ObservableProperty] private int _openExcuseOverflowCount;
|
||||
|
||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
|
||||
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
|
||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
||||
public int LatePercent => Percent(LateCount);
|
||||
public int PresentPercent => Percent(PresentCount);
|
||||
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
|
||||
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
|
||||
|
||||
/// Quick-Win: <see cref="TodayUnexcusedPercent"/> wurde berechnet, aber nirgends gebunden —
|
||||
/// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar.
|
||||
public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %";
|
||||
|
||||
/// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken
|
||||
/// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer
|
||||
/// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe
|
||||
/// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in <see cref="Load"/>.
|
||||
public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %";
|
||||
public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %";
|
||||
public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %";
|
||||
public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %";
|
||||
/// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding
|
||||
/// mit der gerenderten Track-Breite, siehe <see cref="ClassTeacherTrendDay.TotalFraction"/>
|
||||
/// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich
|
||||
/// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence.
|
||||
public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount;
|
||||
public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount;
|
||||
public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount;
|
||||
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||||
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||||
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||||
|
||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
||||
{
|
||||
_settings = settings;
|
||||
_cache = cache;
|
||||
_schoolYear = schoolYear;
|
||||
_workTasks = workTasks;
|
||||
_students = students;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
DetailsTab = detailsTab;
|
||||
}
|
||||
|
||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||
partial void OnSelectedRosterFilterChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||||
OnPropertyChanged(nameof(AllFilterSelected));
|
||||
ApplyRosterFilter();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
NotifyRosterState();
|
||||
return;
|
||||
}
|
||||
|
||||
var className = HomeroomClassName!;
|
||||
DetailsTab.Initialize(className);
|
||||
Busy = true;
|
||||
NotifyRosterState();
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var sevenDayStart = today.AddDays(-6);
|
||||
var trendDays = LastSchoolDays(today, 7);
|
||||
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
|
||||
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
||||
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
||||
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
|
||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
||||
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
|
||||
var schoolDaysElapsed = CountWeekdays(yearStart, today);
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||
LateCount = Roster.Count(r => r.IsLate);
|
||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(weekAbsenceDays);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||
finally { Busy = false; NotifyRosterState(); }
|
||||
}
|
||||
|
||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenClassRegister()
|
||||
{
|
||||
DetailsTab.StudentFilter = "";
|
||||
ActiveTabIndex = 1;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenAbsences()
|
||||
{
|
||||
DetailsTab.StudentFilter = "";
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
DetailsTab.StudentFilter = row.StudentName;
|
||||
ActiveTabIndex = row.HasAbsenceToday ? 2 : 1;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand] private async Task GoToSettings()
|
||||
{ if (OnNavigateToSettings is not null) await OnNavigateToSettings(); }
|
||||
|
||||
[RelayCommand] private async Task GoToWorkload()
|
||||
{ if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); }
|
||||
|
||||
private void ApplyRosterFilter()
|
||||
{
|
||||
PrimaryRoster.Clear(); SecondaryRoster.Clear();
|
||||
var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) ||
|
||||
r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase));
|
||||
if (SelectedRosterFilter == 0)
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else if (SelectedRosterFilter == 1)
|
||||
{
|
||||
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
||||
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
SecondarySectionTitle = "Weitere Schüler*innen";
|
||||
foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row);
|
||||
foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row);
|
||||
}
|
||||
NotifyRosterState();
|
||||
}
|
||||
|
||||
/// Letzte <paramref name="days"/> statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von
|
||||
/// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne
|
||||
/// Ferienkalender — siehe <see cref="LastSchoolDays"/>.
|
||||
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, IReadOnlyList<DateOnly> days)
|
||||
{
|
||||
var today = days[^1];
|
||||
var counts = days.Select(date =>
|
||||
{
|
||||
var rows = absenceDays.Where(r => r.Date == date).ToList();
|
||||
var unexcused = rows.Count(r => r.IsUnexcused);
|
||||
var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate);
|
||||
var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate);
|
||||
return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused,
|
||||
Total: unexcused + lateExcused + excused);
|
||||
}).ToList();
|
||||
var max = Math.Max(1, counts.Max(c => c.Total));
|
||||
foreach (var day in counts)
|
||||
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
|
||||
day.Total, day.Unexcused, day.LateExcused, day.Excused,
|
||||
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
||||
}
|
||||
|
||||
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
||||
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
||||
/// Schulferien, nur Wochenenden.
|
||||
private static IReadOnlyList<DateOnly> LastSchoolDays(DateOnly end, int count)
|
||||
{
|
||||
var days = new List<DateOnly>();
|
||||
for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1))
|
||||
if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
|
||||
days.Add(cursor);
|
||||
days.Reverse();
|
||||
return days;
|
||||
}
|
||||
|
||||
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
|
||||
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
|
||||
private static int CountWeekdays(DateOnly start, DateOnly end)
|
||||
{
|
||||
if (end < start) return 0;
|
||||
var count = 0;
|
||||
for (var d = start; d <= end; d = d.AddDays(1))
|
||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
{
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
var displayName = Roster.FirstOrDefault(row =>
|
||||
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
|
||||
var lateDays = rows.Count(row => row.IsLate);
|
||||
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
if (unexcusedDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||
else if (lateDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||||
/// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren
|
||||
/// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares
|
||||
/// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7
|
||||
/// Tagen. Wer schon eine Notiz aus <see cref="BuildPatternNotices"/> hat, wird hier
|
||||
/// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt.
|
||||
private void BuildWeekdayPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
{
|
||||
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||||
var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet();
|
||||
foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted))
|
||||
PatternNotices.Add(notice);
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie
|
||||
/// <see cref="ClassTeacherRosterRow.Build"/>). <paramref name="minSampleSize"/> verhindert,
|
||||
/// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten;
|
||||
/// <paramref name="clusterThreshold"/> verlangt eine deutliche Häufung, nicht nur eine leichte
|
||||
/// Mehrheit.
|
||||
public static IReadOnlyList<ClassTeacherPatternNotice> DetectWeekdayPatterns(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays,
|
||||
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||||
ISet<string> excludeDisplayNames,
|
||||
int minSampleSize = 3, double clusterThreshold = 0.6)
|
||||
{
|
||||
var notices = new List<ClassTeacherPatternNotice>();
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
if (rows.Count < minSampleSize) continue;
|
||||
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||||
if (excludeDisplayNames.Contains(displayName)) continue;
|
||||
|
||||
var top = rows.GroupBy(r => r.Date.DayOfWeek)
|
||||
.Select(g => (Day: g.Key, Count: g.Count()))
|
||||
.OrderByDescending(g => g.Count).First();
|
||||
if ((double)top.Count / rows.Count < clusterThreshold) continue;
|
||||
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}",
|
||||
ClassTeacherStatusKind.Info));
|
||||
}
|
||||
return notices;
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das
|
||||
/// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote
|
||||
/// (<see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/>) mit dem Mitarbeit-Trend aus
|
||||
/// dem Dokumentations-/Mitarbeitsmodul (<see cref="IParticipationRepository"/>, gruppen-
|
||||
/// übergreifend über <c>GetByStudent</c>, gleiches Muster wie die Fehlzeitenbilanz in
|
||||
/// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote
|
||||
/// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche
|
||||
/// simple Heuristik wie <c>ParticipationGradeRow.ComputeTrend</c>), sonst würde die Liste bei
|
||||
/// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb
|
||||
/// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden
|
||||
/// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow):
|
||||
/// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt
|
||||
/// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist
|
||||
/// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
|
||||
private const int CorrelationAbsenceRateThreshold = 15;
|
||||
private void BuildAttendanceParticipationNotices()
|
||||
{
|
||||
var students = _students.GetAll();
|
||||
foreach (var row in Roster.Where(r => r.HasYearSummary &&
|
||||
r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold))
|
||||
{
|
||||
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||||
var student = MatchStudent(row.StudentName, students);
|
||||
if (student is null) continue;
|
||||
|
||||
var points = _participation.GetByStudent(student.Id)
|
||||
.Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e))
|
||||
.Where(x => x.Session is not null && x.Entry.Ratings.Count > 0)
|
||||
.OrderBy(x => x.Session!.Date)
|
||||
.TakeLast(8)
|
||||
.Select(x => x.Entry.Ratings.Average(r => r.Value))
|
||||
.ToList();
|
||||
if (points.Count < 4) continue;
|
||||
|
||||
var mid = points.Count / 2;
|
||||
var diff = points.Skip(mid).Average() - points.Take(mid).Average();
|
||||
if (diff <= -0.4)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||||
$"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit",
|
||||
ClassTeacherStatusKind.Info));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
||||
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||||
/// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen).
|
||||
public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList<Student> students)
|
||||
{
|
||||
var key = UntisNameMatching.NameKey(webUntisDisplayName);
|
||||
return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key);
|
||||
}
|
||||
|
||||
private static string WeekdayLabel(DayOfWeek day) => day switch
|
||||
{
|
||||
DayOfWeek.Monday => "Montag",
|
||||
DayOfWeek.Tuesday => "Dienstag",
|
||||
DayOfWeek.Wednesday => "Mittwoch",
|
||||
DayOfWeek.Thursday => "Donnerstag",
|
||||
DayOfWeek.Friday => "Freitag",
|
||||
DayOfWeek.Saturday => "Samstag",
|
||||
_ => "Sonntag",
|
||||
};
|
||||
|
||||
/// Zeigt höchstens <see cref="OpenExcuseDisplayLimit"/> Zeilen — Nutzer-Feedback zu den
|
||||
/// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest
|
||||
/// zählt in <see cref="OpenExcuseOverflowCount"/> und wird als "+N weitere" angezeigt.
|
||||
private const int OpenExcuseDisplayLimit = 6;
|
||||
|
||||
private void BuildOpenExcuses(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today)
|
||||
{
|
||||
var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today);
|
||||
foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row);
|
||||
OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
DetailsTab.StudentFilter = row.StudentName;
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche
|
||||
/// Erstellen ("Eltern anrufen – Ada Müller") musste man dort noch einmal von Hand eintippen.
|
||||
/// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen —
|
||||
/// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe
|
||||
/// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist
|
||||
/// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben).
|
||||
[RelayCommand]
|
||||
private void CreateReminderForStudent(ClassTeacherRosterRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).",
|
||||
urgent: row.IsOverdue);
|
||||
}
|
||||
|
||||
private void SaveReminder(string studentName, string notes, bool urgent)
|
||||
{
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = $"Eltern kontaktieren – {studentName}",
|
||||
Notes = notes,
|
||||
Kind = TaskKind.Reminder,
|
||||
Priority = urgent ? TaskPriority.High : TaskPriority.Normal,
|
||||
DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1),
|
||||
};
|
||||
_workTasks.Save(task);
|
||||
Status = $"Wiedervorlage „{task.Title}\" angelegt — zu finden unter Aufgaben & Wiedervorlagen.";
|
||||
}
|
||||
|
||||
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
|
||||
private void NotifySummary()
|
||||
{
|
||||
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
|
||||
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
|
||||
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
|
||||
OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel));
|
||||
OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel));
|
||||
OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel));
|
||||
OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction));
|
||||
OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction));
|
||||
OnPropertyChanged(nameof(DayOverviewTooltip));
|
||||
}
|
||||
private void NotifyRosterState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
|
||||
OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>
|
||||
/// WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge — der
|
||||
/// Schülerreport baut "Vorname Nachname" (<see cref="Services.UntisStudentDto.DisplayName"/>),
|
||||
/// die Fehlzeiten-/Klassenbuchberichte liefern typischerweise "Nachname Vorname". Ein exakter
|
||||
/// String-Vergleich zwischen diesen Quellen schlägt deshalb praktisch immer fehl (Bug: gefilterte
|
||||
/// Listen im Klassenlehrer-Bereich blieben leer, Roster-Symbole waren durchgängig falsch). Der
|
||||
/// Vergleich hier ist deshalb reihenfolge-unabhängig: beide Namen werden in Wörter zerlegt und als
|
||||
/// sortierte Menge verglichen (kein Abgleich über eine externe Schülerkennung möglich, da der
|
||||
/// Klassenbuch-Bericht keine liefert, siehe UntisForeignClassRegisterEventDto).
|
||||
/// </summary>
|
||||
public static class UntisNameMatching
|
||||
{
|
||||
public static bool NamesMatch(string? a, string? b) =>
|
||||
!string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && NameKey(a) == NameKey(b);
|
||||
|
||||
public static string NameKey(string value) => string.Join(' ',
|
||||
value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(w => w.Trim().ToLowerInvariant())
|
||||
.OrderBy(w => w, StringComparer.Ordinal));
|
||||
}
|
||||
@@ -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 System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
@@ -34,6 +35,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private readonly PublicHolidayService _publicHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
private readonly ISubstitutionEntryRepository _substitutions;
|
||||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||||
private readonly SchoolWeatherService? _schoolWeather;
|
||||
|
||||
private const int OpenExcuseMaxAgeDays = 21;
|
||||
private const int SupportPlanDueWithinDays = 14;
|
||||
@@ -42,6 +45,11 @@ public partial class DashboardViewModel : ObservableObject
|
||||
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
|
||||
/// um sich abends noch vorzubereiten.
|
||||
private const int UnplannedLessonsLookaheadDays = 1;
|
||||
/// Nutzer-Feedback: unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden
|
||||
/// Schuljahr wird eine hohe Fehlquote NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt,
|
||||
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
||||
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
||||
private const int AttendanceMinSampleSize = 8;
|
||||
|
||||
[ObservableProperty] private string _greeting = "";
|
||||
[ObservableProperty] private string _currentDate = "";
|
||||
@@ -49,6 +57,11 @@ public partial class DashboardViewModel : ObservableObject
|
||||
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
|
||||
[ObservableProperty] private string _selectedDayLabel = "";
|
||||
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
||||
[ObservableProperty] private bool _isWeatherPanelVisible;
|
||||
[ObservableProperty] private string _weatherSummary = "";
|
||||
[ObservableProperty] private string _weatherDetails = "";
|
||||
[ObservableProperty] private string _weatherStatus = "";
|
||||
[ObservableProperty] private bool _hasWeatherWarnings;
|
||||
|
||||
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De);
|
||||
|
||||
@@ -65,6 +78,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
|
||||
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
|
||||
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
|
||||
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
|
||||
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
// Navigation-Callback – wird von App.axaml.cs verdrahtet
|
||||
@@ -74,6 +88,9 @@ public partial class DashboardViewModel : ObservableObject
|
||||
// OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten
|
||||
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
|
||||
public Action<Guid>? OnNavigateToLesson { get; set; }
|
||||
// Direkter Anlege-Einstieg aus der "Offene Aufgaben"-Kachel (Nutzer-Feedback), statt erst über
|
||||
// "Arbeitszeit" navigieren zu müssen — gleiches Dialog-Delegate-Muster wie im Kurs-Dashboard.
|
||||
public Func<bool, Task<WorkTask?>>? OnAddTask { get; set; }
|
||||
public Action<Guid>? OnNavigateToExam { get; set; }
|
||||
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||||
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||||
@@ -90,6 +107,15 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
||||
public DashboardCardOption SupportCard => Card("support");
|
||||
public DashboardCardOption GroupsCard => Card("groups");
|
||||
public int TodayLessonCount => TodaysLessons.Count;
|
||||
public int OpenTaskCount => OpenTasks.Count;
|
||||
public int UpcomingCount => UpcomingDates.Count;
|
||||
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count
|
||||
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count;
|
||||
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
|
||||
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
|
||||
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
|
||||
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
|
||||
|
||||
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
||||
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
||||
@@ -100,7 +126,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
ISubstitutionEntryRepository substitutions)
|
||||
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
||||
{
|
||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
|
||||
@@ -110,6 +137,13 @@ public partial class DashboardViewModel : ObservableObject
|
||||
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
||||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||
_substitutions = substitutions;
|
||||
_annualPlanEvents = annualPlanEvents;
|
||||
_schoolWeather = schoolWeather;
|
||||
if (annualPlanSync is not null)
|
||||
{
|
||||
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
||||
_ = annualPlanSync.PollAsync();
|
||||
}
|
||||
LoadDashboardCards();
|
||||
Load();
|
||||
}
|
||||
@@ -125,6 +159,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
|
||||
CurrentSchoolYear = _sy.CurrentSchoolYear();
|
||||
Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend";
|
||||
_ = LoadWeatherAsync();
|
||||
|
||||
TodaysLessons.Clear();
|
||||
var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id);
|
||||
@@ -156,7 +191,9 @@ public partial class DashboardViewModel : ObservableObject
|
||||
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5))
|
||||
OpenTasks.Add(new() { Title = t.Title,
|
||||
DueDate = t.DueDate?.ToString("dd.MM.") ?? "",
|
||||
IsOverdue = t.DueDate.HasValue && t.DueDate < today });
|
||||
IsOverdue = t.DueDate.HasValue && t.DueDate < today,
|
||||
IsReminder = t.Kind == TaskKind.Reminder,
|
||||
IsHighPriority = t.Priority == TaskPriority.High });
|
||||
|
||||
CurrentGroups.Clear();
|
||||
foreach (var g in groups.Values.OrderBy(g => g.Name))
|
||||
@@ -176,8 +213,84 @@ public partial class DashboardViewModel : ObservableObject
|
||||
LoadOpenCorrections(groups, today);
|
||||
LoadUnplannedLessons(groups, today);
|
||||
LoadAlerts(groups, today);
|
||||
UpdateDashboardSummary();
|
||||
}
|
||||
|
||||
private void UpdateDashboardSummary()
|
||||
{
|
||||
TodayCard.IsEmpty = TodaysLessons.Count == 0;
|
||||
TasksCard.IsEmpty = OpenTasks.Count == 0;
|
||||
CalendarCard.IsEmpty = false;
|
||||
ExcusesCard.IsEmpty = OpenExcuses.Count == 0;
|
||||
UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
|
||||
CorrectionsCard.IsEmpty = OpenCorrections.Count == 0;
|
||||
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
|
||||
AlertsCard.IsEmpty = Alerts.Count == 0;
|
||||
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
|
||||
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
|
||||
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
|
||||
|
||||
OnPropertyChanged(nameof(TodayLessonCount));
|
||||
OnPropertyChanged(nameof(OpenTaskCount));
|
||||
OnPropertyChanged(nameof(UpcomingCount));
|
||||
OnPropertyChanged(nameof(AttentionCount));
|
||||
OnPropertyChanged(nameof(TodayLessonSummary));
|
||||
OnPropertyChanged(nameof(OpenTaskSummary));
|
||||
OnPropertyChanged(nameof(AttentionSummary));
|
||||
OnPropertyChanged(nameof(UpcomingSummary));
|
||||
}
|
||||
|
||||
private async Task LoadWeatherAsync()
|
||||
{
|
||||
if (_schoolWeather?.IsAvailable != true) return;
|
||||
try
|
||||
{
|
||||
var snapshot = await _schoolWeather.GetWeatherAsync();
|
||||
if (snapshot is null) return;
|
||||
var current = snapshot.Forecast
|
||||
.Where(x => x.ValidAt.ToUniversalTime() >= DateTime.UtcNow.AddHours(-1))
|
||||
.MinBy(x => Math.Abs((x.ValidAt.ToUniversalTime() - DateTime.UtcNow).TotalMinutes));
|
||||
WeatherWarnings.Clear();
|
||||
foreach (var warning in snapshot.Warnings)
|
||||
WeatherWarnings.Add(new DashboardWeatherWarningItem(warning));
|
||||
HasWeatherWarnings = WeatherWarnings.Count > 0;
|
||||
|
||||
if (current is not null)
|
||||
{
|
||||
var temperature = current.TemperatureC is { } t ? $"{t:0.#} °C" : "Temperatur unbekannt";
|
||||
WeatherSummary = $"{temperature} · {WeatherDescription(current.WeatherCode)}";
|
||||
var details = new List<string>();
|
||||
if (current.WindSpeedKmh is { } wind) details.Add($"Wind {wind:0.#} km/h");
|
||||
if (current.WindGustKmh is { } gust) details.Add($"Böen {gust:0.#} km/h");
|
||||
if (current.PrecipitationMm is > 0) details.Add($"Niederschlag {current.PrecipitationMm:0.#} mm");
|
||||
details.Add($"DWD-Station {snapshot.StationName} ({snapshot.StationDistanceKm:0.#} km)");
|
||||
WeatherDetails = string.Join(" · ", details);
|
||||
}
|
||||
WeatherStatus = snapshot.IsStale
|
||||
? $"Letzter verfügbarer Stand vom {snapshot.RetrievedAt.ToLocalTime():dd.MM., HH:mm} Uhr"
|
||||
: "";
|
||||
IsWeatherPanelVisible = current is not null || HasWeatherWarnings;
|
||||
}
|
||||
catch (SchoolWeatherException ex)
|
||||
{
|
||||
WeatherStatus = ex.Message;
|
||||
IsWeatherPanelVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string WeatherDescription(int? code) => code switch
|
||||
{
|
||||
0 => "klar",
|
||||
>= 1 and <= 3 => "bewölkt",
|
||||
45 or 48 => "Nebel",
|
||||
>= 51 and <= 67 => "Regen",
|
||||
>= 71 and <= 77 => "Schnee",
|
||||
>= 80 and <= 82 => "Regenschauer",
|
||||
85 or 86 => "Schneeschauer",
|
||||
>= 95 and <= 99 => "Gewitter",
|
||||
_ => "Wettervorhersage",
|
||||
};
|
||||
|
||||
// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
|
||||
|
||||
private void LoadAttendanceWarnings(DateOnly today)
|
||||
@@ -197,7 +310,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
.Select(t => (t.Item1!.Value, t.Attendance));
|
||||
|
||||
var balance = _attendanceBalance.Calculate(entries, from, to);
|
||||
if (balance.ExceedsThreshold)
|
||||
if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold)
|
||||
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
||||
}
|
||||
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
||||
@@ -273,12 +386,10 @@ public partial class DashboardViewModel : ObservableObject
|
||||
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
||||
.OrderBy(e => e.Date))
|
||||
{
|
||||
var expected = _memberships.GetByGroup(group.Id)
|
||||
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
||||
var evaluated = _examResults.GetByExam(exam.Id)
|
||||
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
||||
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
|
||||
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
|
||||
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
|
||||
group.Name, exam.Date, Math.Min(evaluated, expected), expected, today));
|
||||
group.Name, exam.Date, evaluated, expected, today));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +624,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
entry.Attendance = status;
|
||||
_participationEntries.Save(entry);
|
||||
OpenExcuses.Remove(item);
|
||||
UpdateDashboardSummary();
|
||||
}
|
||||
|
||||
private void LoadCalendar()
|
||||
@@ -554,6 +666,70 @@ public partial class DashboardViewModel : ObservableObject
|
||||
agg.Details.Add(new CalendarEventItem(CalendarEventKind.Exam, exam.Date,
|
||||
exam.Title, g.Name, g.Id));
|
||||
}
|
||||
|
||||
// Sitzungen, die über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstanden sind
|
||||
// (LessonId gesetzt), bekommen bewusst keinen eigenen Kalendereintrag — die Stunde
|
||||
// selbst ist an diesem Tag/dieser Gruppe schon als Lesson-Termin gelistet, ein
|
||||
// zweiter Eintrag für dieselbe Unterrichtsstunde wäre eine Dopplung.
|
||||
foreach (var session in _participationSessions.GetByGroup(g.Id)
|
||||
.Where(s => s.LessonId is null && s.Date >= gridStart && s.Date <= gridEnd))
|
||||
{
|
||||
var agg = Agg(session.Date);
|
||||
agg.HasSession = true;
|
||||
if (g.IsOwnClass) agg.IsOwnClassDay = true;
|
||||
agg.Details.Add(new CalendarEventItem(CalendarEventKind.ParticipationSession, session.Date,
|
||||
g.Name, session.Comment ?? "", g.Id));
|
||||
}
|
||||
}
|
||||
|
||||
// Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an
|
||||
// keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem
|
||||
// betroffenen Kalendertag.
|
||||
if (_annualPlanEvents is not null)
|
||||
{
|
||||
foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd)
|
||||
.Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate;
|
||||
var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate;
|
||||
for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1))
|
||||
{
|
||||
var agg = Agg(date);
|
||||
agg.HasAnnualPlanEvent = true;
|
||||
agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date,
|
||||
annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null,
|
||||
annualEvent.Description));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
|
||||
// existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den
|
||||
// aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht.
|
||||
// Dieselbe Projektion wie bei den ungeplanten Stunden (LoadUnplannedLessons), nur über den
|
||||
// gesamten Kalenderraster statt nur die nächsten Tage.
|
||||
var publicHolidayDatesForGrid = Enumerable.Range(gridStart.Year, gridEnd.Year - gridStart.Year + 1)
|
||||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||
.Select(h => h.Date).ToHashSet();
|
||||
var schoolHolidaysForGrid = _schoolHolidays.GetAll();
|
||||
foreach (var g in groups.Where(g => g.IsOwnClass))
|
||||
{
|
||||
var slots = _timetableSlots.GetByGroup(g.Id);
|
||||
if (slots.Count == 0) continue;
|
||||
|
||||
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
||||
{
|
||||
if (IsFreeDay(date, schoolHolidaysForGrid, publicHolidayDatesForGrid)) continue;
|
||||
var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||
if (daySlots.Count == 0) continue;
|
||||
|
||||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||
.Select(s => s.PeriodNumber).ToHashSet();
|
||||
if (daySlots.All(s => cancelledPeriods.Contains(s.PeriodNumber))) continue;
|
||||
|
||||
Agg(date).IsOwnClassDay = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < 42; i++)
|
||||
@@ -561,8 +737,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
var date = gridStart.AddDays(i);
|
||||
byDay.TryGetValue(date, out var agg);
|
||||
CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today,
|
||||
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.IsOwnClassDay ?? false,
|
||||
agg?.Details ?? []));
|
||||
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false,
|
||||
agg?.HasAnnualPlanEvent ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
|
||||
}
|
||||
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
|
||||
?? CalendarDays.First(d => d.IsCurrentMonth));
|
||||
@@ -582,8 +758,9 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private void OpenCalendarEvent(CalendarEventItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId);
|
||||
else OnNavigateToLesson?.Invoke(item.GroupId);
|
||||
if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information.
|
||||
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId);
|
||||
else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit"
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -629,13 +806,78 @@ public partial class DashboardViewModel : ObservableObject
|
||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
||||
[RelayCommand] private void Refresh() => Load();
|
||||
|
||||
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
|
||||
[RelayCommand] private Task AddReminder() => AddTaskInternal(startAsReminder: true);
|
||||
|
||||
private async Task AddTaskInternal(bool startAsReminder)
|
||||
{
|
||||
if (OnAddTask is null) return;
|
||||
var result = await OnAddTask(startAsReminder);
|
||||
if (result is null) return;
|
||||
_tasks.Save(result);
|
||||
Load();
|
||||
}
|
||||
|
||||
private class DayAgg
|
||||
{
|
||||
public bool HasLesson;
|
||||
public bool HasExam;
|
||||
public bool HasSession;
|
||||
public bool HasAnnualPlanEvent;
|
||||
public bool IsOwnClassDay;
|
||||
public List<CalendarEventItem> Details { get; } = [];
|
||||
}
|
||||
|
||||
private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry)
|
||||
{
|
||||
string time;
|
||||
if (entry.IsAllDay)
|
||||
{
|
||||
time = entry.StartDate == entry.EndDate
|
||||
? "Ganztägig"
|
||||
: $"{entry.StartDate:dd.MM.}–{entry.EndDate:dd.MM.yyyy} · ganztägig";
|
||||
}
|
||||
else if (entry.StartDate == entry.EndDate)
|
||||
{
|
||||
time = entry.EndTime is { } end
|
||||
? $"{entry.StartTime:HH\\:mm}–{end:HH\\:mm}"
|
||||
: $"{entry.StartTime:HH\\:mm}";
|
||||
}
|
||||
else
|
||||
{
|
||||
time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}–" +
|
||||
$"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}";
|
||||
}
|
||||
|
||||
return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location }
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DashboardWeatherWarningItem
|
||||
{
|
||||
public string Headline { get; }
|
||||
public string Description { get; }
|
||||
public string Instruction { get; }
|
||||
public string PeriodDisplay { get; }
|
||||
public string SeverityColor { get; }
|
||||
|
||||
public DashboardWeatherWarningItem(WeatherWarning warning)
|
||||
{
|
||||
Headline = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline;
|
||||
Description = warning.Description;
|
||||
Instruction = warning.Instruction;
|
||||
PeriodDisplay = (warning.Onset, warning.Expires) switch
|
||||
{
|
||||
({ } onset, { } expires) => $"{onset.ToLocalTime():dd.MM., HH:mm}–{expires.ToLocalTime():HH:mm} Uhr",
|
||||
({ } onset, null) => $"ab {onset.ToLocalTime():dd.MM., HH:mm} Uhr",
|
||||
_ => "",
|
||||
};
|
||||
SeverityColor = warning.Severity switch
|
||||
{
|
||||
"Extreme" => "#7E0023", "Severe" => "#D32F2F", "Moderate" => "#F59E0B", _ => "#FDD835",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class LessonItem
|
||||
@@ -648,7 +890,7 @@ public class LessonItem
|
||||
public string Room { get; set; } = "";
|
||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||
}
|
||||
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } }
|
||||
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
||||
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
|
||||
|
||||
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
||||
@@ -716,12 +958,15 @@ public partial class CalendarDayCell : ObservableObject
|
||||
public bool IsToday { get; }
|
||||
public bool HasLesson { get; }
|
||||
public bool HasExam { get; }
|
||||
public bool HasSession { get; }
|
||||
public bool HasAnnualPlanEvent { get; }
|
||||
public bool IsOwnClassDay { get; }
|
||||
public string Tooltip { get; }
|
||||
public IReadOnlyList<CalendarEventItem> Events { get; }
|
||||
|
||||
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
|
||||
bool hasLesson, bool hasExam, bool isOwnClassDay, List<CalendarEventItem> details)
|
||||
bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent,
|
||||
bool isOwnClassDay, List<CalendarEventItem> details)
|
||||
{
|
||||
Date = date;
|
||||
DayNumber = date.Day;
|
||||
@@ -729,6 +974,8 @@ public partial class CalendarDayCell : ObservableObject
|
||||
IsToday = isToday;
|
||||
HasLesson = hasLesson;
|
||||
HasExam = hasExam;
|
||||
HasSession = hasSession;
|
||||
HasAnnualPlanEvent = hasAnnualPlanEvent;
|
||||
IsOwnClassDay = isOwnClassDay;
|
||||
Events = details;
|
||||
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
|
||||
@@ -736,17 +983,24 @@ public partial class CalendarDayCell : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
public enum CalendarEventKind { Lesson, Exam }
|
||||
public enum CalendarEventKind { Lesson, Exam, ParticipationSession, AnnualPlan }
|
||||
|
||||
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
|
||||
string subtitle, Guid groupId)
|
||||
string subtitle, Guid? groupId, string description = "")
|
||||
{
|
||||
public CalendarEventKind Kind { get; } = kind;
|
||||
public DateOnly Date { get; } = date;
|
||||
public string Title { get; } = title;
|
||||
public string Subtitle { get; } = subtitle;
|
||||
public Guid GroupId { get; } = groupId;
|
||||
public string KindLabel => Kind == CalendarEventKind.Exam ? "Klausur" : "Unterricht";
|
||||
public Guid? GroupId { get; } = groupId;
|
||||
public string Description { get; } = description;
|
||||
public string KindLabel => Kind switch
|
||||
{
|
||||
CalendarEventKind.Exam => "Klausur",
|
||||
CalendarEventKind.ParticipationSession => "Sitzung",
|
||||
CalendarEventKind.AnnualPlan => "Jahresplan",
|
||||
_ => "Unterricht"
|
||||
};
|
||||
}
|
||||
|
||||
public enum UpcomingDateKind { Exam, SupportPlan, Deadline }
|
||||
@@ -813,18 +1067,29 @@ public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string stu
|
||||
public partial class DashboardCardOption : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private bool _isVisible;
|
||||
[ObservableProperty] private bool _isEmpty;
|
||||
[ObservableProperty] private int _row;
|
||||
[ObservableProperty] private int _column;
|
||||
public string Key { get; }
|
||||
public string Title { get; }
|
||||
public bool HideWhenEmpty { get; }
|
||||
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
|
||||
public Action? OnVisibilityChanged { get; set; }
|
||||
|
||||
public DashboardCardOption(string key, string title, bool isVisible)
|
||||
{
|
||||
Key = key;
|
||||
Title = title;
|
||||
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
|
||||
or "alerts" or "attendance" or "support";
|
||||
_isVisible = isVisible;
|
||||
}
|
||||
|
||||
partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke();
|
||||
partial void OnIsVisibleChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(EffectiveIsVisible));
|
||||
OnVisibilityChanged?.Invoke();
|
||||
}
|
||||
|
||||
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Exams;
|
||||
|
||||
/// Klausuren-Hauptseite (Sidebar "Klausuren"): gruppenübergreifende Liste aller Klausuren des
|
||||
/// aktuellen Schuljahres, sortiert nach einem unsichtbaren Prioritäts-Score
|
||||
/// (`ExamPriorityService`) statt nach Datum — unbearbeitete/überfällige Korrekturen oben,
|
||||
/// erledigte unten. Oben ein Detailbereich zur ausgewählten Klausur, der sich je nach Status
|
||||
/// unterscheidet (Korrekturfortschritt vs. Notenspiegel), inklusive Umschalter für Parallelkurse
|
||||
/// (gleiche Arbeit, mehrere Kurse — siehe `Exam.SharedExamGroupId`).
|
||||
public partial class ExamsOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly GradingService _grading;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
|
||||
public ObservableCollection<ExamListRowViewModel> Rows { get; } = [];
|
||||
public ObservableCollection<ExamListRowViewModel> Siblings { get; } = [];
|
||||
public ObservableCollection<GradeBarItem> DetailGradeDistribution { get; } = [];
|
||||
|
||||
[ObservableProperty] private ExamListRowViewModel? _selectedRow;
|
||||
[ObservableProperty] private bool _hasSelection;
|
||||
[ObservableProperty] private string _emptyHint = "";
|
||||
|
||||
[ObservableProperty] private bool _showCorrectionProgress;
|
||||
[ObservableProperty] private string _progressLabel = "";
|
||||
[ObservableProperty] private double _progressBarWidth;
|
||||
[ObservableProperty] private bool _showGradeSummary;
|
||||
[ObservableProperty] private string _averageLabel = "";
|
||||
[ObservableProperty] private string _approvalLabel = "";
|
||||
[ObservableProperty] private string _announcementLabel = "";
|
||||
|
||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
|
||||
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
|
||||
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
|
||||
SchoolYearService schoolYear)
|
||||
{
|
||||
_exams = exams; _examResults = examResults; _groups = groups;
|
||||
_memberships = memberships; _grading = grading; _schoolYear = schoolYear;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void Load()
|
||||
{
|
||||
var selectedId = SelectedRow?.Exam.Id;
|
||||
Rows.Clear();
|
||||
|
||||
var groups = _groups.GetBySchoolYear(_schoolYear.CurrentSchoolYear());
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var rows = new List<ExamListRowViewModel>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var groupMemberships = _memberships.GetByGroup(group.Id);
|
||||
foreach (var exam in _exams.GetByGroup(group.Id))
|
||||
{
|
||||
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, groupMemberships,
|
||||
_examResults.GetByExam(exam.Id));
|
||||
var info = ExamPriorityService.Evaluate(exam, expected, evaluated, today);
|
||||
rows.Add(new ExamListRowViewModel(exam, group, expected, evaluated, info, today));
|
||||
}
|
||||
}
|
||||
|
||||
// Parallelkurse markieren, damit die Liste einen Verknüpfungs-Hinweis zeigen kann.
|
||||
foreach (var group in rows.GroupBy(r => r.Exam.SharedExamGroupId ?? r.Exam.Id))
|
||||
{
|
||||
if (group.Count() <= 1) continue;
|
||||
foreach (var row in group) row.HasSibling = true;
|
||||
}
|
||||
|
||||
foreach (var row in rows.OrderByDescending(r => r.Score))
|
||||
Rows.Add(row);
|
||||
|
||||
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
|
||||
SelectedRow = selectedId is { } id
|
||||
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
|
||||
: Rows.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedRowChanged(ExamListRowViewModel? value) => UpdateDetail(value);
|
||||
|
||||
private void UpdateDetail(ExamListRowViewModel? row)
|
||||
{
|
||||
HasSelection = row is not null;
|
||||
Siblings.Clear();
|
||||
DetailGradeDistribution.Clear();
|
||||
ShowCorrectionProgress = false;
|
||||
ShowGradeSummary = false;
|
||||
if (row is null) return;
|
||||
|
||||
ApprovalLabel = row.Exam.ApprovalGrantedAt is { } a
|
||||
? $"Genehmigt am {a.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
|
||||
: "Genehmigung noch ausstehend";
|
||||
AnnouncementLabel = row.Exam.AnnouncedAt is { } n
|
||||
? $"Angekündigt am {n.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
|
||||
: "Noch nicht angekündigt";
|
||||
|
||||
if (row.HasSibling)
|
||||
{
|
||||
var anchor = row.Exam.SharedExamGroupId ?? row.Exam.Id;
|
||||
foreach (var sibling in Rows.Where(r => r != row && (r.Exam.SharedExamGroupId ?? r.Exam.Id) == anchor))
|
||||
Siblings.Add(sibling);
|
||||
}
|
||||
|
||||
ShowCorrectionProgress = row.Status is ExamListStatus.AwaitingCorrection
|
||||
or ExamListStatus.CorrectionInProgress or ExamListStatus.CorrectionStuck;
|
||||
if (ShowCorrectionProgress)
|
||||
{
|
||||
ProgressLabel = $"{row.Evaluated} / {row.Expected} korrigiert";
|
||||
var fraction = row.Expected <= 0 ? 0 : Math.Clamp((double)row.Evaluated / row.Expected, 0, 1);
|
||||
ProgressBarWidth = fraction * 240.0;
|
||||
}
|
||||
|
||||
ShowGradeSummary = row.Status is ExamListStatus.AwaitingReturn or ExamListStatus.Returned;
|
||||
if (ShowGradeSummary) LoadGradeSummary(row);
|
||||
}
|
||||
|
||||
private void LoadGradeSummary(ExamListRowViewModel row)
|
||||
{
|
||||
var grades = _examResults.GetByExam(row.Exam.Id)
|
||||
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||
.Select(r => r.Grade!).ToList();
|
||||
if (grades.Count == 0) { AverageLabel = "Noch keine Noten erfasst."; return; }
|
||||
|
||||
AverageLabel = "Ø " + _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList())
|
||||
.ToString("0.00", CultureInfo.InvariantCulture);
|
||||
|
||||
var counts = grades.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count());
|
||||
var maxCount = counts.Count == 0 ? 0 : counts.Values.Max();
|
||||
foreach (var entry in row.Exam.GradingKey.OrderByDescending(e => e.MinPercent))
|
||||
{
|
||||
counts.TryGetValue(entry.Grade, out var count);
|
||||
DetailGradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectExam(ExamListRowViewModel row) => SelectedRow = row;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GradeSelected()
|
||||
{
|
||||
if (SelectedRow is null || OnGradeExam is null) return;
|
||||
await OnGradeExam(SelectedRow.Exam);
|
||||
Load();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EvaluateSelected()
|
||||
{
|
||||
if (SelectedRow is null || OnEvaluateExam is null) return;
|
||||
await OnEvaluateExam(SelectedRow.Exam);
|
||||
Load();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void GoToGroup()
|
||||
{
|
||||
if (SelectedRow is null) return;
|
||||
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
|
||||
(e, v) => e.ApprovalGrantedAt = v);
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleAnnouncement() => ToggleDate(SelectedRow?.Exam, e => e.AnnouncedAt,
|
||||
(e, v) => e.AnnouncedAt = v);
|
||||
|
||||
private void ToggleDate(Exam? exam, Func<Exam, DateOnly?> get, Action<Exam, DateOnly?> set)
|
||||
{
|
||||
if (exam is null) return;
|
||||
set(exam, get(exam) is null ? DateOnly.FromDateTime(DateTime.Today) : null);
|
||||
exam.UpdatedAt = DateTime.UtcNow;
|
||||
_exams.Save(exam);
|
||||
UpdateDetail(SelectedRow);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zeile in der Klausurliste ────────────────────────────────────────────────
|
||||
|
||||
public class ExamListRowViewModel
|
||||
{
|
||||
public Exam Exam { get; }
|
||||
public Guid GroupId { get; }
|
||||
public string Title { get; }
|
||||
public string GroupLabel { get; }
|
||||
public int Expected { get; }
|
||||
public int Evaluated { get; }
|
||||
public ExamListStatus Status { get; }
|
||||
public double Score { get; }
|
||||
public string StatusLabel { get; }
|
||||
public string SubLabel { get; }
|
||||
public bool HasSibling { get; set; }
|
||||
|
||||
public bool IsOk { get; }
|
||||
public bool IsInfo { get; }
|
||||
public bool IsWarning { get; }
|
||||
public bool IsDanger { get; }
|
||||
|
||||
public ExamListRowViewModel(Exam exam, LearningGroup group, int expected, int evaluated,
|
||||
ExamPriorityInfo info, DateOnly today)
|
||||
{
|
||||
Exam = exam; GroupId = group.Id; Title = exam.Title; GroupLabel = group.Name;
|
||||
Expected = expected; Evaluated = evaluated; Status = info.Status; Score = info.Score;
|
||||
|
||||
(StatusLabel, SubLabel, IsOk, IsInfo, IsWarning, IsDanger) = Status switch
|
||||
{
|
||||
ExamListStatus.Planned => ("Geplant", $"geplant für {RelativeDay(exam.Date, today)}",
|
||||
false, false, false, false),
|
||||
ExamListStatus.AwaitingCorrection when today.DayNumber - exam.Date.DayNumber <= 3 =>
|
||||
("Korrektur ausstehend", $"geschrieben {RelativeDay(exam.Date, today)}",
|
||||
false, false, true, false),
|
||||
ExamListStatus.AwaitingCorrection =>
|
||||
("überfällig", $"geschrieben {RelativeDay(exam.Date, today)}", false, false, false, true),
|
||||
ExamListStatus.CorrectionInProgress =>
|
||||
("Korrektur läuft", $"{evaluated}/{expected} korrigiert", false, false, true, false),
|
||||
ExamListStatus.CorrectionStuck =>
|
||||
("hängt fest", $"{evaluated}/{expected} korrigiert", false, false, false, false),
|
||||
ExamListStatus.AwaitingReturn => ("Korrigiert", "Rückgabe aussteht", false, true, false, false),
|
||||
ExamListStatus.Returned => ("Abgeschlossen",
|
||||
exam.ReturnedAt is { } r ? $"zurückgegeben {r:dd.MM.yyyy}" : "zurückgegeben", true, false, false, false),
|
||||
_ => ("", "", false, false, false, false),
|
||||
};
|
||||
}
|
||||
|
||||
private static string RelativeDay(DateOnly date, DateOnly today)
|
||||
{
|
||||
var diff = date.DayNumber - today.DayNumber;
|
||||
return diff switch
|
||||
{
|
||||
0 => "heute",
|
||||
1 => "morgen",
|
||||
-1 => "gestern",
|
||||
> 1 => $"in {diff} Tagen",
|
||||
_ => $"vor {-diff} Tagen",
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user