Browse Source

fix some comments; add failing tests

pull/2954/head
Jason Dove 1 week ago
parent
commit
525fcd3854
No known key found for this signature in database
  1. 2
      CHANGELOG.md
  2. 215
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs
  3. 9
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

2
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 - 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 - Allow multiple ETV instances when multiple config folders are used
- Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding - 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 ## [26.6.0] - 2026-07-09
### Added ### Added

215
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;
/// <summary>
/// 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.
/// </summary>
[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<string>();
for (var hour = 0; hour < 24 * 7; hour++)
{
DateTimeOffset now = start.AddHours(hour);
Either<BaseError, PlayoutBuildResult> 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<BaseError, PlayoutBuildResult> 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<IConfigElementRepository>();
configRepo
.GetValue<int>(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any<CancellationToken>())
.Returns(Some(DaysToBuild));
var televisionRepo = new FakeTelevisionRepository();
IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder(
configRepo,
collectionRepo,
televisionRepo,
artistRepo,
factory,
new MockFileSystem(),
rerunHelper,
Logger);
var items = new List<ProgramScheduleItem>
{
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<Deco>.None,
[],
[],
playout.ProgramSchedule,
[],
[],
TimeSpan.Zero);
return (builder, playout, referenceData);
}
/// <summary>
/// Anchor dates are compared in machine-local time
/// (<see cref="PlayoutProgramScheduleAnchor.AnchorDateOffset" />), 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.
/// </summary>
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);
}
}

9
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -169,7 +169,9 @@ public class PlayoutBuilder : IPlayoutBuilder
// _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors); // _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); playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null);
// _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors); // _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors);
@ -455,7 +457,7 @@ public class PlayoutBuilder : IPlayoutBuilder
return result; 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) while (finish < playoutFinish)
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
@ -495,7 +497,8 @@ public class PlayoutBuilder : IPlayoutBuilder
if (start < playoutFinish) 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); _logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish);
Either<BaseError, PlayoutBuildResult> buildResult = await BuildPlayoutItems( Either<BaseError, PlayoutBuildResult> buildResult = await BuildPlayoutItems(
playout, playout,

Loading…
Cancel
Save