feat: Anhänge je Stunde, Klausur-Sitzplan mischen, Gefährdungsbeurteilungs-Assistent

Lesson bekommt dieselbe Anhang-Infrastruktur wie Documentation (Material,
Arbeitsblätter, Experimentunterlagen), samt Fix einer Sync-Lücke, die Anhang-
Dateibytes bisher nur für Documentation statt generisch übertragen hat
(IHasAttachments). Sitzplan-Tab bekommt einen "Plätze mischen"-Button für
Klausursitzpläne. Neu: mehrschrittiger Gefährdungsbeurteilungs-Assistent mit
optionalem KI-Entwurf (ai-backend/gbu.php) und PDF-Export, Format bewusst als
JSON-Anhang statt eigener Datenbank-Entität. Details und Architekturentscheidungen
in TODO.md (4.2, 7.1.5, 10.1.8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:13:14 +02:00
co-authored by Claude Sonnet 5
parent 331033db5c
commit 038337997f
29 changed files with 1886 additions and 22 deletions
@@ -542,4 +542,69 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal("Sitzplan", session.Comment);
Assert.Equal(session.Id, vm.SelectedSession?.Id);
}
[Fact]
public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
{
var groupId = Guid.NewGuid();
var anna = new Student { FirstName = "Anna", LastName = "A" };
var ben = new Student { FirstName = "Ben", LastName = "B" };
var cara = new Student { FirstName = "Cara", LastName = "C" };
var students = new FakeStudents([anna, ben, cara]);
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = anna.Id },
new GroupMembership { GroupId = groupId, StudentId = ben.Id },
new GroupMembership { GroupId = groupId, StudentId = cara.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Klausur", Room = "B204", Rows = 1, Columns = 3,
Assignments =
[
new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id },
new SeatAssignment { Row = 0, Column = 1, StudentId = ben.Id },
new SeatAssignment { Row = 0, Column = 2, StudentId = cara.Id },
],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.ShuffleSeatsCommand.Execute(null);
var all = plans.GetByGroup(groupId);
Assert.Equal(2, all.Count);
var shuffled = Assert.Single(all, p => p.Id != plan.Id);
Assert.Equal("B204", shuffled.Room);
Assert.Equal(1, shuffled.Rows);
Assert.Equal(3, shuffled.Columns);
// Dieselben drei Schüler auf denselben drei Koordinaten, nur die Zuordnung untereinander
// wurde neu gewürfelt.
Assert.Equal(new[] { anna.Id, ben.Id, cara.Id }.OrderBy(id => id),
shuffled.Assignments.Select(a => a.StudentId).OrderBy(id => id));
Assert.Equal(new[] { (0, 0), (0, 1), (0, 2) },
shuffled.Assignments.Select(a => (a.Row, a.Column)).OrderBy(t => t.Item2));
// Ursprungsplan bleibt unverändert erhalten.
Assert.Equal(3, plans.GetById(plan.Id)!.Assignments.Count);
Assert.Equal(vm.SelectedPlan?.Id, shuffled.Id);
}
[Fact]
public void ShuffleSeatsCommand_OhneBearbeitungsmodus_NichtAusfuehrbar()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
Assert.False(vm.ShuffleSeatsCommand.CanExecute(null));
vm.IsEditMode = true;
Assert.True(vm.ShuffleSeatsCommand.CanExecute(null));
}
}