Compare commits
3
Commits
10921deb34
...
d61a97de1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d61a97de1e | ||
|
|
41649b0a05 | ||
|
|
651c9cddf8 |
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user