feat: Kompetenz-Bewertung je Schüler/Sitzung aus Mitarbeit-Durchschnitt schätzen
CI / build-and-test (push) Canceled after 0s
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:
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user