feat: expand teaching mode with live timeline and seating quick checks
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-13 23:16:36 +02:00
parent b8193be6ec
commit eb1b2340b7
14 changed files with 837 additions and 65 deletions
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TeachingTimelineViewModelTests
{
private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc);
private DateTime _now;
private readonly FakeLessons _lessons = new();
private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true)
{
_now = _start.AddMinutes(5);
var local = _start.ToLocalTime();
var lesson = new Lesson
{
Date = DateOnly.FromDateTime(local),
StartTime = scheduled ? TimeOnly.FromDateTime(local) : null,
Phases = [new() { Name = "Einstieg", DurationMinutes = 10 },
new() { Name = "Arbeit", DurationMinutes = 10 },
new() { Name = "Sicherung", DurationMinutes = 10 }]
};
_lessons.Add(lesson);
return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now));
}
[Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary()
{
var (_, vm) = Build();
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(50, vm.Phases[0].Progress);
_now = _start.AddMinutes(10);
vm.Refresh();
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Single(vm.Phases, p => p.IsActive);
}
[Fact] public void NoStartTime_RequiresExplicitStart()
{
var (_, vm) = Build(scheduled: false);
Assert.True(vm.NeedsStart);
Assert.DoesNotContain(vm.Phases, p => p.IsActive);
vm.StartNowCommand.Execute(null);
Assert.False(vm.NeedsStart);
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(_now, vm.Phases[0].StartUtc);
}
[Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc);
Assert.True(vm.Phases[2].IsOverflow);
Assert.Equal(10, lesson.Phases[0].DurationMinutes);
Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline);
}
[Fact] public void FinishEarly_StartsNextPhaseNow()
{
var (_, vm) = Build();
vm.Phases[0].FinishCommand.Execute(null);
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Equal(_now, vm.Phases[1].StartUtc);
}
[Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext()
{
var (lesson, vm) = Build();
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(65);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsHeld);
Assert.True(reopened.Phases[0].IsActive);
Assert.Single(reopened.Phases, p => p.IsActive);
reopened.Phases[0].FinishCommand.Execute(null);
Assert.True(reopened.Phases[1].IsActive);
Assert.False(reopened.Phases[0].IsHeld);
Assert.Equal(_now, reopened.Phases[1].StartUtc);
}
[Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase()
{
var (_, vm) = Build();
var chosen = vm.Phases[2];
chosen.BringForwardCommand.Execute(null);
Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name));
Assert.True(chosen.IsActive);
Assert.Equal(_now, chosen.StartUtc);
Assert.False(vm.Phases[2].IsCompleted);
}
[Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.True(remainder.IsOverflow);
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now, remainder.StartUtc);
}
[Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource()
{
var (lesson, _) = Build();
var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" };
_lessons.Add(target);
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
vm.Phases[0].ExtendTenCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
var copy = Assert.Single(target.Phases);
Assert.Equal("Sicherung", copy.Name);
Assert.NotEqual(lesson.Phases[2].Id, copy.Id);
Assert.Equal(3, lesson.Phases.Count);
Assert.True(vm.Phases[2].IsTransferred);
}
[Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendFiveCommand.Execute(null);
_now = _start.AddMinutes(28);
vm.Refresh();
Assert.True(vm.Phases[2].IsActive);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
Assert.Equal(5, remainder.RemainingMinutes);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now.AddMinutes(5), remainder.EndUtc);
}
[Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock()
{
var (lesson, _) = Build(scheduled: false);
var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now);
vm.StartNowCommand.Execute(null);
Assert.Null(lesson.TeachingTimeline);
Assert.True(vm.NeedsStart);
}
[Fact] public void AlternativePhases_AreNotRunAlongsideMainPath()
{
var (lesson, _) = Build();
lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 });
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.Equal(3, vm.Phases.Count);
Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc);
}
[Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift()
{
using var stream = new MemoryStream();
using var db = new LehrerApp.Data.LiteDbContext(stream);
var repository = new LehrerApp.Data.Repositories.LessonRepository(db);
var (lesson, _) = Build();
repository.Save(lesson);
var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now);
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(40);
var loaded = repository.GetById(lesson.Id)!;
var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsActive);
Assert.True(reopened.Phases[0].IsHeld);
Assert.Equal(_start, reopened.Phases[0].StartUtc);
Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc);
}
}