Compare commits
100
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 | ||
|
|
9106b4b0e1 | ||
|
|
b2c1b15167 | ||
|
|
1731e5088e | ||
|
|
f868b3a259 | ||
|
|
979511ab6a | ||
|
|
ba8786906a | ||
|
|
e7e5faeba8 | ||
|
|
5bd6967421 | ||
|
|
270c4d40cd | ||
|
|
dea7da8cca | ||
|
|
722b7f87ff | ||
|
|
dc21cb2319 | ||
|
|
26461bd2f2 | ||
|
|
41bf2c0756 | ||
|
|
462080ab92 | ||
|
|
a0f44f1b25 | ||
|
|
f2cb3c98c6 | ||
|
|
d59d658bba | ||
|
|
fd509fc921 | ||
|
|
a36b1d1d40 | ||
|
|
4510c56bb4 | ||
|
|
762c8236fe | ||
|
|
f8058b537b | ||
|
|
65c2211dab | ||
|
|
99efa3a0fe | ||
|
|
cdac335ad1 | ||
|
|
a68bb933c5 | ||
|
|
788eb42fd2 | ||
|
|
858499e8ec | ||
|
|
e679be7e9d | ||
|
|
37f4fee574 | ||
|
|
9286bfa3b5 | ||
|
|
6774123270 | ||
|
|
fc2d7aea3e | ||
|
|
de98b4ed54 | ||
|
|
95345f9c46 | ||
|
|
f0f8fa25e5 | ||
|
|
ce4dfb0197 | ||
|
|
1240a2cd8a | ||
|
|
ed34e5d036 | ||
|
|
831cc1c16c | ||
|
|
6f9de325d5 | ||
|
|
8efeb68e93 | ||
|
|
50f3b2d6a9 | ||
|
|
ad40d11d6e |
@@ -4,3 +4,6 @@
|
|||||||
# Mindestens 32 zufällige Zeichen – z.B. generiert mit:
|
# Mindestens 32 zufällige Zeichen – z.B. generiert mit:
|
||||||
# openssl rand -base64 32
|
# openssl rand -base64 32
|
||||||
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
|
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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
out/
|
out/
|
||||||
|
build/
|
||||||
|
|
||||||
# ── .NET ──────────────────────────────────────────────────────────────────────
|
# ── .NET ──────────────────────────────────────────────────────────────────────
|
||||||
*.user
|
*.user
|
||||||
|
|||||||
@@ -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
|
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
|
||||||
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
|
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
|
||||||
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
|
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
|
- **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
|
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
|
||||||
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
|
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
|
||||||
configured (`AppBootstrapper.LoadServerUrl`).
|
configured (`AppBootstrapper.LoadServerUrl`).
|
||||||
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` →
|
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` →
|
||||||
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
|
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)
|
### MVVM conventions (Desktop)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
<!-- Datenbank -->
|
<!-- Datenbank -->
|
||||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
<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 -->
|
<!-- API -->
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
// LiteDB nutzt einen statischen, geteilten BsonMapper.Global für die Reflection-basierte
|
||||||
|
// Index-Auflösung (EnsureIndex mit Lambda-Ausdrücken). Bei paralleler Testausführung über
|
||||||
|
// mehrere Testklassen hinweg (xUnit-Standard) konkurrieren mehrere Threads beim erstmaligen
|
||||||
|
// Aufbau der Typ-Metadaten für verschiedene Modelle — das führt zu sporadischen
|
||||||
|
// "Member X not found on BsonMapper"-Fehlern, die mit dem eigentlichen Testinhalt nichts zu
|
||||||
|
// tun haben (gleiches Muster wie in LehrerApp.Data.Tests/LehrerApp.Desktop.Tests). Tests in
|
||||||
|
// diesem Projekt laufen deshalb sequenziell.
|
||||||
|
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AttachmentStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreAsync_GefolgtVonOpenRead_LiefertByteidentischeDatei()
|
||||||
|
{
|
||||||
|
using var temp = new TempDataPath();
|
||||||
|
var store = new AttachmentStore(temp.Path);
|
||||||
|
byte[] original = [1, 2, 3, 4, 5, 255, 0, 42];
|
||||||
|
|
||||||
|
await store.StoreAsync("user-1", "abc123", new MemoryStream(original));
|
||||||
|
|
||||||
|
using var read = store.OpenRead("user-1", "abc123");
|
||||||
|
Assert.NotNull(read);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await read!.CopyToAsync(ms);
|
||||||
|
Assert.Equal(original, ms.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenRead_UnbekannteStorageId_GibtNullZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDataPath();
|
||||||
|
var store = new AttachmentStore(temp.Path);
|
||||||
|
|
||||||
|
Assert.Null(store.OpenRead("user-1", "unbekannt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreAsync_TrenntAnhaengeVerschiedenerNutzer()
|
||||||
|
{
|
||||||
|
using var temp = new TempDataPath();
|
||||||
|
var store = new AttachmentStore(temp.Path);
|
||||||
|
|
||||||
|
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
|
||||||
|
|
||||||
|
Assert.Null(store.OpenRead("user-2", "shared-id"));
|
||||||
|
using var user1Attachment = store.OpenRead("user-1", "shared-id");
|
||||||
|
Assert.NotNull(user1Attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreAsync_BereinigtStorageIdMitPathTraversalZeichen()
|
||||||
|
{
|
||||||
|
using var temp = new TempDataPath();
|
||||||
|
var store = new AttachmentStore(temp.Path);
|
||||||
|
|
||||||
|
// Darf keinesfalls außerhalb von <root>/attachments/<user> landen.
|
||||||
|
await store.StoreAsync("user-1", "../../evil", new MemoryStream([1, 2, 3]));
|
||||||
|
|
||||||
|
Assert.False(File.Exists(Path.Combine(temp.Path, "evil")));
|
||||||
|
var withinRoot = Directory.EnumerateFiles(Path.Combine(temp.Path, "attachments"), "*", SearchOption.AllDirectories);
|
||||||
|
Assert.Contains(withinRoot, f => Path.GetFileName(f) == "evil");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempDataPath : IDisposable
|
||||||
|
{
|
||||||
|
public string Path { get; } = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"lehrerapp-api-tests-attachments-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
public TempDataPath() => Directory.CreateDirectory(Path);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,184 @@
|
|||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class EventStoreTests
|
||||||
|
{
|
||||||
|
private static SyncEvent MakeEvent(string deviceId, string entityType = "Lesson", string operation = "Save",
|
||||||
|
string? entityId = null, long? basedOnServerSeq = null) => new()
|
||||||
|
{
|
||||||
|
DeviceId = deviceId, DeviceType = DeviceType.Desktop,
|
||||||
|
EntityType = entityType, EntityId = entityId ?? Guid.NewGuid().ToString(),
|
||||||
|
Operation = operation, Payload = "encrypted", BasedOnServerSeq = basedOnServerSeq,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pull_KeineNeuenEreignisse_LiefertUnverändertesSinceAlsWatermark()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
temp.Events.Push("user", [MakeEvent("device-a")]);
|
||||||
|
|
||||||
|
var result = temp.Events.Pull("user", since: 5, requestingDeviceId: "device-b");
|
||||||
|
|
||||||
|
Assert.Empty(result.Events);
|
||||||
|
Assert.Equal(5, result.ServerSequenceNr);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pull_LiefertEreignisseAnderesGeraets_WatermarkEntsprichtHoechsterZurueckgegebenerServerSeq()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
temp.Events.Push("user", [MakeEvent("device-a"), MakeEvent("device-a")]);
|
||||||
|
|
||||||
|
var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b");
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Events.Count);
|
||||||
|
Assert.Equal(2, result.ServerSequenceNr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: das genau beobachtete Symptom — Gerät A pusht erfolgreich (Server bestätigt),
|
||||||
|
/// Gerät B pullt und bekommt 0 Ereignisse zurück, obwohl Gerät A's Ereignisse eigentlich für
|
||||||
|
/// Gerät B bestimmt waren. Ursache: Pull() gab bisher IMMER den globalen Höchststand
|
||||||
|
/// (LastSeq, inkl. der Ereignisse des ANFRAGENDEN Geräts selbst) als neuen "since"-Cursor
|
||||||
|
/// zurück — SyncEngine.PullAsync übernimmt den 1:1. Hat das anfragende Gerät SELBST kurz vorher
|
||||||
|
/// etwas gepusht (dessen Ereignisse hier bewusst per "DeviceId != requestingDeviceId"
|
||||||
|
/// herausgefiltert werden), sprang der Cursor über die noch gar nicht abgeholten Ereignisse
|
||||||
|
/// eines ANDEREN Geräts hinweg, wenn die eigenen neuer waren — sie wurden dauerhaft verpasst.
|
||||||
|
[Fact]
|
||||||
|
public void Pull_AnfragendesGeraetHatSelbstNeuereEreignisseGepusht_UeberspringtFremdeEreignisseNicht()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
// Gerät A pusht zuerst (ServerSeq 1) - das ist die Änderung, die Gerät B eigentlich abholen soll.
|
||||||
|
temp.Events.Push("user", [MakeEvent("device-a")]);
|
||||||
|
// Gerät B hat selbst (z.B. im selben Sync-Zyklus, Push läuft immer vor Pull) etwas Neueres
|
||||||
|
// gepusht (ServerSeq 2) - dieses Ereignis gehört B selbst und wird unten aus der Pull-Antwort
|
||||||
|
// herausgefiltert.
|
||||||
|
temp.Events.Push("user", [MakeEvent("device-b")]);
|
||||||
|
|
||||||
|
// Gerät B pullt mit einem "since" von VOR seinem eigenen Push (0) - Geräts A's Ereignis
|
||||||
|
// (ServerSeq 1) muss zurückkommen.
|
||||||
|
var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b");
|
||||||
|
|
||||||
|
var evt = Assert.Single(result.Events);
|
||||||
|
Assert.Equal("device-a", evt.DeviceId);
|
||||||
|
// Der Bug: hier stand vorher 2 (globaler Höchststand, inkl. Geräts B eigenem Ereignis) -
|
||||||
|
// ein zweiter Pull mit since=2 hätte Geräts A's Ereignis (ServerSeq 1) nie wieder gefunden.
|
||||||
|
Assert.Equal(1, result.ServerSequenceNr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exakte Kollisionsprüfung (BasedOnServerSeq statt 30s-Heuristik) ─────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_NeueEntitaetOhneBasedOnServerSeq_WirdAngenommen()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
|
||||||
|
var result = temp.Events.Push("user", [MakeEvent("device-a", basedOnServerSeq: null)]);
|
||||||
|
|
||||||
|
Assert.Empty(result.ConflictingEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_ZweitesEreignisMitFalschemBasedOnServerSeq_WirdAbgelehnt()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
var entityId = Guid.NewGuid().ToString();
|
||||||
|
temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); // -> ServerSeq 1
|
||||||
|
|
||||||
|
// Gerät B kennt die Entität noch gar nicht (oder einen veralteten Stand) und behauptet fälschlich, sie sei neu.
|
||||||
|
var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null);
|
||||||
|
var result = temp.Events.Push("user", [second]);
|
||||||
|
|
||||||
|
Assert.Contains(second.EventId, result.ConflictingEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_ZweitesEreignisMitKorrektemBasedOnServerSeq_WirdAngenommen()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
var entityId = Guid.NewGuid().ToString();
|
||||||
|
var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
|
||||||
|
var assignedSeq = first.AssignedServerSeqs.Values.Single(); // ServerSeq 1
|
||||||
|
|
||||||
|
var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: assignedSeq);
|
||||||
|
var result = temp.Events.Push("user", [second]);
|
||||||
|
|
||||||
|
Assert.Empty(result.ConflictingEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_AkzeptierteEreignisse_LiefernJeEventIdDieVergebeneServerSeq()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
var evt1 = MakeEvent("device-a");
|
||||||
|
var evt2 = MakeEvent("device-a");
|
||||||
|
|
||||||
|
var result = temp.Events.Push("user", [evt1, evt2]);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.AssignedServerSeqs.Count);
|
||||||
|
Assert.True(result.AssignedServerSeqs.ContainsKey(evt1.EventId));
|
||||||
|
Assert.True(result.AssignedServerSeqs.ContainsKey(evt2.EventId));
|
||||||
|
Assert.Equal([1L, 2L], result.AssignedServerSeqs.Values.OrderBy(v => v));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_AbgelehntesEreignis_TauchtNichtInAssignedServerSeqsAuf()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
var entityId = Guid.NewGuid().ToString();
|
||||||
|
temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
|
||||||
|
var rejected = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null);
|
||||||
|
|
||||||
|
var result = temp.Events.Push("user", [rejected]);
|
||||||
|
|
||||||
|
Assert.False(result.AssignedServerSeqs.ContainsKey(rejected.EventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetLatestForEntity (Sofort-Nachladen nach abgelehntem Push) ─────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLatestForEntity_UnbekannteEntitaet_LiefertNull()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
|
||||||
|
var result = temp.Events.GetLatestForEntity("user", "Lesson", Guid.NewGuid().ToString());
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLatestForEntity_LiefertDenAktuellstenStandDieserEntitaet()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
var entityId = Guid.NewGuid().ToString();
|
||||||
|
var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
|
||||||
|
var seq1 = first.AssignedServerSeqs.Values.Single();
|
||||||
|
temp.Events.Push("user", [MakeEvent("device-b", entityId: entityId, basedOnServerSeq: seq1)]);
|
||||||
|
|
||||||
|
var result = temp.Events.GetLatestForEntity("user", "Lesson", entityId);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("device-b", result!.DeviceId);
|
||||||
|
Assert.Equal(seq1 + 1, result.SequenceNr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempEventStore : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(
|
||||||
|
Path.GetTempPath(), $"lehrerapp-api-tests-eventstore-{Guid.NewGuid():N}");
|
||||||
|
public EventStore Events { get; }
|
||||||
|
|
||||||
|
public TempEventStore()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
Events = new EventStore(_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Events.Dispose();
|
||||||
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LehrerApp.Api\LehrerApp.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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,41 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class PasswordHasherTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Verify_RichtigesPasswort_GibtTrueZurueck()
|
||||||
|
{
|
||||||
|
var hash = PasswordHasher.Hash("korrektes-passwort-123");
|
||||||
|
|
||||||
|
Assert.True(PasswordHasher.Verify("korrektes-passwort-123", hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_FalschesPasswort_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
var hash = PasswordHasher.Hash("korrektes-passwort-123");
|
||||||
|
|
||||||
|
Assert.False(PasswordHasher.Verify("falsches-passwort-456", hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hash_ZweiAufrufeMitGleichemPasswort_ErzeugenUnterschiedlicheHashes()
|
||||||
|
{
|
||||||
|
// Zufälliger Salt pro Nutzer -> selbst identische Passwörter dürfen nicht denselben
|
||||||
|
// gespeicherten Wert ergeben.
|
||||||
|
var hash1 = PasswordHasher.Hash("dasselbe-passwort");
|
||||||
|
var hash2 = PasswordHasher.Hash("dasselbe-passwort");
|
||||||
|
|
||||||
|
Assert.NotEqual(hash1, hash2);
|
||||||
|
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash1));
|
||||||
|
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_UngueltigesGespeichertesFormat_GibtFalseZurueckStattZuWerfen()
|
||||||
|
{
|
||||||
|
Assert.False(PasswordHasher.Verify("irgendwas", "kein-gueltiges-format"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class PlainEventStoreTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Grade")]
|
||||||
|
[InlineData("ExamResult")]
|
||||||
|
public void Push_PersonenbezogenerEntityTyp_WirdAbgelehnt(string entityType)
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
var evt = new PlainSyncEvent { EntityType = entityType, EntityId = "1", Operation = "Save" };
|
||||||
|
|
||||||
|
var result = temp.Plain.Push("user", [evt]);
|
||||||
|
|
||||||
|
Assert.Contains(evt.EventId, result.RejectedEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("WorkTask")]
|
||||||
|
[InlineData("Lesson")]
|
||||||
|
public void Push_UnkritischerEntityTyp_WirdAngenommen(string entityType)
|
||||||
|
{
|
||||||
|
using var temp = new TempEventStore();
|
||||||
|
var evt = new PlainSyncEvent { EntityType = entityType, EntityId = "1", Operation = "Save" };
|
||||||
|
|
||||||
|
var result = temp.Plain.Push("user", [evt]);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(evt.EventId, result.RejectedEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempEventStore : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(
|
||||||
|
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
|
||||||
|
private readonly EventStore _events;
|
||||||
|
public PlainEventStore Plain { get; }
|
||||||
|
|
||||||
|
public TempEventStore()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
_events = new EventStore(_directory);
|
||||||
|
Plain = new PlainEventStore(_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_events.Dispose();
|
||||||
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,89 @@
|
|||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class SnapshotStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Store_ZweiterAufrufOhneCode_ErzeugtNeuenCode()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
|
||||||
|
var first = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p1" });
|
||||||
|
var second = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p2" });
|
||||||
|
|
||||||
|
Assert.NotEqual(first.Code, second.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch — Schritt 1
|
||||||
|
/// ohne EncryptedSyncKey (holt einen Code vom Server), Schritt 2 mit dem gerade erst mit
|
||||||
|
/// diesem Code verschlüsselten Schlüssel. Ohne den zurückgereichten Code erzeugte Store() bei
|
||||||
|
/// jedem Aufruf einen NEUEN Zufallscode — der an den Nutzer angezeigte Code (aus Schritt 2)
|
||||||
|
/// passte dann nie zu dem Code, mit dem der Schlüssel tatsächlich verschlüsselt wurde, und das
|
||||||
|
/// Einlösen auf dem zweiten Gerät schlug beim Schlüssel-Entschlüsseln immer fehl.
|
||||||
|
[Fact]
|
||||||
|
public void Store_ZweiterAufrufMitCodeDesErstenSchritts_AktualisiertDenselbenEintrag()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
|
||||||
|
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
|
||||||
|
var step2 = temp.Store.Store("user1", new SnapshotUploadRequest
|
||||||
|
{
|
||||||
|
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(step1.Code, step2.Code);
|
||||||
|
|
||||||
|
var retrieved = temp.Store.Retrieve("user1", step2.Code);
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.Equal("encrypted-key", retrieved!.EncryptedSyncKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Store_CodeGehoertZuAnderemNutzer_ErzeugtStattdessenNeuenEintrag()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
|
||||||
|
|
||||||
|
var result = temp.Store.Store("user2", new SnapshotUploadRequest
|
||||||
|
{
|
||||||
|
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.NotEqual(step1.Code, result.Code);
|
||||||
|
Assert.NotNull(temp.Store.Retrieve("user2", result.Code));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retrieve_LoeschtDenEintragNachDemErstenAbruf()
|
||||||
|
{
|
||||||
|
using var temp = new TempSnapshotStore();
|
||||||
|
var uploaded = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
|
||||||
|
|
||||||
|
var first = temp.Store.Retrieve("user1", uploaded.Code);
|
||||||
|
var second = temp.Store.Retrieve("user1", uploaded.Code);
|
||||||
|
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.Null(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempSnapshotStore : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(
|
||||||
|
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
|
||||||
|
public LehrerApp.Api.SnapshotStore Store { get; }
|
||||||
|
|
||||||
|
public TempSnapshotStore()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
Store = new LehrerApp.Api.SnapshotStore(_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Store.Dispose();
|
||||||
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, 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,105 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class UserStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateUser_NeuerNutzername_GibtTrueZurueckUndKannSichAnmelden()
|
||||||
|
{
|
||||||
|
using var temp = new TempUserStore();
|
||||||
|
|
||||||
|
var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort");
|
||||||
|
|
||||||
|
Assert.True(created);
|
||||||
|
Assert.NotNull(temp.Store.Authenticate("sebastian", "einSicheresPasswort"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateUser_BereitsVorhandenerNutzername_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempUserStore();
|
||||||
|
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
|
||||||
|
|
||||||
|
var created = temp.Store.CreateUser("sebastian", "einAnderesPasswort");
|
||||||
|
|
||||||
|
Assert.False(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_FalschesPasswort_LiefertNull()
|
||||||
|
{
|
||||||
|
using var temp = new TempUserStore();
|
||||||
|
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
|
||||||
|
|
||||||
|
Assert.Null(temp.Store.Authenticate("sebastian", "falschesPasswort"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_UnbekannterNutzer_LiefertNull()
|
||||||
|
{
|
||||||
|
using var temp = new TempUserStore();
|
||||||
|
|
||||||
|
Assert.Null(temp.Store.Authenticate("unbekannt", "irgendwas"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: LiteDBs Standard-Collation vergleicht den eindeutigen Index auf Username
|
||||||
|
/// case-insensitive - ein Login mit abweichender Groß-/Kleinschreibung authentifiziert also
|
||||||
|
/// erfolgreich. Würde der Aufrufer (Endpoints./api/auth/login) daraufhin den roh eingegebenen
|
||||||
|
/// Namen statt des kanonisch gespeicherten als userId für den Server-seitigen Event-Speicher
|
||||||
|
/// verwenden, würde ein einziger andersartig getippter Login auf einem zweiten Gerät einen
|
||||||
|
/// komplett getrennten, nicht überlappenden Datenbestand erzeugen (siehe TODO 10.2.5 - genau
|
||||||
|
/// das vom Nutzer beobachtete Symptom: nur ein Konto im System, aber Client 2 erhielt trotz
|
||||||
|
/// since=0 keine Ereignisse).
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_AndereGrossKleinschreibung_LiefertDenKanonischGespeichertenNamen()
|
||||||
|
{
|
||||||
|
using var temp = new TempUserStore();
|
||||||
|
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
|
||||||
|
|
||||||
|
var user = temp.Store.Authenticate("Sebastian", "einSicheresPasswort");
|
||||||
|
|
||||||
|
Assert.NotNull(user);
|
||||||
|
Assert.Equal("sebastian", user!.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetPassword_VorhandenerNutzer_AktualisiertPasswortUndAltesFunktioniertNichtMehr()
|
||||||
|
{
|
||||||
|
using var temp = new TempUserStore();
|
||||||
|
temp.Store.CreateUser("sebastian", "altesPasswort123");
|
||||||
|
|
||||||
|
var updated = temp.Store.SetPassword("sebastian", "neuesPasswort123");
|
||||||
|
|
||||||
|
Assert.True(updated);
|
||||||
|
Assert.NotNull(temp.Store.Authenticate("sebastian", "neuesPasswort123"));
|
||||||
|
Assert.Null(temp.Store.Authenticate("sebastian", "altesPasswort123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetPassword_UnbekannterNutzer_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempUserStore();
|
||||||
|
|
||||||
|
Assert.False(temp.Store.SetPassword("unbekannt", "irgendeinPasswort"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempUserStore : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(
|
||||||
|
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
|
||||||
|
public UserStore Store { get; }
|
||||||
|
|
||||||
|
public TempUserStore()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
Store = new UserStore(_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Store.Dispose();
|
||||||
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace LehrerApp.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rohdaten-Ablage für verschlüsselte Datei-Anhänge, getrennt vom Ereignis-/Snapshot-Speicher.
|
||||||
|
/// Server sieht nur verschlüsselte Bytes, kein LiteDB nötig - einfache Dateien pro Nutzer/Id.
|
||||||
|
/// </summary>
|
||||||
|
public class AttachmentStore(string dataPath)
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(dataPath, "attachments");
|
||||||
|
|
||||||
|
public async Task StoreAsync(string userId, string storageId, Stream content)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(_root, Safe(userId));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
await using var file = File.Create(Path.Combine(dir, Safe(storageId)));
|
||||||
|
await content.CopyToAsync(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream? OpenRead(string userId, string storageId)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_root, Safe(userId), Safe(storageId));
|
||||||
|
return File.Exists(path) ? File.OpenRead(path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageId kommt als Routen-Parameter vom Client - nie ungeprüft in einen Dateipfad
|
||||||
|
// übernehmen (Path-Traversal).
|
||||||
|
private static string Safe(string value) =>
|
||||||
|
string.Concat(value.Where(c => char.IsLetterOrDigit(c) || c == '-'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api;
|
||||||
|
|
||||||
|
internal static class Cli
|
||||||
|
{
|
||||||
|
public static Task<int> RunCreateUserAsync(string dataPath, string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length < 2)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Verwendung: create-user <benutzername> [--password <passwort>]");
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
var username = args[1];
|
||||||
|
var password = ParsePassword(args);
|
||||||
|
if (password is null) return Task.FromResult(1);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(dataPath);
|
||||||
|
using var store = new UserStore(dataPath);
|
||||||
|
if (!store.CreateUser(username, password))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Nutzer '{username}' existiert bereits.");
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Nutzer '{username}' angelegt.");
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<int> RunSetPasswordAsync(string dataPath, string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length < 2)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Verwendung: set-password <benutzername> [--password <passwort>]");
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
var username = args[1];
|
||||||
|
var password = ParsePassword(args);
|
||||||
|
if (password is null) return Task.FromResult(1);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(dataPath);
|
||||||
|
using var store = new UserStore(dataPath);
|
||||||
|
if (!store.SetPassword(username, password))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Nutzer '{username}' existiert nicht.");
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Passwort für '{username}' aktualisiert.");
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ParsePassword(string[] args)
|
||||||
|
{
|
||||||
|
string? password = null;
|
||||||
|
for (var i = 2; i < args.Length - 1; i++)
|
||||||
|
if (args[i] == "--password") password = args[i + 1];
|
||||||
|
|
||||||
|
password ??= ReadPassword("Passwort (mind. 12 Zeichen): ");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Passwort muss mindestens 12 Zeichen lang sein.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker exec ohne -it liefert kein TTY -> ReadKey wäre nicht möglich, dann normal lesen.
|
||||||
|
private static string ReadPassword(string prompt)
|
||||||
|
{
|
||||||
|
Console.Write(prompt);
|
||||||
|
if (Console.IsInputRedirected) return Console.ReadLine() ?? "";
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
ConsoleKeyInfo key;
|
||||||
|
while ((key = Console.ReadKey(intercept: true)).Key != ConsoleKey.Enter)
|
||||||
|
{
|
||||||
|
if (key.Key == ConsoleKey.Backspace && sb.Length > 0) { sb.Length--; Console.Write("\b \b"); }
|
||||||
|
else if (!char.IsControl(key.KeyChar)) { sb.Append(key.KeyChar); Console.Write('*'); }
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 +1,11 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Globalization;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace LehrerApp.Api;
|
namespace LehrerApp.Api;
|
||||||
@@ -14,21 +16,20 @@ public static class Endpoints
|
|||||||
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app, string secret)
|
public static void MapAuthEndpoints(this WebApplication app, string secret)
|
||||||
{
|
{
|
||||||
app.MapPost("/api/auth/login", (LoginRequest req) =>
|
app.MapPost("/api/auth/login", (LoginRequest req, UserStore store) =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
|
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
// TODO: Passwort gegen DB prüfen
|
var user = store.Authenticate(req.Username, req.Password);
|
||||||
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
|
// Der kanonisch gespeicherte Username aus UserStore.Authenticate (nicht req.Username!)
|
||||||
});
|
// wird als JWT-userId verwendet - LiteDBs case-insensitive Standard-Collation lässt
|
||||||
|
// einen Login mit abweichender Groß-/Kleinschreibung erfolgreich durch; würde man
|
||||||
app.MapPost("/api/auth/register", (RegisterRequest req) =>
|
// stattdessen req.Username übernehmen, würde jede andersartig getippte Anmeldung einen
|
||||||
{
|
// eigenen, komplett getrennten Server-seitigen Event-Speicher erzeugen (siehe
|
||||||
if (req.Password.Length < 12)
|
// UserStore.Authenticate).
|
||||||
return Results.BadRequest("Passwort mind. 12 Zeichen.");
|
if (user is null) return Results.Unauthorized();
|
||||||
// TODO: User anlegen, Passwort hashen (BCrypt)
|
return Results.Ok(new { token = Jwt(user.Username, secret), userId = user.Username });
|
||||||
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
|
}).RequireRateLimiting("login");
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync ──────────────────────────────────────────────────────────────────
|
// ── Sync ──────────────────────────────────────────────────────────────────
|
||||||
@@ -53,6 +54,41 @@ public static class Endpoints
|
|||||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow });
|
return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow });
|
||||||
});
|
});
|
||||||
|
// Für Clients, deren Push wegen eines neueren Server-Stands abgelehnt wurde (10.3.4-
|
||||||
|
// Nachtrag: exakte Kollisionsprüfung statt 30s-Heuristik) — sofortiges Nachladen des
|
||||||
|
// aktuellen Stands EINER Entität, statt auf den nächsten regulären Pull zu warten.
|
||||||
|
g.MapGet("/entity/{entityType}/{entityId}", (string entityType, string entityId,
|
||||||
|
ClaimsPrincipal user, EventStore store) =>
|
||||||
|
{
|
||||||
|
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (uid is null) return Results.Unauthorized();
|
||||||
|
var result = store.GetLatestForEntity(uid, entityType, entityId);
|
||||||
|
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ──────────
|
||||||
|
|
||||||
|
public static void MapAttachmentEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var g = app.MapGroup("/api/sync/attachments").RequireAuthorization();
|
||||||
|
g.MapPost("/{storageId}", async (string storageId, HttpRequest req,
|
||||||
|
ClaimsPrincipal user, AttachmentStore store) =>
|
||||||
|
{
|
||||||
|
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (uid is null) return Results.Unauthorized();
|
||||||
|
if (req.ContentLength is null or > LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes)
|
||||||
|
return Results.BadRequest("Datei zu groß oder Content-Length fehlt.");
|
||||||
|
await store.StoreAsync(uid, storageId, req.Body);
|
||||||
|
return Results.Ok();
|
||||||
|
});
|
||||||
|
g.MapGet("/{storageId}", (string storageId, ClaimsPrincipal user, AttachmentStore store) =>
|
||||||
|
{
|
||||||
|
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (uid is null) return Results.Unauthorized();
|
||||||
|
var stream = store.OpenRead(uid, storageId);
|
||||||
|
return stream is null ? Results.NotFound() : Results.Stream(stream, "application/octet-stream");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
|
// ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
|
||||||
@@ -114,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 ───────────────────────────────────────────────────────────────────
|
// ── JWT ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string Jwt(string userId, string secret)
|
private static string Jwt(string userId, string secret)
|
||||||
@@ -130,4 +283,3 @@ public static class Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
public record LoginRequest(string Username, string Password);
|
||||||
public record RegisterRequest(string Username, string Password, string DisplayName);
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using LiteDB;
|
using LiteDB;
|
||||||
|
using LehrerApp.Sync;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
namespace LehrerApp.Api;
|
namespace LehrerApp.Api;
|
||||||
@@ -11,37 +12,80 @@ public class EventStore(string dataPath) : IDisposable
|
|||||||
private readonly Dictionary<string, LiteDatabase> _dbs = new();
|
private readonly Dictionary<string, LiteDatabase> _dbs = new();
|
||||||
private readonly Lock _lock = new();
|
private readonly Lock _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nimmt Ereignisse an, wenn ihre <see cref="SyncEvent.BasedOnServerSeq"/> exakt der aktuellen
|
||||||
|
/// ServerSeq der jeweiligen Entität entspricht (null == Entität hier noch nie gesehen, z.B.
|
||||||
|
/// Neuanlage) — echte optimistische Nebenläufigkeitskontrolle statt der früheren 30-Sekunden-
|
||||||
|
/// Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst"), die sowohl falsch-
|
||||||
|
/// positiv (zwei Geräte bearbeiten zufällig kurz hintereinander verschiedene Felder) als auch
|
||||||
|
/// falsch-negativ (echter Konflikt liegt außerhalb des 30s-Fensters) sein konnte.
|
||||||
|
/// Mehrere Ereignisse derselben Entität IM SELBEN Aufruf bauen bewusst aufeinander auf (das
|
||||||
|
/// zweite prüft gegen den vom ersten gerade neu vergebenen Stand) — der Client schickt ohnehin
|
||||||
|
/// nur noch das jüngste ausstehende Ereignis je Entität (siehe SyncEngine.PushAsync).
|
||||||
|
/// </summary>
|
||||||
public PushResponse Push(string userId, List<SyncEvent> events)
|
public PushResponse Push(string userId, List<SyncEvent> events)
|
||||||
{
|
{
|
||||||
var col = GetCol(userId);
|
var col = GetCol(userId);
|
||||||
var seq = LastSeq(col);
|
var seq = LastSeq(col);
|
||||||
var rejects = new List<Guid>();
|
var rejects = new List<Guid>();
|
||||||
|
var assigned = new Dictionary<Guid, long>();
|
||||||
foreach (var e in events.OrderBy(e => e.Timestamp))
|
foreach (var e in events.OrderBy(e => e.Timestamp))
|
||||||
{
|
{
|
||||||
var recent = col.FindOne(x =>
|
var currentSeq = LatestForEntity(col, e.EntityType, e.EntityId)?.ServerSeq;
|
||||||
x.EntityType == e.EntityType && x.EntityId == e.EntityId &&
|
if (currentSeq != e.BasedOnServerSeq) { rejects.Add(e.EventId); continue; }
|
||||||
x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30));
|
var newSeq = ++seq;
|
||||||
if (recent is not null) { rejects.Add(e.EventId); continue; }
|
|
||||||
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||||
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
||||||
ClientSeq = e.SequenceNr, ServerSeq = ++seq,
|
ClientSeq = e.SequenceNr, ServerSeq = newSeq,
|
||||||
EntityType = e.EntityType, EntityId = e.EntityId,
|
EntityType = e.EntityType, EntityId = e.EntityId,
|
||||||
Operation = e.Operation, Payload = e.Payload });
|
Operation = e.Operation, Payload = e.Payload });
|
||||||
|
assigned[e.EventId] = newSeq;
|
||||||
}
|
}
|
||||||
return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects };
|
return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects,
|
||||||
|
AssignedServerSeqs = assigned };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Aktuellstes Ereignis einer einzelnen Entität — für Clients, deren Push wegen eines
|
||||||
|
/// neueren Server-Stands abgelehnt wurde (siehe <see cref="Push"/>), um sofort den aktuellen
|
||||||
|
/// Stand nachzuladen, statt auf den nächsten regulären Pull zu warten.</summary>
|
||||||
|
public SyncEvent? GetLatestForEntity(string userId, string entityType, string entityId)
|
||||||
|
{
|
||||||
|
var col = GetCol(userId);
|
||||||
|
var e = LatestForEntity(col, entityType, entityId);
|
||||||
|
// SequenceNr trägt hier (wie bei Pull) die ServerSeq — dieselbe Konvention wie sonst im
|
||||||
|
// Sync-Protokoll: bei vom Server stammenden Ereignissen ist SequenceNr immer die ServerSeq.
|
||||||
|
return e is null ? null : new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||||
|
DeviceType = e.DeviceType, Timestamp = e.Timestamp, SequenceNr = e.ServerSeq,
|
||||||
|
EntityType = e.EntityType, EntityId = e.EntityId, Operation = e.Operation,
|
||||||
|
Payload = e.Payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerEvent? LatestForEntity(ILiteCollection<ServerEvent> col, string entityType, string entityId) =>
|
||||||
|
col.Find(x => x.EntityType == entityType && x.EntityId == entityId)
|
||||||
|
.OrderByDescending(x => x.ServerSeq).FirstOrDefault();
|
||||||
|
|
||||||
public PullResponse Pull(string userId, long since, string requestingDeviceId)
|
public PullResponse Pull(string userId, long since, string requestingDeviceId)
|
||||||
{
|
{
|
||||||
var col = GetCol(userId);
|
var col = GetCol(userId);
|
||||||
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
|
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,
|
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||||
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
||||||
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
||||||
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
|
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
|
||||||
.ToList();
|
.ToList();
|
||||||
return new() { Events = events, ServerSequenceNr = LastSeq(col) };
|
// ServerSequenceNr MUSS die höchste ServerSeq unter den tatsächlich zurückgegebenen
|
||||||
|
// Ereignissen sein, NICHT der globale Höchststand (LastSeq(col)) — der schließt auch
|
||||||
|
// Ereignisse ANDERER Geräte ein, die z.B. gerade erst (nach dem obigen Find-Aufruf, aber
|
||||||
|
// vor dieser Zeile) eingetroffen sind, oder — der Bug, der hier tatsächlich beobachtet
|
||||||
|
// wurde — Ereignisse des anfragenden Geräts selbst, die oben bewusst per
|
||||||
|
// "DeviceId != requestingDeviceId" herausgefiltert wurden. SyncEngine.PullAsync übernimmt
|
||||||
|
// ServerSequenceNr 1:1 als neuen "since"-Cursor für den nächsten Pull; mit dem globalen
|
||||||
|
// Höchststand würde der Client seinen Cursor über Ereignisse hinweg vorrücken, die er nie
|
||||||
|
// erhalten hat, und sie dauerhaft verpassen — genau das vom Nutzer beobachtete Symptom
|
||||||
|
// (Push meldet Erfolg, Pull liefert 0 Ereignisse, obwohl welche ausstehen).
|
||||||
|
var newWatermark = events.Count > 0 ? events.Max(e => e.SequenceNr) : since;
|
||||||
|
return new() { Events = events, ServerSequenceNr = newWatermark };
|
||||||
}
|
}
|
||||||
|
|
||||||
private ILiteCollection<ServerEvent> GetCol(string userId)
|
private ILiteCollection<ServerEvent> GetCol(string userId)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api;
|
||||||
|
|
||||||
|
public static class PasswordHasher
|
||||||
|
{
|
||||||
|
private const int Iterations = 100_000;
|
||||||
|
private const int SaltSize = 16;
|
||||||
|
private const int HashSize = 32;
|
||||||
|
|
||||||
|
public static string Hash(string password)
|
||||||
|
{
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||||
|
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||||
|
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Verify(string password, string stored)
|
||||||
|
{
|
||||||
|
var parts = stored.Split(':');
|
||||||
|
if (parts.Length != 2) return false;
|
||||||
|
var salt = Convert.FromBase64String(parts[0]);
|
||||||
|
var expected = Convert.FromBase64String(parts[1]);
|
||||||
|
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ namespace LehrerApp.Api;
|
|||||||
|
|
||||||
public class PlainEventStore(EventStore eventStore)
|
public class PlainEventStore(EventStore eventStore)
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> Allowed =
|
// Grade/ExamResult bewusst NICHT erlaubt: PlainSyncEvent.Payload ist Klartext-JSON (siehe
|
||||||
["Grade", "ExamResult", "WorkTask", "Lesson"];
|
// SyncModels.cs), beide Typen tragen aber eine StudentId plus Notenwert/-kommentar -
|
||||||
|
// personenbezogene Daten dürfen laut TODO.md 10.3.3 nicht unverschlüsselt übertragen werden.
|
||||||
|
private static readonly HashSet<string> Allowed = ["WorkTask", "Lesson"];
|
||||||
|
|
||||||
public PlainPushResponse Push(string userId, List<PlainSyncEvent> events)
|
public PlainPushResponse Push(string userId, List<PlainSyncEvent> events)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using LehrerApp.Api;
|
using LehrerApp.Api;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -9,10 +12,22 @@ builder.WebHost.UseKestrel(o =>
|
|||||||
{
|
{
|
||||||
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
|
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
|
||||||
o.ListenAnyIP(port);
|
o.ListenAnyIP(port);
|
||||||
|
// Anhänge sind clientseitig auf 10 MB begrenzt (IAttachmentStorage.MaxSizeBytes) - etwas
|
||||||
|
// Puffer für Verschlüsselungs-Overhead/JSON-Ereignisse, aber trotzdem eine harte Obergrenze.
|
||||||
|
o.Limits.MaxRequestBodySize = 15 * 1024 * 1024;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var data = builder.Configuration["Api:DataPath"] ?? "./data";
|
||||||
|
|
||||||
|
if (args.Length > 0 && args[0] == "create-user")
|
||||||
|
return await Cli.RunCreateUserAsync(data, args);
|
||||||
|
if (args.Length > 0 && args[0] == "set-password")
|
||||||
|
return await Cli.RunSetPasswordAsync(data, args);
|
||||||
|
|
||||||
var secret = builder.Configuration["JWT_SECRET"]
|
var secret = builder.Configuration["JWT_SECRET"]
|
||||||
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
|
?? 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)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(o => o.TokenValidationParameters = new()
|
.AddJwtBearer(o => o.TokenValidationParameters = new()
|
||||||
@@ -24,19 +39,78 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
var data = builder.Configuration["Api:DataPath"] ?? "./data";
|
// Läuft hinter einem Reverse Proxy (Dokploy/Traefik) - ohne diese Weiterleitung sähe Kestrel für
|
||||||
|
// JEDE Anfrage dieselbe interne Proxy-IP als Connection.RemoteIpAddress, wodurch das unten
|
||||||
|
// definierte Pro-IP-Rate-Limit faktisch zu einem einzigen globalen Limit für die gesamte
|
||||||
|
// Bereitstellung würde (Bug, siehe TODO.md 10.2.2 Nachtrag). KnownNetworks/KnownProxies bewusst
|
||||||
|
// geleert: der Container ist nur über den Reverse Proxy erreichbar, nie direkt aus dem Internet,
|
||||||
|
// daher ist der unmittelbare Absender von X-Forwarded-For hier immer vertrauenswürdig.
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate Limiting (10.2.2): striktes Limit gezielt gegen Brute-Force auf /api/auth/login, plus ein
|
||||||
|
// grobes globales Limit pro IP als einfacher Schutz vor Überlastung der übrigen Endpunkte.
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("login", o =>
|
||||||
|
{
|
||||||
|
o.PermitLimit = 5;
|
||||||
|
o.Window = TimeSpan.FromMinutes(1);
|
||||||
|
o.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 120,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<UserStore>(_ => new UserStore(data));
|
||||||
|
builder.Services.AddSingleton<AttachmentStore>(_ => new AttachmentStore(data));
|
||||||
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
|
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
|
||||||
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
||||||
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
||||||
builder.Services.AddSingleton<PlainEventStore>(sp =>
|
builder.Services.AddSingleton<PlainEventStore>(sp =>
|
||||||
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
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();
|
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.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapAuthEndpoints(secret);
|
app.MapAuthEndpoints(secret);
|
||||||
app.MapSyncEndpoints();
|
app.MapSyncEndpoints();
|
||||||
|
app.MapAttachmentEndpoints();
|
||||||
app.MapSnapshotEndpoints();
|
app.MapSnapshotEndpoints();
|
||||||
app.MapReadableSnapshotEndpoints();
|
app.MapReadableSnapshotEndpoints();
|
||||||
app.MapPlainSyncEndpoints();
|
app.MapPlainSyncEndpoints();
|
||||||
|
app.MapSchoolWeatherEndpoints();
|
||||||
app.Run();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,22 @@ public class SnapshotStore(string dataPath) : IDisposable
|
|||||||
|
|
||||||
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
|
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
|
||||||
{
|
{
|
||||||
|
// Zweiter Upload-Schritt (Schlüssel nachreichen, mit dem im ersten Schritt vergebenen Code
|
||||||
|
// verschlüsselt): denselben Eintrag aktualisieren statt einen neuen mit neuem Code
|
||||||
|
// anzulegen — sonst würde der Schlüssel dauerhaft mit dem falschen Code verknüpft bleiben
|
||||||
|
// und die Entschlüsselung auf dem Empfängergerät schlägt fehl (Bug, siehe TODO.md 10.3.1).
|
||||||
|
if (!string.IsNullOrEmpty(req.Code))
|
||||||
|
{
|
||||||
|
var existing = Col.FindOne(e => e.UserId == userId && e.Code == req.Code.ToUpperInvariant());
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.EncryptedPayload = req.EncryptedPayload;
|
||||||
|
existing.EncryptedSyncKey = req.EncryptedSyncKey;
|
||||||
|
Col.Update(existing);
|
||||||
|
return new() { Code = existing.Code, ExpiresAt = existing.ExpiresAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Col.DeleteMany(e => e.UserId == userId);
|
Col.DeleteMany(e => e.UserId == userId);
|
||||||
var code = NewCode();
|
var code = NewCode();
|
||||||
var entry = new SnapshotEntry { Code = code, UserId = userId,
|
var entry = new SnapshotEntry { Code = code, UserId = userId,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using LiteDB;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api;
|
||||||
|
|
||||||
|
public class UserStore(string dataPath) : IDisposable
|
||||||
|
{
|
||||||
|
private readonly LiteDatabase _db = new(Path.Combine(dataPath, "users.db"));
|
||||||
|
|
||||||
|
private ILiteCollection<UserEntry> Col
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var col = _db.GetCollection<UserEntry>("users");
|
||||||
|
col.EnsureIndex(x => x.Username, unique: true);
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CreateUser(string username, string password)
|
||||||
|
{
|
||||||
|
if (Col.Exists(x => x.Username == username)) return false;
|
||||||
|
Col.Insert(new UserEntry { Username = username, PasswordHash = PasswordHasher.Hash(password) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SetPassword(string username, string password)
|
||||||
|
{
|
||||||
|
var user = Col.FindOne(x => x.Username == username);
|
||||||
|
if (user is null) return false;
|
||||||
|
user.PasswordHash = PasswordHasher.Hash(password);
|
||||||
|
Col.Update(user);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft die Anmeldedaten und liefert bei Erfolg den GESPEICHERTEN Nutzereintrag zurück -
|
||||||
|
/// nicht bloß true/false. Wichtig: LiteDBs Standard-Collation vergleicht Strings (und damit
|
||||||
|
/// auch den eindeutigen Index auf Username) standardmäßig case-insensitive, d.h. ein Login mit
|
||||||
|
/// abweichender Groß-/Kleinschreibung authentifiziert erfolgreich. Würde der Aufrufer daraufhin
|
||||||
|
/// den roh eingegebenen Benutzernamen als userId für den Server-seitigen Event-Speicher
|
||||||
|
/// verwenden (siehe EventStore.GetCol), würde ein einziger andersartig getippter Login auf
|
||||||
|
/// einem zweiten Gerät einen komplett getrennten, nicht überlappenden Datenbestand erzeugen,
|
||||||
|
/// obwohl es sich aus Nutzersicht um dasselbe Konto handelt. Der kanonisch gespeicherte Name
|
||||||
|
/// aus diesem Rückgabewert stellt sicher, dass die userId unabhängig von der beim Login
|
||||||
|
/// eingegebenen Schreibweise stets identisch ist.
|
||||||
|
/// </summary>
|
||||||
|
public UserEntry? Authenticate(string username, string password)
|
||||||
|
{
|
||||||
|
var user = Col.FindOne(x => x.Username == username);
|
||||||
|
return user is not null && PasswordHasher.Verify(password, user.PasswordHash) ? user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UserEntry
|
||||||
|
{
|
||||||
|
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||||
|
public string Username { get; set; } = "";
|
||||||
|
public string PasswordHash { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -119,3 +119,58 @@ public class AiExplainResponse
|
|||||||
{
|
{
|
||||||
public string Explanation { get; set; } = "";
|
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; } = "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Importing;
|
||||||
|
|
||||||
|
internal sealed record DelimitedRecord(int LineNumber, IReadOnlyList<string> Fields);
|
||||||
|
|
||||||
|
internal static class DelimitedTextParser
|
||||||
|
{
|
||||||
|
public static bool TryParse(
|
||||||
|
string text,
|
||||||
|
char delimiter,
|
||||||
|
out List<DelimitedRecord> records,
|
||||||
|
out string? error)
|
||||||
|
{
|
||||||
|
records = [];
|
||||||
|
error = null;
|
||||||
|
var fields = new List<string>();
|
||||||
|
var field = new StringBuilder();
|
||||||
|
var inQuotes = false;
|
||||||
|
var line = 1;
|
||||||
|
var recordStartLine = 1;
|
||||||
|
|
||||||
|
for (var index = 0; index < text.Length; index++)
|
||||||
|
{
|
||||||
|
var current = text[index];
|
||||||
|
if (current == '"')
|
||||||
|
{
|
||||||
|
if (inQuotes && index + 1 < text.Length && text[index + 1] == '"')
|
||||||
|
{
|
||||||
|
field.Append('"');
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current == delimiter && !inQuotes)
|
||||||
|
{
|
||||||
|
fields.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((current == '\r' || current == '\n') && !inQuotes)
|
||||||
|
{
|
||||||
|
if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n')
|
||||||
|
index++;
|
||||||
|
fields.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
|
||||||
|
fields.Clear();
|
||||||
|
line++;
|
||||||
|
recordStartLine = line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current == '\n') line++;
|
||||||
|
field.Append(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inQuotes)
|
||||||
|
{
|
||||||
|
error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.Length > 0 || fields.Count > 0)
|
||||||
|
{
|
||||||
|
fields.Add(field.ToString());
|
||||||
|
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Importing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit
|
||||||
|
/// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ImportFormatId
|
||||||
|
{
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public ImportFormatId(string value)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||||
|
Value = value.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine vollständig in den Speicher geladene Importdatei.</summary>
|
||||||
|
public sealed class ImportFile
|
||||||
|
{
|
||||||
|
public string FileName { get; }
|
||||||
|
public ReadOnlyMemory<byte> Content { get; }
|
||||||
|
public string Extension => Path.GetExtension(FileName).ToLowerInvariant();
|
||||||
|
|
||||||
|
public ImportFile(string fileName, ReadOnlyMemory<byte> content)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||||
|
FileName = Path.GetFileName(fileName);
|
||||||
|
Content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ImportMessageSeverity { Information, Warning, Error }
|
||||||
|
|
||||||
|
public sealed record ImportMessage(
|
||||||
|
ImportMessageSeverity Severity,
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
string? SourceReference = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen
|
||||||
|
/// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ImportDetection
|
||||||
|
{
|
||||||
|
public int Confidence { get; }
|
||||||
|
public string? Reason { get; }
|
||||||
|
public bool IsMatch => Confidence > 0;
|
||||||
|
|
||||||
|
public ImportDetection(int confidence, string? reason = null)
|
||||||
|
{
|
||||||
|
if (confidence is < 0 or > 100)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(confidence));
|
||||||
|
Confidence = confidence;
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImportDetection NoMatch(string? reason = null) => new(0, reason);
|
||||||
|
public static ImportDetection Match(int confidence, string? reason = null) =>
|
||||||
|
new(confidence, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ImportParseResult<T>
|
||||||
|
{
|
||||||
|
public IReadOnlyList<T> Items { get; }
|
||||||
|
public IReadOnlyList<ImportMessage> Messages { get; }
|
||||||
|
public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error);
|
||||||
|
|
||||||
|
public ImportParseResult(IEnumerable<T> items, IEnumerable<ImportMessage>? messages = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
Items = ReadOnly(items);
|
||||||
|
Messages = ReadOnly(messages ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<TItem> ReadOnly<TItem>(IEnumerable<TItem> values) =>
|
||||||
|
new ReadOnlyCollection<TItem>(values.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein
|
||||||
|
/// normalisiertes fachliches Importmodell.
|
||||||
|
/// </summary>
|
||||||
|
public interface IImportHandler<T>
|
||||||
|
{
|
||||||
|
ImportFormatId FormatId { get; }
|
||||||
|
string DisplayName { get; }
|
||||||
|
IReadOnlyCollection<string> SupportedExtensions { get; }
|
||||||
|
|
||||||
|
ValueTask<ImportDetection> DetectAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
ValueTask<ImportParseResult<T>> ParseAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DetectedImportHandler<T>(
|
||||||
|
IImportHandler<T> Handler,
|
||||||
|
ImportDetection Detection);
|
||||||
|
|
||||||
|
public abstract class ImportFormatException(string message) : Exception(message);
|
||||||
|
|
||||||
|
public sealed class UnsupportedImportFormatException(string fileName)
|
||||||
|
: ImportFormatException($"Für die Datei „{fileName}“ wurde kein passendes Importformat gefunden.");
|
||||||
|
|
||||||
|
public sealed class AmbiguousImportFormatException(string fileName, IEnumerable<string> formats)
|
||||||
|
: ImportFormatException(
|
||||||
|
$"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts
|
||||||
|
/// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImportHandlerCatalog<T>
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<IImportHandler<T>> _handlers;
|
||||||
|
|
||||||
|
public ImportHandlerCatalog(IEnumerable<IImportHandler<T>> handlers)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(handlers);
|
||||||
|
var materialized = handlers.ToList();
|
||||||
|
var duplicate = materialized
|
||||||
|
.GroupBy(handler => handler.FormatId)
|
||||||
|
.FirstOrDefault(group => group.Count() > 1);
|
||||||
|
if (duplicate is not null)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Das Importformat „{duplicate.Key}“ wurde mehrfach registriert.", nameof(handlers));
|
||||||
|
_handlers = new ReadOnlyCollection<IImportHandler<T>>(materialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<IImportHandler<T>> Handlers => _handlers;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> SupportedExtensions => _handlers
|
||||||
|
.SelectMany(handler => handler.SupportedExtensions)
|
||||||
|
.Select(NormalizeExtension)
|
||||||
|
.Where(extension => extension.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(extension => extension, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
|
||||||
|
public async ValueTask<DetectedImportHandler<T>> DetectAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(file);
|
||||||
|
var matches = new List<Candidate>();
|
||||||
|
foreach (var handler in _handlers)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var detection = await handler.DetectAsync(file, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (detection.IsMatch)
|
||||||
|
matches.Add(new Candidate(handler, detection, SupportsExtension(handler, file.Extension)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count == 0)
|
||||||
|
throw new UnsupportedImportFormatException(file.FileName);
|
||||||
|
|
||||||
|
var highestConfidence = matches.Max(match => match.Detection.Confidence);
|
||||||
|
var best = matches.Where(match => match.Detection.Confidence == highestConfidence).ToList();
|
||||||
|
if (best.Count > 1 && best.Any(match => match.ExtensionMatches))
|
||||||
|
best = best.Where(match => match.ExtensionMatches).ToList();
|
||||||
|
|
||||||
|
if (best.Count != 1)
|
||||||
|
throw new AmbiguousImportFormatException(
|
||||||
|
file.FileName,
|
||||||
|
best.Select(match => match.Handler.DisplayName));
|
||||||
|
|
||||||
|
return new DetectedImportHandler<T>(best[0].Handler, best[0].Detection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SupportsExtension(IImportHandler<T> handler, string extension) =>
|
||||||
|
handler.SupportedExtensions
|
||||||
|
.Select(NormalizeExtension)
|
||||||
|
.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string NormalizeExtension(string extension)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(extension)) return "";
|
||||||
|
var trimmed = extension.Trim().ToLowerInvariant();
|
||||||
|
return trimmed.StartsWith('.') ? trimmed : $".{trimmed}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Candidate(
|
||||||
|
IImportHandler<T> Handler,
|
||||||
|
ImportDetection Detection,
|
||||||
|
bool ExtensionMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ImportConflictOption(string Id, string Label, string? Description = null);
|
||||||
|
|
||||||
|
public sealed record ImportConflict(
|
||||||
|
string Id,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string ImportedValue,
|
||||||
|
string? ExistingValue,
|
||||||
|
IReadOnlyList<ImportConflictOption> Options,
|
||||||
|
string DefaultOptionId,
|
||||||
|
string? SourceReference = null);
|
||||||
|
|
||||||
|
/// <summary>Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung.</summary>
|
||||||
|
public sealed record ImportDecision(string ConflictId, string SelectedOptionId);
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Importing;
|
||||||
|
|
||||||
|
/// <summary>Importiert den tabulatorgetrennten Export „Teilnehmerliste“ eines Unterrichts.</summary>
|
||||||
|
public sealed class LessonStudentListCsvImportHandler : IImportHandler<ImportedStudent>
|
||||||
|
{
|
||||||
|
private static readonly string[] SignatureHeaders =
|
||||||
|
["Langname", "Vorname", "Klasse", "Geschlecht", "Eintrittsdatum", "Austrittsdatum",
|
||||||
|
"E-Mail Adresse", "Mobiltelefon", "Telefonnummer"];
|
||||||
|
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||||
|
|
||||||
|
public ImportFormatId FormatId => StudentImportFormats.LessonStudentListCsv;
|
||||||
|
public string DisplayName => "Unterrichts-Teilnehmerliste";
|
||||||
|
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
|
||||||
|
|
||||||
|
public ValueTask<ImportDetection> DetectAsync(ImportFile file, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!TryDecode(file, out var text))
|
||||||
|
return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8."));
|
||||||
|
var headers = FirstLine(text).Split('\t').Select(x => x.Trim())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return ValueTask.FromResult(SignatureHeaders.All(headers.Contains)
|
||||||
|
? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95,
|
||||||
|
"Die charakteristischen Spalten der Unterrichts-Teilnehmerliste wurden gefunden.")
|
||||||
|
: ImportDetection.NoMatch("Die erwarteten Spalten der Unterrichts-Teilnehmerliste fehlen."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var messages = new List<ImportMessage>();
|
||||||
|
if (!TryDecode(file, out var text))
|
||||||
|
return Result([], [Error("lesson-students.encoding", "Die Teilnehmerliste ist nicht als gültiges UTF-8 gespeichert.")]);
|
||||||
|
if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError))
|
||||||
|
return Result([], [Error("lesson-students.structure", parseError ?? "Die Teilnehmerliste ist syntaktisch ungültig.")]);
|
||||||
|
if (records.Count == 0)
|
||||||
|
return Result([], [Error("lesson-students.empty", "Die Teilnehmerliste ist leer.")]);
|
||||||
|
|
||||||
|
var header = records[0].Fields.Select((name, index) => (Name: name.Trim().TrimStart('\uFEFF'), Index: index)).ToList();
|
||||||
|
var duplicate = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(x => x.Count() > 1);
|
||||||
|
if (duplicate is not null)
|
||||||
|
messages.Add(Error("lesson-students.header.duplicate", $"Die Spalte „{duplicate.Key}“ kommt mehrfach vor.", "Kopfzeile"));
|
||||||
|
var columns = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(x => x.Key, x => x.First().Index, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var required in SignatureHeaders.Where(x => !columns.ContainsKey(x)))
|
||||||
|
messages.Add(Error("lesson-students.header.missing", $"Die erforderliche Spalte „{required}“ fehlt.", "Kopfzeile"));
|
||||||
|
if (messages.Any(x => x.Severity == ImportMessageSeverity.Error)) return Result([], messages);
|
||||||
|
|
||||||
|
var students = new List<ImportedStudent>();
|
||||||
|
foreach (var record in records.Skip(1))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (record.Fields.All(string.IsNullOrWhiteSpace)) continue;
|
||||||
|
var source = $"Zeile {record.LineNumber}";
|
||||||
|
if (record.Fields.Count != header.Count)
|
||||||
|
{
|
||||||
|
messages.Add(Error("lesson-students.columns",
|
||||||
|
$"Die Zeile enthält {record.Fields.Count} statt {header.Count} Spalten.", source));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
students.Add(new ImportedStudent
|
||||||
|
{
|
||||||
|
LastName = Value(record, columns, "Langname"),
|
||||||
|
FirstName = Value(record, columns, "Vorname"),
|
||||||
|
GroupName = NullIfEmpty(Value(record, columns, "Klasse")),
|
||||||
|
Gender = ParseGender(Value(record, columns, "Geschlecht"), source, messages),
|
||||||
|
Contact = new ImportedStudentContact
|
||||||
|
{
|
||||||
|
Email = NullIfEmpty(Value(record, columns, "E-Mail Adresse")),
|
||||||
|
MobilePhone = NullIfEmpty(Value(record, columns, "Mobiltelefon")),
|
||||||
|
Phone = NullIfEmpty(Value(record, columns, "Telefonnummer")),
|
||||||
|
},
|
||||||
|
SourceReference = source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Result(students, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gender? ParseGender(string value, string source, ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
return value.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"m" or "männlich" or "maennlich" => Gender.M,
|
||||||
|
"w" or "weiblich" => Gender.W,
|
||||||
|
"d" or "divers" => Gender.D,
|
||||||
|
_ => UnknownGender(value, source, messages),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gender? UnknownGender(string value, string source, ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(ImportMessageSeverity.Warning, "lesson-students.gender.unknown",
|
||||||
|
$"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.", source));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecode(ImportFile file, out string text)
|
||||||
|
{
|
||||||
|
try { text = StrictUtf8.GetString(file.Content.Span); return true; }
|
||||||
|
catch (DecoderFallbackException) { text = ""; return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstLine(string text)
|
||||||
|
{
|
||||||
|
var end = text.IndexOfAny(['\r', '\n']);
|
||||||
|
return (end >= 0 ? text[..end] : text).TrimStart('\uFEFF');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Value(DelimitedRecord row, IReadOnlyDictionary<string, int> columns, string name) =>
|
||||||
|
columns.TryGetValue(name, out var index) && index < row.Fields.Count ? row.Fields[index].Trim() : "";
|
||||||
|
private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
private static ImportMessage Error(string code, string message, string? source = null) =>
|
||||||
|
new(ImportMessageSeverity.Error, code, message, source);
|
||||||
|
private static ValueTask<ImportParseResult<ImportedStudent>> Result(
|
||||||
|
IReadOnlyList<ImportedStudent> students, IReadOnlyList<ImportMessage> messages) =>
|
||||||
|
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(students, messages));
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Importing;
|
||||||
|
|
||||||
|
/// <summary>Erkennt den Notenlistenexport und erklärt dessen fehlende Teilnehmerdaten.</summary>
|
||||||
|
public sealed class MarksPerLessonCsvImportHandler : IImportHandler<ImportedStudent>
|
||||||
|
{
|
||||||
|
private static readonly string[] SignatureHeaders =
|
||||||
|
["Datum", "Name", "Klasse", "Fach", "Prüfungsart", "Note", "Bemerkung", "Benutzer", "Schlüssel (extern)", "Gesamtnote"];
|
||||||
|
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||||
|
|
||||||
|
public ImportFormatId FormatId => StudentImportFormats.MarksPerLessonCsv;
|
||||||
|
public string DisplayName => "Unterrichts-Notenliste";
|
||||||
|
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
|
||||||
|
|
||||||
|
public ValueTask<ImportDetection> DetectAsync(ImportFile file, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string text;
|
||||||
|
try { text = StrictUtf8.GetString(file.Content.Span); }
|
||||||
|
catch (DecoderFallbackException) { return ValueTask.FromResult(ImportDetection.NoMatch()); }
|
||||||
|
var end = text.IndexOfAny(['\r', '\n']);
|
||||||
|
var firstLine = (end >= 0 ? text[..end] : text).TrimStart('\uFEFF');
|
||||||
|
var headers = firstLine.Split('\t').Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return ValueTask.FromResult(SignatureHeaders.All(headers.Contains)
|
||||||
|
? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95)
|
||||||
|
: ImportDetection.NoMatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
IReadOnlyList<ImportMessage> messages =
|
||||||
|
[
|
||||||
|
new(ImportMessageSeverity.Error, "marks-per-lesson.no-participants",
|
||||||
|
"Die Notenliste enthält keine verlässlichen Teilnehmerdaten. Bitte exportiere die echte Kurs-/Teilnehmerliste.")
|
||||||
|
];
|
||||||
|
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Importing;
|
||||||
|
|
||||||
|
public sealed record ImportedStudentContact
|
||||||
|
{
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? MobilePhone { get; init; }
|
||||||
|
public string? Phone { get; init; }
|
||||||
|
public string? Street { get; init; }
|
||||||
|
public string? PostalCode { get; init; }
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public bool HasData => new[] { Email, MobilePhone, Phone, Street, PostalCode, City }
|
||||||
|
.Any(value => !string.IsNullOrWhiteSpace(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Formatunabhängige Schülerdaten, die ein konkreter Handler erzeugt.</summary>
|
||||||
|
public sealed record ImportedStudent
|
||||||
|
{
|
||||||
|
public required string FirstName { get; init; }
|
||||||
|
public required string LastName { get; init; }
|
||||||
|
public DateOnly? DateOfBirth { get; init; }
|
||||||
|
public Gender? Gender { get; init; }
|
||||||
|
public string? ExternalId { get; init; }
|
||||||
|
public string? GroupName { get; init; }
|
||||||
|
public ImportedStudentContact? Contact { get; init; }
|
||||||
|
public string? SourceReference { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StudentImportFormats
|
||||||
|
{
|
||||||
|
public static readonly ImportFormatId MasterDataCsv = new("student-master-data.csv");
|
||||||
|
public static readonly ImportFormatId LessonStudentListCsv = new("lesson-student-list.csv");
|
||||||
|
public static readonly ImportFormatId MarksPerLessonCsv = new("marks-per-lesson.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum StudentImportResolutionKind { CreateNew, UseExisting, Skip }
|
||||||
|
|
||||||
|
public sealed record StudentImportResolution(
|
||||||
|
StudentImportResolutionKind Kind,
|
||||||
|
Guid? ExistingStudentId = null);
|
||||||
|
|
||||||
|
public sealed record StudentImportEntry(
|
||||||
|
string Id,
|
||||||
|
ImportedStudent Student,
|
||||||
|
StudentImportResolution? AutomaticResolution,
|
||||||
|
string? ConflictId,
|
||||||
|
IReadOnlyList<string> FieldsToSupplement);
|
||||||
|
|
||||||
|
public sealed record StudentImportGroupAssignment(
|
||||||
|
string SourceGroupName,
|
||||||
|
Guid? AutomaticGroupId,
|
||||||
|
string? ConflictId);
|
||||||
|
|
||||||
|
public sealed class StudentImportPreview
|
||||||
|
{
|
||||||
|
public ImportFormatId FormatId { get; }
|
||||||
|
public string FormatName { get; }
|
||||||
|
public IReadOnlyList<StudentImportEntry> Entries { get; }
|
||||||
|
public IReadOnlyList<ImportMessage> Messages { get; }
|
||||||
|
public IReadOnlyList<ImportConflict> Conflicts { get; }
|
||||||
|
public IReadOnlyList<StudentImportGroupAssignment> GroupAssignments { get; }
|
||||||
|
public Guid? TargetGroupId { get; }
|
||||||
|
internal string ExistingStudentsFingerprint { get; }
|
||||||
|
internal string GroupStateFingerprint { get; }
|
||||||
|
|
||||||
|
public bool CanApply =>
|
||||||
|
Entries.Count > 0 && Messages.All(message => message.Severity != ImportMessageSeverity.Error);
|
||||||
|
|
||||||
|
internal StudentImportPreview(
|
||||||
|
ImportFormatId formatId,
|
||||||
|
string formatName,
|
||||||
|
IEnumerable<StudentImportEntry> entries,
|
||||||
|
IEnumerable<ImportMessage> messages,
|
||||||
|
IEnumerable<ImportConflict> conflicts,
|
||||||
|
IEnumerable<StudentImportGroupAssignment> groupAssignments,
|
||||||
|
Guid? targetGroupId,
|
||||||
|
string existingStudentsFingerprint,
|
||||||
|
string groupStateFingerprint)
|
||||||
|
{
|
||||||
|
FormatId = formatId;
|
||||||
|
FormatName = formatName;
|
||||||
|
Entries = ReadOnly(entries);
|
||||||
|
Messages = ReadOnly(messages);
|
||||||
|
Conflicts = ReadOnly(conflicts);
|
||||||
|
GroupAssignments = ReadOnly(groupAssignments);
|
||||||
|
TargetGroupId = targetGroupId;
|
||||||
|
ExistingStudentsFingerprint = existingStudentsFingerprint;
|
||||||
|
GroupStateFingerprint = groupStateFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> values) =>
|
||||||
|
new ReadOnlyCollection<T>(values.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StudentImportApplyResult(
|
||||||
|
int CreatedStudents,
|
||||||
|
int UpdatedStudents,
|
||||||
|
int MatchedExistingStudents,
|
||||||
|
int SkippedStudents,
|
||||||
|
int CreatedMemberships,
|
||||||
|
IReadOnlyList<Guid> CreatedStudentIds);
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Importing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest den tabulatorgetrennten Stammdatenexport mit Spalten wie „longName“,
|
||||||
|
/// „foreName“, „birthDate“ und „klasse.name“. Die Datei trägt historisch die
|
||||||
|
/// Endung .csv, obwohl als Trennzeichen ein Tabulator verwendet wird.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StudentMasterDataCsvImportHandler : IImportHandler<ImportedStudent>
|
||||||
|
{
|
||||||
|
private static readonly string[] SignatureHeaders =
|
||||||
|
["longName", "foreName", "gender", "birthDate", "klasse.name", "externKey"];
|
||||||
|
|
||||||
|
private static readonly UTF8Encoding StrictUtf8 = new(
|
||||||
|
encoderShouldEmitUTF8Identifier: false,
|
||||||
|
throwOnInvalidBytes: true);
|
||||||
|
|
||||||
|
public ImportFormatId FormatId => StudentImportFormats.MasterDataCsv;
|
||||||
|
public string DisplayName => "Schüler-Stammdatenliste";
|
||||||
|
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
|
||||||
|
|
||||||
|
public ValueTask<ImportDetection> DetectAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string text;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
text = StrictUtf8.GetString(file.Content.Span);
|
||||||
|
}
|
||||||
|
catch (DecoderFallbackException)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstLineEnd = text.IndexOfAny(['\r', '\n']);
|
||||||
|
var firstLine = (firstLineEnd >= 0 ? text[..firstLineEnd] : text).TrimStart('\uFEFF');
|
||||||
|
var headers = firstLine.Split('\t')
|
||||||
|
.Select(header => header.Trim())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!SignatureHeaders.All(headers.Contains))
|
||||||
|
return ValueTask.FromResult(ImportDetection.NoMatch("Die erwarteten Stammdatenspalten fehlen."));
|
||||||
|
|
||||||
|
return ValueTask.FromResult(ImportDetection.Match(
|
||||||
|
file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95,
|
||||||
|
"Die charakteristischen Stammdatenspalten wurden gefunden."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var messages = new List<ImportMessage>();
|
||||||
|
string text;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
text = StrictUtf8.GetString(file.Content.Span);
|
||||||
|
}
|
||||||
|
catch (DecoderFallbackException)
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"master-data.encoding",
|
||||||
|
"Die Stammdatenliste ist nicht als gültiges UTF-8 gespeichert."));
|
||||||
|
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError))
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"master-data.structure",
|
||||||
|
parseError ?? "Die Stammdatenliste ist syntaktisch ungültig."));
|
||||||
|
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.Count == 0)
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"master-data.empty",
|
||||||
|
"Die Stammdatenliste ist leer."));
|
||||||
|
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = records[0].Fields
|
||||||
|
.Select((header, index) => (Name: header.Trim().TrimStart('\uFEFF'), Index: index))
|
||||||
|
.ToList();
|
||||||
|
var duplicateHeader = headers
|
||||||
|
.GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault(group => group.Count() > 1);
|
||||||
|
if (duplicateHeader is not null)
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"master-data.header.duplicate",
|
||||||
|
$"Die Spalte „{duplicateHeader.Key}“ kommt mehrfach vor.",
|
||||||
|
"Kopfzeile"));
|
||||||
|
|
||||||
|
var columns = headers
|
||||||
|
.GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var required in SignatureHeaders.Where(required => !columns.ContainsKey(required)))
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"master-data.header.missing",
|
||||||
|
$"Die erforderliche Spalte „{required}“ fehlt.",
|
||||||
|
"Kopfzeile"));
|
||||||
|
if (messages.Any(message => message.Severity == ImportMessageSeverity.Error))
|
||||||
|
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
|
||||||
|
|
||||||
|
var students = new List<ImportedStudent>();
|
||||||
|
foreach (var record in records.Skip(1))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (record.Fields.All(string.IsNullOrWhiteSpace)) continue;
|
||||||
|
var source = $"Zeile {record.LineNumber}";
|
||||||
|
if (record.Fields.Count != headers.Count)
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"master-data.columns",
|
||||||
|
$"Die Zeile enthält {record.Fields.Count} statt {headers.Count} Spalten.",
|
||||||
|
source));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gender = ParseGender(Value(record, columns, "gender"), source, messages);
|
||||||
|
var dateOfBirth = ParseDate(Value(record, columns, "birthDate"), source, messages);
|
||||||
|
var externalId = FirstNotEmpty(
|
||||||
|
Value(record, columns, "externKey"),
|
||||||
|
Value(record, columns, "id"));
|
||||||
|
students.Add(new ImportedStudent
|
||||||
|
{
|
||||||
|
FirstName = Value(record, columns, "foreName"),
|
||||||
|
LastName = Value(record, columns, "longName"),
|
||||||
|
Gender = gender,
|
||||||
|
DateOfBirth = dateOfBirth,
|
||||||
|
ExternalId = externalId,
|
||||||
|
GroupName = NullIfWhiteSpace(Value(record, columns, "klasse.name")),
|
||||||
|
Contact = new ImportedStudentContact
|
||||||
|
{
|
||||||
|
Email = NullIfWhiteSpace(Value(record, columns, "address.email")),
|
||||||
|
MobilePhone = NullIfWhiteSpace(Value(record, columns, "address.mobile")),
|
||||||
|
Phone = NullIfWhiteSpace(Value(record, columns, "address.phone")),
|
||||||
|
City = NullIfWhiteSpace(Value(record, columns, "address.city")),
|
||||||
|
PostalCode = NullIfWhiteSpace(Value(record, columns, "address.postCode")),
|
||||||
|
Street = NullIfWhiteSpace(Value(record, columns, "address.street")),
|
||||||
|
},
|
||||||
|
SourceReference = source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>(students, messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gender? ParseGender(
|
||||||
|
string value,
|
||||||
|
string source,
|
||||||
|
ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
return value.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"m" or "männlich" or "maennlich" => Gender.M,
|
||||||
|
"w" or "weiblich" => Gender.W,
|
||||||
|
"d" or "divers" => Gender.D,
|
||||||
|
_ => UnknownGender(value, source, messages),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gender? UnknownGender(
|
||||||
|
string value,
|
||||||
|
string source,
|
||||||
|
ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Warning,
|
||||||
|
"master-data.gender.unknown",
|
||||||
|
$"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.",
|
||||||
|
source));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ParseDate(
|
||||||
|
string value,
|
||||||
|
string source,
|
||||||
|
ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
if (DateOnly.TryParseExact(
|
||||||
|
value.Trim(),
|
||||||
|
"dd.MM.yyyy",
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None,
|
||||||
|
out var date))
|
||||||
|
return date;
|
||||||
|
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"master-data.birth-date.invalid",
|
||||||
|
$"Das Geburtsdatum „{value.Trim()}“ besitzt nicht das erwartete Format TT.MM.JJJJ.",
|
||||||
|
source));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Value(
|
||||||
|
DelimitedRecord record,
|
||||||
|
IReadOnlyDictionary<string, int> columns,
|
||||||
|
string column) =>
|
||||||
|
columns.TryGetValue(column, out var index) && index < record.Fields.Count
|
||||||
|
? record.Fields[index].Trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
private static string? FirstNotEmpty(params string[] values) =>
|
||||||
|
values.Select(NullIfWhiteSpace).FirstOrDefault(value => value is not null);
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ public interface ISeatingPlanRepository
|
|||||||
List<SeatingPlan> GetByGroup(Guid groupId);
|
List<SeatingPlan> GetByGroup(Guid groupId);
|
||||||
void Save(SeatingPlan plan);
|
void Save(SeatingPlan plan);
|
||||||
void Delete(Guid id);
|
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
|
public interface IGroupMembershipRepository
|
||||||
{
|
{
|
||||||
@@ -57,6 +60,8 @@ public interface IGradingKeyTemplateRepository
|
|||||||
GradingKeyTemplate? GetById(Guid id);
|
GradingKeyTemplate? GetById(Guid id);
|
||||||
void Save(GradingKeyTemplate template);
|
void Save(GradingKeyTemplate template);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||||
|
void Restore(Guid trashId);
|
||||||
}
|
}
|
||||||
public interface IGradeRepository
|
public interface IGradeRepository
|
||||||
{
|
{
|
||||||
@@ -64,6 +69,8 @@ public interface IGradeRepository
|
|||||||
List<Grade> GetByGroup(Guid groupId);
|
List<Grade> GetByGroup(Guid groupId);
|
||||||
void Save(Grade grade);
|
void Save(Grade grade);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||||
|
void Restore(Guid trashId);
|
||||||
}
|
}
|
||||||
public interface IGradingSchemeRepository
|
public interface IGradingSchemeRepository
|
||||||
{
|
{
|
||||||
@@ -117,9 +124,70 @@ public interface ISubstitutionEntryRepository
|
|||||||
{
|
{
|
||||||
List<SubstitutionEntry> GetAll();
|
List<SubstitutionEntry> GetAll();
|
||||||
List<SubstitutionEntry> GetByDate(DateOnly date);
|
List<SubstitutionEntry> GetByDate(DateOnly date);
|
||||||
|
/// Für den WebUntis-Abgleich (idempotentes Update statt Duplikat je iCal-UID).
|
||||||
|
SubstitutionEntry? GetByExternalId(string externalId);
|
||||||
void Save(SubstitutionEntry entry);
|
void Save(SubstitutionEntry entry);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
}
|
}
|
||||||
|
/// Zuletzt bekannter Zustand aller WebUntis-iCal-Termine, für den Abgleich bei jedem Abruf.
|
||||||
|
public interface IUntisSnapshotRepository
|
||||||
|
{
|
||||||
|
List<UntisSnapshotEntry> GetAll();
|
||||||
|
void Save(UntisSnapshotEntry entry);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
/// 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
|
public interface IDocumentationRepository
|
||||||
{
|
{
|
||||||
List<Documentation> GetByStudent(Guid studentId);
|
List<Documentation> GetByStudent(Guid studentId);
|
||||||
@@ -136,8 +204,12 @@ public interface IWorkTaskRepository
|
|||||||
{
|
{
|
||||||
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
||||||
List<WorkTask> GetAll();
|
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 Save(WorkTask task);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||||
|
void Restore(Guid trashId);
|
||||||
}
|
}
|
||||||
public interface ITimeEntryRepository
|
public interface ITimeEntryRepository
|
||||||
{
|
{
|
||||||
@@ -146,6 +218,8 @@ public interface ITimeEntryRepository
|
|||||||
List<TimeEntry> GetByTask(Guid taskId);
|
List<TimeEntry> GetByTask(Guid taskId);
|
||||||
void Save(TimeEntry entry);
|
void Save(TimeEntry entry);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
|
||||||
|
void Restore(Guid trashId);
|
||||||
}
|
}
|
||||||
public interface IParticipationSessionRepository
|
public interface IParticipationSessionRepository
|
||||||
{
|
{
|
||||||
@@ -209,3 +283,11 @@ public interface IAlternativeLessonPathRepository
|
|||||||
void Save(AlternativeLessonPath path);
|
void Save(AlternativeLessonPath path);
|
||||||
void Delete(Guid id);
|
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 DateOnly? ReturnedAt { get; set; }
|
||||||
public Niveau? Niveau { get; set; }
|
public Niveau? Niveau { get; set; }
|
||||||
public string? Notes { 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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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,
|
||||||
|
}
|
||||||
@@ -13,6 +13,19 @@ public class LearningGroup
|
|||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public bool IsOwnClass { get; set; }
|
public bool IsOwnClass { get; set; }
|
||||||
public bool IsDifferentiated { get; set; }
|
public bool IsDifferentiated { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
|
||||||
|
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
|
||||||
|
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
|
||||||
|
/// 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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ public enum AttendanceStatus
|
|||||||
OtherSchoolEvent,
|
OtherSchoolEvent,
|
||||||
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
||||||
Present,
|
Present,
|
||||||
|
Late,
|
||||||
|
SignificantlyLate,
|
||||||
|
LeftDuringClass,
|
||||||
|
LearningIsland,
|
||||||
|
Suspended,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public class Unit
|
|||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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 Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid UnitId { get; set; }
|
public Guid UnitId { get; set; }
|
||||||
@@ -56,8 +56,18 @@ public class Lesson
|
|||||||
public TimeOnly? StartTime { get; set; }
|
public TimeOnly? StartTime { get; set; }
|
||||||
public List<LessonPhaseStep> Phases { get; set; } = [];
|
public List<LessonPhaseStep> Phases { get; set; } = [];
|
||||||
public string? Homework { get; set; }
|
public string? Homework { get; set; }
|
||||||
|
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
|
||||||
|
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
|
||||||
|
public bool HomeworkChecked { get; set; }
|
||||||
|
/// Bewusstes Abschalten der Erinnerung, ohne eine tatsächliche Kontrolle vorzutäuschen (z.B.
|
||||||
|
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
||||||
|
public bool HomeworkCheckDismissed { get; set; }
|
||||||
public string? Reflection { get; set; }
|
public string? Reflection { get; set; }
|
||||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
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;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +210,12 @@ public class SubstitutionEntry
|
|||||||
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
|
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
/// Gesetzt, wenn dieser Eintrag automatisch aus dem WebUntis-iCal-Abgleich entstanden ist —
|
||||||
|
/// trägt die stabile iCal-UID des auslösenden Termins. Macht wiederholte Abgleich-Läufe
|
||||||
|
/// idempotent (Update statt Duplikat, siehe ISubstitutionEntryRepository.GetByExternalId) und
|
||||||
|
/// unterscheidet automatisch erzeugte von von Hand eingetragenen Ausnahmen. Bei Handeinträgen
|
||||||
|
/// (SubstitutionEntryDialogViewModel) bleibt es null.
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -12,7 +12,24 @@ public class SeatingPlan
|
|||||||
public string Room { get; set; } = "";
|
public string Room { get; set; } = "";
|
||||||
public int Rows { get; set; } = 4;
|
public int Rows { get; set; } = 4;
|
||||||
public int Columns { get; set; } = 4;
|
public int Columns { get; set; } = 4;
|
||||||
|
/// <summary>
|
||||||
|
/// Zeigt die Tafel unterhalb des Sitzrasters an. Der Standardwert <c>false</c>
|
||||||
|
/// erhält für bestehende Pläne die bisherige Darstellung oberhalb des Rasters.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsBoardAtBottom { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Zusätzlicher horizontaler Abstand nach jeder Spalte, in Pixeln. Der Eintrag mit Index 0
|
||||||
|
/// liegt zwischen der ersten und zweiten Spalte. Fehlende Einträge bedeuten keinen Abstand.
|
||||||
|
/// </summary>
|
||||||
|
public List<double> ColumnGapWidths { get; set; } = [];
|
||||||
public List<SeatAssignment> Assignments { 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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
@@ -23,3 +40,9 @@ public class SeatAssignment
|
|||||||
public int Column { get; set; }
|
public int Column { get; set; }
|
||||||
public Guid StudentId { get; set; }
|
public Guid StudentId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class HiddenSeat
|
||||||
|
{
|
||||||
|
public int Row { get; set; }
|
||||||
|
public int Column { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ public class Student
|
|||||||
public string FullName => $"{LastName}, {FirstName}";
|
public string FullName => $"{LastName}, {FirstName}";
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public Gender? Gender { get; set; }
|
public Gender? Gender { get; set; }
|
||||||
|
/// <summary>Quellsystembezogene Kennungen, z.B. für wiederholbare Stammdatenimporte.</summary>
|
||||||
|
public Dictionary<string, string> ExternalIds { get; set; } = [];
|
||||||
public List<Contact> Contacts { get; set; } = [];
|
public List<Contact> Contacts { get; set; } = [];
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
@@ -22,6 +24,7 @@ public class Contact
|
|||||||
/// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary>
|
/// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary>
|
||||||
public string? LetterSalutation { get; set; }
|
public string? LetterSalutation { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? MobilePhone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Street { get; set; }
|
public string? Street { get; set; }
|
||||||
public string? PostalCode { get; set; }
|
public string? PostalCode { 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;
|
namespace LehrerApp.Core.Models;
|
||||||
|
|
||||||
public class Documentation
|
public class Documentation : IHasAttachments
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid StudentId { get; set; }
|
public Guid StudentId { get; set; }
|
||||||
public Guid? GroupId { 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 DocumentationType Type { get; set; }
|
||||||
public DateOnly Date { get; set; }
|
public DateOnly Date { get; set; }
|
||||||
public string Title { get; set; } = "";
|
public string Title { get; set; } = "";
|
||||||
@@ -17,7 +21,13 @@ public class Documentation
|
|||||||
public List<DocumentAttachment> Attachments { get; set; } = [];
|
public List<DocumentAttachment> Attachments { get; set; } = [];
|
||||||
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
|
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
|
||||||
public List<string> Tags { get; set; } = [];
|
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; }
|
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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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).
|
// 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 long SizeBytes { get; set; }
|
||||||
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
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.
|
// 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 DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
|
||||||
public enum SupportStatus { Active, Completed, Paused }
|
public enum SupportStatus { Active, Completed, Paused }
|
||||||
@@ -87,9 +108,34 @@ public class WorkTask
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4).
|
// 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;
|
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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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 class TimeEntry
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -103,6 +149,8 @@ public class TimeEntry
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
|
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
|
||||||
public enum WorkTaskStatus { Open, InProgress, Done }
|
public enum WorkTaskStatus { Open, InProgress, Done }
|
||||||
public enum TaskRecurrence { None, Weekly, Monthly }
|
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}.");
|
||||||
|
}
|
||||||
@@ -25,10 +25,13 @@ public class AttendanceBalanceService
|
|||||||
.Select(e => e.Status!.Value)
|
.Select(e => e.Status!.Value)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var present = relevant.Count(s => s == AttendanceStatus.Present);
|
var present = relevant.Count(s => s is AttendanceStatus.Present
|
||||||
|
or AttendanceStatus.Late or AttendanceStatus.SignificantlyLate);
|
||||||
var excused = relevant.Count(s => s == AttendanceStatus.Excused);
|
var excused = relevant.Count(s => s == AttendanceStatus.Excused);
|
||||||
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused or AttendanceStatus.Truant);
|
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused
|
||||||
var schoolEvent = relevant.Count(s => s == AttendanceStatus.OtherSchoolEvent);
|
or AttendanceStatus.Truant or AttendanceStatus.LeftDuringClass);
|
||||||
|
var schoolEvent = relevant.Count(s => s is AttendanceStatus.OtherSchoolEvent
|
||||||
|
or AttendanceStatus.LearningIsland or AttendanceStatus.Suspended);
|
||||||
var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
|
var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
|
||||||
var total = relevant.Count;
|
var total = relevant.Count;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
public sealed class DashboardCardSetting
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public bool IsVisible { get; set; } = true;
|
||||||
|
public int Order { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
|
||||||
|
public sealed class DashboardSettingsService
|
||||||
|
{
|
||||||
|
public static readonly string[] DefaultCardOrder =
|
||||||
|
[
|
||||||
|
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||||
|
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly string _configPath;
|
||||||
|
|
||||||
|
public DashboardSettingsService(string appDataPath) =>
|
||||||
|
_configPath = Path.Combine(appDataPath, "dashboardsettings.json");
|
||||||
|
|
||||||
|
public List<DashboardCardSetting> Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_configPath))
|
||||||
|
{
|
||||||
|
var saved = JsonSerializer.Deserialize<List<DashboardCardSetting>>(
|
||||||
|
File.ReadAllText(_configPath)) ?? [];
|
||||||
|
var byKey = saved
|
||||||
|
.Where(s => DefaultCardOrder.Contains(s.Key))
|
||||||
|
.GroupBy(s => s.Key).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
return DefaultCardOrder.Select((key, defaultOrder) => byKey.TryGetValue(key, out var item)
|
||||||
|
? new DashboardCardSetting { Key = key, IsVisible = item.IsVisible, Order = item.Order }
|
||||||
|
: new DashboardCardSetting { Key = key, IsVisible = true, Order = defaultOrder })
|
||||||
|
.OrderBy(s => s.Order).ThenBy(s => Array.IndexOf(DefaultCardOrder, s.Key))
|
||||||
|
.Select((s, index) => new DashboardCardSetting
|
||||||
|
{ Key = s.Key, IsVisible = s.IsVisible, Order = index })
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* beschädigte Konfiguration -> Standardreihenfolge */ }
|
||||||
|
|
||||||
|
return DefaultCardOrder.Select((key, index) => new DashboardCardSetting
|
||||||
|
{ Key = key, IsVisible = true, Order = index }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(IEnumerable<DashboardCardSetting> settings)
|
||||||
|
{
|
||||||
|
var normalized = settings.Select((s, index) => new DashboardCardSetting
|
||||||
|
{
|
||||||
|
Key = s.Key,
|
||||||
|
IsVisible = s.IsVisible,
|
||||||
|
Order = index,
|
||||||
|
}).ToList();
|
||||||
|
File.WriteAllText(_configPath, JsonSerializer.Serialize(normalized));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
IsActive = true,
|
||||||
IsOwnClass = source.IsOwnClass,
|
IsOwnClass = source.IsOwnClass,
|
||||||
IsDifferentiated = source.IsDifferentiated,
|
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;
|
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,840 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Importing;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestriert den Schülerimport ohne UI-Abhängigkeit: Format erkennen, vollständig
|
||||||
|
/// analysieren, Konflikte beschreiben und erst nach Bestätigung speichern.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StudentImportService
|
||||||
|
{
|
||||||
|
private const string CreateOption = "create";
|
||||||
|
private const string SkipOption = "skip";
|
||||||
|
private const string ExistingOptionPrefix = "existing:";
|
||||||
|
private const string GroupOptionPrefix = "group:";
|
||||||
|
private const string ImportedContactRelation = "Stammdaten";
|
||||||
|
|
||||||
|
private readonly ImportHandlerCatalog<ImportedStudent> _handlers;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
|
private readonly IGroupMembershipRepository _memberships;
|
||||||
|
private readonly SchoolYearService _schoolYears;
|
||||||
|
|
||||||
|
public StudentImportService(
|
||||||
|
IEnumerable<IImportHandler<ImportedStudent>> handlers,
|
||||||
|
IStudentRepository students,
|
||||||
|
IGroupRepository groups,
|
||||||
|
IGroupMembershipRepository memberships,
|
||||||
|
SchoolYearService schoolYears)
|
||||||
|
{
|
||||||
|
_handlers = new ImportHandlerCatalog<ImportedStudent>(handlers);
|
||||||
|
_students = students;
|
||||||
|
_groups = groups;
|
||||||
|
_memberships = memberships;
|
||||||
|
_schoolYears = schoolYears;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<IImportHandler<ImportedStudent>> Handlers => _handlers.Handlers;
|
||||||
|
public IReadOnlyList<string> SupportedExtensions => _handlers.SupportedExtensions;
|
||||||
|
|
||||||
|
public async Task<StudentImportPreview> AnalyzeAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
await AnalyzeInternalAsync(file, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public async Task<StudentImportPreview> AnalyzeAsync(
|
||||||
|
ImportFile file,
|
||||||
|
Guid targetGroupId,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
await AnalyzeInternalAsync(file, targetGroupId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
private async Task<StudentImportPreview> AnalyzeInternalAsync(
|
||||||
|
ImportFile file,
|
||||||
|
Guid? targetGroupId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(file);
|
||||||
|
var detected = await _handlers.DetectAsync(file, cancellationToken).ConfigureAwait(false);
|
||||||
|
var parsed = await detected.Handler.ParseAsync(file, cancellationToken).ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var messages = parsed.Messages.ToList();
|
||||||
|
var imported = parsed.Items.Select(Normalize).ToList();
|
||||||
|
Validate(imported, messages);
|
||||||
|
if (imported.Count == 0 && messages.All(message => message.Severity != ImportMessageSeverity.Error))
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"students.empty",
|
||||||
|
"Die Importdatei enthält keine Schülerdaten."));
|
||||||
|
|
||||||
|
var existing = _students.GetAll(includeInactive: true);
|
||||||
|
var groups = _groups.GetAll(includeInactive: true);
|
||||||
|
var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList();
|
||||||
|
var targetGroup = targetGroupId is null
|
||||||
|
? null
|
||||||
|
: groups.SingleOrDefault(group => group.Id == targetGroupId.Value);
|
||||||
|
if (targetGroupId is not null && targetGroup is not { IsActive: true })
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"student.target-group.unavailable",
|
||||||
|
"Die Ziel-Lerngruppe existiert nicht oder ist archiviert."));
|
||||||
|
var currentSchoolYear = _schoolYears.CurrentSchoolYear();
|
||||||
|
var currentGroups = groups
|
||||||
|
.Where(group => group.IsActive && group.SchoolYear == currentSchoolYear)
|
||||||
|
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
var entries = new List<StudentImportEntry>();
|
||||||
|
var conflicts = new List<ImportConflict>();
|
||||||
|
|
||||||
|
for (var index = 0; index < imported.Count; index++)
|
||||||
|
{
|
||||||
|
var candidate = imported[index];
|
||||||
|
var entryId = $"student:{index + 1}";
|
||||||
|
var externalMatches = FindByExternalId(existing, detected.Handler.FormatId, candidate.ExternalId);
|
||||||
|
if (externalMatches.Count == 1)
|
||||||
|
{
|
||||||
|
var matched = externalMatches[0];
|
||||||
|
AddDifferenceWarning(candidate, matched, messages);
|
||||||
|
entries.Add(new StudentImportEntry(
|
||||||
|
entryId,
|
||||||
|
candidate,
|
||||||
|
new StudentImportResolution(StudentImportResolutionKind.UseExisting, matched.Id),
|
||||||
|
null,
|
||||||
|
FieldsToSupplement(matched, candidate, detected.Handler.FormatId)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (externalMatches.Count > 1)
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"student.external-id.ambiguous",
|
||||||
|
"Die externe Schüler-ID ist mehreren vorhandenen Schülern zugeordnet.",
|
||||||
|
candidate.SourceReference));
|
||||||
|
entries.Add(new StudentImportEntry(
|
||||||
|
entryId,
|
||||||
|
candidate,
|
||||||
|
new StudentImportResolution(StudentImportResolutionKind.Skip),
|
||||||
|
null,
|
||||||
|
[]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sameName = existing.Where(student => NamesEqual(student, candidate)).ToList();
|
||||||
|
var exact = candidate.DateOfBirth is not null
|
||||||
|
? sameName.Where(student => student.DateOfBirth == candidate.DateOfBirth).ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (candidate.DateOfBirth is null && exact.Count == 0 && targetGroup is not null)
|
||||||
|
{
|
||||||
|
var memberIds = groupMemberships
|
||||||
|
.Where(membership => membership.GroupId == targetGroup.Id)
|
||||||
|
.Select(membership => membership.StudentId)
|
||||||
|
.ToHashSet();
|
||||||
|
var memberMatches = sameName.Where(student => memberIds.Contains(student.Id)).ToList();
|
||||||
|
if (memberMatches.Count == 1)
|
||||||
|
exact = memberMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exact.Count == 1)
|
||||||
|
{
|
||||||
|
AddDifferenceWarning(candidate, exact[0], messages);
|
||||||
|
entries.Add(new StudentImportEntry(
|
||||||
|
entryId,
|
||||||
|
candidate,
|
||||||
|
new StudentImportResolution(StudentImportResolutionKind.UseExisting, exact[0].Id),
|
||||||
|
null,
|
||||||
|
FieldsToSupplement(exact[0], candidate, detected.Handler.FormatId)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameName.Count == 0)
|
||||||
|
{
|
||||||
|
entries.Add(new StudentImportEntry(
|
||||||
|
entryId,
|
||||||
|
candidate,
|
||||||
|
new StudentImportResolution(StudentImportResolutionKind.CreateNew),
|
||||||
|
null,
|
||||||
|
[]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflictId = $"student-match:{index + 1}";
|
||||||
|
conflicts.Add(CreateMatchConflict(
|
||||||
|
conflictId,
|
||||||
|
candidate,
|
||||||
|
sameName,
|
||||||
|
detected.Handler.FormatId));
|
||||||
|
entries.Add(new StudentImportEntry(entryId, candidate, null, conflictId, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupAssignments = targetGroup is { IsActive: true }
|
||||||
|
? [new StudentImportGroupAssignment(targetGroup.Name, targetGroup.Id, null)]
|
||||||
|
: targetGroupId is null
|
||||||
|
? BuildGroupAssignments(imported, currentGroups, conflicts, messages)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return new StudentImportPreview(
|
||||||
|
detected.Handler.FormatId,
|
||||||
|
detected.Handler.DisplayName,
|
||||||
|
entries,
|
||||||
|
messages,
|
||||||
|
conflicts,
|
||||||
|
groupAssignments,
|
||||||
|
targetGroupId,
|
||||||
|
Fingerprint(existing),
|
||||||
|
GroupStateFingerprint(groups, groupMemberships, currentSchoolYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<StudentImportApplyResult> ApplyAsync(
|
||||||
|
StudentImportPreview preview,
|
||||||
|
IEnumerable<ImportDecision>? decisions = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(preview);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!preview.CanApply)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Der Import enthält Fehler oder keine Schüler und kann nicht angewendet werden.");
|
||||||
|
|
||||||
|
var existing = _students.GetAll(includeInactive: true);
|
||||||
|
if (!string.Equals(
|
||||||
|
preview.ExistingStudentsFingerprint,
|
||||||
|
Fingerprint(existing),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Die Schülerdaten wurden seit der Vorschau verändert. Bitte analysiere die Datei erneut.");
|
||||||
|
|
||||||
|
var groups = _groups.GetAll(includeInactive: true);
|
||||||
|
var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList();
|
||||||
|
if (!string.Equals(
|
||||||
|
preview.GroupStateFingerprint,
|
||||||
|
GroupStateFingerprint(groups, groupMemberships, _schoolYears.CurrentSchoolYear()),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Die Lerngruppen oder Zuordnungen wurden seit der Vorschau verändert. "
|
||||||
|
+ "Bitte analysiere die Datei erneut.");
|
||||||
|
|
||||||
|
var decisionMap = BuildDecisionMap(preview, decisions ?? []);
|
||||||
|
var resolutions = preview.Entries
|
||||||
|
.Select(entry => Resolve(entry, decisionMap))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Alle Entscheidungen werden vor dem ersten Schreibzugriff geprüft.
|
||||||
|
ValidateResolutions(resolutions, existing);
|
||||||
|
var groupResolutions = preview.GroupAssignments.ToDictionary(
|
||||||
|
assignment => assignment.SourceGroupName,
|
||||||
|
assignment => ResolveGroup(assignment, decisionMap),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
ValidateGroupResolutions(groupResolutions.Values, groups);
|
||||||
|
|
||||||
|
var createdIds = new List<Guid>();
|
||||||
|
var studentIdsByEntry = new Dictionary<string, Guid?>(StringComparer.Ordinal);
|
||||||
|
var existingById = existing.ToDictionary(student => student.Id);
|
||||||
|
var updated = 0;
|
||||||
|
var matched = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
foreach (var (entry, resolution) in preview.Entries.Zip(resolutions))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
switch (resolution.Kind)
|
||||||
|
{
|
||||||
|
case StudentImportResolutionKind.CreateNew:
|
||||||
|
var created = CreateStudent(entry.Student, preview.FormatId);
|
||||||
|
_students.Save(created);
|
||||||
|
createdIds.Add(created.Id);
|
||||||
|
studentIdsByEntry[entry.Id] = created.Id;
|
||||||
|
break;
|
||||||
|
case StudentImportResolutionKind.UseExisting:
|
||||||
|
var existingStudent = existingById[resolution.ExistingStudentId!.Value];
|
||||||
|
if (Supplement(existingStudent, entry.Student, preview.FormatId))
|
||||||
|
{
|
||||||
|
_students.Save(existingStudent);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
matched++;
|
||||||
|
studentIdsByEntry[entry.Id] = existingStudent.Id;
|
||||||
|
break;
|
||||||
|
case StudentImportResolutionKind.Skip:
|
||||||
|
skipped++;
|
||||||
|
studentIdsByEntry[entry.Id] = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException("Unbekannte Importentscheidung.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdMemberships = 0;
|
||||||
|
foreach (var entry in preview.Entries)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!studentIdsByEntry.TryGetValue(entry.Id, out var studentId)
|
||||||
|
|| studentId is null
|
||||||
|
|| !TryResolveTargetGroup(preview, entry, groupResolutions, out var groupId)
|
||||||
|
|| groupId is null
|
||||||
|
|| _memberships.GetByStudentAndGroup(studentId.Value, groupId.Value) is not null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_memberships.Save(new GroupMembership
|
||||||
|
{
|
||||||
|
StudentId = studentId.Value,
|
||||||
|
GroupId = groupId.Value,
|
||||||
|
AddedOn = DateOnly.FromDateTime(DateTime.Today),
|
||||||
|
Period = MembershipPeriod.FullYear,
|
||||||
|
});
|
||||||
|
createdMemberships++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new StudentImportApplyResult(
|
||||||
|
createdIds.Count,
|
||||||
|
updated,
|
||||||
|
matched,
|
||||||
|
skipped,
|
||||||
|
createdMemberships,
|
||||||
|
createdIds.AsReadOnly()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveTargetGroup(
|
||||||
|
StudentImportPreview preview,
|
||||||
|
StudentImportEntry entry,
|
||||||
|
IReadOnlyDictionary<string, Guid?> groupResolutions,
|
||||||
|
out Guid? groupId)
|
||||||
|
{
|
||||||
|
if (preview.TargetGroupId is not null)
|
||||||
|
{
|
||||||
|
groupId = preview.TargetGroupId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.Student.GroupName is { } sourceGroup
|
||||||
|
&& groupResolutions.TryGetValue(sourceGroup, out groupId))
|
||||||
|
return true;
|
||||||
|
groupId = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImportedStudent Normalize(ImportedStudent student) => student with
|
||||||
|
{
|
||||||
|
FirstName = student.FirstName?.Trim() ?? "",
|
||||||
|
LastName = student.LastName?.Trim() ?? "",
|
||||||
|
ExternalId = NullIfWhiteSpace(student.ExternalId),
|
||||||
|
GroupName = NullIfWhiteSpace(student.GroupName),
|
||||||
|
Contact = Normalize(student.Contact),
|
||||||
|
SourceReference = NullIfWhiteSpace(student.SourceReference),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ImportedStudentContact? Normalize(ImportedStudentContact? contact)
|
||||||
|
{
|
||||||
|
if (contact is null) return null;
|
||||||
|
var normalized = contact with
|
||||||
|
{
|
||||||
|
Email = NullIfWhiteSpace(contact.Email),
|
||||||
|
MobilePhone = NullIfWhiteSpace(contact.MobilePhone),
|
||||||
|
Phone = NullIfWhiteSpace(contact.Phone),
|
||||||
|
Street = NullIfWhiteSpace(contact.Street),
|
||||||
|
PostalCode = NullIfWhiteSpace(contact.PostalCode),
|
||||||
|
City = NullIfWhiteSpace(contact.City),
|
||||||
|
};
|
||||||
|
return normalized.HasData ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static void Validate(
|
||||||
|
IReadOnlyList<ImportedStudent> imported,
|
||||||
|
ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < imported.Count; index++)
|
||||||
|
{
|
||||||
|
var student = imported[index];
|
||||||
|
var source = student.SourceReference ?? $"Eintrag {index + 1}";
|
||||||
|
if (student.FirstName.Length == 0)
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"student.first-name.required",
|
||||||
|
"Der Vorname fehlt.",
|
||||||
|
source));
|
||||||
|
if (student.LastName.Length == 0)
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"student.last-name.required",
|
||||||
|
"Der Nachname fehlt.",
|
||||||
|
source));
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateExternalIds = imported
|
||||||
|
.Where(student => student.ExternalId is not null)
|
||||||
|
.GroupBy(student => student.ExternalId!, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Where(group => group.Count() > 1);
|
||||||
|
foreach (var duplicate in duplicateExternalIds)
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"student.external-id.duplicate",
|
||||||
|
$"Die externe Schüler-ID „{duplicate.Key}“ kommt mehrfach vor."));
|
||||||
|
|
||||||
|
var duplicateIdentities = imported
|
||||||
|
.Where(student => student.FirstName.Length > 0
|
||||||
|
&& student.LastName.Length > 0
|
||||||
|
&& student.DateOfBirth is not null)
|
||||||
|
.GroupBy(student =>
|
||||||
|
$"{student.FirstName.ToUpperInvariant()}\u001f{student.LastName.ToUpperInvariant()}\u001f{student.DateOfBirth:yyyyMMdd}")
|
||||||
|
.Where(group => group.Count() > 1);
|
||||||
|
foreach (var duplicate in duplicateIdentities)
|
||||||
|
{
|
||||||
|
var student = duplicate.First();
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Error,
|
||||||
|
"student.duplicate",
|
||||||
|
$"„{student.LastName}, {student.FirstName}“ mit Geburtsdatum "
|
||||||
|
+ $"{DateDisplay(student.DateOfBirth)} kommt mehrfach im Import vor."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImportConflict CreateMatchConflict(
|
||||||
|
string conflictId,
|
||||||
|
ImportedStudent imported,
|
||||||
|
IReadOnlyList<Student> candidates,
|
||||||
|
ImportFormatId formatId)
|
||||||
|
{
|
||||||
|
var options = candidates.Select(candidate => new ImportConflictOption(
|
||||||
|
ExistingOption(candidate.Id),
|
||||||
|
$"Vorhandenen Schüler „{candidate.FullName}“ verwenden",
|
||||||
|
SupplementDescription(candidate, imported, formatId)))
|
||||||
|
.Append(new ImportConflictOption(
|
||||||
|
CreateOption,
|
||||||
|
"Als neuen Schüler anlegen",
|
||||||
|
"Der vorhandene Datensatz bleibt unverändert."))
|
||||||
|
.Append(new ImportConflictOption(
|
||||||
|
SkipOption,
|
||||||
|
"Eintrag überspringen"))
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
|
||||||
|
return new ImportConflict(
|
||||||
|
conflictId,
|
||||||
|
"Möglicher vorhandener Schüler",
|
||||||
|
"Vor- und Nachname stimmen überein, der Datensatz konnte aber nicht eindeutig zugeordnet werden.",
|
||||||
|
ImportedDetails(imported),
|
||||||
|
string.Join(Environment.NewLine, candidates.Select(StudentDetails)),
|
||||||
|
options,
|
||||||
|
SkipOption,
|
||||||
|
imported.SourceReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<Student> FindByExternalId(
|
||||||
|
IEnumerable<Student> existing,
|
||||||
|
ImportFormatId formatId,
|
||||||
|
string? externalId)
|
||||||
|
{
|
||||||
|
if (externalId is null) return [];
|
||||||
|
return existing.Where(student =>
|
||||||
|
student.ExternalIds is not null
|
||||||
|
&& student.ExternalIds.TryGetValue(formatId.Value, out var stored)
|
||||||
|
&& string.Equals(stored, externalId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddDifferenceWarning(
|
||||||
|
ImportedStudent imported,
|
||||||
|
Student existing,
|
||||||
|
ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
var differences = new List<string>();
|
||||||
|
if (existing.DateOfBirth is not null && imported.DateOfBirth is not null
|
||||||
|
&& existing.DateOfBirth != imported.DateOfBirth)
|
||||||
|
differences.Add("Geburtsdatum");
|
||||||
|
if (existing.Gender is not null && imported.Gender is not null
|
||||||
|
&& existing.Gender != imported.Gender)
|
||||||
|
differences.Add("Geschlecht");
|
||||||
|
if (differences.Count == 0) return;
|
||||||
|
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Warning,
|
||||||
|
"student.existing-values-kept",
|
||||||
|
$"Vorhandene abweichende Werte ({string.Join(", ", differences)}) werden nicht überschrieben.",
|
||||||
|
imported.SourceReference));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> FieldsToSupplement(
|
||||||
|
Student existing,
|
||||||
|
ImportedStudent imported,
|
||||||
|
ImportFormatId formatId)
|
||||||
|
{
|
||||||
|
var fields = new List<string>();
|
||||||
|
if (existing.DateOfBirth is null && imported.DateOfBirth is not null)
|
||||||
|
fields.Add("Geburtsdatum");
|
||||||
|
if (existing.Gender is null && imported.Gender is not null)
|
||||||
|
fields.Add("Geschlecht");
|
||||||
|
if (imported.ExternalId is not null
|
||||||
|
&& (existing.ExternalIds is null || !existing.ExternalIds.ContainsKey(formatId.Value)))
|
||||||
|
fields.Add("externe Kennung");
|
||||||
|
|
||||||
|
if (imported.Contact is { HasData: true } contact)
|
||||||
|
{
|
||||||
|
var target = existing.Contacts?.FirstOrDefault(IsImportedContact);
|
||||||
|
AddMissingField(fields, "E-Mail", target?.Email, contact.Email);
|
||||||
|
AddMissingField(fields, "Mobiltelefon", target?.MobilePhone, contact.MobilePhone);
|
||||||
|
AddMissingField(fields, "Telefon", target?.Phone, contact.Phone);
|
||||||
|
AddMissingField(fields, "Straße", target?.Street, contact.Street);
|
||||||
|
AddMissingField(fields, "Postleitzahl", target?.PostalCode, contact.PostalCode);
|
||||||
|
AddMissingField(fields, "Ort", target?.City, contact.City);
|
||||||
|
}
|
||||||
|
return fields.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMissingField(
|
||||||
|
ICollection<string> fields,
|
||||||
|
string label,
|
||||||
|
string? existing,
|
||||||
|
string? imported)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(existing) && !string.IsNullOrWhiteSpace(imported))
|
||||||
|
fields.Add(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SupplementDescription(
|
||||||
|
Student existing,
|
||||||
|
ImportedStudent imported,
|
||||||
|
ImportFormatId formatId)
|
||||||
|
{
|
||||||
|
var supplements = FieldsToSupplement(existing, imported, formatId);
|
||||||
|
return supplements.Count == 0
|
||||||
|
? $"{StudentDetails(existing)} · keine Ergänzungen erforderlich"
|
||||||
|
: $"{StudentDetails(existing)} · ergänzt: {string.Join(", ", supplements)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StudentImportGroupAssignment> BuildGroupAssignments(
|
||||||
|
IEnumerable<ImportedStudent> imported,
|
||||||
|
IReadOnlyList<LearningGroup> currentGroups,
|
||||||
|
ICollection<ImportConflict> conflicts,
|
||||||
|
ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
var result = new List<StudentImportGroupAssignment>();
|
||||||
|
var sourceGroups = imported
|
||||||
|
.Select(student => student.GroupName)
|
||||||
|
.Where(group => group is not null)
|
||||||
|
.Select(group => group!)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(group => group, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var sourceGroup in sourceGroups)
|
||||||
|
{
|
||||||
|
var sourceGradeLevel = LeadingGradeLevel(sourceGroup);
|
||||||
|
var exact = currentGroups
|
||||||
|
.Where(group => string.Equals(
|
||||||
|
group.Name.Trim(), sourceGroup, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& group.Type == GroupType.Class
|
||||||
|
&& (sourceGradeLevel is null || group.GradeLevel == sourceGradeLevel))
|
||||||
|
.ToList();
|
||||||
|
var ownClassMatches = exact.Where(group => group.IsOwnClass).ToList();
|
||||||
|
if (ownClassMatches.Count == 1)
|
||||||
|
exact = ownClassMatches;
|
||||||
|
if (exact.Count == 1)
|
||||||
|
{
|
||||||
|
result.Add(new StudentImportGroupAssignment(sourceGroup, exact[0].Id, null));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = exact.Count > 1 ? exact : currentGroups;
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
messages.Add(new ImportMessage(
|
||||||
|
ImportMessageSeverity.Warning,
|
||||||
|
"student.group.not-found",
|
||||||
|
$"Für die Klasse „{sourceGroup}“ gibt es im aktuellen Schuljahr keine aktive Lerngruppe; "
|
||||||
|
+ "die Schüler werden ohne Gruppenzuordnung importiert."));
|
||||||
|
result.Add(new StudentImportGroupAssignment(sourceGroup, null, null));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflictId = $"student-group:{sourceGroup.ToLowerInvariant()}";
|
||||||
|
var options = candidates.Select(group => new ImportConflictOption(
|
||||||
|
GroupOption(group.Id),
|
||||||
|
$"Lerngruppe „{group.Name}“ verwenden",
|
||||||
|
$"{group.SchoolYear} · Klassenstufe {group.GradeLevel}"))
|
||||||
|
.Append(new ImportConflictOption(
|
||||||
|
SkipOption,
|
||||||
|
"Keine Lerngruppe zuordnen"))
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
conflicts.Add(new ImportConflict(
|
||||||
|
conflictId,
|
||||||
|
"Lerngruppe zuordnen",
|
||||||
|
exact.Count > 1
|
||||||
|
? $"Für „{sourceGroup}“ wurden mehrere namensgleiche Lerngruppen gefunden."
|
||||||
|
: $"Für „{sourceGroup}“ wurde keine eindeutig namensgleiche Lerngruppe gefunden.",
|
||||||
|
sourceGroup,
|
||||||
|
null,
|
||||||
|
options,
|
||||||
|
SkipOption));
|
||||||
|
result.Add(new StudentImportGroupAssignment(sourceGroup, null, conflictId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? LeadingGradeLevel(string groupName)
|
||||||
|
{
|
||||||
|
var digits = new string(groupName.Trim().TakeWhile(char.IsDigit).ToArray());
|
||||||
|
return int.TryParse(digits, CultureInfo.InvariantCulture, out var gradeLevel)
|
||||||
|
? gradeLevel
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, ImportDecision> BuildDecisionMap(
|
||||||
|
StudentImportPreview preview,
|
||||||
|
IEnumerable<ImportDecision> decisions)
|
||||||
|
{
|
||||||
|
var conflicts = preview.Conflicts.ToDictionary(conflict => conflict.Id, StringComparer.Ordinal);
|
||||||
|
var map = new Dictionary<string, ImportDecision>(StringComparer.Ordinal);
|
||||||
|
foreach (var decision in decisions)
|
||||||
|
{
|
||||||
|
if (!conflicts.TryGetValue(decision.ConflictId, out var conflict))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Die Entscheidung verweist auf den unbekannten Konflikt „{decision.ConflictId}“.");
|
||||||
|
if (!conflict.Options.Any(option => option.Id == decision.SelectedOptionId))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Die Option „{decision.SelectedOptionId}“ gehört nicht zum Konflikt „{decision.ConflictId}“.");
|
||||||
|
if (!map.TryAdd(decision.ConflictId, decision))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Für den Konflikt „{decision.ConflictId}“ wurden mehrere Entscheidungen übergeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing = conflicts.Keys.Where(id => !map.ContainsKey(id)).ToList();
|
||||||
|
if (missing.Count > 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Vor dem Import müssen alle Konflikte entschieden werden.");
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StudentImportResolution Resolve(
|
||||||
|
StudentImportEntry entry,
|
||||||
|
IReadOnlyDictionary<string, ImportDecision> decisions)
|
||||||
|
{
|
||||||
|
if (entry.AutomaticResolution is not null)
|
||||||
|
return entry.AutomaticResolution;
|
||||||
|
if (entry.ConflictId is null || !decisions.TryGetValue(entry.ConflictId, out var decision))
|
||||||
|
throw new InvalidOperationException("Für einen Importkonflikt fehlt die Entscheidung.");
|
||||||
|
|
||||||
|
if (decision.SelectedOptionId == CreateOption)
|
||||||
|
return new StudentImportResolution(StudentImportResolutionKind.CreateNew);
|
||||||
|
if (decision.SelectedOptionId == SkipOption)
|
||||||
|
return new StudentImportResolution(StudentImportResolutionKind.Skip);
|
||||||
|
if (TryReadExistingOption(decision.SelectedOptionId, out var existingId))
|
||||||
|
return new StudentImportResolution(StudentImportResolutionKind.UseExisting, existingId);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Die Konfliktoption „{decision.SelectedOptionId}“ ist unbekannt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? ResolveGroup(
|
||||||
|
StudentImportGroupAssignment assignment,
|
||||||
|
IReadOnlyDictionary<string, ImportDecision> decisions)
|
||||||
|
{
|
||||||
|
if (assignment.ConflictId is null)
|
||||||
|
return assignment.AutomaticGroupId;
|
||||||
|
if (!decisions.TryGetValue(assignment.ConflictId, out var decision))
|
||||||
|
throw new InvalidOperationException("Für die Lerngruppenzuordnung fehlt die Entscheidung.");
|
||||||
|
if (decision.SelectedOptionId == SkipOption)
|
||||||
|
return null;
|
||||||
|
if (TryReadGroupOption(decision.SelectedOptionId, out var groupId))
|
||||||
|
return groupId;
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Die Gruppenoption „{decision.SelectedOptionId}“ ist unbekannt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateResolutions(
|
||||||
|
IReadOnlyList<StudentImportResolution> resolutions,
|
||||||
|
IReadOnlyCollection<Student> existing)
|
||||||
|
{
|
||||||
|
var existingIds = existing.Select(student => student.Id).ToHashSet();
|
||||||
|
foreach (var resolution in resolutions.Where(resolution =>
|
||||||
|
resolution.Kind == StudentImportResolutionKind.UseExisting))
|
||||||
|
{
|
||||||
|
if (resolution.ExistingStudentId is not Guid id || !existingIds.Contains(id))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Ein ausgewählter vorhandener Schüler existiert nicht mehr.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateGroupResolutions(
|
||||||
|
IEnumerable<Guid?> resolutions,
|
||||||
|
IReadOnlyCollection<LearningGroup> groups)
|
||||||
|
{
|
||||||
|
var writableGroupIds = groups
|
||||||
|
.Where(group => group.IsActive)
|
||||||
|
.Select(group => group.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
if (resolutions.Any(id => id is not null && !writableGroupIds.Contains(id.Value)))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Eine ausgewählte Lerngruppe existiert nicht mehr oder ist archiviert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Student CreateStudent(ImportedStudent imported, ImportFormatId formatId)
|
||||||
|
{
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
FirstName = imported.FirstName,
|
||||||
|
LastName = imported.LastName,
|
||||||
|
DateOfBirth = imported.DateOfBirth,
|
||||||
|
Gender = imported.Gender,
|
||||||
|
};
|
||||||
|
if (imported.ExternalId is not null)
|
||||||
|
student.ExternalIds[formatId.Value] = imported.ExternalId;
|
||||||
|
var contact = CreateImportedContact(student, imported.Contact);
|
||||||
|
if (contact is not null) student.Contacts.Add(contact);
|
||||||
|
return student;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Supplement(
|
||||||
|
Student target,
|
||||||
|
ImportedStudent imported,
|
||||||
|
ImportFormatId formatId)
|
||||||
|
{
|
||||||
|
var changed = false;
|
||||||
|
if (target.DateOfBirth is null && imported.DateOfBirth is not null)
|
||||||
|
{
|
||||||
|
target.DateOfBirth = imported.DateOfBirth;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (target.Gender is null && imported.Gender is not null)
|
||||||
|
{
|
||||||
|
target.Gender = imported.Gender;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.ExternalIds ??= [];
|
||||||
|
if (imported.ExternalId is not null && !target.ExternalIds.ContainsKey(formatId.Value))
|
||||||
|
{
|
||||||
|
target.ExternalIds[formatId.Value] = imported.ExternalId;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imported.Contact is not { HasData: true } importedContact)
|
||||||
|
return changed;
|
||||||
|
target.Contacts ??= [];
|
||||||
|
var contact = target.Contacts.FirstOrDefault(IsImportedContact);
|
||||||
|
if (contact is null)
|
||||||
|
{
|
||||||
|
contact = CreateImportedContact(target, importedContact)!;
|
||||||
|
target.Contacts.Add(contact);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed |= FillIfEmpty(contact.Email, importedContact.Email, value => contact.Email = value);
|
||||||
|
changed |= FillIfEmpty(contact.MobilePhone, importedContact.MobilePhone, value => contact.MobilePhone = value);
|
||||||
|
changed |= FillIfEmpty(contact.Phone, importedContact.Phone, value => contact.Phone = value);
|
||||||
|
changed |= FillIfEmpty(contact.Street, importedContact.Street, value => contact.Street = value);
|
||||||
|
changed |= FillIfEmpty(contact.PostalCode, importedContact.PostalCode, value => contact.PostalCode = value);
|
||||||
|
changed |= FillIfEmpty(contact.City, importedContact.City, value => contact.City = value);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Contact? CreateImportedContact(
|
||||||
|
Student student,
|
||||||
|
ImportedStudentContact? imported)
|
||||||
|
{
|
||||||
|
if (imported is not { HasData: true }) return null;
|
||||||
|
return new Contact
|
||||||
|
{
|
||||||
|
Name = $"{student.FirstName} {student.LastName}".Trim(),
|
||||||
|
Relation = ImportedContactRelation,
|
||||||
|
Email = imported.Email,
|
||||||
|
MobilePhone = imported.MobilePhone,
|
||||||
|
Phone = imported.Phone,
|
||||||
|
Street = imported.Street,
|
||||||
|
PostalCode = imported.PostalCode,
|
||||||
|
City = imported.City,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsImportedContact(Contact contact) =>
|
||||||
|
string.Equals(contact.Relation, ImportedContactRelation, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool FillIfEmpty(string? existing, string? imported, Action<string> assign)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(existing) || string.IsNullOrWhiteSpace(imported))
|
||||||
|
return false;
|
||||||
|
assign(imported);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NamesEqual(Student existing, ImportedStudent imported) =>
|
||||||
|
string.Equals(existing.FirstName.Trim(), imported.FirstName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(existing.LastName.Trim(), imported.LastName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string ExistingOption(Guid id) => $"{ExistingOptionPrefix}{id:N}";
|
||||||
|
private static string GroupOption(Guid id) => $"{GroupOptionPrefix}{id:N}";
|
||||||
|
|
||||||
|
private static bool TryReadExistingOption(string option, out Guid id)
|
||||||
|
{
|
||||||
|
id = Guid.Empty;
|
||||||
|
return option.StartsWith(ExistingOptionPrefix, StringComparison.Ordinal)
|
||||||
|
&& Guid.TryParseExact(option[ExistingOptionPrefix.Length..], "N", out id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadGroupOption(string option, out Guid id)
|
||||||
|
{
|
||||||
|
id = Guid.Empty;
|
||||||
|
return option.StartsWith(GroupOptionPrefix, StringComparison.Ordinal)
|
||||||
|
&& Guid.TryParseExact(option[GroupOptionPrefix.Length..], "N", out id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ImportedDetails(ImportedStudent student) =>
|
||||||
|
$"{student.LastName}, {student.FirstName} · {DateDisplay(student.DateOfBirth)}";
|
||||||
|
|
||||||
|
private static string StudentDetails(Student student) =>
|
||||||
|
$"{student.FullName} · {DateDisplay(student.DateOfBirth)}";
|
||||||
|
|
||||||
|
private static string DateDisplay(DateOnly? date) =>
|
||||||
|
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "Geburtsdatum unbekannt";
|
||||||
|
|
||||||
|
private static string Fingerprint(IEnumerable<Student> students)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
foreach (var student in students.OrderBy(student => student.Id))
|
||||||
|
{
|
||||||
|
builder.Append(student.Id.ToString("N"))
|
||||||
|
.Append(':').Append(student.UpdatedAt.Ticks)
|
||||||
|
.Append(':').Append(student.FirstName)
|
||||||
|
.Append(':').Append(student.LastName)
|
||||||
|
.Append(':').Append(student.DateOfBirth?.ToString("yyyyMMdd", CultureInfo.InvariantCulture))
|
||||||
|
.Append(':').Append(student.Gender)
|
||||||
|
.Append(':').Append(student.IsActive)
|
||||||
|
.Append(':').Append(string.Join(",", (student.ExternalIds ?? [])
|
||||||
|
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||||
|
.Select(pair => $"{pair.Key}={pair.Value}")))
|
||||||
|
.Append(':').Append(string.Join(",", (student.Contacts ?? [])
|
||||||
|
.OrderBy(contact => contact.Id)
|
||||||
|
.Select(contact =>
|
||||||
|
$"{contact.Id:N}={contact.Name}/{contact.Relation}/{contact.Email}/"
|
||||||
|
+ $"{contact.MobilePhone}/{contact.Phone}/{contact.Street}/"
|
||||||
|
+ $"{contact.PostalCode}/{contact.City}")))
|
||||||
|
.Append('|');
|
||||||
|
}
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GroupStateFingerprint(
|
||||||
|
IEnumerable<LearningGroup> groups,
|
||||||
|
IEnumerable<GroupMembership> memberships,
|
||||||
|
string currentSchoolYear)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder(currentSchoolYear).Append('|');
|
||||||
|
foreach (var group in groups.OrderBy(group => group.Id))
|
||||||
|
builder.Append(group.Id.ToString("N"))
|
||||||
|
.Append(':').Append(group.UpdatedAt.Ticks)
|
||||||
|
.Append(':').Append(group.Name)
|
||||||
|
.Append(':').Append(group.SchoolYear)
|
||||||
|
.Append(':').Append(group.IsActive)
|
||||||
|
.Append('|');
|
||||||
|
foreach (var membership in memberships.OrderBy(membership => membership.Id))
|
||||||
|
builder.Append(membership.Id.ToString("N"))
|
||||||
|
.Append(':').Append(membership.StudentId.ToString("N"))
|
||||||
|
.Append(':').Append(membership.GroupId.ToString("N"))
|
||||||
|
.Append('|');
|
||||||
|
return builder.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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Data.Repositories;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Data.Tests;
|
||||||
|
|
||||||
|
// Baustein 4: pro öffentlichem Repository-Aufruf genau EIN Sync-Ereignis, auch wenn die
|
||||||
|
// eigentliche Kaskade mehrere Collections betrifft. Batch-Methoden feuern ein Ereignis pro
|
||||||
|
// betroffener Entität.
|
||||||
|
public sealed class ChangeHookCascadeTests
|
||||||
|
{
|
||||||
|
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GroupRepository_Delete_LoestGenauEinEreignisAusTrotzKaskade()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var groupRepo = new GroupRepository(db);
|
||||||
|
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||||
|
groupRepo.Save(group);
|
||||||
|
db.Grades.Insert(new Grade { GroupId = group.Id, StudentId = Guid.NewGuid() });
|
||||||
|
db.Exams.Insert(new Exam { GroupId = group.Id });
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
groupRepo.Delete(group.Id);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal((nameof(LearningGroup), "Delete"), call);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamRepository_Delete_LoestGenauEinEreignisAusTrotzErgebnisKaskade()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new ExamRepository(db);
|
||||||
|
var exam = new Exam { GroupId = Guid.NewGuid() };
|
||||||
|
repo.Save(exam);
|
||||||
|
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
|
||||||
|
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
repo.Delete(exam.Id);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal((nameof(Exam), "Delete"), call);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParticipationSessionRepository_Delete_LoestGenauEinEreignisAusTrotzEintragKaskade()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new ParticipationSessionRepository(db);
|
||||||
|
var session = new ParticipationSession { GroupId = Guid.NewGuid() };
|
||||||
|
repo.Save(session);
|
||||||
|
db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
repo.Delete(session.Id);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal((nameof(ParticipationSession), "Delete"), call);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamResultRepository_SaveMany_LoestEinEreignisProEintragAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new ExamResultRepository(db);
|
||||||
|
var results = new List<ExamResult>
|
||||||
|
{
|
||||||
|
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
|
||||||
|
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
|
||||||
|
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
|
||||||
|
};
|
||||||
|
var calls = new List<(string EntityType, string EntityId, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op));
|
||||||
|
|
||||||
|
repo.SaveMany(results);
|
||||||
|
|
||||||
|
Assert.Equal(3, calls.Count);
|
||||||
|
Assert.All(calls, c => Assert.Equal((nameof(ExamResult), "Save"), (c.EntityType, c.Operation)));
|
||||||
|
Assert.Equal(results.Select(r => r.Id.ToString()).OrderBy(x => x), calls.Select(c => c.EntityId).OrderBy(x => x));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParticipationRepository_SaveMany_LoestEinEreignisProEintragAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new ParticipationRepository(db);
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var entries = new List<ParticipationEntry>
|
||||||
|
{
|
||||||
|
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
|
||||||
|
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
|
||||||
|
};
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
repo.SaveMany(entries);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls.Count);
|
||||||
|
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Save"), c));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParticipationRepository_DeleteBySession_LoestEinEreignisProEintragAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new ParticipationRepository(db);
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
|
||||||
|
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
repo.DeleteBySession(sessionId);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls.Count);
|
||||||
|
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Delete"), c));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompetencyDomainRepository_ReplaceForSubjectAndGrade_LoestDeleteFuerAlteUndSaveFuerNeueAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new CompetencyDomainRepository(db);
|
||||||
|
var subjectId = Guid.NewGuid();
|
||||||
|
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "Alt" });
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
repo.ReplaceForSubjectAndGrade(subjectId, 8,
|
||||||
|
[
|
||||||
|
new CompetencyDomain { Name = "Neu 1" },
|
||||||
|
new CompetencyDomain { Name = "Neu 2" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(3, calls.Count);
|
||||||
|
Assert.Equal(1, calls.Count(c => c.Operation == "Delete"));
|
||||||
|
Assert.Equal(2, calls.Count(c => c.Operation == "Save"));
|
||||||
|
Assert.All(calls, c => Assert.Equal(nameof(CompetencyDomain), c.EntityType));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompetencyDomainRepository_DeleteBySubjectAndGrade_LoestEinEreignisProDomaenAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new CompetencyDomainRepository(db);
|
||||||
|
var subjectId = Guid.NewGuid();
|
||||||
|
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "A" });
|
||||||
|
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "B" });
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
repo.DeleteBySubjectAndGrade(subjectId, 8);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls.Count);
|
||||||
|
Assert.All(calls, c => Assert.Equal((nameof(CompetencyDomain), "Delete"), c));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DocumentationRepository_Delete_LoestSaveMitIsDeletedAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new DocumentationRepository(db);
|
||||||
|
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
|
||||||
|
repo.Save(doc);
|
||||||
|
var calls = new List<(string EntityType, string Operation, object? Payload)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
|
||||||
|
|
||||||
|
repo.Delete(doc.Id);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal((nameof(Documentation), "Save"), (call.EntityType, call.Operation));
|
||||||
|
Assert.True(((Documentation)call.Payload!).IsDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DocumentationRepository_HardDelete_LoestDeleteAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new DocumentationRepository(db);
|
||||||
|
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
|
||||||
|
repo.Save(doc);
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
repo.HardDelete(doc.Id);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal((nameof(Documentation), "Delete"), call);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Data.Repositories;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Data.Tests;
|
||||||
|
|
||||||
|
// Baustein 3: prüft für jedes der ~19 "einfachen" Repositories (keine Kaskaden/Batches), dass
|
||||||
|
// Save/Delete den OnChange-Hook mit dem korrekten EntityType auslöst. Tabellengetrieben statt
|
||||||
|
// eine Datei pro Repository, um die immer gleiche Prüfung nicht 19x zu wiederholen.
|
||||||
|
public sealed class ChangeHookMatrixTests
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> Cases()
|
||||||
|
{
|
||||||
|
yield return Case("SeatingPlan", nameof(SeatingPlan), db =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
return (plan.Id, () => repo.Delete(plan.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("GroupMembership", nameof(GroupMembership), db =>
|
||||||
|
{
|
||||||
|
var repo = new GroupMembershipRepository(db);
|
||||||
|
var membership = new GroupMembership { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid() };
|
||||||
|
repo.Save(membership);
|
||||||
|
return (membership.Id, () => repo.Delete(membership.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("GradingKeyTemplate", nameof(GradingKeyTemplate), db =>
|
||||||
|
{
|
||||||
|
var repo = new GradingKeyTemplateRepository(db);
|
||||||
|
var template = new GradingKeyTemplate { Name = "Standard", GradingSystem = GradingSystem.Points0To15 };
|
||||||
|
repo.Save(template);
|
||||||
|
return (template.Id, () => repo.Delete(template.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("Grade", nameof(Grade), db =>
|
||||||
|
{
|
||||||
|
var repo = new GradeRepository(db);
|
||||||
|
var grade = new Grade { GroupId = Guid.NewGuid(), StudentId = Guid.NewGuid(), Value = "2" };
|
||||||
|
repo.Save(grade);
|
||||||
|
return (grade.Id, () => repo.Delete(grade.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("GradingScheme", nameof(GradingScheme), db =>
|
||||||
|
{
|
||||||
|
var repo = new GradingSchemeRepository(db);
|
||||||
|
var scheme = new GradingScheme { GroupId = Guid.NewGuid(), ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 };
|
||||||
|
repo.Save(scheme);
|
||||||
|
return (scheme.Id, () => repo.Delete(scheme.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("ReportGrade", nameof(ReportGrade), db =>
|
||||||
|
{
|
||||||
|
var repo = new ReportGradeRepository(db);
|
||||||
|
var grade = new ReportGrade { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Period = "1. Halbjahr" };
|
||||||
|
repo.Save(grade);
|
||||||
|
return (grade.Id, () => repo.Delete(grade.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("Unit", nameof(Unit), db =>
|
||||||
|
{
|
||||||
|
var repo = new UnitRepository(db);
|
||||||
|
var unit = new Unit { GroupId = Guid.NewGuid(), Title = "Einheit" };
|
||||||
|
repo.Save(unit);
|
||||||
|
return (unit.Id, () => repo.Delete(unit.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("Lesson", nameof(Lesson), db =>
|
||||||
|
{
|
||||||
|
var repo = new LessonRepository(db);
|
||||||
|
var lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new DateOnly(2026, 3, 12) };
|
||||||
|
repo.Save(lesson);
|
||||||
|
return (lesson.Id, () => repo.Delete(lesson.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("WorkTask", nameof(WorkTask), db =>
|
||||||
|
{
|
||||||
|
var repo = new WorkTaskRepository(db);
|
||||||
|
var task = new WorkTask { Title = "Klausur korrigieren", Category = TaskCategory.Correction };
|
||||||
|
repo.Save(task);
|
||||||
|
return (task.Id, () => repo.Delete(task.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("TimeEntry", nameof(TimeEntry), db =>
|
||||||
|
{
|
||||||
|
var repo = new TimeEntryRepository(db);
|
||||||
|
var entry = new TimeEntry { Date = new DateOnly(2026, 3, 12), DurationMinutes = 30, Category = "Vorbereitung" };
|
||||||
|
repo.Save(entry);
|
||||||
|
return (entry.Id, () => repo.Delete(entry.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("ParticipationAspect", nameof(ParticipationAspect), db =>
|
||||||
|
{
|
||||||
|
var repo = new ParticipationAspectRepository(db);
|
||||||
|
var aspect = new ParticipationAspect { GroupId = Guid.NewGuid(), Key = "quality", Label = "Qualität" };
|
||||||
|
repo.Save(aspect);
|
||||||
|
return (aspect.Id, () => repo.Delete(aspect.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("ParticipationSection", nameof(ParticipationSection), db =>
|
||||||
|
{
|
||||||
|
var repo = new ParticipationSectionRepository(db);
|
||||||
|
var section = new ParticipationSection
|
||||||
|
{
|
||||||
|
GroupId = Guid.NewGuid(), Label = "1. Abschnitt",
|
||||||
|
StartDate = new DateOnly(2026, 3, 1), EndDate = new DateOnly(2026, 4, 1),
|
||||||
|
};
|
||||||
|
repo.Save(section);
|
||||||
|
return (section.Id, () => repo.Delete(section.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("Subject", nameof(Subject), db =>
|
||||||
|
{
|
||||||
|
var repo = new SubjectRepository(db);
|
||||||
|
var subject = new Subject { Name = "Chemie" };
|
||||||
|
repo.Save(subject);
|
||||||
|
return (subject.Id, () => repo.Delete(subject.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("ShorthandCode", nameof(ShorthandCode), db =>
|
||||||
|
{
|
||||||
|
var repo = new ShorthandCodeRepository(db);
|
||||||
|
var code = new ShorthandCode { Code = "Tb", Label = "Tafelbild" };
|
||||||
|
repo.Save(code);
|
||||||
|
return (code.Id, () => repo.Delete(code.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("AlternativeLessonPath", nameof(AlternativeLessonPath), db =>
|
||||||
|
{
|
||||||
|
var repo = new AlternativeLessonPathRepository(db);
|
||||||
|
var path = new AlternativeLessonPath { Name = "Kurzversion" };
|
||||||
|
repo.Save(path);
|
||||||
|
return (path.Id, () => repo.Delete(path.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("TimetableSlot", nameof(TimetableSlot), db =>
|
||||||
|
{
|
||||||
|
var repo = new TimetableSlotRepository(db);
|
||||||
|
var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 };
|
||||||
|
repo.Save(slot);
|
||||||
|
return (slot.Id, () => repo.Delete(slot.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("SchoolHoliday", nameof(SchoolHoliday), db =>
|
||||||
|
{
|
||||||
|
var repo = new SchoolHolidayRepository(db);
|
||||||
|
var holiday = new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) };
|
||||||
|
repo.Save(holiday);
|
||||||
|
return (holiday.Id, () => repo.Delete(holiday.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("SupervisionDuty", nameof(SupervisionDuty), db =>
|
||||||
|
{
|
||||||
|
var repo = new SupervisionDutyRepository(db);
|
||||||
|
var duty = new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" };
|
||||||
|
repo.Save(duty);
|
||||||
|
return (duty.Id, () => repo.Delete(duty.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return Case("SubstitutionEntry", nameof(SubstitutionEntry), db =>
|
||||||
|
{
|
||||||
|
var repo = new SubstitutionEntryRepository(db);
|
||||||
|
var entry = new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Lesson, PeriodNumber = 3, Description = "Vertretung 8a" };
|
||||||
|
repo.Save(entry);
|
||||||
|
return (entry.Id, () => repo.Delete(entry.Id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Cases))]
|
||||||
|
public void Save_LoestOnChangeMitKorrektemEntityTypeAus(
|
||||||
|
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
|
||||||
|
{
|
||||||
|
using var db = new LiteDbContext(new MemoryStream());
|
||||||
|
var calls = new List<(string EntityType, string Operation)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||||
|
|
||||||
|
setup(db);
|
||||||
|
|
||||||
|
Assert.Contains((entityType, "Save"), calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Cases))]
|
||||||
|
public void Delete_LoestOnChangeMitKorrektemEntityTypeAus(
|
||||||
|
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
|
||||||
|
{
|
||||||
|
using var db = new LiteDbContext(new MemoryStream());
|
||||||
|
var (id, delete) = setup(db);
|
||||||
|
var calls = new List<(string EntityType, string EntityId, string Operation)>();
|
||||||
|
db.OnChange = (type, entId, op, payload) => calls.Add((type, entId, op));
|
||||||
|
|
||||||
|
delete();
|
||||||
|
|
||||||
|
Assert.Contains((entityType, id.ToString(), "Delete"), calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object[] Case(string name, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup) =>
|
||||||
|
[name, entityType, setup];
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Data.Repositories;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Data.Tests;
|
||||||
|
|
||||||
|
// Vorlage-Test für den OnChange-Hook (Baustein 2), den die übrigen Repositories in den
|
||||||
|
// Bausteinen 3/4 auf dieselbe Weise bekommen.
|
||||||
|
public sealed class ChangeHookTests
|
||||||
|
{
|
||||||
|
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentRepository_Save_LoestOnChangeMitKorrektenArgumentenAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
|
||||||
|
var repo = new StudentRepository(db);
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
|
||||||
|
repo.Save(student);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal("Student", call.EntityType);
|
||||||
|
Assert.Equal(student.Id.ToString(), call.EntityId);
|
||||||
|
Assert.Equal("Save", call.Operation);
|
||||||
|
Assert.Same(student, call.Payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentRepository_Delete_LoestOnChangeMitNullPayloadAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new StudentRepository(db);
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
repo.Save(student);
|
||||||
|
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
|
||||||
|
|
||||||
|
repo.Delete(student.Id);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal("Student", call.EntityType);
|
||||||
|
Assert.Equal(student.Id.ToString(), call.EntityId);
|
||||||
|
Assert.Equal("Delete", call.Operation);
|
||||||
|
Assert.Null(call.Payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentRepository_Save_OhneGesetztenHook_WirftNicht()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new StudentRepository(db);
|
||||||
|
|
||||||
|
var exception = Record.Exception(() => repo.Save(new Student { FirstName = "Anna", LastName = "Beispiel" }));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,6 +267,8 @@ public sealed class RepositoryTests
|
|||||||
repo.Save(new SeatingPlan
|
repo.Save(new SeatingPlan
|
||||||
{
|
{
|
||||||
GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3,
|
GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3,
|
||||||
|
IsBoardAtBottom = true,
|
||||||
|
ColumnGapWidths = [0, 90],
|
||||||
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }],
|
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,7 +276,9 @@ public sealed class RepositoryTests
|
|||||||
|
|
||||||
Assert.Equal(2, plans.Count);
|
Assert.Equal(2, plans.Count);
|
||||||
Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id);
|
Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id);
|
||||||
Assert.Contains(plans, p => p.Room == "Physik" && p.Assignments.Single().StudentId == ben.Id);
|
Assert.Contains(plans, p => p.Room == "Physik" && p.IsBoardAtBottom
|
||||||
|
&& p.ColumnGapWidths.SequenceEqual([0, 90])
|
||||||
|
&& p.Assignments.Single().StudentId == ben.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -299,6 +303,90 @@ public sealed class RepositoryTests
|
|||||||
Assert.Throws<InvalidOperationException>(() => new SeatingPlanRepository(db).Save(plan));
|
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()
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
Room = null!,
|
||||||
|
Rows = 2,
|
||||||
|
Columns = 2,
|
||||||
|
};
|
||||||
|
db.SeatingPlans.Insert(plan);
|
||||||
|
plan.Assignments.Add(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.True(string.IsNullOrEmpty(saved.Room));
|
||||||
|
Assert.Equal(student.Id, saved.Assignments.Single().StudentId);
|
||||||
|
|
||||||
|
// LiteDB may materialize the empty optional value as null again. A later
|
||||||
|
// drag/drop save must therefore remain safe as well.
|
||||||
|
new SeatingPlanRepository(db).Save(saved);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb()
|
public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb()
|
||||||
{
|
{
|
||||||
@@ -652,6 +740,25 @@ public sealed class RepositoryTests
|
|||||||
Assert.Equal(2, result[2].LessonNumber);
|
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 ───────────────────────────────────────────────
|
// ── ShorthandCodeRepository ───────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -847,4 +954,238 @@ public sealed class RepositoryTests
|
|||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Equal("Vertretung 8a", result[0].Description);
|
Assert.Equal("Vertretung 8a", result[0].Description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubstitutionEntryRepository_GetByExternalId_FindetAutomatischErzeugtenEintrag()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new SubstitutionEntryRepository(db);
|
||||||
|
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Cancelled, PeriodNumber = 3, ExternalId = "38818-2004584-2004587" });
|
||||||
|
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Cancelled, PeriodNumber = 4 });
|
||||||
|
|
||||||
|
var result = repo.GetByExternalId("38818-2004584-2004587");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(new DateOnly(2026, 3, 12), result!.Date);
|
||||||
|
Assert.Null(repo.GetByExternalId("unbekannt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UntisSnapshotRepository / UntisSlotMappingRepository ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntisSnapshotRepository_SaveUndGetAll_RoundTrip()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new UntisSnapshotRepository(db);
|
||||||
|
repo.Save(new UntisSnapshotEntry
|
||||||
|
{
|
||||||
|
Uid = "38926-2013812-2013815", Date = new DateOnly(2026, 8, 17),
|
||||||
|
StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
|
||||||
|
Summary = "SOL", Location = "Medien", Description = "10c HED",
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = repo.GetAll();
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("38926-2013812-2013815", result[0].Uid);
|
||||||
|
Assert.Equal("SOL", result[0].Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntisSnapshotRepository_Save_AktualisiertVorhandenenEintrag()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new UntisSnapshotRepository(db);
|
||||||
|
var entry = new UntisSnapshotEntry { Uid = "38926-2013812-2013815", Location = "Medien" };
|
||||||
|
repo.Save(entry);
|
||||||
|
|
||||||
|
entry.Location = "A1";
|
||||||
|
entry.Status = "CANCELLED";
|
||||||
|
repo.Save(entry);
|
||||||
|
|
||||||
|
var result = Assert.Single(repo.GetAll());
|
||||||
|
Assert.Equal("A1", result.Location);
|
||||||
|
Assert.Equal("CANCELLED", result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntisSlotMappingRepository_SaveDeleteUndGetAll_RoundTrip()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new UntisSlotMappingRepository(db);
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var mapping = new UntisSlotMapping
|
||||||
|
{
|
||||||
|
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50),
|
||||||
|
Summary = "SOL", ClassToken = "10c", GroupId = groupId, Confirmed = true,
|
||||||
|
};
|
||||||
|
repo.Save(mapping);
|
||||||
|
|
||||||
|
Assert.Single(repo.GetAll());
|
||||||
|
|
||||||
|
repo.Delete(mapping.Id);
|
||||||
|
|
||||||
|
Assert.Empty(repo.GetAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
// EventApplier (Baustein 5) muss dieselben internen Kaskaden-Hilfsmethoden auf LiteDbContext
|
||||||
|
// wiederverwenden können wie die Repositories selbst (siehe LiteDbContext.CascadeDelete*).
|
||||||
|
[assembly: InternalsVisibleTo("LehrerApp.Sync")]
|
||||||
@@ -3,6 +3,10 @@ using LehrerApp.Core.Models;
|
|||||||
|
|
||||||
namespace LehrerApp.Data;
|
namespace LehrerApp.Data;
|
||||||
|
|
||||||
|
/// Wird nach jedem Save/Delete einer Entität aufgerufen; payload ist die gespeicherte Entität
|
||||||
|
/// bzw. null bei Delete. Sync-agnostisch – siehe <see cref="LiteDbContext.OnChange"/>.
|
||||||
|
public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -62,11 +66,162 @@ public class LiteDbContext : IDisposable
|
|||||||
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
|
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
|
||||||
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
|
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
|
||||||
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
|
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
|
||||||
|
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
|
||||||
|
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
|
||||||
|
public ILiteCollection<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();
|
public void Checkpoint() => _db.Checkpoint();
|
||||||
|
|
||||||
public int SchemaVersion => ReadSchemaVersion();
|
public int SchemaVersion => ReadSchemaVersion();
|
||||||
|
|
||||||
|
/// Wird von den Repositories nach jedem Save/Delete aufgerufen (payload = die gespeicherte
|
||||||
|
/// Entität bzw. null bei Delete). Bewusst hier statt in LehrerApp.Sync definiert, damit Data
|
||||||
|
/// weiterhin ohne Verweis auf Sync auskommt — die eigentliche Sync-Anbindung setzt diesen
|
||||||
|
/// Hook von außen (siehe AppBootstrapper).
|
||||||
|
public ChangeHandler? OnChange { get; set; }
|
||||||
|
|
||||||
|
/// Führt dieselbe Kaskade wie <c>GroupRepository.Delete</c> aus. Hier auf dem Context statt
|
||||||
|
/// im Repository, damit ein später eingehendes Sync-Ereignis (Baustein 5) dieselbe Kaskade
|
||||||
|
/// nachvollziehen kann, ohne über die Repository-Save/Delete-Methoden (und damit erneut über
|
||||||
|
/// <see cref="OnChange"/>) zu laufen.
|
||||||
|
internal void CascadeDeleteGroup(Guid id)
|
||||||
|
{
|
||||||
|
ExecuteInTransaction(() =>
|
||||||
|
{
|
||||||
|
foreach (var membership in Memberships.Find(e => e.GroupId == id).ToList())
|
||||||
|
Memberships.Delete(membership.Id);
|
||||||
|
|
||||||
|
foreach (var exam in Exams.Find(e => e.GroupId == id).ToList())
|
||||||
|
{
|
||||||
|
foreach (var result in ExamResults.Find(r => r.ExamId == exam.Id).ToList())
|
||||||
|
ExamResults.Delete(result.Id);
|
||||||
|
Exams.Delete(exam.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var grade in Grades.Find(g => g.GroupId == id).ToList())
|
||||||
|
Grades.Delete(grade.Id);
|
||||||
|
|
||||||
|
foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList())
|
||||||
|
ReportGrades.Delete(reportGrade.Id);
|
||||||
|
|
||||||
|
foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList())
|
||||||
|
GradingSchemes.Delete(scheme.Id);
|
||||||
|
|
||||||
|
foreach (var unit in Units.Find(u => u.GroupId == id).ToList())
|
||||||
|
{
|
||||||
|
foreach (var lesson in Lessons.Find(l => l.UnitId == unit.Id).ToList())
|
||||||
|
Lessons.Delete(lesson.Id);
|
||||||
|
Units.Delete(unit.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var lesson in Lessons.Find(l => l.GroupId == id).ToList())
|
||||||
|
Lessons.Delete(lesson.Id);
|
||||||
|
|
||||||
|
foreach (var session in ParticipationSessions.Find(s => s.GroupId == id).ToList())
|
||||||
|
{
|
||||||
|
foreach (var entry in ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
|
||||||
|
ParticipationEntries.Delete(entry.Id);
|
||||||
|
ParticipationSessions.Delete(session.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var aspect in ParticipationAspects.Find(a => a.GroupId == id).ToList())
|
||||||
|
ParticipationAspects.Delete(aspect.Id);
|
||||||
|
|
||||||
|
foreach (var section in ParticipationSections.Find(s => s.GroupId == id).ToList())
|
||||||
|
ParticipationSections.Delete(section.Id);
|
||||||
|
|
||||||
|
foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList())
|
||||||
|
SeatingPlans.Delete(plan.Id);
|
||||||
|
|
||||||
|
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
|
||||||
|
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
|
||||||
|
foreach (var documentation in Documentation.Find(d => d.GroupId == id).ToList())
|
||||||
|
{
|
||||||
|
documentation.GroupId = null;
|
||||||
|
documentation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
Documentation.Update(documentation);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var task in Tasks.Find(t => t.GroupId == id).ToList())
|
||||||
|
{
|
||||||
|
task.GroupId = null;
|
||||||
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
|
Tasks.Update(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var timeEntry in TimeEntries.Find(t => t.GroupId == id).ToList())
|
||||||
|
{
|
||||||
|
timeEntry.GroupId = null;
|
||||||
|
TimeEntries.Update(timeEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Groups.Delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Führt dieselbe Kaskade wie <c>ExamRepository.Delete</c> aus (siehe <see cref="CascadeDeleteGroup"/>).
|
||||||
|
internal void CascadeDeleteExam(Guid id)
|
||||||
|
{
|
||||||
|
foreach (var result in ExamResults.Find(r => r.ExamId == id).ToList())
|
||||||
|
ExamResults.Delete(result.Id);
|
||||||
|
Exams.Delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Führt dieselbe Kaskade wie <c>ParticipationSessionRepository.Delete</c> aus (siehe
|
||||||
|
/// <see cref="CascadeDeleteGroup"/>).
|
||||||
|
internal void CascadeDeleteParticipationSession(Guid id)
|
||||||
|
{
|
||||||
|
ParticipationSessions.Delete(id);
|
||||||
|
foreach (var e in ParticipationEntries.Find(e => e.SessionId == id).ToList())
|
||||||
|
ParticipationEntries.Delete(e.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Führt dieselbe Kaskade wie <c>DocumentationRepository.HardDelete</c> aus (siehe
|
||||||
|
/// <see cref="CascadeDeleteGroup"/>).
|
||||||
|
internal void CascadeHardDeleteDocumentation(Guid id)
|
||||||
|
{
|
||||||
|
if (Documentation.FindById(id) is { } doc)
|
||||||
|
foreach (var attachment in doc.Attachments) Attachments.Delete(attachment.StorageId);
|
||||||
|
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)
|
internal void ExecuteInTransaction(Action action)
|
||||||
{
|
{
|
||||||
_db.BeginTrans();
|
_db.BeginTrans();
|
||||||
@@ -381,6 +536,16 @@ public class LiteDbContext : IDisposable
|
|||||||
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
|
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
|
||||||
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
|
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
|
||||||
SubstitutionEntries.EnsureIndex(x => x.Date);
|
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();
|
public void Dispose() => _db.Dispose();
|
||||||
|
|||||||
@@ -35,7 +35,14 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
db.ReportGrades.Count(g => g.StudentId == studentId),
|
db.ReportGrades.Count(g => g.StudentId == studentId),
|
||||||
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
||||||
db.Documentation.Count(d => d.StudentId == studentId));
|
db.Documentation.Count(d => d.StudentId == studentId));
|
||||||
public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); }
|
public void Save(Student s)
|
||||||
|
{
|
||||||
|
s.ExternalIds ??= [];
|
||||||
|
s.Contacts ??= [];
|
||||||
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
|
db.Students.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s);
|
||||||
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
var references = GetReferenceSummary(id);
|
var references = GetReferenceSummary(id);
|
||||||
@@ -43,6 +50,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
|
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
|
||||||
db.Students.Delete(id);
|
db.Students.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(Student), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,81 +81,13 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
|
|||||||
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
|
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
|
||||||
g.UpdatedAt = DateTime.UtcNow;
|
g.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Groups.Upsert(g);
|
db.Groups.Upsert(g);
|
||||||
|
db.OnChange?.Invoke(nameof(LearningGroup), g.Id.ToString(), "Save", g);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
||||||
db.ExecuteInTransaction(() =>
|
db.CascadeDeleteGroup(id);
|
||||||
{
|
db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
|
||||||
foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
|
|
||||||
db.Memberships.Delete(membership.Id);
|
|
||||||
|
|
||||||
foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList())
|
|
||||||
{
|
|
||||||
foreach (var result in db.ExamResults.Find(r => r.ExamId == exam.Id).ToList())
|
|
||||||
db.ExamResults.Delete(result.Id);
|
|
||||||
db.Exams.Delete(exam.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList())
|
|
||||||
db.Grades.Delete(grade.Id);
|
|
||||||
|
|
||||||
foreach (var reportGrade in db.ReportGrades.Find(g => g.GroupId == id).ToList())
|
|
||||||
db.ReportGrades.Delete(reportGrade.Id);
|
|
||||||
|
|
||||||
foreach (var scheme in db.GradingSchemes.Find(s => s.GroupId == id).ToList())
|
|
||||||
db.GradingSchemes.Delete(scheme.Id);
|
|
||||||
|
|
||||||
foreach (var unit in db.Units.Find(u => u.GroupId == id).ToList())
|
|
||||||
{
|
|
||||||
foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList())
|
|
||||||
db.Lessons.Delete(lesson.Id);
|
|
||||||
db.Units.Delete(unit.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList())
|
|
||||||
db.Lessons.Delete(lesson.Id);
|
|
||||||
|
|
||||||
foreach (var session in db.ParticipationSessions.Find(s => s.GroupId == id).ToList())
|
|
||||||
{
|
|
||||||
foreach (var entry in db.ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
|
|
||||||
db.ParticipationEntries.Delete(entry.Id);
|
|
||||||
db.ParticipationSessions.Delete(session.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList())
|
|
||||||
db.ParticipationAspects.Delete(aspect.Id);
|
|
||||||
|
|
||||||
foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList())
|
|
||||||
db.ParticipationSections.Delete(section.Id);
|
|
||||||
|
|
||||||
foreach (var plan in db.SeatingPlans.Find(p => p.GroupId == id).ToList())
|
|
||||||
db.SeatingPlans.Delete(plan.Id);
|
|
||||||
|
|
||||||
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
|
|
||||||
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
|
|
||||||
foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList())
|
|
||||||
{
|
|
||||||
documentation.GroupId = null;
|
|
||||||
documentation.UpdatedAt = DateTime.UtcNow;
|
|
||||||
db.Documentation.Update(documentation);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var task in db.Tasks.Find(t => t.GroupId == id).ToList())
|
|
||||||
{
|
|
||||||
task.GroupId = null;
|
|
||||||
task.UpdatedAt = DateTime.UtcNow;
|
|
||||||
db.Tasks.Update(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var timeEntry in db.TimeEntries.Find(t => t.GroupId == id).ToList())
|
|
||||||
{
|
|
||||||
timeEntry.GroupId = null;
|
|
||||||
db.TimeEntries.Update(timeEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Groups.Delete(id);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,8 +102,13 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
|||||||
public void Save(SeatingPlan plan)
|
public void Save(SeatingPlan plan)
|
||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
||||||
plan.Name = plan.Name.Trim();
|
// LiteDB can deserialize missing/legacy optional string fields as null even
|
||||||
plan.Room = plan.Room.Trim();
|
// though the current model initializes them with an empty string.
|
||||||
|
plan.Name = plan.Name?.Trim() ?? "";
|
||||||
|
plan.Room = plan.Room?.Trim() ?? "";
|
||||||
|
plan.Assignments ??= [];
|
||||||
|
plan.ColumnGapWidths ??= [];
|
||||||
|
plan.HiddenSeats ??= [];
|
||||||
if (plan.Name.Length == 0)
|
if (plan.Name.Length == 0)
|
||||||
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
|
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
|
||||||
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
|
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
|
||||||
@@ -171,6 +116,12 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
|||||||
if (db.Groups.FindById(plan.GroupId) is null)
|
if (db.Groups.FindById(plan.GroupId) is null)
|
||||||
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
|
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
|
||||||
|
|
||||||
|
plan.ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1))
|
||||||
|
.Select(i => i < plan.ColumnGapWidths.Count && double.IsFinite(plan.ColumnGapWidths[i])
|
||||||
|
? Math.Clamp(plan.ColumnGapWidths[i], 0, 300)
|
||||||
|
: 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId)
|
var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId)
|
||||||
.FirstOrDefault(p => p.Id != plan.Id
|
.FirstOrDefault(p => p.Id != plan.Id
|
||||||
&& string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)
|
&& string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
@@ -191,15 +142,36 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
|||||||
if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId)))
|
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.");
|
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;
|
plan.UpdatedAt = DateTime.UtcNow;
|
||||||
db.SeatingPlans.Upsert(plan);
|
db.SeatingPlans.Upsert(plan);
|
||||||
|
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Delete(Guid id)
|
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);
|
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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,12 +190,14 @@ public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepos
|
|||||||
if (existing is not null && existing.Id != membership.Id)
|
if (existing is not null && existing.Id != membership.Id)
|
||||||
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
|
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
|
||||||
db.Memberships.Upsert(membership);
|
db.Memberships.Upsert(membership);
|
||||||
|
db.OnChange?.Invoke(nameof(GroupMembership), membership.Id.ToString(), "Save", membership);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Memberships.FindById(id) is { } membership)
|
if (db.Memberships.FindById(id) is { } membership)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
|
||||||
db.Memberships.Delete(id);
|
db.Memberships.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(GroupMembership), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,14 +212,14 @@ public class ExamRepository(LiteDbContext db) : IExamRepository
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
|
||||||
e.UpdatedAt = DateTime.UtcNow;
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Exams.Upsert(e);
|
db.Exams.Upsert(e);
|
||||||
|
db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Exams.FindById(id) is { } exam)
|
if (db.Exams.FindById(id) is { } exam)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
||||||
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList())
|
db.CascadeDeleteExam(id);
|
||||||
db.ExamResults.Delete(result.Id);
|
db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
|
||||||
db.Exams.Delete(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +237,7 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
||||||
r.UpdatedAt = DateTime.UtcNow;
|
r.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ExamResults.Upsert(r);
|
db.ExamResults.Upsert(r);
|
||||||
|
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
|
||||||
}
|
}
|
||||||
public void SaveMany(List<ExamResult> results)
|
public void SaveMany(List<ExamResult> results)
|
||||||
{
|
{
|
||||||
@@ -272,6 +247,8 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var r in results) r.UpdatedAt = now;
|
foreach (var r in results) r.UpdatedAt = now;
|
||||||
db.ExamResults.Upsert(results);
|
db.ExamResults.Upsert(results);
|
||||||
|
foreach (var r in results)
|
||||||
|
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +259,24 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat
|
|||||||
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
|
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
|
||||||
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
|
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
|
||||||
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
|
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
|
||||||
public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); }
|
public void Save(GradingKeyTemplate t)
|
||||||
public void Delete(Guid id) => db.GradingKeyTemplates.Delete(id);
|
{
|
||||||
|
t.UpdatedAt = DateTime.UtcNow;
|
||||||
|
db.GradingKeyTemplates.Upsert(t);
|
||||||
|
db.OnChange?.Invoke(nameof(GradingKeyTemplate), t.Id.ToString(), "Save", t);
|
||||||
|
}
|
||||||
|
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
|
public class GradeRepository(LiteDbContext db) : IGradeRepository
|
||||||
@@ -296,12 +289,21 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
|
|||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
|
||||||
db.Grades.Upsert(g);
|
db.Grades.Upsert(g);
|
||||||
|
db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
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);
|
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
||||||
|
db.MoveToTrash(nameof(Grade), id, grade,
|
||||||
|
$"Note {grade.Value} ({grade.Date:dd.MM.yyyy})");
|
||||||
db.Grades.Delete(id);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,12 +317,14 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor
|
|||||||
if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||||
s.UpdatedAt = DateTime.UtcNow;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
db.GradingSchemes.Upsert(s);
|
db.GradingSchemes.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
|
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||||
db.GradingSchemes.Delete(id);
|
db.GradingSchemes.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(GradingScheme), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,12 +338,14 @@ public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId);
|
||||||
r.UpdatedAt = DateTime.UtcNow;
|
r.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ReportGrades.Upsert(r);
|
db.ReportGrades.Upsert(r);
|
||||||
|
db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ReportGrades.FindById(id) is { } grade)
|
if (db.ReportGrades.FindById(id) is { } grade)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
||||||
db.ReportGrades.Delete(id);
|
db.ReportGrades.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(ReportGrade), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,12 +359,14 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
||||||
u.UpdatedAt = DateTime.UtcNow;
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Units.Upsert(u);
|
db.Units.Upsert(u);
|
||||||
|
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Units.FindById(id) is { } unit)
|
if (db.Units.FindById(id) is { } unit)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
|
||||||
db.Units.Delete(id);
|
db.Units.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(Unit), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,12 +384,17 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId);
|
||||||
l.UpdatedAt = DateTime.UtcNow;
|
l.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Lessons.Upsert(l);
|
db.Lessons.Upsert(l);
|
||||||
|
db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Lessons.FindById(id) is { } lesson)
|
if (db.Lessons.FindById(id) is { } lesson)
|
||||||
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
|
||||||
|
foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId);
|
||||||
|
}
|
||||||
db.Lessons.Delete(id);
|
db.Lessons.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +407,12 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
|||||||
.OrderByDescending(d => d.Date).ToList();
|
.OrderByDescending(d => d.Date).ToList();
|
||||||
public List<Documentation> GetAll() =>
|
public List<Documentation> GetAll() =>
|
||||||
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
|
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
|
||||||
public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); }
|
public void Save(Documentation d)
|
||||||
|
{
|
||||||
|
d.UpdatedAt = DateTime.UtcNow;
|
||||||
|
db.Documentation.Upsert(d);
|
||||||
|
db.OnChange?.Invoke(nameof(Documentation), d.Id.ToString(), "Save", d);
|
||||||
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
var doc = db.Documentation.FindById(id);
|
var doc = db.Documentation.FindById(id);
|
||||||
@@ -402,13 +420,14 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
|||||||
doc.IsDeleted = true;
|
doc.IsDeleted = true;
|
||||||
doc.DeletedAt = DateTime.UtcNow;
|
doc.DeletedAt = DateTime.UtcNow;
|
||||||
db.Documentation.Update(doc);
|
db.Documentation.Update(doc);
|
||||||
|
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
|
||||||
|
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen.
|
||||||
|
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Save", doc);
|
||||||
}
|
}
|
||||||
public void HardDelete(Guid id)
|
public void HardDelete(Guid id)
|
||||||
{
|
{
|
||||||
var doc = db.Documentation.FindById(id);
|
db.CascadeHardDeleteDocumentation(id);
|
||||||
if (doc is not null)
|
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
|
||||||
foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
|
|
||||||
db.Documentation.Delete(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,8 +437,26 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
|||||||
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
|
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
|
||||||
public List<WorkTask> GetAll() =>
|
public List<WorkTask> GetAll() =>
|
||||||
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
|
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
|
||||||
public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); }
|
public List<WorkTask> GetByGroup(Guid groupId) =>
|
||||||
public void Delete(Guid id) => db.Tasks.Delete(id);
|
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;
|
||||||
|
db.Tasks.Upsert(t);
|
||||||
|
db.OnChange?.Invoke(nameof(WorkTask), t.Id.ToString(), "Save", t);
|
||||||
|
}
|
||||||
|
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
|
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
|
||||||
@@ -430,8 +467,24 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
|
|||||||
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
|
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
|
||||||
public List<TimeEntry> GetByTask(Guid id) =>
|
public List<TimeEntry> GetByTask(Guid id) =>
|
||||||
db.TimeEntries.Find(e => e.TaskId == id).ToList();
|
db.TimeEntries.Find(e => e.TaskId == id).ToList();
|
||||||
public void Save(TimeEntry e) => db.TimeEntries.Upsert(e);
|
public void Save(TimeEntry e)
|
||||||
public void Delete(Guid id) => db.TimeEntries.Delete(id);
|
{
|
||||||
|
db.TimeEntries.Upsert(e);
|
||||||
|
db.OnChange?.Invoke(nameof(TimeEntry), e.Id.ToString(), "Save", e);
|
||||||
|
}
|
||||||
|
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
|
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
|
||||||
@@ -444,14 +497,14 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||||
s.UpdatedAt = DateTime.UtcNow;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ParticipationSessions.Upsert(s);
|
db.ParticipationSessions.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ParticipationSessions.FindById(id) is { } session)
|
if (db.ParticipationSessions.FindById(id) is { } session)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||||
db.ParticipationSessions.Delete(id);
|
db.CascadeDeleteParticipationSession(id);
|
||||||
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList())
|
db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
|
||||||
db.ParticipationEntries.Delete(e.Id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +522,7 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||||
e.UpdatedAt = DateTime.UtcNow;
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ParticipationEntries.Upsert(e);
|
db.ParticipationEntries.Upsert(e);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
|
||||||
}
|
}
|
||||||
public void SaveMany(List<ParticipationEntry> entries)
|
public void SaveMany(List<ParticipationEntry> entries)
|
||||||
{
|
{
|
||||||
@@ -478,13 +532,18 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var e in entries) e.UpdatedAt = now;
|
foreach (var e in entries) e.UpdatedAt = now;
|
||||||
db.ParticipationEntries.Upsert(entries);
|
db.ParticipationEntries.Upsert(entries);
|
||||||
|
foreach (var e in entries)
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
|
||||||
}
|
}
|
||||||
public void DeleteBySession(Guid sessionId)
|
public void DeleteBySession(Guid sessionId)
|
||||||
{
|
{
|
||||||
if (db.ParticipationSessions.FindById(sessionId) is { } session)
|
if (db.ParticipationSessions.FindById(sessionId) is { } session)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||||
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
|
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
|
||||||
|
{
|
||||||
db.ParticipationEntries.Delete(e.Id);
|
db.ParticipationEntries.Delete(e.Id);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,12 +573,14 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp
|
|||||||
throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits.");
|
throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits.");
|
||||||
a.UpdatedAt = DateTime.UtcNow;
|
a.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ParticipationAspects.Upsert(a);
|
db.ParticipationAspects.Upsert(a);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
|
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||||
db.ParticipationAspects.Delete(id);
|
db.ParticipationAspects.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationAspect), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,12 +592,14 @@ public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSe
|
|||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||||
db.ParticipationSections.Upsert(s);
|
db.ParticipationSections.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ParticipationSections.FindById(id) is { } section)
|
if (db.ParticipationSections.FindById(id) is { } section)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
|
||||||
db.ParticipationSections.Delete(id);
|
db.ParticipationSections.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationSection), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,12 +619,14 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository
|
|||||||
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
||||||
s.UpdatedAt = DateTime.UtcNow;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Subjects.Upsert(s);
|
db.Subjects.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(Subject), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id))
|
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id))
|
||||||
throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet.");
|
throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet.");
|
||||||
db.Subjects.Delete(id);
|
db.Subjects.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(Subject), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,8 +644,13 @@ public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepositor
|
|||||||
throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits.");
|
throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits.");
|
||||||
c.UpdatedAt = DateTime.UtcNow;
|
c.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ShorthandCodes.Upsert(c);
|
db.ShorthandCodes.Upsert(c);
|
||||||
|
db.OnChange?.Invoke(nameof(ShorthandCode), c.Id.ToString(), "Save", c);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
db.ShorthandCodes.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(ShorthandCode), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id) => db.ShorthandCodes.Delete(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository
|
public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository
|
||||||
@@ -598,8 +668,13 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes
|
|||||||
throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits.");
|
throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits.");
|
||||||
p.UpdatedAt = DateTime.UtcNow;
|
p.UpdatedAt = DateTime.UtcNow;
|
||||||
db.AlternativeLessonPaths.Upsert(p);
|
db.AlternativeLessonPaths.Upsert(p);
|
||||||
|
db.OnChange?.Invoke(nameof(AlternativeLessonPath), p.Id.ToString(), "Save", p);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
db.AlternativeLessonPaths.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(AlternativeLessonPath), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
|
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
|
||||||
@@ -616,20 +691,30 @@ public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepositor
|
|||||||
if (occupied is not null && occupied.Id != slot.Id)
|
if (occupied is not null && occupied.Id != slot.Id)
|
||||||
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
||||||
db.TimetableSlots.Upsert(slot);
|
db.TimetableSlots.Upsert(slot);
|
||||||
|
db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.TimetableSlots.FindById(id) is { } slot)
|
if (db.TimetableSlots.FindById(id) is { } slot)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
|
||||||
db.TimetableSlots.Delete(id);
|
db.TimetableSlots.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
|
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
|
||||||
{
|
{
|
||||||
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
|
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
|
||||||
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday);
|
public void Save(SchoolHoliday holiday)
|
||||||
public void Delete(Guid id) => db.SchoolHolidays.Delete(id);
|
{
|
||||||
|
db.SchoolHolidays.Upsert(holiday);
|
||||||
|
db.OnChange?.Invoke(nameof(SchoolHoliday), holiday.Id.ToString(), "Save", holiday);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
db.SchoolHolidays.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(SchoolHoliday), id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository
|
public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository
|
||||||
@@ -643,8 +728,13 @@ public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepos
|
|||||||
if (occupied is not null && occupied.Id != duty.Id)
|
if (occupied is not null && occupied.Id != duty.Id)
|
||||||
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
|
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
|
||||||
db.SupervisionDuties.Upsert(duty);
|
db.SupervisionDuties.Upsert(duty);
|
||||||
|
db.OnChange?.Invoke(nameof(SupervisionDuty), duty.Id.ToString(), "Save", duty);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
db.SupervisionDuties.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(SupervisionDuty), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id) => db.SupervisionDuties.Delete(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository
|
public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository
|
||||||
@@ -652,8 +742,115 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
|
|||||||
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
|
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
|
||||||
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
|
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
|
||||||
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
|
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
|
||||||
public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry);
|
public SubstitutionEntry? GetByExternalId(string externalId) =>
|
||||||
public void Delete(Guid id) => db.SubstitutionEntries.Delete(id);
|
db.SubstitutionEntries.FindOne(e => e.ExternalId == externalId);
|
||||||
|
public void Save(SubstitutionEntry entry)
|
||||||
|
{
|
||||||
|
db.SubstitutionEntries.Upsert(entry);
|
||||||
|
db.OnChange?.Invoke(nameof(SubstitutionEntry), entry.Id.ToString(), "Save", entry);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
db.SubstitutionEntries.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(SubstitutionEntry), id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||||
@@ -664,14 +861,26 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
.OrderBy(d => d.SortOrder)
|
.OrderBy(d => d.SortOrder)
|
||||||
.ToList();
|
.ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||||
public void Save(CompetencyDomain d) { d.UpdatedAt = DateTime.UtcNow; db.CompetencyDomains.Upsert(d); }
|
public void Save(CompetencyDomain d)
|
||||||
public void Delete(Guid id) => db.CompetencyDomains.Delete(id);
|
{
|
||||||
|
d.UpdatedAt = DateTime.UtcNow;
|
||||||
|
db.CompetencyDomains.Upsert(d);
|
||||||
|
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Save", d);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
db.CompetencyDomains.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(CompetencyDomain), id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
|
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
|
||||||
{
|
{
|
||||||
foreach (var d in db.CompetencyDomains
|
foreach (var d in db.CompetencyDomains
|
||||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.ToList())
|
.ToList())
|
||||||
|
{
|
||||||
db.CompetencyDomains.Delete(d.Id);
|
db.CompetencyDomains.Delete(d.Id);
|
||||||
|
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
|
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
|
||||||
{
|
{
|
||||||
@@ -679,7 +888,10 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
{
|
{
|
||||||
foreach (var domain in db.CompetencyDomains
|
foreach (var domain in db.CompetencyDomains
|
||||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
|
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
|
||||||
|
{
|
||||||
db.CompetencyDomains.Delete(domain.Id);
|
db.CompetencyDomains.Delete(domain.Id);
|
||||||
|
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var domain in domains)
|
foreach (var domain in domains)
|
||||||
{
|
{
|
||||||
@@ -687,7 +899,23 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
domain.GradeLevel = gradeLevel;
|
domain.GradeLevel = gradeLevel;
|
||||||
domain.UpdatedAt = now;
|
domain.UpdatedAt = now;
|
||||||
db.CompetencyDomains.Upsert(domain);
|
db.CompetencyDomains.Upsert(domain);
|
||||||
|
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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,7 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer
|
||||||
|
// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere
|
||||||
|
// Testklassen hinweg (SettingsViewModelTests/SyncStatusViewModelTests konstruieren beide
|
||||||
|
// LiteDbContext).
|
||||||
|
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||||
@@ -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;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer
|
/// 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
|
/// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
|
||||||
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...).
|
/// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
|
||||||
|
/// (offene Aufgaben, ...).
|
||||||
public sealed class DashboardViewModelTests
|
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()
|
private static PeriodScheduleService NewPeriodSchedule()
|
||||||
{
|
{
|
||||||
var tempPath = System.IO.Path.Combine(
|
var tempPath = System.IO.Path.Combine(
|
||||||
@@ -18,17 +35,44 @@ public sealed class DashboardViewModelTests
|
|||||||
return new PeriodScheduleService(tempPath);
|
return new PeriodScheduleService(tempPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
|
private static DashboardSettingsService NewDashboardSettings()
|
||||||
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null)
|
|
||||||
{
|
{
|
||||||
var lessons = new FakeLessons();
|
var tempPath = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"lehrerapp-dashboardsettings-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new DashboardSettingsService(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SchoolCalendarSettingsService NewCalendarSettings()
|
||||||
|
{
|
||||||
|
var tempPath = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new SchoolCalendarSettingsService(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
|
||||||
|
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
|
||||||
|
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||||
|
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,
|
||||||
|
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||||
|
FakeAnnualPlanEvents? annualPlanEvents = null)
|
||||||
|
{
|
||||||
|
lessons ??= new FakeLessons();
|
||||||
lessons.Add(lesson);
|
lessons.Add(lesson);
|
||||||
return new DashboardViewModel(
|
return new DashboardViewModel(
|
||||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||||
new FakeExams([]), new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
|
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||||
new FakeStudents([]), new FakeDocumentation(),
|
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||||
|
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||||
|
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||||
new AttendanceBalanceService(), new SchoolYearService());
|
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||||
|
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||||
|
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -108,4 +152,409 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
Assert.Equal(group.Id, navigatedTo);
|
Assert.Equal(group.Id, navigatedTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpcomingDates_BuendeltKlausurenFristenUndFoerderplanPruefungen()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var exams = new FakeExams([
|
||||||
|
new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = today.AddDays(5) },
|
||||||
|
]);
|
||||||
|
var tasks = new FakeWorkTasks();
|
||||||
|
tasks.Add(new WorkTask { Title = "Notenschluss", DueDate = today.AddDays(10) });
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var documentation = new FakeDocumentation();
|
||||||
|
documentation.Add(new Documentation
|
||||||
|
{
|
||||||
|
StudentId = student.Id, Type = DocumentationType.SupportPlan, Title = "Leseförderung",
|
||||||
|
SupportData = new SupportData { Status = SupportStatus.Active, ReviewDate = today.AddDays(3) },
|
||||||
|
});
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams,
|
||||||
|
tasks: tasks, students: new FakeStudents([student]), documentation: documentation);
|
||||||
|
|
||||||
|
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Exam && i.Title == "Chemie-Test");
|
||||||
|
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Deadline && i.Title == "Notenschluss");
|
||||||
|
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.SupportPlan && i.StudentId == student.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenCorrections_ZeigtBewertungsfortschrittJeKlausur()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var exam = new Exam
|
||||||
|
{ GroupId = group.Id, Title = "Klausur 1", Date = today.AddDays(-2), Status = ExamStatus.Conducted };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var ben = new Student { FirstName = "Ben", LastName = "B" };
|
||||||
|
var memberships = new FakeMemberships([
|
||||||
|
new GroupMembership { GroupId = group.Id, StudentId = anna.Id },
|
||||||
|
new GroupMembership { GroupId = group.Id, StudentId = ben.Id },
|
||||||
|
]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = anna.Id, Grade = "2" });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
exams: new FakeExams([exam]), results: results, memberships: memberships,
|
||||||
|
students: new FakeStudents([anna, ben]));
|
||||||
|
|
||||||
|
var correction = Assert.Single(vm.OpenCorrections);
|
||||||
|
Assert.Equal(1, correction.Completed);
|
||||||
|
Assert.Equal(2, correction.Total);
|
||||||
|
Assert.Equal(50, correction.Percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alerts_ErkenntDeutlichenNotenabfall()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c", GradingSystem = GradingSystem.Grades1To6 };
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var memberships = new FakeMemberships([
|
||||||
|
new GroupMembership { GroupId = group.Id, StudentId = student.Id },
|
||||||
|
]);
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-20), Value = "2" });
|
||||||
|
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-15), Value = "2" });
|
||||||
|
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-10), Value = "4" });
|
||||||
|
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-5), Value = "4" });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
|
||||||
|
memberships: memberships, students: new FakeStudents([student]));
|
||||||
|
|
||||||
|
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kalenderauswahl_ZeigtTermineDesGeklicktenTages()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var examDate = today.AddDays(2);
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
exams: new FakeExams([new Exam { GroupId = group.Id, Title = "Test", Date = examDate }]));
|
||||||
|
var day = vm.CalendarDays.Single(d => d.Date == examDate);
|
||||||
|
|
||||||
|
vm.SelectCalendarDayCommand.Execute(day);
|
||||||
|
|
||||||
|
Assert.True(day.IsSelected);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var settings = NewDashboardSettings();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, dashboardSettings: settings);
|
||||||
|
vm.TodayCard.IsVisible = false;
|
||||||
|
vm.MoveCardDownCommand.Execute(vm.TasksCard);
|
||||||
|
|
||||||
|
var saved = settings.Load();
|
||||||
|
|
||||||
|
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
|
||||||
|
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
// "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
|
||||||
|
// Vorgriff fällt und den Test verfälscht.
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||||
|
|
||||||
|
var item = Assert.Single(vm.UnplannedLessons);
|
||||||
|
Assert.Equal(group.Id, item.GroupId);
|
||||||
|
Assert.Equal(1, item.PeriodNumber);
|
||||||
|
Assert.Equal("Heute", item.DateDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
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 = "Redoxreaktionen" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_Feiertag_WirdUebersprungen()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||||
|
var schoolHolidays = new FakeSchoolHolidays();
|
||||||
|
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.UnplannedLessons.Count);
|
||||||
|
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
|
||||||
|
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
|
||||||
|
var substitutions = new FakeSubstitutionEntries();
|
||||||
|
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
|
||||||
|
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
|
||||||
|
|
||||||
|
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);
|
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]
|
[Fact]
|
||||||
public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
|
public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
|
||||||
{
|
{
|
||||||
@@ -187,6 +205,60 @@ public sealed class DocumentationDialogViewModelTests
|
|||||||
Assert.NotEqual("", vm.AttachmentError);
|
Assert.NotEqual("", vm.AttachmentError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OhneStudentOptions_CanPickStudentIstFalse()
|
||||||
|
{
|
||||||
|
var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage());
|
||||||
|
|
||||||
|
Assert.False(vm.CanPickStudent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MitStudentOptions_OhneAuswahl_SaveSetztFehlerUndSpeichertNicht()
|
||||||
|
{
|
||||||
|
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
|
||||||
|
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(), [anna])
|
||||||
|
{
|
||||||
|
Title = "Vorfall", TypeName = "Vorkommnis",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Null(vm.Result);
|
||||||
|
Assert.NotEqual("", vm.StudentError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
|
||||||
|
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
|
||||||
|
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
|
||||||
|
[anna, ben], groupId)
|
||||||
|
{
|
||||||
|
Title = "Vorfall", TypeName = "Vorkommnis", SelectedStudent = ben,
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(ben.Id, vm.Result!.StudentId);
|
||||||
|
Assert.Equal(groupId, vm.Result.GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
||||||
|
{
|
||||||
|
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
|
||||||
|
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
|
||||||
|
var existing = new Documentation { StudentId = ben.Id, Title = "Alt" };
|
||||||
|
|
||||||
|
var vm = new DocumentationDialogViewModel(ben.Id, existing, new FakeAttachmentStorage(), [anna, ben]);
|
||||||
|
|
||||||
|
Assert.Equal(ben, vm.SelectedStudent);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge()
|
public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using System.Text;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class CsvBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AddRow_MaskiertTrennzeichen_AnfuehrungszeichenUndZeilenumbrueche()
|
||||||
|
{
|
||||||
|
var csv = new CsvBuilder()
|
||||||
|
.AddRow("Normal", "A;B", "Zitat \"wichtig\"", "Zeile 1\nZeile 2")
|
||||||
|
.ToString();
|
||||||
|
|
||||||
|
Assert.Contains("Normal;\"A;B\"", csv);
|
||||||
|
Assert.Contains("\"Zitat \"\"wichtig\"\"\"", csv);
|
||||||
|
Assert.Contains("\"Zeile 1\nZeile 2\"", csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CsvExportfile_BereinigtDateinameUndErgaenztEndungUndUtf8Bom()
|
||||||
|
{
|
||||||
|
var export = ExportFile.Csv("Export", "Auswertung/Chemie", "Änderung");
|
||||||
|
var bytes = export.Content.ToArray();
|
||||||
|
var preamble = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetPreamble();
|
||||||
|
|
||||||
|
Assert.Equal("Auswertung_Chemie.csv", export.SuggestedFileName);
|
||||||
|
Assert.Equal(preamble, bytes[..preamble.Length]);
|
||||||
|
Assert.Equal("Änderung", Encoding.UTF8.GetString(bytes[preamble.Length..]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Crypto;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
@@ -26,6 +30,83 @@ public static class TestSupport
|
|||||||
public static AiPlanningService BuildAiPlanningService() => new(
|
public static AiPlanningService BuildAiPlanningService() => new(
|
||||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
|
||||||
|
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||||
|
public static WebUntisSettingsService BuildWebUntisSettingsService()
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettings-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new WebUntisSettingsService(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analog zu 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()
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettings-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new SyncSettingsService(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe
|
||||||
|
/// BuildAiPlanningService).
|
||||||
|
public static SyncAuthService BuildSyncAuthService() => new(new HttpClient());
|
||||||
|
|
||||||
|
/// Eigenes Temp-Verzeichnis je Aufruf (echte, dateibasierte LiteDB wie bei EventQueue üblich).
|
||||||
|
public static EventQueue BuildEventQueue()
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-eventqueue-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new EventQueue(Path.Combine(tempPath, "queue.db"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||||
|
public static PeriodScheduleService BuildPeriodScheduleService()
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new PeriodScheduleService(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||||
|
public static AppLogger BuildAppLogger()
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-applogger-tests-{Guid.NewGuid():N}");
|
||||||
|
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
|
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||||
@@ -72,14 +153,29 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
|
|||||||
_all.Add(plan);
|
_all.Add(plan);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id);
|
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
|
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
|
||||||
{
|
{
|
||||||
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
||||||
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||||
public void Save(ParticipationSession session) { }
|
public void Save(ParticipationSession session)
|
||||||
public void Delete(Guid id) { }
|
{
|
||||||
|
all.RemoveAll(s => s.Id == session.Id);
|
||||||
|
all.Add(session);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeEntries : IParticipationRepository
|
public class FakeEntries : IParticipationRepository
|
||||||
@@ -135,6 +231,7 @@ public class FakeGrades : IGradeRepository
|
|||||||
_all.Add(grade);
|
_all.Add(grade);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
|
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
|
public class FakeExams(List<Exam> all) : IExamRepository
|
||||||
@@ -172,6 +269,7 @@ public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
|
|||||||
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id);
|
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 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 Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
|
||||||
|
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeSchemes : IGradingSchemeRepository
|
public class FakeSchemes : IGradingSchemeRepository
|
||||||
@@ -340,18 +438,102 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository
|
|||||||
public void Add(SubstitutionEntry e) => _all.Add(e);
|
public void Add(SubstitutionEntry e) => _all.Add(e);
|
||||||
public List<SubstitutionEntry> GetAll() => _all.ToList();
|
public List<SubstitutionEntry> GetAll() => _all.ToList();
|
||||||
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
|
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
|
||||||
|
public SubstitutionEntry? GetByExternalId(string externalId) => _all.FirstOrDefault(e => e.ExternalId == externalId);
|
||||||
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
|
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
|
||||||
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class FakeUntisSnapshots : IUntisSnapshotRepository
|
||||||
|
{
|
||||||
|
private readonly List<UntisSnapshotEntry> _all = [];
|
||||||
|
public void Add(UntisSnapshotEntry e) => _all.Add(e);
|
||||||
|
public List<UntisSnapshotEntry> GetAll() => _all.ToList();
|
||||||
|
public void Save(UntisSnapshotEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
|
||||||
|
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FakeUntisSlotMappings : IUntisSlotMappingRepository
|
||||||
|
{
|
||||||
|
private readonly List<UntisSlotMapping> _all = [];
|
||||||
|
public void Add(UntisSlotMapping m) => _all.Add(m);
|
||||||
|
public List<UntisSlotMapping> GetAll() => _all.ToList();
|
||||||
|
public void Save(UntisSlotMapping mapping) { _all.RemoveAll(m => m.Id == mapping.Id); _all.Add(mapping); }
|
||||||
|
public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class 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
|
public class FakeWorkTasks : IWorkTaskRepository
|
||||||
{
|
{
|
||||||
private readonly List<WorkTask> _all = [];
|
private readonly List<WorkTask> _all = [];
|
||||||
public void Add(WorkTask t) => _all.Add(t);
|
public void Add(WorkTask t) => _all.Add(t);
|
||||||
public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList();
|
public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList();
|
||||||
public List<WorkTask> GetAll() => _all.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 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 Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
|
||||||
|
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeTimeEntries : ITimeEntryRepository
|
public class FakeTimeEntries : ITimeEntryRepository
|
||||||
@@ -364,6 +546,7 @@ public class FakeTimeEntries : ITimeEntryRepository
|
|||||||
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList();
|
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 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 Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||||
|
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeReportGrades : IReportGradeRepository
|
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]
|
[Fact]
|
||||||
public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer()
|
public void Save_Doppelstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
|
||||||
{
|
{
|
||||||
var groupId = Guid.NewGuid();
|
var groupId = Guid.NewGuid();
|
||||||
var unitId = Guid.NewGuid();
|
var unitId = Guid.NewGuid();
|
||||||
@@ -98,9 +98,50 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
|
|||||||
|
|
||||||
vm.SaveCommand.Execute(null);
|
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);
|
Assert.Equal(2, vm.Result!.Created);
|
||||||
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList();
|
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]
|
[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,14 +18,18 @@ public sealed class GroupDetailViewModelTests
|
|||||||
var grades = new FakeGrades();
|
var grades = new FakeGrades();
|
||||||
|
|
||||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
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(),
|
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||||
students, memberships, groups, new FakeCompetencyDomains()),
|
students, memberships, groups, new FakeCompetencyDomains()),
|
||||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||||
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
|
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 CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
|
||||||
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
|
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
|
||||||
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships));
|
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
|
||||||
|
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
|
||||||
|
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
|
||||||
|
|
||||||
vm.LoadGroup(group.Id);
|
vm.LoadGroup(group.Id);
|
||||||
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Tests für den bisher als Platzhalter existierenden Gruppen-Tab "Dokumentation" (Nutzer-Feedback):
|
||||||
|
/// listet Dokumentationseinträge aller Gruppen-Schüler, zeigt Einträge aus anderen Lerngruppen
|
||||||
|
/// standardmäßig mit an (optisch abgesetzt statt versteckt), mit optionalem Filter darauf.
|
||||||
|
public sealed class GroupDocumentationTabViewModelTests
|
||||||
|
{
|
||||||
|
private static GroupDocumentationTabViewModel BuildVm(
|
||||||
|
List<Student> students, List<LearningGroup> groups, FakeDocumentation? docs = null)
|
||||||
|
{
|
||||||
|
var vm = new GroupDocumentationTabViewModel(docs ?? new FakeDocumentation(),
|
||||||
|
new FakeStudents(students), new FakeGroups(groups));
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Initialize_ZeigtEintraegeAllerGruppenschuelerSortiertNachDatumAbsteigend()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Alt", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Neu", Date = new DateOnly(2025, 9, 10) });
|
||||||
|
|
||||||
|
var vm = BuildVm([anna, ben], [group], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.Entries.Count);
|
||||||
|
Assert.Equal("Neu", vm.Entries[0].Model.Title);
|
||||||
|
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Aus anderem Kurs", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
|
||||||
|
var vm = BuildVm([anna], [group, otherGroup], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.Entries);
|
||||||
|
Assert.False(entry.IsOwnGroup);
|
||||||
|
Assert.Equal("Mathematik 9b", entry.OtherGroupLabel);
|
||||||
|
Assert.True(entry.ContentOpacity < 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Initialize_EintragOhneGruppe_GiltAlsEigen()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation { StudentId = anna.Id, GroupId = null, Title = "Allgemein", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
|
||||||
|
var vm = BuildVm([anna], [group], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.Entries);
|
||||||
|
Assert.True(entry.IsOwnGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnlyThisGroup_Aktiviert_BlendetEintraegeAusAnderenGruppenAus()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Eigen", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Fremd", Date = new DateOnly(2025, 9, 2) });
|
||||||
|
|
||||||
|
var vm = BuildVm([anna], [group, otherGroup], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
vm.OnlyThisGroup = true;
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.Entries);
|
||||||
|
Assert.Equal("Eigen", entry.Model.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedStudentFilter_AufEinzelnenSchueler_FiltertListe()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Anna-Eintrag", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Ben-Eintrag", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
|
||||||
|
var vm = BuildVm([anna, ben], [group], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
vm.SelectedStudentFilter = vm.StudentFilterOptions.Single(s => s.Id == anna.Id);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.Entries);
|
||||||
|
Assert.Equal("Anna-Eintrag", entry.Model.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddDocumentation_SpeichertErgebnisUndLaedtNeu()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
var vm = BuildVm([anna], [group], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
|
||||||
|
vm.OnEditDocumentation = (groupId, options, editing) =>
|
||||||
|
{
|
||||||
|
Assert.Equal(group.Id, groupId);
|
||||||
|
Assert.Contains(options, o => o.Id == anna.Id);
|
||||||
|
Assert.Null(editing);
|
||||||
|
return Task.FromResult<Documentation?>(new Documentation
|
||||||
|
{ StudentId = anna.Id, GroupId = groupId, Title = "Neu", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
};
|
||||||
|
|
||||||
|
await vm.AddDocumentationCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.Entries);
|
||||||
|
Assert.Equal("Neu", entry.Model.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteDocumentation_NachBestaetigung_EntferntEintragAusListe()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Weg", Date = new DateOnly(2025, 9, 1) });
|
||||||
|
var vm = BuildVm([anna], [group], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
vm.OnConfirmDeleteDocumentation = _ => Task.FromResult(true);
|
||||||
|
|
||||||
|
await vm.DeleteDocumentationCommand.ExecuteAsync(vm.Entries[0]);
|
||||||
|
|
||||||
|
Assert.Empty(vm.Entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
@@ -18,7 +19,7 @@ public sealed class LessonDialogViewModelTests
|
|||||||
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null,
|
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null,
|
||||||
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) =>
|
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) =>
|
||||||
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new FakeAttachmentStorage(),
|
||||||
unitId, groupId, "10c", "Chemie", [], [], editing);
|
unitId, groupId, "10c", "Chemie", [], [], editing);
|
||||||
|
|
||||||
/// Nutzer-Feedback: bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere
|
/// 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 codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
|
||||||
var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]),
|
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
|
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);
|
Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
|
||||||
@@ -155,7 +156,7 @@ public sealed class LessonDialogViewModelTests
|
|||||||
var groupId = Guid.NewGuid();
|
var groupId = Guid.NewGuid();
|
||||||
var lessons = new FakeLessons();
|
var lessons = new FakeLessons();
|
||||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
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",
|
Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45",
|
||||||
};
|
};
|
||||||
@@ -179,6 +180,111 @@ public sealed class LessonDialogViewModelTests
|
|||||||
Assert.Single(lessons.GetByUnit(unitId));
|
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()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
|
||||||
|
Assert.False(vm.HasHomeworkText);
|
||||||
|
|
||||||
|
vm.Homework = " ";
|
||||||
|
Assert.False(vm.HasHomeworkText);
|
||||||
|
|
||||||
|
vm.Homework = "S. 42";
|
||||||
|
Assert.True(vm.HasHomeworkText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_UebernimmtHomeworkCheckedUndDismissed()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
|
||||||
|
vm.Topic = "Brechung"; vm.DateText = "01.09.2025";
|
||||||
|
vm.Homework = "S. 42"; vm.HomeworkChecked = true;
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.Result!.HomeworkChecked);
|
||||||
|
Assert.False(vm.Result.HomeworkCheckDismissed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HomeworkChecked_UndHomeworkCheckDismissed_SchliessenSichGegenseitigAus()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
|
||||||
|
|
||||||
|
vm.HomeworkChecked = true;
|
||||||
|
Assert.False(vm.HomeworkCheckDismissed);
|
||||||
|
|
||||||
|
vm.HomeworkCheckDismissed = true;
|
||||||
|
Assert.False(vm.HomeworkChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bearbeiten_UebernimmtGespeicherteHomeworkFlags()
|
||||||
|
{
|
||||||
|
var editing = new Lesson
|
||||||
|
{
|
||||||
|
Topic = "Alt", Date = new DateOnly(2025, 9, 1), Homework = "S. 42",
|
||||||
|
HomeworkCheckDismissed = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing);
|
||||||
|
|
||||||
|
Assert.True(vm.HomeworkCheckDismissed);
|
||||||
|
Assert.False(vm.HomeworkChecked);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HasAlternativePath_AngehaktOeffnetZuweisungUndUebernimmtErgebnis()
|
public void HasAlternativePath_AngehaktOeffnetZuweisungUndUebernimmtErgebnis()
|
||||||
{
|
{
|
||||||
@@ -252,7 +358,7 @@ public sealed class LessonDialogViewModelTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths,
|
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.True(vm.Phases[0].HasAlternativePath);
|
||||||
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName);
|
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName);
|
||||||
@@ -388,4 +494,91 @@ public sealed class LessonDialogViewModelTests
|
|||||||
|
|
||||||
Assert.False(vm.HasTimeBudgetInfo);
|
Assert.False(vm.HasTimeBudgetInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf
|
||||||
|
// "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode
|
||||||
|
// zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer
|
||||||
|
|
||||||
|
Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor()
|
||||||
|
{
|
||||||
|
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 = 3 });
|
||||||
|
|
||||||
|
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||||
|
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]
|
||||||
|
public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 });
|
||||||
|
var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
|
||||||
|
|
||||||
|
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||||
|
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[0].Phases[0].TimeDisplay);
|
||||||
Assert.Equal("ab 12:00", vm.PhaseGroups[1].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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ParticipationSessionEditingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Bearbeitungsdialog_AendertBestehendeSessionOhneVerknuepfungenZuErsetzen()
|
||||||
|
{
|
||||||
|
var session = new ParticipationSession
|
||||||
|
{
|
||||||
|
GroupId = Guid.NewGuid(),
|
||||||
|
Date = new DateOnly(2026, 8, 19),
|
||||||
|
Comment = "Sitzplan",
|
||||||
|
LessonId = Guid.NewGuid(),
|
||||||
|
CompetencyCodes = ["K1"],
|
||||||
|
};
|
||||||
|
var vm = new AddSessionDialogViewModel(session)
|
||||||
|
{
|
||||||
|
DateText = "20.08.2026",
|
||||||
|
Comment = "Elektrische Stromkreise",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Same(session, vm.Result);
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 20), session.Date);
|
||||||
|
Assert.Equal("Elektrische Stromkreise", session.Comment);
|
||||||
|
Assert.NotNull(session.LessonId);
|
||||||
|
Assert.Equal(["K1"], session.CompetencyCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BearbeitenBefehl_SpeichertSessionUndAktualisiertAnzeige()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
|
||||||
|
var session = new ParticipationSession
|
||||||
|
{
|
||||||
|
GroupId = group.Id,
|
||||||
|
Date = new DateOnly(2026, 8, 19),
|
||||||
|
Comment = "Sitzplan",
|
||||||
|
};
|
||||||
|
var sessions = new FakeSessions([session]);
|
||||||
|
var vm = new ParticipationTabViewModel(
|
||||||
|
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
|
||||||
|
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
|
||||||
|
vm.OnEditSession = existing =>
|
||||||
|
{
|
||||||
|
existing.Comment = "Nachbesprechung Elektrizität";
|
||||||
|
return Task.FromResult<ParticipationSession?>(existing);
|
||||||
|
};
|
||||||
|
vm.Initialize(group.Id, group.SchoolYear);
|
||||||
|
|
||||||
|
await vm.EditSessionCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(session.Id, vm.SelectedSession?.Id);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class PlanningExchangeServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnitRoundtrip_ErzeugtNeueObjekteUndErhaeltPlanungsdaten()
|
||||||
|
{
|
||||||
|
var units = new FakeUnits();
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var shortPath = new AlternativeLessonPath { Name = "Kurzversion" };
|
||||||
|
var paths = new FakeAlternativeLessonPaths([shortPath]);
|
||||||
|
var service = new PlanningExchangeService(units, lessons, paths);
|
||||||
|
var sourceGroupId = Guid.NewGuid();
|
||||||
|
var targetGroupId = Guid.NewGuid();
|
||||||
|
var unit = new Unit
|
||||||
|
{
|
||||||
|
GroupId = sourceGroupId,
|
||||||
|
Title = "Optik",
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2026, 10, 1),
|
||||||
|
Competencies = ["UF1", "E4"],
|
||||||
|
Status = UnitStatus.Active,
|
||||||
|
Notes = "Experimente",
|
||||||
|
};
|
||||||
|
units.Add(unit);
|
||||||
|
var sourceLesson = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = unit.Id,
|
||||||
|
GroupId = sourceGroupId,
|
||||||
|
Date = new DateOnly(2026, 9, 8),
|
||||||
|
LessonNumber = 3,
|
||||||
|
Topic = "Reflexion",
|
||||||
|
StartTime = new TimeOnly(9, 50),
|
||||||
|
Status = LessonStatus.Planned,
|
||||||
|
Homework = "Aufgabe 2",
|
||||||
|
Phases =
|
||||||
|
[
|
||||||
|
new LessonPhaseStep
|
||||||
|
{
|
||||||
|
Name = "Experiment", DurationMinutes = 25, Activity = "Winkel messen",
|
||||||
|
Material = "Optikbox", Shorthand = "PA", AlternativePathId = shortPath.Id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
lessons.Add(sourceLesson);
|
||||||
|
|
||||||
|
var json = service.ExportUnit(unit, new PlanningExchangeContext
|
||||||
|
{
|
||||||
|
Group = "8a", Subject = "Physik", GradeLevel = 8,
|
||||||
|
});
|
||||||
|
var result = service.ImportUnit(json, targetGroupId);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(unit.Id.ToString(), json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain(sourceLesson.Id.ToString(), json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.NotEqual(unit.Id, result.Unit.Id);
|
||||||
|
Assert.Equal(targetGroupId, result.Unit.GroupId);
|
||||||
|
Assert.Equal("Optik", result.Unit.Title);
|
||||||
|
Assert.Equal(UnitStatus.Active, result.Unit.Status);
|
||||||
|
Assert.Equal(["UF1", "E4"], result.Unit.Competencies);
|
||||||
|
var importedLesson = Assert.Single(result.Lessons);
|
||||||
|
Assert.NotEqual(sourceLesson.Id, importedLesson.Id);
|
||||||
|
Assert.Equal(result.Unit.Id, importedLesson.UnitId);
|
||||||
|
Assert.Equal(targetGroupId, importedLesson.GroupId);
|
||||||
|
Assert.Equal(new TimeOnly(9, 50), importedLesson.StartTime);
|
||||||
|
Assert.Equal(shortPath.Id, Assert.Single(importedLesson.Phases).AlternativePathId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LessonImport_AkzeptiertMarkdownCodeblockUndLegtNeueStundeAn()
|
||||||
|
{
|
||||||
|
var units = new FakeUnits();
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var service = new PlanningExchangeService(units, lessons, new FakeAlternativeLessonPaths([]));
|
||||||
|
var source = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new DateOnly(2026, 9, 8),
|
||||||
|
Topic = "Reflexion", Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 }],
|
||||||
|
};
|
||||||
|
lessons.Add(source);
|
||||||
|
var json = service.ExportLesson(source, new PlanningExchangeContext());
|
||||||
|
var targetUnitId = Guid.NewGuid();
|
||||||
|
var targetGroupId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var imported = service.ImportLesson($"```json\n{json}\n```", targetUnitId, targetGroupId);
|
||||||
|
|
||||||
|
Assert.NotEqual(source.Id, imported.Id);
|
||||||
|
Assert.Equal(targetUnitId, imported.UnitId);
|
||||||
|
Assert.Equal(targetGroupId, imported.GroupId);
|
||||||
|
Assert.Equal("Reflexion", imported.Topic);
|
||||||
|
Assert.Equal(10, Assert.Single(imported.Phases).DurationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImportMitFalschemSchema_SchreibtKeineDaten()
|
||||||
|
{
|
||||||
|
var units = new FakeUnits();
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var service = new PlanningExchangeService(units, lessons, new FakeAlternativeLessonPaths([]));
|
||||||
|
const string json = """
|
||||||
|
{ "schema": "anderes.format", "version": 1, "unit": { "title": "Optik" } }
|
||||||
|
""";
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var error = Assert.Throws<PlanningExchangeException>(() => service.ImportUnit(json, groupId));
|
||||||
|
|
||||||
|
Assert.Contains("Falsches Format", error.Message);
|
||||||
|
Assert.Empty(units.GetByGroup(groupId));
|
||||||
|
Assert.Empty(lessons.GetByGroupAndRange(groupId, DateOnly.MinValue, DateOnly.MaxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportVerwendetDokumentiertesSchemaUndCamelCase()
|
||||||
|
{
|
||||||
|
var service = new PlanningExchangeService(new FakeUnits(), new FakeLessons(),
|
||||||
|
new FakeAlternativeLessonPaths([]));
|
||||||
|
var lesson = new Lesson { Date = new DateOnly(2026, 9, 8), Topic = "Optik" };
|
||||||
|
|
||||||
|
using var json = JsonDocument.Parse(service.ExportLesson(lesson, new PlanningExchangeContext()));
|
||||||
|
|
||||||
|
Assert.Equal(PlanningExchangeService.LessonSchema, json.RootElement.GetProperty("schema").GetString());
|
||||||
|
Assert.Equal(1, json.RootElement.GetProperty("version").GetInt32());
|
||||||
|
Assert.Equal("2026-09-08", json.RootElement.GetProperty("lesson").GetProperty("date").GetString());
|
||||||
|
Assert.Equal("planned", json.RootElement.GetProperty("lesson").GetProperty("status").GetString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public class PlanningTabViewModelTests
|
|||||||
var lessons = new FakeLessons();
|
var lessons = new FakeLessons();
|
||||||
|
|
||||||
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
|
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
|
||||||
TestSupport.BuildAiSettingsService());
|
TestSupport.BuildAiSettingsService(), new FakeSessions([]));
|
||||||
vm.Initialize(groupId);
|
vm.Initialize(groupId);
|
||||||
|
|
||||||
return (vm, units, lessons, groupId);
|
return (vm, units, lessons, groupId);
|
||||||
@@ -227,4 +227,71 @@ public class PlanningTabViewModelTests
|
|||||||
|
|
||||||
Assert.Equal(["L->AB01", "Plenum"], vm.KnownShorthands);
|
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,104 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class QuickInputViewModelTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AttendanceStatus.Late, false, "Verspätet", "V")]
|
||||||
|
[InlineData(AttendanceStatus.SignificantlyLate, false, "Erheblich verspätet", "V!")]
|
||||||
|
[InlineData(AttendanceStatus.LeftDuringClass, true, "Während des Unterrichts abgängig", "A")]
|
||||||
|
[InlineData(AttendanceStatus.LearningIsland, true, "Lerninsel", "L")]
|
||||||
|
[InlineData(AttendanceStatus.Suspended, true, "Suspendiert", "S")]
|
||||||
|
public void ZusaetzlicheAnwesenheitsstatus_HabenAnzeigeUndPassendeAbwesenheitswertung(
|
||||||
|
AttendanceStatus status, bool isAbsent, string label, string symbol)
|
||||||
|
{
|
||||||
|
var row = new ParticipationStudentRow(Guid.NewGuid(), "Anna",
|
||||||
|
new ParticipationEntry { Attendance = status }, [], []);
|
||||||
|
|
||||||
|
Assert.Equal(isAbsent, row.IsAbsent);
|
||||||
|
Assert.Equal(label, row.AttendanceTooltip);
|
||||||
|
Assert.Equal(symbol, row.AttendanceLabel);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(AttendanceDisplay.Color(status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZurueckZuVorherigemSchueler_ZeigtBereitsGesetzteBewertung()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "mitarbeit", Label = "Mitarbeit" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
vm.SetRatingByNumber(4); // Scale5, 4. Stufe -> Rohwert 1 ("+"), Anna bewerten
|
||||||
|
vm.NextStudent(); // zu Ben
|
||||||
|
vm.PreviousStudent(); // zurück zu Anna
|
||||||
|
|
||||||
|
Assert.Equal(1, vm.AspectRows[0].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnwesenderSchueler_CurrentStudentIsAbsent_IstFalse()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "a", Label = "A" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry { Attendance = AttendanceStatus.Present }, aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
Assert.False(vm.CurrentStudentIsAbsent);
|
||||||
|
Assert.Equal(1.0, vm.CurrentStudentContentOpacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbwesenderSchueler_CurrentStudentIsAbsent_IstTrueUndGedimmt()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "a", Label = "A" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
new(Guid.NewGuid(), "Ben", new ParticipationEntry { Attendance = AttendanceStatus.Unexcused }, aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
vm.NextStudent(); // zu Ben, unentschuldigt abwesend
|
||||||
|
|
||||||
|
Assert.True(vm.CurrentStudentIsAbsent);
|
||||||
|
Assert.Equal("Krank, unentschuldigt", vm.CurrentStudentAttendanceLabel);
|
||||||
|
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "a", Label = "A" }),
|
||||||
|
new(new ParticipationAspect { Key = "b", Label = "B" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
vm.PreviousAspect(); // von Index 0 rückwärts -> letzter Aspekt (Umlauf)
|
||||||
|
|
||||||
|
Assert.Equal(1, vm.AspectIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,15 @@ public class ReportGradeCalculationTests
|
|||||||
|
|
||||||
private static ReportGradeDialogViewModel BuildViewModel(
|
private static ReportGradeDialogViewModel BuildViewModel(
|
||||||
FakeGrades grades, FakeSchemes schemes, FakeReportGrades reportGrades,
|
FakeGrades grades, FakeSchemes schemes, FakeReportGrades reportGrades,
|
||||||
FakeExams exams, FakeResults results)
|
FakeExams exams, FakeResults results, FakeSessions? sessions = null, FakeEntries? entries = null)
|
||||||
{
|
{
|
||||||
var students = new FakeStudents([Anna, Ben]);
|
var students = new FakeStudents([Anna, Ben]);
|
||||||
var memberships = new FakeMemberships([]);
|
var memberships = new FakeMemberships([]);
|
||||||
var grading = new GradingService();
|
var grading = new GradingService();
|
||||||
|
|
||||||
return new ReportGradeDialogViewModel(grades, exams, results, students, memberships,
|
return new ReportGradeDialogViewModel(grades, exams, results, students, memberships,
|
||||||
schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
|
schemes, reportGrades, sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||||
|
new AttendanceBalanceService(), grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
|
||||||
"Testgruppe", "2025/26");
|
"Testgruppe", "2025/26");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +69,28 @@ public class ReportGradeCalculationTests
|
|||||||
Assert.Equal("–", ben.CalculatedDisplay);
|
Assert.Equal("–", ben.CalculatedDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbsenceRatePercent_BerechnetFehlquoteAusSitzungenDieserGruppeImZeitraum()
|
||||||
|
{
|
||||||
|
var (_, _, exams, results) = BuildExams();
|
||||||
|
var session1 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 5) };
|
||||||
|
var session2 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 12) };
|
||||||
|
var sessions = new FakeSessions([session1, session2]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
entries.Add(new ParticipationEntry { SessionId = session1.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Present });
|
||||||
|
entries.Add(new ParticipationEntry { SessionId = session2.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Unexcused });
|
||||||
|
|
||||||
|
var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), new FakeReportGrades(), exams, results,
|
||||||
|
sessions, entries);
|
||||||
|
|
||||||
|
var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
|
||||||
|
Assert.Equal(50.0, anna.AbsenceRatePercent);
|
||||||
|
Assert.True(anna.HasHighAbsenceRate);
|
||||||
|
var ben = vm.Rows.Single(r => r.StudentId == Ben.Id);
|
||||||
|
Assert.Equal(0.0, ben.AbsenceRatePercent);
|
||||||
|
Assert.False(ben.HasHighAbsenceRate);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung()
|
public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,41 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.Views.Groups;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
public sealed class SeatingPlanViewModelTests
|
public sealed class SeatingPlanViewModelTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TafelpositionWirdAusPlanGeladenUndBeimBearbeitenGespeichert()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
Name = "Standard",
|
||||||
|
Rows = 1,
|
||||||
|
Columns = 1,
|
||||||
|
IsBoardAtBottom = true,
|
||||||
|
};
|
||||||
|
var plans = new FakeSeatingPlans([plan]);
|
||||||
|
var tabVm = new SeatingPlanTabViewModel(
|
||||||
|
plans, new FakeStudents([]), new FakeMemberships([]),
|
||||||
|
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||||
|
|
||||||
|
tabVm.Initialize(groupId, isReadOnly: false);
|
||||||
|
|
||||||
|
Assert.True(tabVm.IsBoardAtBottom);
|
||||||
|
Assert.False(tabVm.IsBoardAtTop);
|
||||||
|
|
||||||
|
var dialogVm = tabVm.CreateDialogViewModel(plan);
|
||||||
|
dialogVm.IsBoardAtBottom = false;
|
||||||
|
dialogVm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.False(plans.GetById(plan.Id)!.IsBoardAtBottom);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuswahlEinesBereitsZugeordnetenSchuelers_VerschiebtIhnAufDenNeuenPlatz()
|
public void AuswahlEinesBereitsZugeordnetenSchuelers_VerschiebtIhnAufDenNeuenPlatz()
|
||||||
{
|
{
|
||||||
@@ -24,8 +54,10 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
|
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
|
||||||
};
|
};
|
||||||
var plans = new FakeSeatingPlans([plan]);
|
var plans = new FakeSeatingPlans([plan]);
|
||||||
var vm = new SeatingPlanTabViewModel(plans, students, memberships);
|
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
|
||||||
|
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||||
vm.Initialize(groupId, isReadOnly: false);
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
vm.IsEditMode = true;
|
||||||
|
|
||||||
vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id);
|
vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id);
|
||||||
|
|
||||||
@@ -40,7 +72,8 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
var groupId = Guid.NewGuid();
|
var groupId = Guid.NewGuid();
|
||||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||||
var vm = new SeatingPlanTabViewModel(
|
var vm = new SeatingPlanTabViewModel(
|
||||||
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]));
|
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
|
||||||
|
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||||
|
|
||||||
vm.Initialize(groupId, isReadOnly: true);
|
vm.Initialize(groupId, isReadOnly: true);
|
||||||
|
|
||||||
@@ -48,4 +81,574 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.False(vm.Seats.Single().CanEdit);
|
Assert.False(vm.Seats.Single().CanEdit);
|
||||||
Assert.False(vm.AddPlanCommand.CanExecute(null));
|
Assert.False(vm.AddPlanCommand.CanExecute(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DragDrop_ZwischenBelegtenPlaetzen_TauschtDieSchueler()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var ben = new Student { FirstName = "Ben", LastName = "B" };
|
||||||
|
var students = new FakeStudents([anna, ben]);
|
||||||
|
var memberships = new FakeMemberships([
|
||||||
|
new GroupMembership { GroupId = groupId, StudentId = anna.Id },
|
||||||
|
new GroupMembership { GroupId = groupId, StudentId = ben.Id },
|
||||||
|
]);
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
|
||||||
|
Assignments =
|
||||||
|
[
|
||||||
|
new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id },
|
||||||
|
new SeatAssignment { Row = 0, Column = 1, StudentId = ben.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.MoveSeat(vm.Seats[0], vm.Seats[1]);
|
||||||
|
|
||||||
|
Assert.Equal(ben.Id, vm.Seats[0].SelectedOption.StudentId);
|
||||||
|
Assert.Equal(anna.Id, vm.Seats[1].SelectedOption.StudentId);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
|
||||||
|
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var vm = new SeatingPlanTabViewModel(
|
||||||
|
new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
|
||||||
|
vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
|
||||||
|
|
||||||
|
Assert.False(vm.IsEditMode);
|
||||||
|
Assert.False(vm.CanEditLayout);
|
||||||
|
Assert.False(vm.EditPlanCommand.CanExecute(null));
|
||||||
|
Assert.Equal(student.Id, vm.Seats[0].SelectedOption.StudentId);
|
||||||
|
Assert.Null(vm.Seats[1].SelectedOption.StudentId);
|
||||||
|
|
||||||
|
vm.IsEditMode = true;
|
||||||
|
|
||||||
|
Assert.True(vm.CanEditLayout);
|
||||||
|
Assert.True(vm.EditPlanCommand.CanExecute(null));
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 2, Columns = 4,
|
||||||
|
ColumnGapWidths = [0, 80, 20],
|
||||||
|
};
|
||||||
|
var plans = new FakeSeatingPlans([plan]);
|
||||||
|
var vm = new SeatingPlanDialogViewModel(plans, groupId, plan);
|
||||||
|
|
||||||
|
Assert.Equal([0m, 80m, 20m], vm.ColumnGaps.Select(g => g.Width));
|
||||||
|
vm.ColumnGaps[0].Width = 40;
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal([40d, 80d, 20d], plans.GetById(plan.Id)!.ColumnGapWidths);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SitzplanPanel_FuegtAbstandNurAnKonfigurierterSpaltengrenzeEin()
|
||||||
|
{
|
||||||
|
var panel = new SeatingPlanPanel
|
||||||
|
{
|
||||||
|
Columns = 3,
|
||||||
|
ColumnGapWidths = [0, 70],
|
||||||
|
};
|
||||||
|
for (var i = 0; i < 6; i++)
|
||||||
|
panel.Children.Add(new Avalonia.Controls.Border { Width = 100, Height = 50 });
|
||||||
|
|
||||||
|
panel.Measure(Avalonia.Size.Infinity);
|
||||||
|
panel.Arrange(new Avalonia.Rect(panel.DesiredSize));
|
||||||
|
|
||||||
|
Assert.Equal(370, panel.DesiredSize.Width);
|
||||||
|
Assert.Equal(100, panel.DesiredSize.Height);
|
||||||
|
Assert.Equal(0, panel.Children[0].Bounds.X);
|
||||||
|
Assert.Equal(100, panel.Children[1].Bounds.X);
|
||||||
|
Assert.Equal(270, panel.Children[2].Bounds.X);
|
||||||
|
Assert.Equal(270, panel.Children[5].Bounds.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SitzplatzBewertung_ErstelltHeutigeSitzungUndSpeichertAlleDreiBereiche()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||||
|
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||||
|
|
||||||
|
vm.SetRatingByNumber(5);
|
||||||
|
vm.ApplyAttendanceShortcut(1, clear: false);
|
||||||
|
vm.ApplyHomeworkShortcut(7, clear: false);
|
||||||
|
|
||||||
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||||
|
Assert.Equal("Sitzplan", session.Comment);
|
||||||
|
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||||
|
Assert.Equal(2, entry.Ratings.Single(r => r.Key == "quality").Value);
|
||||||
|
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||||
|
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||||
|
Assert.True(entry.HomeworkMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SitzplatzBewertung_ArchiviertOhneSitzung_LegtKeineNeueSitzungAn()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
|
||||||
|
var vm = new SeatAssessmentViewModel(sessions, new FakeEntries(), new FakeAspects(),
|
||||||
|
groupId, Guid.NewGuid(), "Beispiel, Anna", canEdit: false);
|
||||||
|
vm.SetRatingByNumber(5);
|
||||||
|
|
||||||
|
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,7 +1,11 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
using System.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -10,7 +14,10 @@ public sealed class SettingsViewModelTests
|
|||||||
{
|
{
|
||||||
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
||||||
FakeSupervisionDuties? supervisionDuties = null,
|
FakeSupervisionDuties? supervisionDuties = null,
|
||||||
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null)
|
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = 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,
|
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||||
@@ -26,7 +33,188 @@ public sealed class SettingsViewModelTests
|
|||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||||
|
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]
|
||||||
|
public void SyncConflicts_ZeigtUnreviewedKonflikteBeimLaden()
|
||||||
|
{
|
||||||
|
var queue = TestSupport.BuildEventQueue();
|
||||||
|
var conflict = new ConflictEntry
|
||||||
|
{
|
||||||
|
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||||
|
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||||
|
Resolution = "RemoteWon",
|
||||||
|
};
|
||||||
|
queue.AddConflict(conflict);
|
||||||
|
|
||||||
|
var vm = BuildViewModel(eventQueue: queue);
|
||||||
|
|
||||||
|
var item = Assert.Single(vm.SyncConflicts);
|
||||||
|
Assert.Equal(conflict.Id, item.Id);
|
||||||
|
Assert.Contains("anderen Gerät", item.ResolutionDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkConflictReviewed_EntferntKonfliktAusListeUndAusDerQueue()
|
||||||
|
{
|
||||||
|
var queue = TestSupport.BuildEventQueue();
|
||||||
|
var conflict = new ConflictEntry
|
||||||
|
{
|
||||||
|
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||||
|
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||||
|
Resolution = "LocalWon",
|
||||||
|
};
|
||||||
|
queue.AddConflict(conflict);
|
||||||
|
var vm = BuildViewModel(eventQueue: queue);
|
||||||
|
var item = vm.SyncConflicts.Single();
|
||||||
|
|
||||||
|
vm.MarkConflictReviewedCommand.Execute(item);
|
||||||
|
|
||||||
|
Assert.Empty(vm.SyncConflicts);
|
||||||
|
Assert.Empty(queue.GetUnreviewed());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -87,6 +275,34 @@ public sealed class SettingsViewModelTests
|
|||||||
Assert.Empty(vm.SchoolHolidayEntries);
|
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]
|
[Fact]
|
||||||
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
|
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
|
||||||
{
|
{
|
||||||
@@ -102,7 +318,12 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
|
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||||
|
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||||
|
|
||||||
vm.SelectedStateName = "Bayern";
|
vm.SelectedStateName = "Bayern";
|
||||||
|
|
||||||
@@ -124,7 +345,12 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
|
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||||
|
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||||
|
|
||||||
vm.PeriodTimes[0].StartText = "08:00";
|
vm.PeriodTimes[0].StartText = "08:00";
|
||||||
vm.PeriodTimes[0].EndText = "08:45";
|
vm.PeriodTimes[0].EndText = "08:45";
|
||||||
@@ -150,7 +376,12 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
|
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
|
||||||
|
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
|
||||||
|
|
||||||
vm.PeriodTimes[0].StartText = "08:45";
|
vm.PeriodTimes[0].StartText = "08:45";
|
||||||
vm.PeriodTimes[0].EndText = "08:00";
|
vm.PeriodTimes[0].EndText = "08:00";
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using LehrerApp.Core.Importing;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class StudentImportDialogViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Vorschau_ZeigtZusammenfassungUndImportiertNeueSchueler()
|
||||||
|
{
|
||||||
|
var stored = new List<Student>();
|
||||||
|
var service = Service(stored,
|
||||||
|
[
|
||||||
|
new ImportedStudent
|
||||||
|
{
|
||||||
|
FirstName = "Max",
|
||||||
|
LastName = "Beispiel",
|
||||||
|
Gender = Gender.M,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
var preview = await service.AnalyzeAsync(File());
|
||||||
|
var vm = new StudentImportDialogViewModel(service, preview, "students.csv");
|
||||||
|
|
||||||
|
Assert.Contains("1 erkannt", vm.Summary);
|
||||||
|
Assert.Equal(1, vm.NewStudents);
|
||||||
|
Assert.True(vm.IsReadyWithoutConflicts);
|
||||||
|
|
||||||
|
Assert.True(await vm.TryApplyAsync());
|
||||||
|
Assert.Equal(1, vm.Result?.CreatedStudents);
|
||||||
|
Assert.Single(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Konflikt_VerwendetDieSichereStandardoptionUeberspringen()
|
||||||
|
{
|
||||||
|
var stored = new List<Student>
|
||||||
|
{
|
||||||
|
new() { FirstName = "Max", LastName = "Beispiel" },
|
||||||
|
};
|
||||||
|
var service = Service(stored,
|
||||||
|
[
|
||||||
|
new ImportedStudent { FirstName = "Max", LastName = "Beispiel" },
|
||||||
|
]);
|
||||||
|
var preview = await service.AnalyzeAsync(File());
|
||||||
|
var vm = new StudentImportDialogViewModel(service, preview, "students.csv");
|
||||||
|
|
||||||
|
var conflict = Assert.Single(vm.Conflicts);
|
||||||
|
Assert.Equal("skip", conflict.SelectedOption.Id);
|
||||||
|
Assert.Equal("skip", Assert.Single(vm.BuildDecisions()).SelectedOptionId);
|
||||||
|
|
||||||
|
Assert.True(await vm.TryApplyAsync());
|
||||||
|
Assert.Equal(1, vm.Result?.SkippedStudents);
|
||||||
|
Assert.Single(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UebernehmenFuerAlle_WaehltBeiJedemEindeutigenKonfliktDenVorhandenenSchueler()
|
||||||
|
{
|
||||||
|
var stored = new List<Student>
|
||||||
|
{
|
||||||
|
new() { FirstName = "Max", LastName = "Beispiel" },
|
||||||
|
new() { FirstName = "Erika", LastName = "Muster" },
|
||||||
|
};
|
||||||
|
var service = Service(stored,
|
||||||
|
[
|
||||||
|
new ImportedStudent { FirstName = "Max", LastName = "Beispiel" },
|
||||||
|
new ImportedStudent { FirstName = "Erika", LastName = "Muster" },
|
||||||
|
]);
|
||||||
|
var preview = await service.AnalyzeAsync(File());
|
||||||
|
var vm = new StudentImportDialogViewModel(service, preview, "students.csv");
|
||||||
|
|
||||||
|
Assert.True(vm.CanUseExistingForAll);
|
||||||
|
Assert.All(vm.Conflicts, conflict => Assert.Equal("skip", conflict.SelectedOption.Id));
|
||||||
|
|
||||||
|
vm.UseExistingForAll();
|
||||||
|
|
||||||
|
Assert.All(vm.Conflicts,
|
||||||
|
conflict => Assert.StartsWith("existing:", conflict.SelectedOption.Id));
|
||||||
|
Assert.True(await vm.TryApplyAsync());
|
||||||
|
Assert.Equal(2, vm.Result?.MatchedExistingStudents);
|
||||||
|
Assert.Equal(0, vm.Result?.SkippedStudents);
|
||||||
|
Assert.Equal(2, stored.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StudentImportService Service(
|
||||||
|
List<Student> stored,
|
||||||
|
IReadOnlyList<ImportedStudent> imported) => new(
|
||||||
|
[new FakeImportHandler(imported)],
|
||||||
|
new FakeStudents(stored),
|
||||||
|
new FakeGroups([]),
|
||||||
|
new FakeMemberships([]),
|
||||||
|
new SchoolYearService());
|
||||||
|
|
||||||
|
private static ImportFile File() => new("students.csv", "test"u8.ToArray());
|
||||||
|
|
||||||
|
private sealed class FakeImportHandler(IReadOnlyList<ImportedStudent> imported)
|
||||||
|
: IImportHandler<ImportedStudent>
|
||||||
|
{
|
||||||
|
public ImportFormatId FormatId { get; } = new("test.students");
|
||||||
|
public string DisplayName => "Testformat";
|
||||||
|
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
|
||||||
|
|
||||||
|
public ValueTask<ImportDetection> DetectAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.FromResult(ImportDetection.Match(100));
|
||||||
|
|
||||||
|
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(imported));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class SyncSettingsServiceTests
|
||||||
|
{
|
||||||
|
private static string BuildTempPath()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettingssvc-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetServerUrl_PersistiertUeberNeueInstanz()
|
||||||
|
{
|
||||||
|
var path = BuildTempPath();
|
||||||
|
new SyncSettingsService(path).SetServerUrl("https://sync.example.com");
|
||||||
|
|
||||||
|
var reloaded = new SyncSettingsService(path);
|
||||||
|
|
||||||
|
Assert.Equal("https://sync.example.com", reloaded.ServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetCredentialsAndToken_TokenIstVerschluesseltAbrufbar()
|
||||||
|
{
|
||||||
|
var service = new SyncSettingsService(BuildTempPath());
|
||||||
|
|
||||||
|
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
|
||||||
|
|
||||||
|
Assert.True(service.IsLoggedIn);
|
||||||
|
Assert.Equal("sebastian", service.Username);
|
||||||
|
Assert.Equal("geheimes-token-123", service.GetToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Token_UeberlebtNeueInstanzMitDemselbenPfad()
|
||||||
|
{
|
||||||
|
var path = BuildTempPath();
|
||||||
|
new SyncSettingsService(path).SetCredentialsAndToken("sebastian", "token-abc");
|
||||||
|
|
||||||
|
var reloaded = new SyncSettingsService(path);
|
||||||
|
|
||||||
|
Assert.True(reloaded.IsLoggedIn);
|
||||||
|
Assert.Equal("token-abc", reloaded.GetToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TokenDateiEnthaeltNichtDenKlartext()
|
||||||
|
{
|
||||||
|
var path = BuildTempPath();
|
||||||
|
var service = new SyncSettingsService(path);
|
||||||
|
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
|
||||||
|
|
||||||
|
var raw = File.ReadAllText(Path.Combine(path, "sync-settings.json"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("geheimes-token-123", raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetCredentialsAndToken_MitUserId_PersistiertLastUserIdUeberNeueInstanz()
|
||||||
|
{
|
||||||
|
var path = BuildTempPath();
|
||||||
|
new SyncSettingsService(path).SetCredentialsAndToken("Sebastian", "token-abc", "sebastian");
|
||||||
|
|
||||||
|
var reloaded = new SyncSettingsService(path);
|
||||||
|
|
||||||
|
Assert.Equal("sebastian", reloaded.LastUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetCredentialsAndToken_OhneUserId_LaesstVorhandeneLastUserIdUnveraendert()
|
||||||
|
{
|
||||||
|
var service = new SyncSettingsService(BuildTempPath());
|
||||||
|
service.SetCredentialsAndToken("sebastian", "token-1", "sebastian");
|
||||||
|
|
||||||
|
service.SetCredentialsAndToken("sebastian", "token-2");
|
||||||
|
|
||||||
|
Assert.Equal("sebastian", service.LastUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername()
|
||||||
|
{
|
||||||
|
var service = new SyncSettingsService(BuildTempPath());
|
||||||
|
service.SetServerUrl("https://sync.example.com");
|
||||||
|
service.SetCredentialsAndToken("sebastian", "token-abc");
|
||||||
|
|
||||||
|
service.Logout();
|
||||||
|
|
||||||
|
Assert.False(service.IsLoggedIn);
|
||||||
|
Assert.Null(service.GetToken());
|
||||||
|
Assert.Equal("https://sync.example.com", service.ServerUrl);
|
||||||
|
Assert.Equal("sebastian", service.Username);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user