From 525fcd3854ff0290000e75dab96f8dc1057ceebd Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:13:42 -0500 Subject: [PATCH] fix some comments; add failing tests --- CHANGELOG.md | 2 +- .../ClassicScheduling/MultiDayBuildTests.cs | 215 ++++++++++++++++++ ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 9 +- 3 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index faa0fbbd3..a6be7ad65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Previously, only collections scheduled during the first day of a playout build had their start points randomized - Allow multiple ETV instances when multiple config folders are used - Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding - - Both bugs were in ffmpeg and ETV's patched ffmpeg 8.1.2 is required for the fixes + - Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes ## [26.6.0] - 2026-07-09 ### Added diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs new file mode 100644 index 000000000..db7fc160b --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs @@ -0,0 +1,215 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Tests.Fakes; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; + +namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; + +/// +/// Unit-test version of the "fast forward a playout" debugging endpoint that used to live in +/// ChannelController: run hourly continue builds across several days and assert invariants at +/// every step. +/// +[TestFixture] +public class MultiDayBuildTests : PlayoutBuilderTestBase +{ + private const int DaysToBuild = 2; + + [Test] + public async Task Continue_Should_Keep_A_Checkpoint_For_The_Current_Day() + { + // 6 hour movies scheduled 3 at a time, so a block spans 18 hours and regularly + // crosses midnight - which is expected and intended + (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( + TimeSpan.FromHours(6), + multipleCount: "3"); + + // a playout created at 20:00, then built every hour for a week + DateTimeOffset start = LocalTime(20); + var missing = new List(); + + for (var hour = 0; hour < 24 * 7; hour++) + { + DateTimeOffset now = start.AddHours(hour); + + Either result = await builder.Build( + now, + playout, + referenceData, + PlayoutBuildMode.Continue, + CancellationToken); + + result.IsRight.ShouldBeTrue($"build failed at {now:yyyy-MM-dd HH:mm}"); + + // a build never writes a checkpoint for its own first day, and doesn't need to: + // there is no earlier state to restore, so refreshing starts from index 0 and + // reproduces the same schedule + if (now.Date == start.Date) + { + continue; + } + + // from here on, a refresh has to have somewhere to rewind to + bool hasCheckpointForToday = playout.ProgramScheduleAnchors.Any(a => + a.AnchorDateOffset.HasValue && a.AnchorDateOffset.Value.Date == now.Date); + + if (!hasCheckpointForToday) + { + missing.Add($"{now:yyyy-MM-dd HH:mm}"); + } + } + + missing.ShouldBeEmpty( + $"[tz {TimeZoneInfo.Local.Id}] no checkpoint for the current day at {missing.Count} hour(s), " + + $"first: {missing.FirstOrDefault()}"); + } + + [Test] + public async Task Refresh_Should_Not_Reset_Collection_Progress() + { + // a collection large enough that the index can't wrap over the whole run, so the + // index is a straightforward measure of how much progress has been made + (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( + TimeSpan.FromHours(6), + multipleCount: "3", + itemCount: 300); + + DateTimeOffset start = LocalTime(20); + + // three weeks of hourly builds, so that progress is far enough along to be clearly + // distinguishable from a playout that restarted at the beginning + for (var hour = 0; hour < 24 * 21; hour++) + { + await builder.Build( + start.AddHours(hour), + playout, + referenceData, + PlayoutBuildMode.Continue, + CancellationToken); + } + + int before = HeadIndex(playout); + before.ShouldBeGreaterThan(0, "the fast forward should have consumed some of the collection"); + + Either result = await builder.Build( + start.AddDays(21), + playout, + referenceData, + PlayoutBuildMode.Refresh, + CancellationToken); + + result.IsRight.ShouldBeTrue(); + + // a refresh rewinds to the start of today, so the index legitimately moves back by + // up to a day. a refresh that lost its checkpoints restarts at 0 and only advances + // through the build window, landing an order of magnitude lower + int after = HeadIndex(playout); + after.ShouldBeGreaterThan( + before / 2, + $"[tz {TimeZoneInfo.Local.Id}] refresh reset collection progress (was {before}, now {after})"); + } + + private static int HeadIndex(Playout playout) => + playout.ProgramScheduleAnchors + .Filter(a => a.AnchorDate is null) + .Map(a => a.EnumeratorState.Index) + .HeadOrNone() + .IfNone(-1); + + private (PlayoutBuilder, Playout, PlayoutReferenceData) MultipleTestData( + TimeSpan itemDuration, + string multipleCount, + int itemCount = 5) + { + var collection = new Collection + { + Id = 1, + Name = "Test Collection", + MediaItems = Enumerable.Range(1, itemCount) + .Map(i => (MediaItem)TestMovie(i, itemDuration, new DateTime(2000 + i, 1, 1))) + .ToList() + }; + + var collectionRepo = new FakeMediaCollectionRepository(Map((collection.Id, collection.MediaItems.ToList()))); + + IConfigElementRepository configRepo = Substitute.For(); + configRepo + .GetValue(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any()) + .Returns(Some(DaysToBuild)); + + var televisionRepo = new FakeTelevisionRepository(); + IArtistRepository artistRepo = Substitute.For(); + IMultiEpisodeShuffleCollectionEnumeratorFactory factory = + Substitute.For(); + IRerunHelper rerunHelper = Substitute.For(); + + var builder = new PlayoutBuilder( + configRepo, + collectionRepo, + televisionRepo, + artistRepo, + factory, + new MockFileSystem(), + rerunHelper, + Logger); + + var items = new List + { + new ProgramScheduleItemMultiple + { + Id = 1, + Index = 1, + CollectionType = CollectionType.Collection, + Collection = collection, + CollectionId = collection.Id, + StartTime = null, + PlaybackOrder = PlaybackOrder.Chronological, + Count = multipleCount + } + }; + + var playout = new Playout + { + Id = 1, + ScheduleKind = PlayoutScheduleKind.Classic, + ProgramSchedule = new ProgramSchedule { Items = items }, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, + Items = [], + ProgramScheduleAnchors = [], + ProgramScheduleAlternates = [], + FillGroupIndices = [] + }; + + var referenceData = new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); + + return (builder, playout, referenceData); + } + + /// + /// Anchor dates are compared in machine-local time + /// (), so the test clock has to be local + /// too, or day boundaries won't line up. That means these tests run in whatever zone the machine is + /// in - UTC on CI, something else locally - so the window below is deliberately placed in mid-June, + /// where no zone has a DST transition. Day-boundary behaviour across a DST change is a separate + /// concern and wants its own test. + /// + private static DateTimeOffset LocalTime(int hour) + { + var date = new DateTime(2025, 6, 10, 0, 0, 0, DateTimeKind.Unspecified); + return new DateTimeOffset(date, TimeZoneInfo.Local.GetUtcOffset(date)) + TimeSpan.FromHours(hour); + } +} diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index b60b3ac83..15b62d580 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -169,7 +169,9 @@ public class PlayoutBuilder : IPlayoutBuilder // _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors); - // remove null anchor date ("continue" anchors) + // remove the "continue" anchors (null anchor date), which hold the state at the head of + // the playout; they have to go rather than just be ignored, because GetMediaCollectionEnumerator + // ranks a null anchor date above every checkpoint and we want to rewind to today's checkpoint playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null); // _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors); @@ -455,7 +457,7 @@ public class PlayoutBuilder : IPlayoutBuilder return result; } - // build each day with "continue" anchors + // build one whole day at a time, saving a checkpoint anchor at each day boundary while (finish < playoutFinish) { if (cancellationToken.IsCancellationRequested) @@ -495,7 +497,8 @@ public class PlayoutBuilder : IPlayoutBuilder if (start < playoutFinish) { - // build one final time without continue anchors + // build the remaining partial day; this writes "continue" anchors (null anchor date) + // instead of a checkpoint, since we aren't stopping on a day boundary _logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish); Either buildResult = await BuildPlayoutItems( playout,