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); } }