Compare commits

...
3 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5.5 d61a97de1e Punkteeingabe: nur angefahrene Zeilen nicht mehr als 0 Punkte/Note 6 speichern
CI / build-and-test (push) Canceled after 0s
Das Verlassen eines unveränderten Punktefelds löste Autosave aus; leere Zellen
wurden als 0 gespeichert und ergaben Note 6. Zellen melden nur noch echte
Änderungen, Zeilen ohne Werte werden nicht angelegt und haben keine Note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:12:13 +02:00
adminandClaude Sonnet 5.5 41649b0a05 Förderschüler je Gruppe markieren und Förderarbeit darauf beschränken
GroupMembership.IsSupportStudent markiert Förderschüler unabhängig von der
Niveaudifferenzierung (Checkbox-Spalte in der Schülerliste; in differenzierten
Gruppen zählt das Förderniveau). Punkteeingabe und Deckblätter einer
Förderarbeit zeigen nur Förderschüler und Schüler mit vorhandenem Ergebnis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:00:56 +02:00
adminandClaude Sonnet 5.5 651c9cddf8 Förderarbeit als dritte Folgearbeits-Art ergänzen
Neben Wiederholung und Nachschreibarbeit gibt es im Arbeitsdialog die Art
Förderarbeit (Exam.IsSupportWork). Sie darf am Tag der ersten Arbeit liegen,
übernimmt dessen Datum als Vorschlag und ersetzt wie eine Nachschreibarbeit
nur fehlende Noten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:55:27 +02:00
22 changed files with 394 additions and 38 deletions

No files matched your search

+11
View File
@@ -32,6 +32,10 @@ public class Exam
/// erste Arbeit (<see cref="RepeatOfExamId"/>) versäumt haben. Anders als eine Wiederholung
/// wird sie nur von diesen Schülern geschrieben und ersetzt deren fehlende Note.</summary>
public bool IsMakeUp { get; set; }
/// <summary>Förderarbeit: inhaltlich eigene Arbeit, die einzelne Schüler statt der verknüpften
/// ersten Arbeit schreiben — typischerweise am selben Tag, daher darf sie am Datum der ersten
/// Arbeit liegen. Zählt wie eine Nachschreibarbeit (ersetzt nur eine fehlende Note).</summary>
public bool IsSupportWork { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -78,6 +82,13 @@ public enum RepeatGradeRule { Better, Repeat, Original }
/// <summary>Reihenfolge ist persistiert (LiteDB speichert den Enum-Wert) — nur hinten anhängen.</summary>
public enum ExamKind { Klausur, Test, KontrollierteHausaufgabe, Sonstiges }
public static class ExamFollowUpExtensions
{
/// <summary>Nachschreib- und Förderarbeiten sind inhaltlich eigene Arbeiten nur für einzelne
/// Schüler: ihre Note ersetzt eine fehlende Note der ersten Arbeit, verdrängt sie aber nie.</summary>
public static bool ReplacesMissingGrade(this Exam exam) => exam.IsMakeUp || exam.IsSupportWork;
}
public static class ExamKindExtensions
{
public static bool CountsAsWritten(this ExamKind kind) => kind == ExamKind.Klausur;
+5
View File
@@ -68,6 +68,11 @@ public class GroupMembership
public DateOnly? JoinedAt { get; set; }
public DateOnly? LeftAt { get; set; }
public Niveau? Niveau { get; set; }
/// <summary>Förderschüler-Markierung, unabhängig von der Niveaudifferenzierung der Gruppe
/// (<see cref="LearningGroup.IsDifferentiated"/>): auch in einem nicht differenzierten Kurs
/// bekommen einzelne Schüler eigenes Fördermaterial. Auswertung siehe
/// <see cref="Services.GroupMembershipService.IsSupportStudent"/>.</summary>
public bool IsSupportStudent { get; set; }
}
public enum MembershipPeriod { FullYear, H1Only, H2Only, Custom }
public enum GroupType { Class, Course }
@@ -37,12 +37,12 @@ public static class ExamCoverSheetService
/// <summary>Wer eine Arbeit mitschreibt — eine Regel für Punkteeingabe und Deckblätter.</summary>
public static class ExamParticipantService
{
/// <summary>Bei einer Nachschreibarbeit: Schüler, die die erste Arbeit bereits mit Note
/// geschrieben haben. Sonst leer.</summary>
/// <summary>Bei einer Nachschreib- oder Förderarbeit: Schüler, die die erste Arbeit bereits mit
/// Note geschrieben haben. Sonst leer.</summary>
public static HashSet<Guid> WroteOriginal(Exam exam, Func<Guid, List<ExamResult>> resultsByExam)
{
var wroteOriginal = new HashSet<Guid>();
if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
if (exam.ReplacesMissingGrade() && exam.RepeatOfExamId is { } originalId)
foreach (var r in resultsByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
wroteOriginal.Add(r.StudentId);
return wroteOriginal;
@@ -54,7 +54,9 @@ public static class ExamParticipantService
// Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten
// weiterhin für die ganze Gruppe.
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false;
// Nachschreibarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf —
// Förderarbeit: nur Förderschüler (außer es gibt hier bereits ein Ergebnis).
if (exam.IsSupportWork && !hasResult && !GroupMembershipService.IsSupportStudent(membership)) return false;
// Nachschreib-/Förderarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf —
// außer es gibt hier bereits ein Ergebnis (nichts verstecken, was schon eingetragen wurde).
return hasResult || !wroteOriginal;
}
@@ -45,10 +45,10 @@ public static class ExamScoringService
AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum();
/// <summary>
/// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreibarbeit)
/// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreib-/Förderarbeit)
/// genau eine Note. Andere Arbeiten bleiben erhalten. Fehlt die erste Arbeit (Schüler war
/// abwesend), zählt die Nachschreibarbeit allein; hat der Schüler beide geschrieben, zählt bei
/// einer Nachschreibarbeit immer die erste — sie ersetzt nur eine fehlende Note.
/// abwesend), zählt die Nachschreib-/Förderarbeit allein; hat der Schüler beide geschrieben,
/// zählt immer die erste — sie ersetzen nur eine fehlende Note.
/// </summary>
public static List<string> EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries,
GradingSystem system)
@@ -72,7 +72,7 @@ public static class ExamScoringService
var current = ordered[0].Grade;
foreach (var next in ordered.Skip(1))
current = Choose(current, next.Grade,
next.Exam.IsMakeUp ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system);
next.Exam.ReplacesMissingGrade() ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system);
result.Add(current);
}
return result;
@@ -10,6 +10,11 @@ public enum SchoolYearPeriodKind { FullYear, H1, H2 }
/// </summary>
public static class GroupMembershipService
{
/// <summary>Förderschüler ist, wer markiert ist oder in einer differenzierten Gruppe das
/// Förderniveau hat.</summary>
public static bool IsSupportStudent(GroupMembership? membership) =>
membership is { } m && (m.IsSupportStudent || m.Niveau == Niveau.Foerder);
public static bool IsActiveOn(GroupMembership membership, DateOnly date)
{
if (membership.JoinedAt is { } joinedAt && date < joinedAt) return false;
@@ -80,6 +80,7 @@ public sealed class GroupRolloverService(
JoinedAt = targetStart,
LeftAt = null,
Niveau = membership.Niveau,
IsSupportStudent = membership.IsSupportStudent,
});
}
@@ -84,4 +84,81 @@ public sealed class ExamDialogViewModelTests
Assert.Equal(10, result.TotalPoints);
Assert.Equal("1", result.Grade);
}
// ── Förderarbeit ─────────────────────────────────────────────────────────
private static (ExamDialogViewModel Vm, Exam Original) BuildFollowUpViewModel(DateOnly originalDate)
{
var original = new Exam { GroupId = Guid.NewGuid(), Title = "Atombau", Date = originalDate };
var vm = new ExamDialogViewModel(new FakeExams([original]), new FakeCompetencyDomains(),
new FakeGradingKeyTemplates(), new GradingService(), original.GroupId, null, 9,
GradingSystem.Grades1To6, "Chemie", false, null, null);
vm.Title = "Atombau (Förder)";
vm.Tasks.Add(new ExamTaskEditItem("A1", 10, 1, [], []) { OnChanged = () => { } });
vm.Tasks[0].MaxPoints = 10;
vm.SelectedOriginalExam = original;
return (vm, original);
}
[Fact]
public void Foerderarbeit_UebernimmtDatumDerErstenArbeitUndDarfAmSelbenTagLiegen()
{
var (vm, original) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14));
vm.FollowUpKindName = "Förderarbeit";
Assert.Equal("14.10.2026", vm.DateText);
vm.SaveCommand.Execute(null);
Assert.Equal("", vm.RepeatError);
Assert.NotNull(vm.Result);
Assert.True(vm.Result!.IsSupportWork);
Assert.False(vm.Result.IsMakeUp);
Assert.Equal(original.Id, vm.Result.RepeatOfExamId);
Assert.Equal(original.Date, vm.Result.Date);
}
[Fact]
public void Foerderarbeit_VorDerErstenArbeit_WirdAbgelehnt()
{
var (vm, _) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14));
vm.FollowUpKindName = "Förderarbeit";
vm.DateText = "13.10.2026";
vm.SaveCommand.Execute(null);
Assert.Contains("Förderarbeit", vm.RepeatError);
Assert.Null(vm.Result);
}
[Fact]
public void Nachschreibarbeit_AmSelbenTag_BleibtAbgelehnt()
{
var (vm, _) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14));
vm.FollowUpKindName = "Nachschreibarbeit";
vm.DateText = "14.10.2026";
vm.SaveCommand.Execute(null);
Assert.Contains("Nachschreibarbeit", vm.RepeatError);
Assert.Null(vm.Result);
}
[Fact]
public void Foerderarbeit_WirdBeimBearbeitenWiederhergestelltOhneDatumZuUeberschreiben()
{
var original = new Exam { GroupId = Guid.NewGuid(), Title = "Atombau", Date = new DateOnly(2026, 10, 14) };
var support = new Exam
{
GroupId = original.GroupId, Title = "Förder", Date = new DateOnly(2026, 10, 16),
RepeatOfExamId = original.Id, IsSupportWork = true,
};
var vm = new ExamDialogViewModel(new FakeExams([original, support]), new FakeCompetencyDomains(),
new FakeGradingKeyTemplates(), new GradingService(), original.GroupId, null, 9,
GradingSystem.Grades1To6, "Chemie", false, support, null);
Assert.True(vm.IsSupportWork);
Assert.Equal("16.10.2026", vm.DateText);
Assert.False(vm.ShowRepeatRule);
}
}
@@ -1,3 +1,4 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
@@ -133,4 +134,95 @@ public sealed class ExamGradingDialogViewModelTests
Assert.Equal(["Aufgabe 1", "★ Bonus", "2. Redox"], vm.TaskHeaders);
}
private sealed class RecordingResults : FakeResults, IExamResultRepository
{
public List<ExamResult> Saved { get; } = [];
public new void Save(ExamResult result) => Saved.Add(result);
}
private static (ExamGradingDialogViewModel Vm, RecordingResults Results) CreateRecordingGrid(
ExamResult? existing = null)
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
var exam = new Exam
{
GroupId = group.Id, Title = "Klausur 1", Tasks = [.. ThreeTasks()],
GradingKey = GradingService.DefaultKey1To6(),
};
var results = new RecordingResults();
if (existing is not null)
{
existing.ExamId = exam.Id; existing.StudentId = student.Id;
results.Add(existing);
}
var vm = new ExamGradingDialogViewModel(results, new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = group.Id, StudentId = student.Id }]),
new GradingService(), exam, group.Id);
return (vm, results);
}
[Fact]
public void NurAngefahreneZelle_LegtKeinErgebnisMitNullPunktenAn()
{
var (vm, results) = CreateRecordingGrid();
// Fokus in die Zelle und wieder heraus ohne Eingabe: die View meldet das als TrySetValue(null).
vm.Rows[0].Cells[0].TrySetValue(null);
vm.Rows[0].Cells[1].TrySetValue(null);
Assert.Empty(results.Saved);
Assert.Equal("", vm.Rows[0].GradeDisplay);
}
[Fact]
public void ErsterEingetragenerWert_LegtDasErgebnisAn()
{
var (vm, results) = CreateRecordingGrid();
vm.Rows[0].Cells[0].TrySetValue(4);
var saved = Assert.Single(results.Saved);
Assert.Equal([4, 0, 0], saved.Points);
Assert.Equal(4, saved.TotalPoints);
}
[Fact]
public void UnveraenderterWert_SpeichertNichtErneut()
{
var (vm, results) = CreateRecordingGrid();
vm.Rows[0].Cells[0].TrySetValue(4);
vm.Rows[0].Cells[0].TrySetValue(4);
Assert.Single(results.Saved);
}
[Fact]
public void LeereZeileMitBestehendemErgebnis_WirdAlsUnkorrigiertGespeichertNichtAlsNull()
{
var (vm, results) = CreateRecordingGrid(
new ExamResult { Points = [4], TotalPoints = 4, Grade = "4" });
vm.Rows[0].Cells[0].TrySetValue(null);
var saved = Assert.Single(results.Saved);
Assert.Empty(saved.Points);
Assert.Null(saved.Grade);
Assert.False(ExamCoverSheetService.IsCorrected(saved));
}
[Fact]
public void AbwesendheitOhnePunkte_WirdOhnePunkteUndNoteGespeichert()
{
var (vm, results) = CreateRecordingGrid();
vm.Rows[0].Absent = true;
var saved = Assert.Single(results.Saved);
Assert.True(saved.Absent);
Assert.Empty(saved.Points);
Assert.Null(saved.Grade);
}
}
@@ -22,7 +22,8 @@ public static class GradeReportDrawingBuilder
if (exam.RepeatOfExamId is null) return new DrawingValue([], 0);
if (original is null || original.Id != exam.RepeatOfExamId || original.GroupId != exam.GroupId)
throw new InvalidDataException("Die verknüpfte erste Arbeit ist nicht verfügbar.");
var rule = exam.IsMakeUp ? "Die Nachschreibarbeit ersetzt die versäumte erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit."
var rule = exam.IsSupportWork ? "Die Förderarbeit ersetzt die erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit."
: exam.IsMakeUp ? "Die Nachschreibarbeit ersetzt die versäumte erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit."
: exam.RepeatGradeRule switch
{
RepeatGradeRule.Repeat => "Für die Bewertung zählt die Note der Wiederholungsarbeit.",
@@ -35,7 +36,7 @@ public static class GradeReportDrawingBuilder
ExamScoringService.Maximum(original.Tasks, originalResult.Variant), original.GradingKey);
var firstGrade = originalResult is { Absent: true } ? "abwesend" : Value(grade);
return Text(template, ReportTextDrawing.RepeatInfo,
exam.IsMakeUp ? "Nachschreibarbeit" : "Wiederholungsarbeit",
exam.IsSupportWork ? "Förderarbeit" : exam.IsMakeUp ? "Nachschreibarbeit" : "Wiederholungsarbeit",
[$"Erste Arbeit: {original.Title} vom {original.Date:dd.MM.yyyy}",
$"Note der ersten Arbeit: {firstGrade}", rule]);
}
@@ -17,6 +17,7 @@ public class GroupMembershipTools(
[Description("Schüler-ID.")] Guid studentId,
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
[Description("Förderschüler-Markierung (auch ohne Niveaudifferenzierung der Gruppe). Unverändert lassen: weglassen.")] bool? isSupportStudent = null,
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
@@ -34,6 +35,7 @@ public class GroupMembershipTools(
var changes = new StringBuilder();
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
if (isSupportStudent is not null && isSupportStudent != target.IsSupportStudent) { changes.AppendLine($"Förderschüler: {(target.IsSupportStudent ? "ja" : "nein")} → {(isSupportStudent.Value ? "ja" : "nein")}"); target.IsSupportStudent = isSupportStudent.Value; }
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
@@ -57,6 +59,7 @@ public class GroupMembershipTools(
{
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
IsSupportStudent = m.IsSupportStudent,
};
private static string NiveauName(Niveau? n) => n switch
@@ -42,7 +42,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
public ObservableCollection<RepeatComparisonItem> RepeatComparison { get; } = [];
public bool HasRepeatComparison => RepeatComparison.Count > 0;
public string RepeatComparisonTitle { get; private set; } = "";
/// "Wiederholung" bzw. "Nachschreibarbeit" — Beschriftung der Folgearbeit im Vergleich.
/// "Wiederholung", "Nachschreibarbeit" bzw. "Förderarbeit" — Beschriftung der Folgearbeit im Vergleich.
public string FollowUpLabel { get; private set; } = "Wiederholung";
public string FollowUpComparisonHeading => $"Vergleich mit der {FollowUpLabel}";
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
@@ -72,8 +72,8 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
: _exams.GetByGroup(_exam.GroupId).Where(e => e.RepeatOfExamId == _exam.Id)
.OrderBy(e => e.Date).FirstOrDefault();
if (repeat is null) return;
FollowUpLabel = repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung";
var rule = repeat.IsMakeUp ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch
FollowUpLabel = repeat.IsSupportWork ? "Förderarbeit" : repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung";
var rule = repeat.ReplacesMissingGrade() ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch
{
RepeatGradeRule.Repeat => "Wiederholung zählt",
RepeatGradeRule.Original => "Erste Arbeit zählt",
@@ -65,11 +65,21 @@ public partial class ExamGradingDialogViewModel : ObservableObject
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
row.OnChanged = SaveRow;
row.HasStoredResult = result is not null;
Rows.Add(row);
}
}
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
/// Eine Zeile, die nur angefahren wurde (Fokus rein, Fokus raus), ist kein Ergebnis: ohne
/// Punkte, Abwesenheit oder Kommentar und ohne bereits gespeichertes Ergebnis wird nichts
/// angelegt — sonst stünde der Schüler später mit 0 Punkten und Note 6 in der Liste.
private void SaveRow(ExamResultRow row)
{
if (!row.HasStoredResult && !row.HasPoints && !row.Absent && string.IsNullOrWhiteSpace(row.Comment))
return;
_results.Save(row.ToModel(_exam.Id));
row.HasStoredResult = true;
}
partial void OnEntryOrderNameChanged(string value) =>
_settings?.Save(new ExamGradingSettings { EntryOrder = EntryOrder });
@@ -160,6 +170,12 @@ public partial class ExamResultRow : ObservableObject
public Action<ExamResultRow>? OnChanged { get; set; }
/// Mindestens eine Aufgabe hat einen eingetragenen Wert (auch 0). Ohne Werte gibt es weder
/// Summe noch Note.
public bool HasPoints => Cells.Any(c => c.Value is not null);
/// Für diesen Schüler liegt in der Datenbank bereits ein Ergebnis dieser Arbeit.
public bool HasStoredResult { get; set; }
public ExamResultRow(Guid studentId, string name, List<ExamTask> tasks, ExamResult? existing,
List<GradingKeyEntry> gradingKey, double examMaxPoints, GradingService grading)
{
@@ -202,7 +218,7 @@ public partial class ExamResultRow : ObservableObject
{
TotalPoints = Cells.Sum(c => c.IsApplicable && !c.IsInvalid ? (c.Value ?? 0) : 0);
OnPropertyChanged(nameof(TotalPointsDisplay));
GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints,
GradeDisplay = Absent ? "abwesend" : !HasPoints ? "" : _grading.CalculateGrade(TotalPoints,
ExamScoringService.Maximum(_tasks, Variant), _gradingKey);
}
@@ -211,9 +227,9 @@ public partial class ExamResultRow : ObservableObject
Id = _resultId,
ExamId = examId,
StudentId = StudentId,
Points = Cells.Select(c => c.Value ?? 0).ToList(),
Points = HasPoints ? Cells.Select(c => c.Value ?? 0).ToList() : [],
TotalPoints = TotalPoints,
Grade = Absent ? null : GradeDisplay,
Grade = Absent || !HasPoints ? null : GradeDisplay,
Absent = Absent,
Variant = Variant,
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
@@ -240,11 +256,14 @@ public partial class PointsCell : ObservableObject
/// Setzt den Wert; bei Punkten außerhalb 0..Maximalpunkte wird nur rot markiert,
/// aber nicht gespeichert (1.4.5) — OnChanged (und damit Autosave) wird dann nicht ausgelöst.
/// Auch unverändert gelassene Zellen melden sich beim Verlassen des Eingabefelds hier —
/// gespeichert wird nur bei einer tatsächlichen Änderung.
public void TrySetValue(double? value)
{
var changed = Value != value;
Value = value;
IsInvalid = IsOutOfRange(value);
if (!IsInvalid) OnChanged?.Invoke();
if (!IsInvalid && changed) OnChanged?.Invoke();
}
private bool IsOutOfRange(double? value) =>
@@ -47,19 +47,33 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private string _repeatError = "";
[ObservableProperty] private string _taskPointsError = "";
[ObservableProperty] private string _kindName = ExamKind.Klausur.Label();
[ObservableProperty] private bool _isMakeUp;
[ObservableProperty] private string _followUpKindName = WiederholungName;
private const string WiederholungName = "Wiederholung";
private const string NachschreibarbeitName = "Nachschreibarbeit";
private const string FoerderarbeitName = "Förderarbeit";
// Beim Befüllen aus einer vorhandenen Arbeit darf die Datums-Übernahme das gespeicherte Datum
// nicht überschreiben.
private bool _initializing = true;
public bool IsDifferentiated { get; }
public string[] NiveauOptions => NiveauDisplay.Options;
public string[] KindOptions { get; } = Enum.GetValues<ExamKind>().Select(k => k.Label()).ToArray();
public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"];
public string[] FollowUpKindOptions { get; } = [WiederholungName, NachschreibarbeitName, FoerderarbeitName];
public bool IsMakeUp => FollowUpKindName == NachschreibarbeitName;
public bool IsSupportWork => FollowUpKindName == FoerderarbeitName;
public bool HasSelectedOriginalExam => SelectedOriginalExam is not null;
/// Art und Zählweise sind bei einer verknüpften Folgearbeit festgelegt: sie erbt die Art der
/// ersten Arbeit, eine Nachschreibarbeit ersetzt nur fehlende Noten (keine Regelwahl nötig).
/// ersten Arbeit, Nachschreib- und Förderarbeiten ersetzen nur fehlende Noten (keine Regelwahl nötig).
public bool CanChooseKind => !HasSelectedOriginalExam;
public bool ShowRepeatRule => HasSelectedOriginalExam && !IsMakeUp;
public string LinkedExamLabel => IsMakeUp
? "Nachschreibarbeit zu (optional)" : "Wiederholung oder Nachschreibarbeit zu (optional)";
public bool ShowRepeatRule => HasSelectedOriginalExam && FollowUpKindName == WiederholungName;
public string LinkedExamLabel => FollowUpKindName switch
{
NachschreibarbeitName => "Nachschreibarbeit zu (optional)",
FoerderarbeitName => "Förderarbeit zu (optional)",
_ => "Wiederholung, Nachschreib- oder Förderarbeit zu (optional)",
};
private ExamKind Kind => Enum.GetValues<ExamKind>().FirstOrDefault(k => k.Label() == KindName);
public ObservableCollection<Exam> AvailableOriginalExams { get; } = [];
@@ -128,7 +142,9 @@ public partial class ExamDialogViewModel : ObservableObject
if (!isDuplicate)
{
SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId);
IsMakeUp = source.IsMakeUp && SelectedOriginalExam is not null;
if (SelectedOriginalExam is not null)
FollowUpKindName = source.IsSupportWork ? FoerderarbeitName
: source.IsMakeUp ? NachschreibarbeitName : WiederholungName;
RepeatRuleName = source.RepeatGradeRule switch
{
RepeatGradeRule.Repeat => "Wiederholung zählt",
@@ -152,6 +168,7 @@ public partial class ExamDialogViewModel : ObservableObject
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
RecomputeTotals();
UpdateWeekLoadHint();
_initializing = false;
}
partial void OnUseWeightingChanged(bool value)
@@ -161,17 +178,29 @@ public partial class ExamDialogViewModel : ObservableObject
partial void OnSelectedOriginalExamChanged(Exam? value)
{
if (value is not null) KindName = value.Kind.Label();
else IsMakeUp = false;
else FollowUpKindName = WiederholungName;
OnPropertyChanged(nameof(HasSelectedOriginalExam));
OnPropertyChanged(nameof(CanChooseKind));
OnPropertyChanged(nameof(ShowRepeatRule));
TakeOverOriginalDateForSupportWork();
UpdateWeekLoadHint();
}
partial void OnIsMakeUpChanged(bool value)
partial void OnFollowUpKindNameChanged(string value)
{
OnPropertyChanged(nameof(IsMakeUp));
OnPropertyChanged(nameof(IsSupportWork));
OnPropertyChanged(nameof(ShowRepeatRule));
OnPropertyChanged(nameof(LinkedExamLabel));
TakeOverOriginalDateForSupportWork();
}
/// Eine Förderarbeit wird meist parallel zur ersten Arbeit geschrieben: das Datum der
/// ersten Arbeit ist der Vorschlag, bleibt aber änderbar.
private void TakeOverOriginalDateForSupportWork()
{
if (_initializing || !IsSupportWork || SelectedOriginalExam is null) return;
DateText = SelectedOriginalExam.Date.ToString("dd.MM.yyyy");
}
partial void OnKindNameChanged(string value) => UpdateWeekLoadHint();
@@ -382,12 +411,25 @@ public partial class ExamDialogViewModel : ObservableObject
var gradingKey = BuildGradingKeyEntries();
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
if (SelectedOriginalExam is not null && SelectedOriginalExam.Date >= date)
// Datumsprüfung nur bei gültigem Datum — sonst meldet das Datumsfeld den Fehler bereits.
// Eine Förderarbeit entsteht parallel zur ersten Arbeit und darf am selben Tag liegen.
if (SelectedOriginalExam is not null && DateTextError == "")
{
RepeatError = IsMakeUp
? "Die erste Arbeit muss vor der Nachschreibarbeit liegen."
: "Die erste Arbeit muss vor der Wiederholung liegen.";
valid = false;
if (IsSupportWork)
{
if (SelectedOriginalExam.Date > date)
{
RepeatError = "Die Förderarbeit darf nicht vor der ersten Arbeit liegen.";
valid = false;
}
}
else if (SelectedOriginalExam.Date >= date)
{
RepeatError = IsMakeUp
? "Die erste Arbeit muss vor der Nachschreibarbeit liegen."
: "Die erste Arbeit muss vor der Wiederholung liegen.";
valid = false;
}
}
if (Tasks.Any(t => t.VariantName != "Beide") &&
(TotalPoints <= 0 || Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints) <= 0))
@@ -410,6 +452,7 @@ public partial class ExamDialogViewModel : ObservableObject
Result.RepeatOfExamId = SelectedOriginalExam?.Id;
Result.Kind = SelectedOriginalExam?.Kind ?? Kind;
Result.IsMakeUp = IsMakeUp && SelectedOriginalExam is not null;
Result.IsSupportWork = IsSupportWork && SelectedOriginalExam is not null;
Result.RepeatGradeRule = RepeatRuleName switch
{
"Wiederholung zählt" => RepeatGradeRule.Repeat,
@@ -198,6 +198,8 @@ public partial class GroupDetailViewModel : ObservableObject
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
/// In differenzierten Gruppen übernimmt das Förderniveau die Rolle der Markierung.
public bool ShowSupportColumn => !IsDifferentiated;
public bool IsReadOnly => Group is { IsActive: false };
public bool IsEditable => Group is { IsActive: true };
public string SubjectName { get; private set; } = "";
@@ -205,6 +207,7 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnGroupChanged(LearningGroup? value)
{
OnPropertyChanged(nameof(IsDifferentiated));
OnPropertyChanged(nameof(ShowSupportColumn));
OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands();
@@ -324,6 +327,7 @@ public partial class GroupDetailViewModel : ObservableObject
var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
if (membership is null) return;
membership.Niveau = summary.Niveau;
membership.IsSupportStudent = summary.IsSupportStudent;
_memberships.Save(membership);
}
@@ -587,6 +591,7 @@ public partial class StudentSummary : ObservableObject
public string AvatarTooltip { get; }
[ObservableProperty] private Niveau? _niveau;
[ObservableProperty] private bool _isSupportStudent;
public string NiveauName
{
@@ -612,8 +617,11 @@ public partial class StudentSummary : ObservableObject
? IsFormer ? $"Ausgetreten am {exitDate:dd.MM.yyyy}" : $"Austritt am {exitDate:dd.MM.yyyy}"
: "Aktiv";
_niveau = membership?.Niveau;
_isSupportStudent = membership?.IsSupportStudent ?? false;
}
partial void OnIsSupportStudentChanged(bool value) => OnChanged?.Invoke(this);
partial void OnNiveauChanged(Niveau? value)
{
OnPropertyChanged(nameof(NiveauName));
@@ -656,6 +664,7 @@ public class ExamSummary
NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle";
var hasVariants = e.Tasks.Any(t => t.Variant is not null);
var followUp = e.RepeatOfExamId is null ? null
: e.IsSupportWork ? (hasVariants ? "Förder · A/B" : "Förderarbeit")
: e.IsMakeUp ? (hasVariants ? "Nachschr. · A/B" : "Nachschreiben")
: hasVariants ? "Wdh. · A/B" : "Wiederholung";
// Folgearbeiten behalten ihre Art im Hintergrund (sie erben sie), die Spalte nennt nur
@@ -373,7 +373,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
if (!ReportGradeCalculator.ExamApplies(exam, membership)) continue;
var results = _calculator.ResultsByExam[exam.Id];
results.TryGetValue(studentId, out var own);
// Nachschreib- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes
// Nachschreib-, Förder- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes
// Ergebnis gehören sie nicht in diese Übersicht.
if (own is null && exam.RepeatOfExamId.HasValue) continue;
@@ -396,7 +396,8 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
var classAverage = classGrades.Count > 0 ? $"Ø Kurs {Format(_grading.WeightedAverage(classGrades))}" : "";
var kind = exam.Kind.Label();
if (exam.IsMakeUp) kind += " · Nachschreibarbeit";
if (exam.IsSupportWork) kind += " · Förderarbeit";
else if (exam.IsMakeUp) kind += " · Nachschreibarbeit";
else if (exam.RepeatOfExamId.HasValue) kind += " · Wiederholung";
Works.Add(new AssistantWorkCard(exam.Title, exam.Date, kind, gradeDisplay, percentDisplay,
@@ -76,9 +76,9 @@
<Button Grid.Column="1" Content="Auswahl löschen" Command="{Binding ClearOriginalExamCommand}"
IsVisible="{Binding HasSelectedOriginalExam}"/>
</Grid>
<CheckBox Content="Nachschreibarbeit für Schüler, die die erste Arbeit versäumt haben"
IsChecked="{Binding IsMakeUp}" IsVisible="{Binding HasSelectedOriginalExam}"
ToolTip.Tip="Inhaltlich eigene Arbeit nur für Fehlende. Die Note ersetzt die fehlende Note der ersten Arbeit; die Punkteeingabe zeigt nur die Schüler, die die erste Arbeit nicht mitgeschrieben haben. Ohne Haken gilt es als Wiederholung für alle."/>
<ComboBox ItemsSource="{Binding FollowUpKindOptions}" SelectedItem="{Binding FollowUpKindName}"
IsVisible="{Binding HasSelectedOriginalExam}" HorizontalAlignment="Stretch"
ToolTip.Tip="Wiederholung: Arbeit für alle, es zählt die gewählte Regel. Nachschreibarbeit: inhaltlich eigene Arbeit nur für Schüler, die die erste Arbeit versäumt haben. Förderarbeit: inhaltlich eigene Arbeit für die als Förderschüler markierten Schüler der Gruppe, die sie statt der ersten Arbeit schreiben (darf am selben Tag liegen; das Datum der ersten Arbeit wird vorgeschlagen). Bei Nachschreib- und Förderarbeit ersetzt die Note eine fehlende Note der ersten Arbeit, und die Punkteeingabe blendet Schüler mit vorhandener Note der ersten Arbeit aus."/>
<ComboBox ItemsSource="{Binding RepeatRuleOptions}" SelectedItem="{Binding RepeatRuleName}"
IsVisible="{Binding ShowRepeatRule}"/>
<TextBlock Text="{Binding RepeatError}" Foreground="Red" FontSize="11"
@@ -96,6 +96,16 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Förder" Width="80"
IsVisible="{Binding ShowSupportColumn}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:StudentSummary">
<CheckBox IsChecked="{Binding IsSupportStudent}" IsEnabled="{Binding CanEdit}"
HorizontalAlignment="Center" Margin="2"
ToolTip.Tip="Förderschüler: bekommt eigenes Fördermaterial. Förderarbeiten zeigen in der Punkteeingabe nur markierte Schüler."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</ContentPage>
@@ -60,6 +60,41 @@ public class ExamCoverSheetServiceTests
Assert.False(ExamParticipantService.Includes(exam, new GroupMembership { Niveau = Niveau.E }, false, false));
}
[Fact]
public void Includes_Foerderarbeit_BlendetSchuelerMitNoteDerErstenArbeitAus()
{
var original = new Exam();
var support = new Exam { IsSupportWork = true, RepeatOfExamId = original.Id, Date = original.Date };
var wrote = Guid.NewGuid();
var supported = Guid.NewGuid();
List<ExamResult> originalResults =
[
new() { ExamId = original.Id, StudentId = wrote, Grade = "3" },
new() { ExamId = original.Id, StudentId = supported, Absent = true },
];
var wroteOriginal = ExamParticipantService.WroteOriginal(support, _ => originalResults);
Assert.Equal([wrote], wroteOriginal);
var flagged = new GroupMembership { IsSupportStudent = true };
Assert.False(ExamParticipantService.Includes(support, flagged, false, wroteOriginal.Contains(wrote)));
Assert.True(ExamParticipantService.Includes(support, flagged, false, wroteOriginal.Contains(supported)));
}
[Fact]
public void Includes_Foerderarbeit_NurMarkierteFoerderschueler()
{
var support = new Exam { IsSupportWork = true, RepeatOfExamId = Guid.NewGuid() };
Assert.True(ExamParticipantService.Includes(support, new GroupMembership { IsSupportStudent = true }, false, false));
// In differenzierten Gruppen gilt das Förderniveau als Markierung.
Assert.True(ExamParticipantService.Includes(support, new GroupMembership { Niveau = Niveau.Foerder }, false, false));
Assert.False(ExamParticipantService.Includes(support, new GroupMembership { Niveau = Niveau.G }, false, false));
Assert.False(ExamParticipantService.Includes(support, new GroupMembership(), false, false));
// Ein bereits vorhandenes Ergebnis bleibt sichtbar, auch ohne Markierung.
Assert.True(ExamParticipantService.Includes(support, new GroupMembership(), true, false));
}
[Fact]
public void Includes_Nachschreibarbeit_OhneErgebnisNurWerDieErsteArbeitVersaeumtHat()
{
@@ -97,6 +97,19 @@ public class ExamScoringServiceTests
[(original, "4"), (makeUp, "1")], GradingSystem.Grades1To6));
}
[Fact]
public void Foerderarbeit_ErsetztNurFehlendeNote()
{
var original = new Exam();
var support = new Exam { RepeatOfExamId = original.Id, IsSupportWork = true };
// Förderschüler hat die erste Arbeit nicht geschrieben: die Förderarbeit zählt allein.
Assert.Equal(["3"], ExamScoringService.EffectiveGrades([(support, "3")], GradingSystem.Grades1To6));
// Liegen beide Noten vor, zählt die erste — auch wenn die Förderarbeit besser ist.
Assert.Equal(["4"], ExamScoringService.EffectiveGrades(
[(original, "4"), (support, "1")], GradingSystem.Grades1To6));
}
[Fact]
public void MehrereFolgearbeiten_ZaehlenGenauEinmal()
{
+2 -1
View File
@@ -19,7 +19,7 @@ public sealed class GroupRolloverServiceTests
var oldMembership = new GroupMembership
{
StudentId = Guid.NewGuid(), GroupId = source.Id, Period = MembershipPeriod.H2Only,
JoinedAt = new DateOnly(2026, 2, 1), LeftAt = new DateOnly(2026, 7, 31), Niveau = Niveau.E,
JoinedAt = new DateOnly(2026, 2, 1), LeftAt = new DateOnly(2026, 7, 31), Niveau = Niveau.E, IsSupportStudent = true,
};
var groups = new MemoryGroups([source]);
var memberships = new MemoryMemberships([oldMembership]);
@@ -48,6 +48,7 @@ public sealed class GroupRolloverServiceTests
Assert.Equal(new DateOnly(2026, 8, 1), copied.JoinedAt);
Assert.Null(copied.LeftAt);
Assert.Equal(Niveau.E, copied.Niveau);
Assert.True(copied.IsSupportStudent);
var scheme = schemes.GetByGroup(target.Id);
Assert.NotNull(scheme);
+20
View File
@@ -59,6 +59,26 @@ Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
nicht in Klausurwochen-Belastung und Vorjahresvergleich. Folgearbeiten erben die Art.
Bestehende Klausuren bleiben unverändert (Default `Klausur`).
- [x] **Nachtrag 2026-10: Förderarbeit als dritte Folgearbeits-Art.** Eine Förderarbeit
(`Exam.IsSupportWork`) ist eine inhaltlich eigene Arbeit, die einzelne Schüler statt der
parallel geschriebenen ersten Arbeit schreiben (Workflow: erste Arbeit per „Duplizieren“
kopieren, Aufgaben/Schlüssel anpassen, im Arbeitsdialog mit der ersten Arbeit verknüpfen).
Der Arbeitsdialog ersetzt die Nachschreib-Checkbox durch eine Auswahl
Wiederholung/Nachschreibarbeit/Förderarbeit; die Förderarbeit darf am selben Tag wie die erste
Arbeit liegen (nur *vor* der ersten Arbeit ist gesperrt) und übernimmt beim Auswählen dessen
Datum als änderbaren Vorschlag. Sie zählt wie eine Nachschreibarbeit
(`ExamFollowUpExtensions.ReplacesMissingGrade`): ihre Note ersetzt nur eine fehlende Note, die
Punkteeingabe blendet Schüler mit Note der ersten Arbeit aus — Schüler ohne Eintrag in der
ersten Arbeit (z. B. Kranke) lassen sich mit „Rest als abwesend markieren“ ausblenden.
Nachtrag: `GroupMembership.IsSupportStudent` markiert Förderschüler je Gruppe, auch ohne
Niveaudifferenzierung (Checkbox-Spalte „Förder“ in der Schülerliste; in differenzierten
Gruppen zählt das Förderniveau). Die Punkteeingabe/Deckblätter einer Förderarbeit zeigen nur
Förderschüler (plus Schüler mit vorhandenem Ergebnis), das Kranke-Ausblenden entfällt damit.
Fix Punkteeingabe: eine nur angefahrene Zeile (Fokus rein/raus ohne Eingabe) legt kein
Ergebnis mit 0 Punkten und Note 6 mehr an (`ExamGradingDialogViewModel.SaveRow` speichert nur
bei Wert, Abwesenheit, Kommentar oder vorhandenem Ergebnis; `PointsCell.TrySetValue` meldet
nur echte Änderungen; Zeilen ohne Werte haben keine Note und speichern keine Punkteliste).
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.
+9 -1
View File
@@ -29,6 +29,9 @@ keine Aufnahme oder Einschreibung an der Schule.
- `Period`: ganzes Schuljahr, erstes Halbjahr, zweites Halbjahr oder eigener Zeitraum
- `JoinedAt` / `LeftAt`: Grenzen eines eigenen Teilnahmezeitraums
- `Niveau`: optionale Niveaudifferenzierung
- `IsSupportStudent`: Förderschüler-Markierung je Gruppe, unabhängig von der Niveaudifferenzierung
(auch ein nicht differenzierter Kurs kann Förderschüler mit eigenem Material haben); als
Förderschüler gilt außerdem, wer das Niveau `Foerder` hat (`GroupMembershipService.IsSupportStudent`)
Das Schuljahr wird über die Lerngruppe ermittelt und deshalb nicht zusätzlich
in der Gruppenzuordnung gespeichert. Für eine spätere echte Schulaufnahme wäre
@@ -142,7 +145,12 @@ Einige berechnete Werte bleiben absichtlich gespeichert:
- `Exam.RepeatOfExamId` verweist auf die ursprüngliche Arbeit derselben Gruppe.
`RepeatGradeRule` legt fest, ob die bessere, die wiederholte oder die erste Note
zählt. Beide Ergebnisse bleiben sichtbar, gehen aber als ein Wert in die
berechneten Durchschnitts- und Zeugnisnoten ein.
berechneten Durchschnitts- und Zeugnisnoten ein. `Exam.IsMakeUp` (Nachschreibarbeit) und
`Exam.IsSupportWork` (Förderarbeit) markieren Folgearbeiten nur für einzelne Schüler; ihre Note
ersetzt ausschließlich eine fehlende Note der ersten Arbeit. Anders als Nachschreibarbeiten darf
eine Förderarbeit am selben Tag wie die erste Arbeit liegen (sie wird parallel geschrieben). Die
Punkteeingabe einer Förderarbeit zeigt nur Förderschüler der Gruppe (`GroupMembership.IsSupportStudent`
oder Niveau `Foerder`) sowie Schüler mit bereits vorhandenem Ergebnis.
- `CompetencyReference` (`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies`,
`ParticipationSession.Competencies`) verweist über `ItemId` auf ein `CompetencyItem`, hält aber
zusätzlich `Code`/`Description` zum Zuordnungszeitpunkt fest (TODO 16.5, abgelöst von einer reinen