3.1.3: Aspekt-Typen Scale3/Binary/Points in Raster und Dialog unterstützt

Neue Klasse ParticipationRatingScale (Core) ist die einzige Quelle für
Rohwerte je Aspekt-Typ. Scale3/Binary liegen bewusst direkt auf derselben
-2..+2-Achse wie Scale5 (nur mit weniger Zwischenschritten), damit die
bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote unverändert
kompatibel bleibt. Points ist grundverschieden (echter Zählwert 0..MaxPoints,
neues Feld auf ParticipationAspect) und wird nur zur Aggregation linear auf
dieselbe Achse normiert.

Ohne diese Normierung hätte ein Punkte-Aspekt die Mitarbeitsnote verfälscht:
drei Stellen summierten bisher den Rohwert direkt (ParticipationGradeDialog-
ViewModel.Recompute, ParticipationWizardViewModels.WeightedRating und
.ComputeSuggestion) - alle drei sind jetzt auf die Normierung umgestellt.

Raster: Punkte-Aspekte zeigen ein NumericUpDown statt fester Stufen-Buttons.
Schnelleingabe-Dialog: Zifferntasten/+/- sind jetzt typabhängig, Legende
zeigt live die für den aktuellen Aspekt gültigen Tasten. Aspekt-Verwaltung
um "Max. Punkte"-Feld ergänzt (nur bei Typ "Punkte" sichtbar).

Bewusst nicht angefasst: die Trendlinien-Visualisierung im
Mitarbeits-Assistenten bleibt fest auf die drei Standardaspekte
zugeschnitten - eigener, größerer Umbau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 00:12:16 +02:00
co-authored by Claude Sonnet 5
parent f8dd567ef8
commit 16ff4875d8
12 changed files with 502 additions and 97 deletions
+42
View File
@@ -96,6 +96,9 @@ public class ParticipationAspect
public string Key { get; set; } = ""; public string Key { get; set; } = "";
public string Label { get; set; } = ""; public string Label { get; set; } = "";
public AspectValueType ValueType { get; set; } = AspectValueType.Scale5; public AspectValueType ValueType { get; set; } = AspectValueType.Scale5;
// Nur bei ValueType.Points relevant: die Rohbewertung ist dann ein echter Punktwert
// 0..MaxPoints (nicht auf der -2..+2-Achse), siehe ParticipationRatingScale.
public int MaxPoints { get; set; } = 5;
public double Weight { get; set; } = 1.0; public double Weight { get; set; } = 1.0;
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int SortOrder { get; set; } public int SortOrder { get; set; }
@@ -113,3 +116,42 @@ public static class DefaultParticipationAspects
new() { Key = "workphase", Label = "Arbeitsphase", ValueType = AspectValueType.Scale5, SortOrder = 2 }, new() { Key = "workphase", Label = "Arbeitsphase", ValueType = AspectValueType.Scale5, SortOrder = 2 },
]; ];
} }
/// <summary>
/// Definiert je <see cref="AspectValueType"/>, welche Rohwerte eine Bewertung annehmen kann und
/// wie sie sich auf die gemeinsame Qualitätsachse (-2..+2, wie <see cref="AspectValueType.Scale5"/>)
/// abbilden, auf der die Gewichtung/Mittelwertbildung zur Mitarbeitsnote (3.2) rechnet.
/// Scale3/Binary liegen bewusst direkt AUF dieser Achse (nur mit weniger Zwischenschritten)
/// statt einer eigenen Wertemenge zu bekommen — dadurch bleibt die bestehende Aggregation
/// unverändert kompatibel, ohne pro Bewertung erst umrechnen zu müssen. Nur "Points" hat eine
/// eigene, von der Lehrkraft festgelegte Obergrenze (<see cref="ParticipationAspect.MaxPoints"/>)
/// und wird für die Aggregation linear auf dieselbe Achse normiert (0 → -2, MaxPoints → +2).
/// </summary>
public static class ParticipationRatingScale
{
/// Feste Rohwert/Label-Paare für Typen mit einer kleinen, festen Stufenzahl — Grundlage für
/// Raster-Buttons und Schnelleingabe-Zifferntasten. Leer für Points (freie Zahleneingabe).
public static IReadOnlyList<(int Value, string Label)> Steps(AspectValueType type) => type switch
{
AspectValueType.Scale5 => [(-2, "−−"), (-1, ""), (0, ""), (1, "+"), (2, "++")],
AspectValueType.Scale3 => [(-2, ""), (0, ""), (2, "+")],
AspectValueType.Binary => [(-2, "Nein"), (2, "Ja")],
_ => [],
};
public static string DisplayLabel(AspectValueType type, int? value)
{
if (value is null) return "·";
if (type == AspectValueType.Points) return value.Value.ToString();
var match = Steps(type).FirstOrDefault(s => s.Value == value.Value);
return match.Label ?? "·";
}
/// Bildet einen Rohwert auf die gemeinsame -2..+2-Achse ab, für die Mittelwertbildung in 3.2.
public static double Normalize(AspectValueType type, int value, int maxPoints) => type switch
{
AspectValueType.Points when maxPoints > 0 => Math.Clamp(value, 0, maxPoints) / (double)maxPoints * 4.0 - 2.0,
AspectValueType.Points => 0, // keine sinnvolle Obergrenze konfiguriert -> neutral werten
_ => value,
};
}
@@ -157,4 +157,43 @@ public class ParticipationGradeAggregationTests
.Where(g => g.Category == GradeCategory.Participation).ToList(); .Where(g => g.Category == GradeCategory.Participation).ToList();
Assert.Single(annaGrades); Assert.Single(annaGrades);
} }
/// 3.1.3: ein Punkte-Aspekt darf nicht als roher Zahlenwert in die Mittelwertbildung
/// einfließen (z.B. "4 Punkte" wäre auf der -2..+2-Achse ein Extremwert weit über +2) —
/// er muss vorher auf dieselbe Achse normiert werden wie Scale5/Scale3/Binary.
[Fact]
public void Recompute_PunkteAspekt_WirdVorDerMittelungAufMinus2Bis2AchseNormiert()
{
var groupId = Guid.NewGuid();
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([anna]);
var memberships = new FakeMemberships([]);
var aspects = new FakeAspects();
aspects.Save(new ParticipationAspect
{
GroupId = groupId, Key = "raised_hand", Label = "Meldungen",
ValueType = AspectValueType.Points, MaxPoints = 4,
});
var session = new ParticipationSession { GroupId = groupId, Date = new DateOnly(2025, 9, 1) };
var sessions = new FakeSessions([session]);
var entries = new FakeEntries();
// 2 von 4 Punkten -> Mitte der Skala, normiert genau auf 0.
entries.Add(new ParticipationEntry
{
SessionId = session.Id, StudentId = anna.Id,
Ratings = [new() { Key = "raised_hand", Value = 2 }],
});
var grades = new FakeGrades();
var grading = new GradingService();
var vm = new ParticipationGradeDialogViewModel(sessions, entries, aspects, students, memberships,
grades, grading, groupId, "2025/26", GradingSystem.Grades1To6);
var row = Assert.Single(vm.Rows);
Assert.Equal(0.0, double.Parse(row.AverageDisplay.Replace(',', '.'),
System.Globalization.CultureInfo.InvariantCulture), precision: 2);
}
} }
@@ -0,0 +1,81 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für 3.1.3: RatingCell muss je AspectValueType unterschiedliche gültige Rohwerte und
/// Schrittweiten für Hoch/Runter (Raster) durchsetzen, nicht mehr pauschal -2..+2 wie zuvor.
public sealed class RatingCellTests
{
[Fact]
public void CycleUp_Scale3_SpringtZwischenDenDreiStufen()
{
var cell = new RatingCell(Guid.NewGuid(), "k", null, AspectValueType.Scale3);
cell.CycleUpCommand.Execute(null);
Assert.Equal(-2, cell.Value);
cell.CycleUpCommand.Execute(null);
Assert.Equal(0, cell.Value);
cell.CycleUpCommand.Execute(null);
Assert.Equal(2, cell.Value);
cell.CycleUpCommand.Execute(null); // bleibt am oberen Ende
Assert.Equal(2, cell.Value);
}
[Fact]
public void CycleUp_Binary_ToggeltNurZwischenDenBeidenExtremwerten()
{
var cell = new RatingCell(Guid.NewGuid(), "k", null, AspectValueType.Binary);
cell.CycleUpCommand.Execute(null);
Assert.Equal(-2, cell.Value);
cell.CycleUpCommand.Execute(null);
Assert.Equal(2, cell.Value);
}
[Fact]
public void SetValue_Points_KapptAufMaxPoints()
{
var cell = new RatingCell(Guid.NewGuid(), "k", null, AspectValueType.Points, maxPoints: 4);
cell.SetValue(99);
Assert.Equal(4, cell.Value);
}
[Fact]
public void CycleUp_Points_ZaehltEinzelnHochBisMaxPoints()
{
var cell = new RatingCell(Guid.NewGuid(), "k", 3, AspectValueType.Points, maxPoints: 4);
cell.CycleUpCommand.Execute(null);
Assert.Equal(4, cell.Value);
cell.CycleUpCommand.Execute(null); // bleibt am Maximum
Assert.Equal(4, cell.Value);
}
[Fact]
public void CycleDown_Points_ZaehltEinzelnRunterBisNull()
{
var cell = new RatingCell(Guid.NewGuid(), "k", 1, AspectValueType.Points, maxPoints: 4);
cell.CycleDownCommand.Execute(null);
Assert.Equal(0, cell.Value);
cell.CycleDownCommand.Execute(null); // bleibt bei 0
Assert.Equal(0, cell.Value);
}
[Fact]
public void DisplayLabel_Scale5_UnveraendertGegenueberBisherigemVerhalten()
{
var cell = new RatingCell(Guid.NewGuid(), "k", 2);
Assert.Equal("++", cell.DisplayLabel);
}
}
@@ -35,6 +35,9 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
public ObservableCollection<ParticipationGradeRow> Rows { get; } = []; public ObservableCollection<ParticipationGradeRow> Rows { get; } = [];
public ObservableCollection<AspectWeightItem> AspectWeights { get; } = []; public ObservableCollection<AspectWeightItem> AspectWeights { get; } = [];
// Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
// Gewichtung — AspectWeightItem kennt selbst nur Key/Label/Weight, nicht ValueType/MaxPoints.
private readonly Dictionary<string, ParticipationAspect> _aspectsByKey = [];
public ParticipationGradeDialogViewModel( public ParticipationGradeDialogViewModel(
IParticipationSessionRepository sessions, IParticipationRepository entries, IParticipationSessionRepository sessions, IParticipationRepository entries,
@@ -63,6 +66,7 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
foreach (var a in all) foreach (var a in all)
{ {
_aspectsByKey[a.Key] = a;
var item = new AspectWeightItem(a, _aspects); var item = new AspectWeightItem(a, _aspects);
item.OnChanged = Recompute; item.OnChanged = Recompute;
AspectWeights.Add(item); AspectWeights.Add(item);
@@ -105,7 +109,10 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
{ {
var w = aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; var w = aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue; if (w <= 0) continue;
valueSum += r.Value * w; var normalized = _aspectsByKey.TryGetValue(r.Key, out var aspect)
? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
: r.Value;
valueSum += normalized * w;
weightSum += w; weightSum += w;
} }
if (weightSum > 0) points.Add((session.Date, valueSum / weightSum)); if (weightSum > 0) points.Add((session.Date, valueSum / weightSum));
@@ -383,7 +383,7 @@ public partial class ParticipationStudentRow : ObservableObject
foreach (var a in aspects) foreach (var a in aspects)
{ {
var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key); var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key);
var cell = new RatingCell(id, a.Key, existing?.Value); var cell = new RatingCell(id, a.Key, existing?.Value, a.ValueType, a.MaxPoints);
cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val); cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val);
Cells.Add(cell); Cells.Add(cell);
} }
@@ -564,53 +564,64 @@ public partial class RatingCell : ObservableObject
{ {
public Guid StudentId { get; } public Guid StudentId { get; }
public string AspectKey { get; } public string AspectKey { get; }
public AspectValueType Type { get; }
public int MaxPoints { get; }
[ObservableProperty] private int? _value; [ObservableProperty] private int? _value;
[ObservableProperty] private string _displayLabel = ""; [ObservableProperty] private string _displayLabel = "";
public Action<Guid, string, int?>? OnChanged { get; set; } public Action<Guid, string, int?>? OnChanged { get; set; }
public RatingCell(Guid studentId, string key, int? value) public RatingCell(Guid studentId, string key, int? value,
AspectValueType type = AspectValueType.Scale5, int maxPoints = 5)
{ {
StudentId = studentId; StudentId = studentId;
AspectKey = key; AspectKey = key;
_value = value; Type = type;
MaxPoints = maxPoints;
_value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value;
UpdateLabel(); UpdateLabel();
} }
public void SetValue(int? value) public void SetValue(int? value)
{ {
Value = value; Value = Type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, MaxPoints) : value;
UpdateLabel(); UpdateLabel();
OnChanged?.Invoke(StudentId, AspectKey, value); OnChanged?.Invoke(StudentId, AspectKey, Value);
} }
[RelayCommand] [RelayCommand]
private void CycleUp() private void CycleUp()
{ {
var next = Value is null ? -2 : Math.Min(2, Value.Value + 1); if (Type == AspectValueType.Points)
SetValue(next); {
SetValue(Value is null ? 0 : Math.Min(MaxPoints, Value.Value + 1));
return;
}
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = Value is null ? -1 : steps.IndexOf(Value.Value);
SetValue(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
} }
[RelayCommand] [RelayCommand]
private void CycleDown() private void CycleDown()
{ {
var next = Value is null ? 2 : Math.Max(-2, Value.Value - 1); if (Type == AspectValueType.Points)
SetValue(next); {
SetValue(Value is null ? MaxPoints : Math.Max(0, Value.Value - 1));
return;
}
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = Value is null ? steps.Count : steps.IndexOf(Value.Value);
SetValue(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
} }
[RelayCommand] [RelayCommand]
private void Clear() => SetValue(null); private void Clear() => SetValue(null);
private void UpdateLabel() => DisplayLabel = Value switch private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
{
2 => "++",
1 => "+",
0 => "~",
-1 => "",
-2 => "−−",
_ => "",
};
} }
// ── Kompetenz-Tags ──────────────────────────────────────────────────────────── // ── Kompetenz-Tags ────────────────────────────────────────────────────────────
@@ -650,7 +661,12 @@ public class AspectColumnDef
{ {
public string Key { get; } public string Key { get; }
public string Label { get; } public string Label { get; }
public AspectColumnDef(ParticipationAspect a) { Key = a.Key; Label = a.Label; } public AspectValueType ValueType { get; }
public int MaxPoints { get; }
public AspectColumnDef(ParticipationAspect a)
{
Key = a.Key; Label = a.Label; ValueType = a.ValueType; MaxPoints = a.MaxPoints;
}
} }
public class ParticipationSessionItem public class ParticipationSessionItem
@@ -712,6 +728,26 @@ public partial class QuickInputViewModel : ObservableObject
public ObservableCollection<QuickAspectRow> AspectRows { get; } = []; public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
/// gültige Zifferntasten als Scale5, Points nutzt 0-9 als direkte Punkteingabe statt fester
/// Stufen. Ändert sich mit AspectIndex, siehe OnAspectIndexChanged.
public string HotkeyLegend
{
get
{
var ratingHint = CurrentAspectType() switch
{
AspectValueType.Scale5 => "15 bewerten",
AspectValueType.Scale3 => "13 bewerten",
AspectValueType.Binary => "1 Nein / 2 Ja",
AspectValueType.Points => $"09 Punkte eingeben (bis {CurrentAspectMaxPoints()})",
_ => "15 bewerten",
};
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste nächster Aspekt · " +
"Enter nächster Schüler · Backspace vorheriger · +/ anpassen · Esc schließen";
}
}
public QuickInputViewModel(List<ParticipationStudentRow> rows, List<AspectColumnDef> aspects) public QuickInputViewModel(List<ParticipationStudentRow> rows, List<AspectColumnDef> aspects)
{ {
_rows = rows; _rows = rows;
@@ -719,6 +755,13 @@ public partial class QuickInputViewModel : ObservableObject
if (rows.Any()) ShowStudent(0); if (rows.Any()) ShowStudent(0);
} }
partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend));
private AspectValueType CurrentAspectType() =>
_aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType;
private int CurrentAspectMaxPoints() =>
_aspects.Count == 0 ? 5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].MaxPoints;
private void ShowStudent(int index) private void ShowStudent(int index)
{ {
if (index < 0 || index >= _rows.Count) return; if (index < 0 || index >= _rows.Count) return;
@@ -731,7 +774,7 @@ public partial class QuickInputViewModel : ObservableObject
foreach (var (a, i) in _aspects.Select((a, i) => (a, i))) foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
{ {
var val = row.GetRating(a.Key); var val = row.GetRating(a.Key);
AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex)); AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex, a.ValueType));
} }
UpdateCurrentAspect(); UpdateCurrentAspect();
} }
@@ -748,26 +791,48 @@ public partial class QuickInputViewModel : ObservableObject
public void SetRatingByNumber(int num) public void SetRatingByNumber(int num)
{ {
// 1=−−, 2=, 3=~, 4=+, 5=++ var type = CurrentAspectType();
var val = num switch { 1 => -2, 2 => -1, 3 => 0, 4 => 1, 5 => 2, _ => (int?)null }; if (type == AspectValueType.Points)
if (val is null) return; {
ApplyRating(val.Value); ApplyRating(Math.Clamp(num, 0, CurrentAspectMaxPoints()));
return;
}
var steps = ParticipationRatingScale.Steps(type);
if (num < 1 || num > steps.Count) return;
ApplyRating(steps[num - 1].Value);
} }
public void IncrementRating() public void IncrementRating()
{ {
var cell = GetCurrentCell(); var cell = GetCurrentCell();
if (cell is null) return; if (cell is null) return;
var next = cell.Value is null ? -2 : Math.Min(2, cell.Value.Value + 1); var type = CurrentAspectType();
ApplyRating(next); if (type == AspectValueType.Points)
{
var max = CurrentAspectMaxPoints();
ApplyRating(cell.Value is null ? 0 : Math.Min(max, cell.Value.Value + 1));
return;
}
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = cell.Value is null ? -1 : steps.IndexOf(cell.Value.Value);
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
} }
public void DecrementRating() public void DecrementRating()
{ {
var cell = GetCurrentCell(); var cell = GetCurrentCell();
if (cell is null) return; if (cell is null) return;
var next = cell.Value is null ? 2 : Math.Max(-2, cell.Value.Value - 1); var type = CurrentAspectType();
ApplyRating(next); if (type == AspectValueType.Points)
{
ApplyRating(cell.Value is null ? CurrentAspectMaxPoints() : Math.Max(0, cell.Value.Value - 1));
return;
}
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = cell.Value is null ? steps.Count : steps.IndexOf(cell.Value.Value);
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
} }
private void ApplyRating(int val) private void ApplyRating(int val)
@@ -781,7 +846,7 @@ public partial class QuickInputViewModel : ObservableObject
row.Value = val; row.Value = val;
row.UpdateLabel(); row.UpdateLabel();
} }
CurrentValueLabel = RatingLabel(val); CurrentValueLabel = ParticipationRatingScale.DisplayLabel(CurrentAspectType(), val);
} }
public void SelectAspect(int index) public void SelectAspect(int index)
@@ -819,34 +884,28 @@ public partial class QuickInputViewModel : ObservableObject
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key; var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key); return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key);
} }
private static string RatingLabel(int? v) => v switch
{
2 => "++", 1 => "+", 0 => "~", -1 => "", -2 => "−−", _ => "",
};
} }
public partial class QuickAspectRow : ObservableObject public partial class QuickAspectRow : ObservableObject
{ {
public int Index { get; } public int Index { get; }
public string Label { get; } public string Label { get; }
public AspectValueType Type { get; }
[ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isActive;
[ObservableProperty] private string _displayLabel = ""; [ObservableProperty] private string _displayLabel = "";
public int? Value { get; set; } public int? Value { get; set; }
public QuickAspectRow(int index, string label, int? value, bool isActive) public QuickAspectRow(int index, string label, int? value, bool isActive, AspectValueType type = AspectValueType.Scale5)
{ {
Index = index; Index = index;
Label = label; Label = label;
Value = value; Value = value;
IsActive = isActive; IsActive = isActive;
Type = type;
UpdateLabel(); UpdateLabel();
} }
public void UpdateLabel() => DisplayLabel = Value switch public void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
{
2 => "++", 1 => "+", 0 => "~", -1 => "", -2 => "−−", _ => "·",
};
} }
// ── Schnelleingabe Anwesenheit / Hausaufgaben ──────────────────────────────── // ── Schnelleingabe Anwesenheit / Hausaufgaben ────────────────────────────────
@@ -935,11 +994,15 @@ public partial class AspectEditItem : ObservableObject
[ObservableProperty] private string _label; [ObservableProperty] private string _label;
[ObservableProperty] private string _valueTypeName; [ObservableProperty] private string _valueTypeName;
[ObservableProperty] private int _maxPoints;
[ObservableProperty] private double _weight; [ObservableProperty] private double _weight;
[ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isActive;
[ObservableProperty] private string _errorMessage = ""; [ObservableProperty] private string _errorMessage = "";
public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; public string[] ValueTypeOptions => AspectValueTypeDisplay.Options;
// Nur bei ValueType "Punkte" relevant (3.1.3) — MaxPoints-Feld nur dann in der Verwaltung
// anzeigen, siehe ParticipationAspectsDialog.axaml.
public bool IsPointsType => AspectValueTypeDisplay.FromName(ValueTypeName) == AspectValueType.Points;
public Action<AspectEditItem>? OnDelete { get; set; } public Action<AspectEditItem>? OnDelete { get; set; }
public IRelayCommand DeleteCommand { get; } public IRelayCommand DeleteCommand { get; }
public IRelayCommand MoveUpCommand { get; } public IRelayCommand MoveUpCommand { get; }
@@ -951,6 +1014,7 @@ public partial class AspectEditItem : ObservableObject
_aspect = aspect; _repo = repo; _aspect = aspect; _repo = repo;
_label = aspect.Label; _label = aspect.Label;
_valueTypeName = AspectValueTypeDisplay.ToName(aspect.ValueType); _valueTypeName = AspectValueTypeDisplay.ToName(aspect.ValueType);
_maxPoints = aspect.MaxPoints;
_weight = aspect.Weight; _weight = aspect.Weight;
_isActive = aspect.IsActive; _isActive = aspect.IsActive;
@@ -979,7 +1043,7 @@ public partial class AspectEditItem : ObservableObject
private void TrySave(Action apply) private void TrySave(Action apply)
{ {
ErrorMessage = ""; ErrorMessage = "";
var before = (_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive); var before = (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive);
apply(); apply();
try { _repo.Save(_aspect); } try { _repo.Save(_aspect); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
@@ -988,18 +1052,23 @@ public partial class AspectEditItem : ObservableObject
// die Backing-Felder direkt gesetzt statt der generierten Properties — über die // die Backing-Felder direkt gesetzt statt der generierten Properties — über die
// Properties würde erneut On{X}Changed feuern und damit wieder TrySave aufrufen. // Properties würde erneut On{X}Changed feuern und damit wieder TrySave aufrufen.
#pragma warning disable MVVMTK0034 #pragma warning disable MVVMTK0034
(_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive) = before; (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive) = before;
_label = _aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(_aspect.ValueType); _label = _aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(_aspect.ValueType);
_weight = _aspect.Weight; _isActive = _aspect.IsActive; _maxPoints = _aspect.MaxPoints; _weight = _aspect.Weight; _isActive = _aspect.IsActive;
#pragma warning restore MVVMTK0034 #pragma warning restore MVVMTK0034
OnPropertyChanged(nameof(Label)); OnPropertyChanged(nameof(ValueTypeName)); OnPropertyChanged(nameof(Label)); OnPropertyChanged(nameof(ValueTypeName));
OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive)); OnPropertyChanged(nameof(MaxPoints)); OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive));
ErrorMessage = ex.Message; ErrorMessage = ex.Message;
} }
} }
partial void OnLabelChanged(string value) => TrySave(() => _aspect.Label = value); partial void OnLabelChanged(string value) => TrySave(() => _aspect.Label = value);
partial void OnValueTypeNameChanged(string value) => TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value)); partial void OnValueTypeNameChanged(string value)
{
TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value));
OnPropertyChanged(nameof(IsPointsType));
}
partial void OnMaxPointsChanged(int value) => TrySave(() => _aspect.MaxPoints = value);
partial void OnWeightChanged(double value) => TrySave(() => _aspect.Weight = value); partial void OnWeightChanged(double value) => TrySave(() => _aspect.Weight = value);
partial void OnIsActiveChanged(bool value) => TrySave(() => _aspect.IsActive = value); partial void OnIsActiveChanged(bool value) => TrySave(() => _aspect.IsActive = value);
} }
@@ -1017,9 +1086,11 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
[ObservableProperty] private string _newKey = ""; [ObservableProperty] private string _newKey = "";
[ObservableProperty] private string _newLabel = ""; [ObservableProperty] private string _newLabel = "";
[ObservableProperty] private string _newValueTypeName = AspectValueTypeDisplay.Options[0]; [ObservableProperty] private string _newValueTypeName = AspectValueTypeDisplay.Options[0];
[ObservableProperty] private int _newMaxPoints = 5;
[ObservableProperty] private string _newAspectError = ""; [ObservableProperty] private string _newAspectError = "";
public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; public string[] ValueTypeOptions => AspectValueTypeDisplay.Options;
public bool IsNewPointsType => AspectValueTypeDisplay.FromName(NewValueTypeName) == AspectValueType.Points;
public ObservableCollection<AspectEditItem> Aspects { get; } = []; public ObservableCollection<AspectEditItem> Aspects { get; } = [];
public Func<AspectEditItem, Task<bool>>? OnConfirmDelete { get; set; } public Func<AspectEditItem, Task<bool>>? OnConfirmDelete { get; set; }
@@ -1029,6 +1100,8 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
Load(); Load();
} }
partial void OnNewValueTypeNameChanged(string value) => OnPropertyChanged(nameof(IsNewPointsType));
private void Load() private void Load()
{ {
Aspects.Clear(); Aspects.Clear();
@@ -1069,6 +1142,7 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
Key = NewKey.Trim(), Key = NewKey.Trim(),
Label = NewLabel.Trim(), Label = NewLabel.Trim(),
ValueType = AspectValueTypeDisplay.FromName(NewValueTypeName), ValueType = AspectValueTypeDisplay.FromName(NewValueTypeName),
MaxPoints = NewMaxPoints,
SortOrder = Aspects.Count, SortOrder = Aspects.Count,
}; };
try { _repo.Save(aspect); } try { _repo.Save(aspect); }
@@ -1080,7 +1154,7 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
Aspects.Add(CreateItem(aspect)); Aspects.Add(CreateItem(aspect));
RefreshMoveState(); RefreshMoveState();
NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; NewMaxPoints = 5;
} }
private async void DeleteAspectAsync(AspectEditItem item) private async void DeleteAspectAsync(AspectEditItem item)
@@ -30,6 +30,9 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private readonly List<ParticipationSession> _allSessions; private readonly List<ParticipationSession> _allSessions;
private readonly List<ParticipationSection> _sectionList; private readonly List<ParticipationSection> _sectionList;
private readonly Dictionary<string, double> _aspectWeights; private readonly Dictionary<string, double> _aspectWeights;
// Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
// Gewichtung, analog zu ParticipationGradeDialogViewModel.
private readonly Dictionary<string, ParticipationAspect> _aspectsByKey;
public string GroupLabel { get; } public string GroupLabel { get; }
@@ -81,10 +84,10 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
GroupLabel = groupLabel; GroupLabel = groupLabel;
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId); _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
_aspectWeights = aspects.GetDefaults() var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
.Concat(aspects.GetByGroup(groupId)) .GroupBy(a => a.Key).Select(g => g.Last()).ToList();
.GroupBy(a => a.Key) _aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight);
.ToDictionary(g => g.Key, g => g.Last().Weight); _aspectsByKey = applicableAspects.ToDictionary(a => a.Key);
var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear); var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear);
_students = students.GetByGroup(groupId) _students = students.GetByGroup(groupId)
.Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership) .Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership)
@@ -221,11 +224,15 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
{ {
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue; if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w; valueSum += NormalizedValue(r) * w; weightSum += w;
} }
return weightSum <= 0 ? null : valueSum / weightSum; return weightSum <= 0 ? null : valueSum / weightSum;
} }
private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect)
? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
: r.Value;
private static string RatingLabel(int v) => v switch private static string RatingLabel(int v) => v switch
{ {
>= 2 => "++", 1 => "+", 0 => "~", -1 => "", _ => "−−", >= 2 => "++", 1 => "+", 0 => "~", -1 => "", _ => "−−",
@@ -306,7 +313,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
{ {
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue; if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w; valueSum += NormalizedValue(r) * w; weightSum += w;
} }
if (weightSum > 0) points.Add(valueSum / weightSum); if (weightSum > 0) points.Add(valueSum / weightSum);
} }
@@ -20,23 +20,27 @@
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" <Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8"> BorderThickness="0,0,0,1" Padding="0,8">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<Grid ColumnDefinitions="70,*,120,80,60,26,26,26"> <Grid ColumnDefinitions="70,*,120,55,80,60,26,26,26">
<TextBlock Grid.Column="0" Text="{Binding Key}" FontSize="12" Opacity="0.6" <TextBlock Grid.Column="0" Text="{Binding Key}" FontSize="12" Opacity="0.6"
VerticalAlignment="Center" Margin="0,0,8,0" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" Margin="0,0,8,0" TextTrimming="CharacterEllipsis"
ToolTip.Tip="Schlüssel, nach dem Neuanlegen nicht mehr änderbar (Verknüpfung zu bereits erfassten Bewertungen)."/> ToolTip.Tip="Schlüssel, nach dem Neuanlegen nicht mehr änderbar (Verknüpfung zu bereits erfassten Bewertungen)."/>
<TextBox Grid.Column="1" Text="{Binding Label}" Margin="0,0,6,0"/> <TextBox Grid.Column="1" Text="{Binding Label}" Margin="0,0,6,0"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding ValueTypeOptions}" <ComboBox Grid.Column="2" ItemsSource="{Binding ValueTypeOptions}"
SelectedItem="{Binding ValueTypeName}" Margin="0,0,6,0"/> SelectedItem="{Binding ValueTypeName}" Margin="0,0,6,0"/>
<NumericUpDown Grid.Column="3" Value="{Binding Weight}" Minimum="0" Maximum="10" <NumericUpDown Grid.Column="3" Value="{Binding MaxPoints}" Minimum="1" Maximum="100"
FormatString="0" ShowButtonSpinner="False" Margin="0,0,6,0"
IsVisible="{Binding IsPointsType}"
ToolTip.Tip="Maximal erreichbare Punktzahl."/>
<NumericUpDown Grid.Column="4" Value="{Binding Weight}" Minimum="0" Maximum="10"
FormatString="0.0" Increment="0.1" ShowButtonSpinner="False" Margin="0,0,6,0"/> FormatString="0.0" Increment="0.1" ShowButtonSpinner="False" Margin="0,0,6,0"/>
<CheckBox Grid.Column="4" Content="Aktiv" IsChecked="{Binding IsActive}" <CheckBox Grid.Column="5" Content="Aktiv" IsChecked="{Binding IsActive}"
VerticalAlignment="Center" Margin="0,0,4,0" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip.Tip="Deaktivieren statt löschen, damit bereits erfasste Bewertungen dieses Aspekts gültig bleiben."/> ToolTip.Tip="Deaktivieren statt löschen, damit bereits erfasste Bewertungen dieses Aspekts gültig bleiben."/>
<Button Grid.Column="5" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2" <Button Grid.Column="6" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
ToolTip.Tip="Nach oben"/> ToolTip.Tip="Nach oben"/>
<Button Grid.Column="6" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2" <Button Grid.Column="7" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
Margin="2,0,0,0" ToolTip.Tip="Nach unten"/> Margin="2,0,0,0" ToolTip.Tip="Nach unten"/>
<Button Grid.Column="7" Content="✕" Command="{Binding DeleteCommand}" Padding="4,2" <Button Grid.Column="8" Content="✕" Command="{Binding DeleteCommand}" Padding="4,2"
Margin="2,0,0,0" ToolTip.Tip="Endgültig löschen"/> Margin="2,0,0,0" ToolTip.Tip="Endgültig löschen"/>
</Grid> </Grid>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="11"
@@ -52,7 +56,7 @@
<Separator Margin="0,4"/> <Separator Margin="0,4"/>
<TextBlock Text="Neuer Aspekt" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Neuer Aspekt" FontSize="14" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="*,12,*,12,160"> <Grid ColumnDefinitions="*,12,*,12,140,12,90">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Schlüssel *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Schlüssel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewKey}" PlaceholderText="z.B. experiment"/> <TextBox Text="{Binding NewKey}" PlaceholderText="z.B. experiment"/>
@@ -66,6 +70,11 @@
<ComboBox ItemsSource="{Binding ValueTypeOptions}" SelectedItem="{Binding NewValueTypeName}" <ComboBox ItemsSource="{Binding ValueTypeOptions}" SelectedItem="{Binding NewValueTypeName}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="6" Spacing="4" IsVisible="{Binding IsNewPointsType}">
<TextBlock Text="Max. Punkte" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewMaxPoints}" Minimum="1" Maximum="100"
FormatString="0" ShowButtonSpinner="False"/>
</StackPanel>
</Grid> </Grid>
<TextBlock Text="{Binding NewAspectError}" Foreground="Red" FontSize="12" <TextBlock Text="{Binding NewAspectError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewAspectError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding NewAspectError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -49,8 +49,7 @@
<!-- Tastenkürzel-Legende + Schließen-Button --> <!-- Tastenkürzel-Legende + Schließen-Button -->
<StackPanel Grid.Row="2" Margin="0,16,0,0" Spacing="6"> <StackPanel Grid.Row="2" Margin="0,16,0,0" Spacing="6">
<TextBlock Opacity="0.35" FontSize="11" TextWrapping="Wrap" <TextBlock Opacity="0.35" FontSize="11" TextWrapping="Wrap" Text="{Binding HotkeyLegend}"/>
Text="15 bewerten · Q/W/E/R/T Aspekt wählen · Leertaste nächster Aspekt · Enter nächster Schüler · Backspace vorheriger · +/ anpassen · Esc schließen"/>
<Button Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/> <Button Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -21,11 +21,17 @@ public partial class ParticipationQuickInputDialog : Window
switch (e.Key) switch (e.Key)
{ {
case Key.D0 or Key.NumPad0: vm.SetRatingByNumber(0); e.Handled = true; break;
case Key.D1 or Key.NumPad1: vm.SetRatingByNumber(1); e.Handled = true; break; case Key.D1 or Key.NumPad1: vm.SetRatingByNumber(1); e.Handled = true; break;
case Key.D2 or Key.NumPad2: vm.SetRatingByNumber(2); e.Handled = true; break; case Key.D2 or Key.NumPad2: vm.SetRatingByNumber(2); e.Handled = true; break;
case Key.D3 or Key.NumPad3: vm.SetRatingByNumber(3); e.Handled = true; break; case Key.D3 or Key.NumPad3: vm.SetRatingByNumber(3); e.Handled = true; break;
case Key.D4 or Key.NumPad4: vm.SetRatingByNumber(4); e.Handled = true; break; case Key.D4 or Key.NumPad4: vm.SetRatingByNumber(4); e.Handled = true; break;
case Key.D5 or Key.NumPad5: vm.SetRatingByNumber(5); e.Handled = true; break; case Key.D5 or Key.NumPad5: vm.SetRatingByNumber(5); e.Handled = true; break;
// 6-9 sind nur für Punkte-Aspekte (3.1.3) sinnvoll, siehe QuickInputViewModel.SetRatingByNumber.
case Key.D6 or Key.NumPad6: vm.SetRatingByNumber(6); e.Handled = true; break;
case Key.D7 or Key.NumPad7: vm.SetRatingByNumber(7); e.Handled = true; break;
case Key.D8 or Key.NumPad8: vm.SetRatingByNumber(8); e.Handled = true; break;
case Key.D9 or Key.NumPad9: vm.SetRatingByNumber(9); e.Handled = true; break;
case Key.Q: vm.SelectAspect(0); e.Handled = true; break; case Key.Q: vm.SelectAspect(0); e.Handled = true; break;
case Key.W: vm.SelectAspect(1); e.Handled = true; break; case Key.W: vm.SelectAspect(1); e.Handled = true; break;
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Avalonia.Data; using Avalonia.Data;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -103,39 +104,76 @@ public partial class ParticipationTabView : UserControl
: row.Cells.ElementAtOrDefault(cellIndex); : row.Cells.ElementAtOrDefault(cellIndex);
if (cell is null) return new TextBlock(); if (cell is null) return new TextBlock();
var panel = new StackPanel // Punkte-Aspekte (3.1.3) sind eine freie Zahl 0..MaxPoints statt einer festen
{ // Stufenauswahl — dafür ein NumericUpDown statt der Stufen-Buttons unten.
Orientation = Avalonia.Layout.Orientation.Horizontal, return cell.Type == AspectValueType.Points
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, ? BuildPointsCell(cell, isReadOnly)
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, : BuildStepButtonsCell(cell, isReadOnly);
Spacing = 2,
Margin = new Avalonia.Thickness(0, 2),
};
foreach (var (label, val) in new[] { ("−−", -2), ("", -1), ("", 0), ("+", 1), ("++", 2) })
{
var btn = new Button
{
Content = label,
Padding = new Avalonia.Thickness(5, 1),
FontSize = 11,
Opacity = cell.Value == val ? 1.0 : 0.3,
IsEnabled = !isReadOnly,
};
var capturedVal = val;
btn.Click += (_, _) => cell.SetValue(capturedVal);
cell.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(RatingCell.Value))
btn.Opacity = cell.Value == capturedVal ? 1.0 : 0.3;
};
panel.Children.Add(btn);
}
return panel;
}); });
} }
private static Control BuildStepButtonsCell(RatingCell cell, bool isReadOnly)
{
var panel = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Spacing = 2,
Margin = new Avalonia.Thickness(0, 2),
};
foreach (var (val, label) in ParticipationRatingScale.Steps(cell.Type))
{
var btn = new Button
{
Content = label,
Padding = new Avalonia.Thickness(5, 1),
FontSize = 11,
Opacity = cell.Value == val ? 1.0 : 0.3,
IsEnabled = !isReadOnly,
};
var capturedVal = val;
btn.Click += (_, _) => cell.SetValue(capturedVal);
cell.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(RatingCell.Value))
btn.Opacity = cell.Value == capturedVal ? 1.0 : 0.3;
};
panel.Children.Add(btn);
}
return panel;
}
private static Control BuildPointsCell(RatingCell cell, bool isReadOnly)
{
var updown = new NumericUpDown
{
Minimum = 0,
Maximum = cell.MaxPoints,
Increment = 1,
FormatString = "0",
Value = cell.Value,
IsEnabled = !isReadOnly,
Width = 72,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
};
updown.ValueChanged += (_, e) =>
{
var newVal = e.NewValue.HasValue ? (int?)e.NewValue.Value : null;
if (newVal != cell.Value) cell.SetValue(newVal);
};
cell.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName != nameof(RatingCell.Value)) return;
var current = cell.Value.HasValue ? (decimal?)cell.Value.Value : null;
if (updown.Value != current) updown.Value = current;
};
return updown;
}
private static IDataTemplate BuildHomeworkCellTemplate(bool isReadOnly) private static IDataTemplate BuildHomeworkCellTemplate(bool isReadOnly)
{ {
return new FuncDataTemplate<ParticipationStudentRow>((row, _) => return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
@@ -0,0 +1,80 @@
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Tests;
/// Tests für 3.1.3 (Aspekt-Typen Scale3/Binary/Points): ParticipationRatingScale definiert die
/// Rohwerte je Typ und ihre Normierung auf die gemeinsame -2..+2-Achse für die Mitarbeitsnote (3.2).
public sealed class ParticipationRatingScaleTests
{
[Fact]
public void Steps_Scale5_LiefertFuenfStufenAufDerMinus2Bis2Achse()
{
var steps = ParticipationRatingScale.Steps(AspectValueType.Scale5);
Assert.Equal([-2, -1, 0, 1, 2], steps.Select(s => s.Value));
}
[Fact]
public void Steps_Scale3_LiefertDreiStufenAufDerselbenAchse()
{
var steps = ParticipationRatingScale.Steps(AspectValueType.Scale3);
Assert.Equal([-2, 0, 2], steps.Select(s => s.Value));
}
[Fact]
public void Steps_Binary_LiefertNurDieBeidenExtremwerte()
{
var steps = ParticipationRatingScale.Steps(AspectValueType.Binary);
Assert.Equal([-2, 2], steps.Select(s => s.Value));
}
[Fact]
public void Steps_Points_LiefertKeineFestenStufen()
{
Assert.Empty(ParticipationRatingScale.Steps(AspectValueType.Points));
}
[Fact]
public void DisplayLabel_OhneWert_ZeigtPlatzhalter()
{
Assert.Equal("·", ParticipationRatingScale.DisplayLabel(AspectValueType.Scale5, null));
}
[Fact]
public void DisplayLabel_Points_ZeigtRohenZahlenwert()
{
Assert.Equal("3", ParticipationRatingScale.DisplayLabel(AspectValueType.Points, 3));
}
[Theory]
[InlineData(0, 4, -2.0)]
[InlineData(2, 4, 0.0)]
[InlineData(4, 4, 2.0)]
[InlineData(1, 4, -1.0)]
public void Normalize_Points_BildetLinearAufDieMinus2Bis2AchseAb(int value, int maxPoints, double expected)
{
Assert.Equal(expected, ParticipationRatingScale.Normalize(AspectValueType.Points, value, maxPoints));
}
[Fact]
public void Normalize_Points_KappteAusserhalbDesBereichsLiegendeWerte()
{
Assert.Equal(2.0, ParticipationRatingScale.Normalize(AspectValueType.Points, 99, 4));
Assert.Equal(-2.0, ParticipationRatingScale.Normalize(AspectValueType.Points, -5, 4));
}
[Fact]
public void Normalize_Points_OhneMaxPoints_WertetNeutralStattDurchNullZuTeilen()
{
Assert.Equal(0.0, ParticipationRatingScale.Normalize(AspectValueType.Points, 3, 0));
}
[Theory]
[InlineData(AspectValueType.Scale5)]
[InlineData(AspectValueType.Scale3)]
[InlineData(AspectValueType.Binary)]
public void Normalize_NichtPunkteTypen_LiegenBereitsAufDerAchse(AspectValueType type)
{
Assert.Equal(1.0, ParticipationRatingScale.Normalize(type, 1, maxPoints: 5));
}
}
+29 -6
View File
@@ -177,12 +177,35 @@ Siehe [ParticipationViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/Participa
8.1.1, `SortOrder` wird bei jeder Verschiebung sofort und lückenlos gespeichert. 8.1.1, `SortOrder` wird bei jeder Verschiebung sofort und lückenlos gespeichert.
**Drag & Drop nicht umgesetzt:** Hoch/Runter deckt die vollständige Bedienung bereits ab, **Drag & Drop nicht umgesetzt:** Hoch/Runter deckt die vollständige Bedienung bereits ab,
analog zur Drag-Entscheidung bei 4.3.3. analog zur Drag-Entscheidung bei 4.3.3.
- [ ] **3.1.3** Aspekt-Typen `Scale3`, `Binary`, `Points` in Raster und Dialog vollständig unterstützen - [x] **3.1.3** Aspekt-Typen `Scale3`, `Binary`, `Points` in Raster und Dialog vollständig
(bisher primär `Scale5`). **Weiterhin offen:** 3.1.1 macht den Typ zwar anlegbar/speicherbar unterstützen (bisher primär `Scale5`) — Nutzer-Nachtrag, damit das Bewertungsfeature mit
(`AspectValueTypeDisplay`, ComboBox in der Verwaltung), das Bewertungsraster und der selbst angelegten Aspekt-Typen produktiv nutzbar wird.
Schnelleingabe-Dialog rendern und interpretieren Eingaben aber weiterhin nach dem **Umsetzung:** Neue statische Klasse `ParticipationRatingScale` (Core) ist die einzige
Scale5-Schema — ein als `Binary`/`Points`/`Scale3` angelegter Aspekt wird dort noch nicht Quelle für Rohwert/Label-Stufen je Typ und für die Normierung zur Aggregation. Bewusste
passend dargestellt. Bewusst getrennt von 3.1.1, da das die Bewertungs-UI selbst betrifft. Design-Entscheidung: `Scale3`/`Binary` bekommen **keine eigene Wertemenge**, sondern liegen
direkt auf derselben -2..+2-Achse wie `Scale5` (Scale3: -2/0/+2, Binary: -2/+2 für
Nein/Ja) — dadurch bleibt die bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote
(3.2) unverändert kompatibel, ohne pro Bewertung erst umrechnen zu müssen. Nur `Points` ist
grundverschieden (ein echter Zählwert 0..`MaxPoints`, neues Feld auf `ParticipationAspect`)
und wird ausschließlich zum Aggregationszeitpunkt linear auf dieselbe Achse normiert
(`ParticipationRatingScale.Normalize`, 0 → -2, MaxPoints → +2) — dafür mussten die drei
Stellen, die bisher `r.Value * gewicht` direkt aufsummierten
(`ParticipationGradeDialogViewModel.Recompute`, `ParticipationWizardViewModels.WeightedRating`
und `.ComputeSuggestion`), auf die Normierung umgestellt werden; ohne diese Korrektur hätte
ein Punkte-Aspekt die Mitarbeitsnote grob verfälscht (z.B. "4 Punkte" als Rohwert weit
außerhalb der -2..+2-Skala).
Raster (`ParticipationTabView`): Zellen für `Points`-Aspekte sind jetzt ein
`NumericUpDown` (0..MaxPoints) statt der fünf Stufen-Buttons, die anderen Typen zeigen die
jeweils passende Buttonzahl (2/3/5) aus `ParticipationRatingScale.Steps`.
Schnelleingabe-Dialog: Zifferntasten und +/ sind jetzt typabhängig (Scale3: Tasten 13,
Binary: 1/2, Points: Tasten 09 als direkte Zahleneingabe bis `MaxPoints`, sonst Cycling
mit +/); die Tastenkürzel-Legende im Dialog zeigt dafür jetzt dynamisch die für den
aktuell gewählten Aspekt gültigen Tasten statt eines festen Textes.
Aspekt-Verwaltung (3.1.1) um ein "Max. Punkte"-Feld ergänzt, nur sichtbar bei Typ "Punkte".
**Bewusst nicht angefasst:** die spezialisierte Trendlinien-Visualisierung im
Mitarbeits-Assistenten (Kapitel 3.2-Erweiterung) ist weiterhin fest auf die drei
Standardaspekte (Qualität/Quantität/Arbeitsphase) zugeschnitten — eigene Trendlinien für
beliebige, selbst angelegte Aspekte wäre ein eigener, größerer Umbau dieser Ansicht.
- [x] **3.1.4** Aspekt deaktivieren statt löschen, damit alte Einträge gültig bleiben. - [x] **3.1.4** Aspekt deaktivieren statt löschen, damit alte Einträge gültig bleiben.
**Umsetzung:** Checkbox "Aktiv" je Zeile in der neuen Verwaltung (3.1.1); die bestehenden **Umsetzung:** Checkbox "Aktiv" je Zeile in der neuen Verwaltung (3.1.1); die bestehenden
Abfragen (`GetByGroup`/`GetDefaults`, für Bewertungsraster und -aggregation) filterten Abfragen (`GetByGroup`/`GetDefaults`, für Bewertungsraster und -aggregation) filterten