feat: Kompetenz-Bewertung je Schüler/Sitzung aus Mitarbeit-Durchschnitt schätzen
CI / build-and-test (push) Canceled after 0s

Manuelle Einzelerfassung je Kompetenz war im Alltag zu aufwendig (Nutzer-Feedback). Neue
ParticipationCompetencyEstimate mittelt die bereits erfassten Aspekt-Bewertungen der Sitzung
(normiert auf dieselbe -2..+2-Achse) und rundet auf den nächsten Schritt; die Tagesflagge wirkt
symmetrisch (Spitzentag hebt, Schlaftag/Schlechter Tag senken gleichermaßen).

Die Schätzung wird nie automatisch gespeichert - sie erscheint nur als abgeschwächt dargestellter
Zellenwert (RatingCell.IsEstimated), solange keine explizite Bewertung existiert. Ein Klick setzt
den Wert bewusst und persistiert ihn wie bisher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-19 01:57:45 +02:00
1 parent 51e55e1464
commit 455f7765e4
6 files changed
+234 -5

No files matched your search

@@ -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<AspectRating>
{
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<AspectRating> { 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<AspectRating> { 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<AspectRating> { 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<AspectRating> { 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<AspectRating> { 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<AspectRating> { new() { Key = "points", Value = 4 } };
var estimate = ParticipationCompetencyEstimate.Estimate(ratings, [Points], null);
Assert.Equal(2, estimate);
}
}
@@ -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()
{
@@ -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);
}
}
@@ -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 = "";
/// <summary>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.</summary>
[ObservableProperty] private bool _isEstimated;
public Action<Guid, string, int?>? 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
}
}
/// <summary>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.</summary>
public static class ParticipationCompetencyEstimate
{
private const double DayHighlightAdjustment = 1.0;
public static int? Estimate(List<AspectRating> ratings, IReadOnlyList<AspectColumnDef> 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; }
@@ -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
+15
View File
@@ -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)