Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
CI / build-and-test (push) Canceled after 0s

Feste Score-Gewichte durch einen Einstellungsdialog ersetzt. Ein
geschlossener Vorgang dämpft ab seinem Schließ-Datum den zu seinen
Schlagwörtern passenden Score-Bereich (Anwesenheit/Verspätung/
Klassenbuch), ohne Schlagwort-Treffer vorsichtshalber alle drei — die
faktische Fehlquote bleibt davon unberührt. Verschlechtert sich die Lage
danach wieder spürbar, erscheint eine "erneut auffällig"-Warnung im
bestehenden "Muster erkannt"-Panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 23:52:38 +02:00
co-authored by Claude Sonnet 5
parent 0d5fb6d146
commit 216708e879
10 changed files with 543 additions and 36 deletions
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
@@ -889,4 +890,193 @@ public sealed class ClassTeacherViewModelsTests
Assert.Empty(result);
}
// ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ─────────
[Fact]
public void MatchDomains_ErkenntBereicheAnSchluesselwoertern()
{
Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"]));
Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"]));
}
[Fact]
public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche()
{
// Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein
// Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann.
var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]);
Assert.Equal(3, domains.Count);
Assert.Contains(VorgangScoreDomain.Attendance, domains);
Assert.Contains(VorgangScoreDomain.Lateness, domains);
Assert.Contains(VorgangScoreDomain.Classbook, domains);
}
[Fact]
public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum()
{
var closed = new List<Vorgang>
{
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) },
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) },
new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) },
};
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]);
Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]);
Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook));
}
[Fact]
public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum()
{
var closed = new List<Vorgang> { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } };
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
Assert.Empty(cutoffs);
}
[Fact]
public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 };
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
weights: customWeights));
Assert.Equal(10.0, row.PatternScore);
}
[Fact]
public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
// Vor dem Abschluss: darf für den Score nicht mehr zählen.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Nach dem Abschluss: zählt weiter.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
// 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5.
Assert.Equal(3.5, row.PatternScore);
// Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter.
Assert.Equal(2, row.YearUnexcusedDayCount);
Assert.Equal(2, row.YearAbsenceDayCount);
}
[Fact]
public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(0.0, row.PatternScore);
}
[Fact]
public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(0.0, row.RegressionScore);
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
}
[Fact]
public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Nach Abschluss erneut unentschuldigt gefehlt.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(3.0, row.RegressionScore);
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
// Quote weiterhin unangetastet.
Assert.Equal(2, row.YearUnexcusedDayCount);
}
}