diff --git a/LehrerApp.Desktop.Tests/ParticipationCompetencyEstimateTests.cs b/LehrerApp.Desktop.Tests/ParticipationCompetencyEstimateTests.cs new file mode 100644 index 0000000..53a0d0c --- /dev/null +++ b/LehrerApp.Desktop.Tests/ParticipationCompetencyEstimateTests.cs @@ -0,0 +1,98 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Nutzer-Feedback: Kompetenzen je Sitzung von Hand zu bewerten ist zu aufwendig - eine Schätzung +/// aus dem allgemeinen Mitarbeit-Durchschnitt ersetzt die manuelle Erfassung im Regelfall. +public sealed class ParticipationCompetencyEstimateTests +{ + private static readonly AspectColumnDef Quality = new(new ParticipationAspect { Key = "quality", ValueType = AspectValueType.Scale5 }); + private static readonly AspectColumnDef Quantity = new(new ParticipationAspect { Key = "quantity", ValueType = AspectValueType.Scale5 }); + private static readonly AspectColumnDef Points = new(new ParticipationAspect { Key = "points", ValueType = AspectValueType.Points, MaxPoints = 4 }); + + [Fact] + public void Estimate_OhneBewertungen_LiefertNull() + { + var estimate = ParticipationCompetencyEstimate.Estimate([], [Quality], null); + + Assert.Null(estimate); + } + + [Fact] + public void Estimate_MittelwertMehrererAspekte_RundetAufNaechstenSchritt() + { + var ratings = new List + { + new() { Key = "quality", Value = 1 }, + new() { Key = "quantity", Value = 2 }, + }; + + var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Quality, Quantity], null); + + Assert.Equal(2, estimate); // Mittelwert 1.5 -> AwayFromZero gerundet auf 2 + } + + [Fact] + public void Estimate_Spitzentag_HebtDieSchaetzungAn() + { + var ratings = new List { new() { Key = "quality", Value = 0 } }; + + var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Quality], DayHighlightKind.Standout); + + Assert.Equal(1, estimate); + } + + [Fact] + public void Estimate_Schlaftag_SenktDieSchaetzung() + { + var ratings = new List { new() { Key = "quality", Value = 0 } }; + + var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Quality], DayHighlightKind.Sleepy); + + Assert.Equal(-1, estimate); + } + + [Fact] + public void Estimate_SchlechterTag_SenktDieSchaetzungGleichWieSchlaftag() + { + var ratings = new List { new() { Key = "quality", Value = 0 } }; + + var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Quality], DayHighlightKind.Rough); + + Assert.Equal(-1, estimate); + } + + [Fact] + public void Estimate_KapptAufDenGueltigenBereich() + { + var ratings = new List { new() { Key = "quality", Value = 2 } }; + + var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Quality], DayHighlightKind.Standout); + + Assert.Equal(2, estimate); // 2 + 1 würde 3 ergeben, wird auf 2 gekappt + } + + [Fact] + public void Estimate_BeruecksichtigtNurBewerteteAspekte() + { + // "quantity" wurde nie bewertet - fließt nicht als 0 in den Mittelwert ein. + var ratings = new List { new() { Key = "quality", Value = 2 } }; + + var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Quality, Quantity], null); + + Assert.Equal(2, estimate); + } + + [Fact] + public void Estimate_PunkteAspekt_WirdAufDieMinus2Bis2AchseNormiert() + { + // 4 von 4 Punkten -> oberes Ende der Achse (+2). + var ratings = new List { new() { Key = "points", Value = 4 } }; + + var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Points], null); + + Assert.Equal(2, estimate); + } +} diff --git a/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs b/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs index 21f58c1..767bd3f 100644 --- a/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs +++ b/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs @@ -87,6 +87,49 @@ public sealed class ParticipationSessionEditingTests Assert.Null(sessions.GetById(session.Id)); } + [Fact] + public void Sitzung_MitBewertetenAspektenAberOhneKompetenzbewertung_ZeigtGeschaetztenWert() + { + var subjectId = Guid.NewGuid(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27", SubjectId = subjectId, GradeLevel = 8 }; + var student = new Student { FirstName = "Ada", LastName = "Lovelace" }; + var item = new CompetencyItem { Code = "K1", Description = "Kompetenz" }; + var session = new ParticipationSession + { + GroupId = group.Id, Date = new DateOnly(2026, 8, 19), + Competencies = [new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description }], + }; + var sessions = new FakeSessions([session]); + var entries = new FakeEntries(); + entries.Add(new ParticipationEntry + { + SessionId = session.Id, StudentId = student.Id, + Ratings = [new AspectRating { Key = "quality", Value = 2 }], + }); + var aspects = new FakeAspects(); + aspects.Save(new ParticipationAspect + { + Key = "quality", Label = "Qualität", ValueType = AspectValueType.Scale5, IsActive = true, + }); + var versions = new FakeCompetencyCatalogVersions(); + var version = versions.GetOrCreateDefault(subjectId); + var domains = new FakeCompetencyDomains(); + domains.Add(new CompetencyDomain + { + SubjectId = subjectId, GradeLevel = 8, VersionId = version.Id, Name = "Bereich", Items = [item], + }); + var vm = new ParticipationTabViewModel(sessions, entries, aspects, new FakeStudents([student]), + new FakeMemberships([]), new FakeGroups([group]), domains, versions); + vm.Initialize(group.Id, group.SchoolYear); + + vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id); + + var row = Assert.Single(vm.StudentRows); + var cell = Assert.Single(row.CompetencyCells); + Assert.True(cell.IsEstimated); + Assert.Equal(2, cell.Value); // einziger bewerteter Aspekt "quality" = 2, kein Tageshighlight + } + [Fact] public async Task LoeschenBefehl_BrichtBeiAbgelehnterBestaetigungAb() { diff --git a/LehrerApp.Desktop.Tests/RatingCellTests.cs b/LehrerApp.Desktop.Tests/RatingCellTests.cs index f7d2f4a..71a76e0 100644 --- a/LehrerApp.Desktop.Tests/RatingCellTests.cs +++ b/LehrerApp.Desktop.Tests/RatingCellTests.cs @@ -78,4 +78,16 @@ public sealed class RatingCellTests var cell = new RatingCell(Guid.NewGuid(), "k", 2); Assert.Equal("++", cell.DisplayLabel); } + + [Fact] + public void SetValue_AufGeschaetzterZelle_MarkiertSieAlsBestaetigt() + { + var cell = new RatingCell(Guid.NewGuid(), "k", 1, isEstimated: true); + Assert.True(cell.IsEstimated); + + cell.SetValue(1); // bestätigt zufällig denselben Wert wie die Schätzung + + Assert.False(cell.IsEstimated); + Assert.Equal(1, cell.Value); + } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index afbc4f1..8d227ab 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -443,10 +443,16 @@ public partial class ParticipationStudentRow : ObservableObject // AspectKey trägt hier den Kompetenz-ItemId als String - reiner In-Memory-Transport // zwischen Zelle und Save-Callback (siehe SaveCompetencyRating), nie persistiert. + // Ohne eigene Bewertung wird aus dem Mitarbeit-Durchschnitt der Sitzung geschätzt + // (Nutzer-Feedback: manuelle Einzelerfassung je Kompetenz ist im Alltag zu aufwendig) - + // die Schätzung wird NIE automatisch gespeichert, nur angezeigt (siehe RatingCell.IsEstimated); + // ein Klick auf die Zelle setzt den Wert bewusst und persistiert ihn wie gehabt. + var estimate = ParticipationCompetencyEstimate.Estimate(entry.Ratings, aspects, entry.DayHighlight); foreach (var competency in competencies) { var existing = entry.CompetencyRatings.FirstOrDefault(r => r.ItemId == competency.ItemId); - var cell = new RatingCell(id, competency.ItemId.ToString("D"), existing?.Value); + var cell = new RatingCell(id, competency.ItemId.ToString("D"), existing?.Value ?? estimate, + isEstimated: existing is null && estimate is not null); cell.OnChanged = (sid, key, val) => OnCompetencyRatingChanged?.Invoke(sid, key, val); CompetencyCells.Add(cell); } @@ -672,22 +678,33 @@ public partial class RatingCell : ObservableObject [ObservableProperty] private int? _value; [ObservableProperty] private string _displayLabel = ""; + /// true, solange Value aus ParticipationCompetencyEstimate geschätzt und noch nie + /// explizit vom Nutzer gesetzt wurde (siehe ParticipationStudentRow) - steuert nur die Anzeige + /// (reduzierte Deckkraft), nichts davon ist in ParticipationEntry.CompetencyRatings persistiert. + /// Wird bei jedem SetValue-Aufruf unwiderruflich auf false gesetzt, auch wenn der Nutzer + /// zufällig genau den geschätzten Wert bestätigt - ab dann zählt es als bewusste Entscheidung. + [ObservableProperty] private bool _isEstimated; public Action? OnChanged { get; set; } public RatingCell(Guid studentId, string key, int? value, - AspectValueType type = AspectValueType.Scale5, int maxPoints = 5) + AspectValueType type = AspectValueType.Scale5, int maxPoints = 5, bool isEstimated = false) { StudentId = studentId; AspectKey = key; Type = type; MaxPoints = maxPoints; _value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value; + _isEstimated = isEstimated; UpdateLabel(); } public void SetValue(int? value) { + // Reihenfolge wichtig: IsEstimated zuerst, damit ein auf Value lauschender Zellen-Renderer + // (siehe ParticipationTabView.axaml.cs) beim Auswerten von Value bereits den korrekten + // "bestätigt statt geschätzt"-Zustand sieht. + IsEstimated = false; Value = Type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, MaxPoints) : value; UpdateLabel(); OnChanged?.Invoke(StudentId, AspectKey, Value); @@ -741,6 +758,37 @@ public class AspectColumnDef } } +/// Nutzer-Feedback: Kompetenzen je Sitzung einzeln von Hand zu bewerten ist im Alltag zu +/// aufwendig - eine Schätzung aus dem allgemeinen Mitarbeit-Durchschnitt der Sitzung (auf derselben +/// -2..+2-Achse wie die Kompetenz-Bewertung selbst, siehe ParticipationRatingScale.Normalize) ersetzt +/// die manuelle Erfassung im Regelfall; die Lehrkraft korrigiert nur noch die Ausnahmen von Hand. +/// Die Tagesflagge wirkt bewusst symmetrisch (Nutzer-Entscheidung): Spitzentag hebt, Schlaftag und +/// Schlechter Tag senken die Schätzung gleichermaßen, obwohl "Schlaftag" strenggenommen eher die +/// Tagesform als die fachliche Kompetenz beschreibt. +public static class ParticipationCompetencyEstimate +{ + private const double DayHighlightAdjustment = 1.0; + + public static int? Estimate(List ratings, IReadOnlyList aspects, DayHighlightKind? dayHighlight) + { + var normalized = ratings + .Select(r => (Rating: r, Def: aspects.FirstOrDefault(a => a.Key == r.Key))) + .Where(x => x.Def is not null) + .Select(x => ParticipationRatingScale.Normalize(x.Def!.ValueType, x.Rating.Value, x.Def.MaxPoints)) + .ToList(); + if (normalized.Count == 0) return null; + + var adjustment = dayHighlight switch + { + DayHighlightKind.Standout => DayHighlightAdjustment, + DayHighlightKind.Sleepy or DayHighlightKind.Rough => -DayHighlightAdjustment, + _ => 0, + }; + var estimate = (int)Math.Round(normalized.Average() + adjustment, MidpointRounding.AwayFromZero); + return Math.Clamp(estimate, -2, 2); + } +} + public class ParticipationSessionItem { public Guid Id { get; } diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs index b5014ac..72bde2d 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs @@ -133,15 +133,22 @@ public partial class ParticipationTabView : UserControl Content = label, Padding = new Avalonia.Thickness(5, 1), FontSize = 11, - Opacity = cell.Value == val ? 1.0 : 0.3, + Opacity = StepOpacity(cell, val), IsEnabled = !isReadOnly, }; var capturedVal = val; + void UpdateTip() => ToolTip.SetTip(btn, cell.IsEstimated && cell.Value == capturedVal + ? "Geschätzt aus dem Mitarbeit-Durchschnitt der Sitzung - anklicken, um zu bestätigen/ändern." + : null); + UpdateTip(); btn.Click += (_, _) => cell.SetValue(capturedVal); cell.PropertyChanged += (_, pe) => { - if (pe.PropertyName == nameof(RatingCell.Value)) - btn.Opacity = cell.Value == capturedVal ? 1.0 : 0.3; + if (pe.PropertyName is nameof(RatingCell.Value) or nameof(RatingCell.IsEstimated)) + { + btn.Opacity = StepOpacity(cell, capturedVal); + UpdateTip(); + } }; panel.Children.Add(btn); } @@ -149,6 +156,12 @@ public partial class ParticipationTabView : UserControl return panel; } + // Geschätzte (noch nicht bestätigte) Werte werden abgeschwächt dargestellt (siehe + // ParticipationCompetencyEstimate) - deutlich sichtbar genug, um nicht wie eine bewusste + // Bewertung zu wirken, aber klar erkennbar heller als "nicht ausgewählt". + private static double StepOpacity(RatingCell cell, int val) => + cell.Value != val ? 0.3 : cell.IsEstimated ? 0.6 : 1.0; + private static Control BuildPointsCell(RatingCell cell, bool isReadOnly) { var updown = new NumericUpDown diff --git a/TODO.md b/TODO.md index 09a3eb8..db8768f 100644 --- a/TODO.md +++ b/TODO.md @@ -424,6 +424,21 @@ Mitarbeit-Note für denselben Zeitraum statt sie zu duplizieren (erkannt über d die bestehende Notenentwicklung (2.5) zeigt ausschließlich Klausur-/Einzelnoten-Einträge, keine Mitarbeit-Sitzungen — eine visuelle Markierung "im Diagramm" ist erst mit der geplanten Schüler-Überblicksansicht sinnvoll umsetzbar. +- [x] **3.3.7** Kompetenz-Bewertung je Schüler/Sitzung automatisch aus dem Mitarbeit-Durchschnitt + schätzen (Nutzer-Feedback: manuelle Einzelerfassung je Kompetenz war im Alltag zu aufwendig). + Neue Klasse `ParticipationCompetencyEstimate` (Desktop) mittelt die bereits erfassten + `AspectRating`-Werte der Sitzung (normiert auf dieselbe -2..+2-Achse wie die Kompetenz- + Bewertung selbst, `ParticipationRatingScale.Normalize`) und rundet auf den nächsten Schritt; + die Tagesflagge (3.3.6) wirkt bewusst **symmetrisch** (Nutzer-Entscheidung): Spitzentag hebt, + Schlaftag und Schlechter Tag senken die Schätzung gleichermaßen, obwohl "Schlaftag" + streng genommen eher die Tagesform als die fachliche Kompetenz beschreibt. Die Schätzung wird + **nie automatisch gespeichert** — `ParticipationStudentRow` zeigt sie nur als Zellenwert an + (`RatingCell.IsEstimated`, im Raster abgeschwächt dargestellt), solange keine explizite + `CompetencyRating` existiert; ein Klick setzt den Wert bewusst und persistiert ihn wie bisher + über `SaveCompetencyRating`. Bekannte Einschränkung: "Löschen" auf einer geschätzten Zelle + zeigt sie bis zum nächsten Neuladen leer, danach erscheint wieder die (neu berechnete) + Schätzung — es gibt keinen persistierten "bewusst leer"-Zustand, da das eine eigene + Tombstone-Markierung bräuchte. ### Abschnittsnoten & Mitarbeits-Assistent (nicht aus dieser Liste, eigener Workflow-Bedarf)