diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index fbca8ed..9cbd4d5 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -160,6 +160,9 @@ public interface IParticipationAspectRepository { List GetDefaults(); List GetByGroup(Guid groupId); + // Wie GetByGroup, aber inkl. deaktivierter Aspekte — für die Verwaltungsansicht (3.1), + // damit ein deaktivierter Aspekt dort sichtbar bleibt und wieder aktiviert werden kann. + List GetAllByGroup(Guid groupId); void Save(ParticipationAspect aspect); void Delete(Guid id); } diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 7770c4d..4c04c6c 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -376,6 +376,58 @@ public sealed class RepositoryTests Assert.Null(repo.GetById(subject.Id)); } + // ── ParticipationAspectRepository (3.1) ─────────────────────────────────── + + [Fact] + public void ParticipationAspectRepository_Save_LehntLeerenSchluesselUndBezeichnungAb() + { + using var db = NewInMemoryContext(); + var repo = new ParticipationAspectRepository(db); + var groupId = Guid.NewGuid(); + + Assert.Throws(() => repo.Save(new ParticipationAspect { GroupId = groupId, Key = " ", Label = "Experiment" })); + Assert.Throws(() => repo.Save(new ParticipationAspect { GroupId = groupId, Key = "experiment", Label = " " })); + } + + [Fact] + public void ParticipationAspectRepository_Save_LehntDuplikatSchluesselInnerhalbDerGruppeAb() + { + using var db = NewInMemoryContext(); + var repo = new ParticipationAspectRepository(db); + var groupId = Guid.NewGuid(); + repo.Save(new ParticipationAspect { GroupId = groupId, Key = "experiment", Label = "Experiment" }); + + Assert.Throws(() => + repo.Save(new ParticipationAspect { GroupId = groupId, Key = "Experiment", Label = "Anderes Label" })); + } + + [Fact] + public void ParticipationAspectRepository_Save_LehntDuplikatGegenGlobalenStandardAb() + { + using var db = NewInMemoryContext(); + var repo = new ParticipationAspectRepository(db); + var groupId = Guid.NewGuid(); + repo.Save(new ParticipationAspect { GroupId = null, Key = "quality", Label = "Qualität" }); + + // Gleicher Schlüssel wie ein globaler Standardaspekt wäre in der Bewertungsübersicht + // dieser Gruppe eine mehrdeutige Spalte (siehe ParticipationTabViewModel.LoadAspects). + Assert.Throws(() => + repo.Save(new ParticipationAspect { GroupId = groupId, Key = "quality", Label = "Andere Qualität" })); + } + + [Fact] + public void ParticipationAspectRepository_GetByGroup_FiltertInaktiveAus_GetAllByGroupNicht() + { + using var db = NewInMemoryContext(); + var repo = new ParticipationAspectRepository(db); + var groupId = Guid.NewGuid(); + repo.Save(new ParticipationAspect { GroupId = groupId, Key = "a", Label = "Aktiv", IsActive = true }); + repo.Save(new ParticipationAspect { GroupId = groupId, Key = "b", Label = "Inaktiv", IsActive = false }); + + Assert.Single(repo.GetByGroup(groupId)); + Assert.Equal(2, repo.GetAllByGroup(groupId).Count); + } + // ── GradingSchemeRepository ─────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index b0889f5..7cd6833 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -439,9 +439,24 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp db.ParticipationAspects.Find(a => a.GroupId == null && a.IsActive).OrderBy(a => a.SortOrder).ToList(); public List GetByGroup(Guid groupId) => db.ParticipationAspects.Find(a => a.GroupId == groupId && a.IsActive).OrderBy(a => a.SortOrder).ToList(); + public List GetAllByGroup(Guid groupId) => + db.ParticipationAspects.Find(a => a.GroupId == groupId).OrderBy(a => a.SortOrder).ToList(); public void Save(ParticipationAspect a) { if (a.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); + a.Key = a.Key.Trim(); + a.Label = a.Label.Trim(); + if (a.Key.Length == 0) throw new ArgumentException("Der Schlüssel darf nicht leer sein."); + if (a.Label.Length == 0) throw new ArgumentException("Die Bezeichnung darf nicht leer sein."); + // Ein Schlüssel muss innerhalb dessen, was für eine Gruppe tatsächlich gilt, eindeutig + // sein — das sind die globalen Standardaspekte UND die gruppenspezifischen zusammen + // (siehe ParticipationTabViewModel.LoadAspects, das beide konkateniert). Sonst entstünde + // in der Bewertungsübersicht eine mehrdeutige Spalte mit identischem Schlüssel. + var relevant = db.ParticipationAspects.Find(x => x.GroupId == null || x.GroupId == a.GroupId); + var duplicate = relevant.FirstOrDefault(x => + x.Id != a.Id && string.Equals(x.Key, a.Key, StringComparison.OrdinalIgnoreCase)); + if (duplicate is not null) + throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits."); a.UpdatedAt = DateTime.UtcNow; db.ParticipationAspects.Upsert(a); } diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index fe01197..b23f3b3 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -87,10 +87,25 @@ public class FakeEntries : IParticipationRepository public class FakeAspects : IParticipationAspectRepository { - public List GetDefaults() => []; - public List GetByGroup(Guid groupId) => []; - public void Save(ParticipationAspect aspect) { } - public void Delete(Guid id) { } + private readonly List _all = []; + public List GetDefaults() => + _all.Where(a => a.GroupId == null && a.IsActive).OrderBy(a => a.SortOrder).ToList(); + public List GetByGroup(Guid groupId) => + _all.Where(a => a.GroupId == groupId && a.IsActive).OrderBy(a => a.SortOrder).ToList(); + public List GetAllByGroup(Guid groupId) => + _all.Where(a => a.GroupId == groupId).OrderBy(a => a.SortOrder).ToList(); + public void Save(ParticipationAspect a) + { + a.Key = a.Key.Trim(); a.Label = a.Label.Trim(); + if (a.Key.Length == 0) throw new ArgumentException("Der Schlüssel darf nicht leer sein."); + if (a.Label.Length == 0) throw new ArgumentException("Die Bezeichnung darf nicht leer sein."); + var duplicate = _all.Where(x => x.GroupId == null || x.GroupId == a.GroupId) + .FirstOrDefault(x => x.Id != a.Id && string.Equals(x.Key, a.Key, StringComparison.OrdinalIgnoreCase)); + if (duplicate is not null) throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits."); + _all.RemoveAll(x => x.Id == a.Id); + _all.Add(a); + } + public void Delete(Guid id) => _all.RemoveAll(a => a.Id == id); } public class FakeGrades : IGradeRepository diff --git a/LehrerApp.Desktop.Tests/ParticipationAspectsDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ParticipationAspectsDialogViewModelTests.cs new file mode 100644 index 0000000..9bf5584 --- /dev/null +++ b/LehrerApp.Desktop.Tests/ParticipationAspectsDialogViewModelTests.cs @@ -0,0 +1,104 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für die Aspekt-Verwaltung (3.1.1/3.1.2/3.1.4): Anlegen, Umsortieren, Deaktivieren, +/// Löschen. FakeAspects verhält sich hier wie der echte Repository (Validierung, Duplikatprüfung), +/// damit auch Fehlerfälle über die ViewModel-Schicht hinweg realistisch getestet werden. +public sealed class ParticipationAspectsDialogViewModelTests +{ + [Fact] + public void AddAspect_LegtNeuenAspektMitFortlaufenderSortOrderAn() + { + var groupId = Guid.NewGuid(); + var vm = new ParticipationAspectsDialogViewModel(new FakeAspects(), groupId) + { + NewKey = "experiment", NewLabel = "Experiment", + }; + + vm.AddAspectCommand.Execute(null); + + var item = Assert.Single(vm.Aspects); + Assert.Equal("experiment", item.Key); + Assert.Equal("Experiment", item.Label); + Assert.Equal("", vm.NewKey); // Formular wird nach Erfolg geleert + } + + [Fact] + public void AddAspect_OhneSchluessel_ZeigtFehlerUndLegtNichtsAn() + { + var vm = new ParticipationAspectsDialogViewModel(new FakeAspects(), Guid.NewGuid()) + { + NewKey = "", NewLabel = "Experiment", + }; + + vm.AddAspectCommand.Execute(null); + + Assert.Empty(vm.Aspects); + Assert.NotEqual("", vm.NewAspectError); + } + + [Fact] + public void AddAspect_DuplikatSchluessel_ZeigtRepositoryFehlerAnUndLegtNichtsAnderesAn() + { + var groupId = Guid.NewGuid(); + var repo = new FakeAspects(); + var vm = new ParticipationAspectsDialogViewModel(repo, groupId) { NewKey = "a", NewLabel = "Erste" }; + vm.AddAspectCommand.Execute(null); + + vm.NewKey = "a"; vm.NewLabel = "Zweite"; + vm.AddAspectCommand.Execute(null); + + Assert.Single(vm.Aspects); + Assert.NotEqual("", vm.NewAspectError); + } + + [Fact] + public void MoveAspect_VertauschtReihenfolgeUndPersistiertSortOrder() + { + var groupId = Guid.NewGuid(); + var repo = new FakeAspects(); + var vm = new ParticipationAspectsDialogViewModel(repo, groupId) { NewKey = "a", NewLabel = "Erste" }; + vm.AddAspectCommand.Execute(null); + vm.NewKey = "b"; vm.NewLabel = "Zweite"; + vm.AddAspectCommand.Execute(null); + + vm.Aspects[1].MoveUpCommand.Execute(null); + + Assert.Equal("b", vm.Aspects[0].Key); + Assert.Equal("a", vm.Aspects[1].Key); + var stored = repo.GetAllByGroup(groupId).OrderBy(a => a.SortOrder).ToList(); + Assert.Equal(["b", "a"], stored.Select(a => a.Key)); + } + + [Fact] + public void IsActive_AendernSpeichertSofortUndBleibtInGetAllByGroupSichtbar() + { + var groupId = Guid.NewGuid(); + var repo = new FakeAspects(); + var vm = new ParticipationAspectsDialogViewModel(repo, groupId) { NewKey = "a", NewLabel = "Erste" }; + vm.AddAspectCommand.Execute(null); + + vm.Aspects[0].IsActive = false; + + Assert.Empty(repo.GetByGroup(groupId)); + Assert.Single(repo.GetAllByGroup(groupId)); + } + + [Fact] + public void Label_UngueltigeAenderung_RolltZurueckUndZeigtFehler() + { + var groupId = Guid.NewGuid(); + var repo = new FakeAspects(); + var vm = new ParticipationAspectsDialogViewModel(repo, groupId) { NewKey = "a", NewLabel = "Erste" }; + vm.AddAspectCommand.Execute(null); + + vm.Aspects[0].Label = " "; // leer nach Trim -> Repository lehnt ab + + Assert.Equal("Erste", vm.Aspects[0].Label); + Assert.NotEqual("", vm.Aspects[0].ErrorMessage); + Assert.Equal("Erste", repo.GetAllByGroup(groupId).Single().Label); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index 4091c9c..7f31327 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -52,6 +52,7 @@ public partial class ParticipationTabViewModel : ObservableObject public Func? OnStatusQuickInput { get; set; } public Func? OnComputeGrade { get; set; } public Func? OnOpenWizard { get; set; } + public Func? OnManageAspects { get; set; } public ParticipationTabViewModel( IParticipationSessionRepository sessions, @@ -296,6 +297,15 @@ public partial class ParticipationTabViewModel : ObservableObject await OnOpenWizard(this); } + [RelayCommand] + private async Task ManageAspects() + { + if (IsReadOnly || OnManageAspects is null) return; + await OnManageAspects(this); + LoadAspects(); + if (SelectedSession is not null) LoadGrid(SelectedSession.Id); + } + [RelayCommand] private void DeleteSession() { @@ -883,3 +893,202 @@ public partial class AttendanceHomeworkQuickInputViewModel : ObservableObject if (AdvanceAfterInput) MoveSelection(1); } } + +// ── Aspekte verwalten (3.1) ────────────────────────────────────────────────── + +/// Deutsche Anzeige für , gleiches Muster wie NiveauDisplay/ +/// GradeCategoryDisplay — ComboBox bindet an das Enum sonst über ToString() (englische Namen). +public static class AspectValueTypeDisplay +{ + public static string[] Options { get; } = ["Skala 1–5", "Skala 1–3", "Ja/Nein", "Punkte"]; + + public static string ToName(AspectValueType t) => t switch + { + AspectValueType.Scale5 => "Skala 1–5", + AspectValueType.Scale3 => "Skala 1–3", + AspectValueType.Binary => "Ja/Nein", + AspectValueType.Points => "Punkte", + _ => "Skala 1–5", + }; + + public static AspectValueType FromName(string? name) => name switch + { + "Skala 1–3" => AspectValueType.Scale3, + "Ja/Nein" => AspectValueType.Binary, + "Punkte" => AspectValueType.Points, + _ => AspectValueType.Scale5, + }; +} + +/// Eine Zeile in der Aspekt-Verwaltung (3.1.1/3.1.2/3.1.4). Label/Gewichtung/Aktiv-Status +/// speichern bei jeder Änderung sofort (gleiches Muster wie AspectWeightItem in 3.2.1) — +/// "Schlüssel" ist bewusst nur beim Neuanlegen editierbar, nicht nachträglich in der Zeile: er +/// ist die Verknüpfung zu historischen AspectRating-Einträgen (per Key, nicht per Id), ein +/// nachträgliches Umbenennen würde alte Bewertungen dieses Aspekts unauffindbar machen. +public partial class AspectEditItem : ObservableObject +{ + private readonly ParticipationAspect _aspect; + private readonly IParticipationAspectRepository _repo; + + public Guid Id => _aspect.Id; + public string Key => _aspect.Key; + + [ObservableProperty] private string _label; + [ObservableProperty] private string _valueTypeName; + [ObservableProperty] private double _weight; + [ObservableProperty] private bool _isActive; + [ObservableProperty] private string _errorMessage = ""; + + public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; + public Action? OnDelete { get; set; } + public IRelayCommand DeleteCommand { get; } + public IRelayCommand MoveUpCommand { get; } + public IRelayCommand MoveDownCommand { get; } + + public AspectEditItem(ParticipationAspect aspect, IParticipationAspectRepository repo, + Action? onMoveUp = null, Action? onMoveDown = null) + { + _aspect = aspect; _repo = repo; + _label = aspect.Label; + _valueTypeName = AspectValueTypeDisplay.ToName(aspect.ValueType); + _weight = aspect.Weight; + _isActive = aspect.IsActive; + + DeleteCommand = new RelayCommand(() => OnDelete?.Invoke(this)); + MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp); + MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown); + } + + private bool _canMoveUp; + private bool _canMoveDown; + + internal void SetMoveState(bool canMoveUp, bool canMoveDown) + { + _canMoveUp = canMoveUp; _canMoveDown = canMoveDown; + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + } + + internal void SetSortOrder(int sortOrder) + { + if (_aspect.SortOrder == sortOrder) return; + _aspect.SortOrder = sortOrder; + _repo.Save(_aspect); + } + + private void TrySave(Action apply) + { + ErrorMessage = ""; + var before = (_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive); + apply(); + try { _repo.Save(_aspect); } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + // Zurückrollen, damit Feld und gespeicherter Stand nicht auseinanderlaufen. Bewusst + // die Backing-Felder direkt gesetzt statt der generierten Properties — über die + // Properties würde erneut On{X}Changed feuern und damit wieder TrySave aufrufen. +#pragma warning disable MVVMTK0034 + (_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive) = before; + _label = _aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(_aspect.ValueType); + _weight = _aspect.Weight; _isActive = _aspect.IsActive; +#pragma warning restore MVVMTK0034 + OnPropertyChanged(nameof(Label)); OnPropertyChanged(nameof(ValueTypeName)); + OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive)); + ErrorMessage = ex.Message; + } + } + + partial void OnLabelChanged(string value) => TrySave(() => _aspect.Label = value); + partial void OnValueTypeNameChanged(string value) => TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value)); + partial void OnWeightChanged(double value) => TrySave(() => _aspect.Weight = value); + partial void OnIsActiveChanged(bool value) => TrySave(() => _aspect.IsActive = value); +} + +/// Verwaltungsdialog für ParticipationAspect (3.1.1/3.1.2/3.1.4), pro Gruppe. Verwaltet bewusst +/// nur die gruppenspezifischen Aspekte (GroupId = diese Gruppe), nicht den globalen +/// Standardkatalog (GroupId = null) — der wird bislang nirgends befüllt (siehe +/// DefaultParticipationAspects, reiner In-Memory-Fallback ohne UI) und eine Änderung dort würde +/// sofort alle Gruppen betreffen; das wäre ein eigener, separat zu entscheidender Schritt. +public partial class ParticipationAspectsDialogViewModel : ObservableObject +{ + private readonly IParticipationAspectRepository _repo; + private readonly Guid _groupId; + + [ObservableProperty] private string _newKey = ""; + [ObservableProperty] private string _newLabel = ""; + [ObservableProperty] private string _newValueTypeName = AspectValueTypeDisplay.Options[0]; + [ObservableProperty] private string _newAspectError = ""; + + public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; + public ObservableCollection Aspects { get; } = []; + public Func>? OnConfirmDelete { get; set; } + + public ParticipationAspectsDialogViewModel(IParticipationAspectRepository repo, Guid groupId) + { + _repo = repo; _groupId = groupId; + Load(); + } + + private void Load() + { + Aspects.Clear(); + foreach (var a in _repo.GetAllByGroup(_groupId)) + Aspects.Add(CreateItem(a)); + RefreshMoveState(); + } + + private AspectEditItem CreateItem(ParticipationAspect a) => + new(a, _repo, item => MoveAspect(item, -1), item => MoveAspect(item, 1)) { OnDelete = DeleteAspectAsync }; + + private void MoveAspect(AspectEditItem item, int offset) + { + var oldIndex = Aspects.IndexOf(item); + var newIndex = oldIndex + offset; + if (oldIndex < 0 || newIndex < 0 || newIndex >= Aspects.Count) return; + Aspects.Move(oldIndex, newIndex); + for (var i = 0; i < Aspects.Count; i++) Aspects[i].SetSortOrder(i); + RefreshMoveState(); + } + + private void RefreshMoveState() + { + for (var i = 0; i < Aspects.Count; i++) + Aspects[i].SetMoveState(i > 0, i < Aspects.Count - 1); + } + + [RelayCommand] + private void AddAspect() + { + NewAspectError = ""; + if (string.IsNullOrWhiteSpace(NewKey)) { NewAspectError = "Schlüssel erforderlich."; return; } + if (string.IsNullOrWhiteSpace(NewLabel)) { NewAspectError = "Bezeichnung erforderlich."; return; } + + var aspect = new ParticipationAspect + { + GroupId = _groupId, + Key = NewKey.Trim(), + Label = NewLabel.Trim(), + ValueType = AspectValueTypeDisplay.FromName(NewValueTypeName), + SortOrder = Aspects.Count, + }; + try { _repo.Save(aspect); } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + NewAspectError = ex.Message; + return; + } + + Aspects.Add(CreateItem(aspect)); + RefreshMoveState(); + NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; + } + + private async void DeleteAspectAsync(AspectEditItem item) + { + if (OnConfirmDelete is not null && !await OnConfirmDelete(item)) return; + _repo.Delete(item.Id); + Aspects.Remove(item); + for (var i = 0; i < Aspects.Count; i++) Aspects[i].SetSortOrder(i); + RefreshMoveState(); + } +} diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationAspectsDialog.axaml b/LehrerApp.Desktop/Views/Groups/ParticipationAspectsDialog.axaml new file mode 100644 index 0000000..fccab13 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/ParticipationAspectsDialog.axaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + +