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,