mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
190 lines
6.7 KiB
190 lines
6.7 KiB
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
using ErsatzTV.Core.Scheduling; |
|
using ErsatzTV.Core.Scheduling.Engine; |
|
using NSubstitute; |
|
using NUnit.Framework; |
|
using Shouldly; |
|
|
|
namespace ErsatzTV.Core.Tests.Scheduling; |
|
|
|
/// <summary> |
|
/// Marathon content builds a <see cref="PlaylistEnumerator" /> over one playlist item per group, |
|
/// so it inherits that enumerator's save/restore behaviour. These cover the state handling, not |
|
/// the grouping. |
|
/// </summary> |
|
[TestFixture] |
|
public class MarathonEnumeratorTests |
|
{ |
|
private const int Seed = 987654321; |
|
|
|
[SetUp] |
|
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token; |
|
|
|
private CancellationToken _cancellationToken; |
|
|
|
/// <summary> |
|
/// A cycle completes when every media item has been played, which resets the index to 0 and |
|
/// reshuffles the groups. Two shows of three episodes each is six items, so the index has to |
|
/// come back to 0 within six moves. |
|
/// </summary> |
|
[Test] |
|
public async Task Marathon_Should_Complete_A_Cycle() |
|
{ |
|
List<MediaItem> mediaItems = |
|
[ |
|
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), |
|
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) |
|
]; |
|
|
|
PlaylistEnumerator enumerator = await CreateMarathon(mediaItems); |
|
|
|
List<int> indexes = Advance(enumerator, 12); |
|
|
|
indexes.ShouldContain(0, "the index never returned to 0, so the cycle never completed"); |
|
} |
|
|
|
/// <summary> |
|
/// Season 0 episodes are filtered out by the season/episode enumerator but still counted as |
|
/// items the cycle is waiting on, so the cycle can never complete. |
|
/// </summary> |
|
[Test] |
|
public async Task Marathon_Should_Complete_A_Cycle_With_Specials() |
|
{ |
|
List<MediaItem> mediaItems = |
|
[ |
|
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), |
|
.. Show(showId: 1, seasonNumber: 0, episodes: 1, firstId: 15), |
|
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) |
|
]; |
|
|
|
PlaylistEnumerator enumerator = await CreateMarathon(mediaItems); |
|
|
|
List<int> indexes = Advance(enumerator, 60); |
|
|
|
indexes.ShouldContain(0, "the index never returned to 0, so the cycle never completed"); |
|
} |
|
|
|
/// <summary> |
|
/// The flood and duration schedulers snapshot every enumerator's state before trying an item |
|
/// and hand it back when the item doesn't fit, so ResetState has to actually rewind. |
|
/// </summary> |
|
[Test] |
|
public async Task Marathon_Should_Rewind_On_ResetState() |
|
{ |
|
List<MediaItem> mediaItems = |
|
[ |
|
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), |
|
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) |
|
]; |
|
|
|
PlaylistEnumerator enumerator = await CreateMarathon(mediaItems); |
|
|
|
|
|
enumerator.MoveNext(Option<DateTimeOffset>.None); |
|
|
|
CollectionEnumeratorState snapshot = enumerator.State.Clone(); |
|
int expected = enumerator.Current.Map(mi => mi.Id).IfNone(-1); |
|
|
|
enumerator.MoveNext(Option<DateTimeOffset>.None); |
|
enumerator.MoveNext(Option<DateTimeOffset>.None); |
|
|
|
enumerator.ResetState(snapshot); |
|
|
|
enumerator.Current.Map(mi => mi.Id).IfNone(-1).ShouldBe(expected); |
|
} |
|
|
|
/// <summary> |
|
/// Rebuilding a playout restores the enumerator from the persisted state, which has to land on |
|
/// the same item the previous build stopped at. |
|
/// </summary> |
|
[Test] |
|
public async Task Marathon_Should_Resume_Where_It_Left_Off() |
|
{ |
|
List<MediaItem> mediaItems = |
|
[ |
|
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), |
|
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) |
|
]; |
|
|
|
PlaylistEnumerator reference = await CreateMarathon(mediaItems); |
|
|
|
var sequence = new List<int>(); |
|
var states = new List<CollectionEnumeratorState>(); |
|
for (var i = 0; i < 12; i++) |
|
{ |
|
states.Add(reference.State.Clone()); |
|
sequence.Add(reference.Current.Map(mi => mi.Id).IfNone(-1)); |
|
reference.MoveNext(Option<DateTimeOffset>.None); |
|
} |
|
|
|
var mismatches = new List<string>(); |
|
for (var i = 0; i < states.Count; i++) |
|
{ |
|
PlaylistEnumerator resumed = await CreateMarathon(mediaItems, states[i]); |
|
int actual = resumed.Current.Map(mi => mi.Id).IfNone(-1); |
|
if (actual != sequence[i]) |
|
{ |
|
mismatches.Add($"position {i} (index {states[i].Index}): expected {sequence[i]}, got {actual}"); |
|
} |
|
} |
|
|
|
mismatches.ShouldBeEmpty(string.Join("; ", mismatches)); |
|
} |
|
|
|
private async Task<PlaylistEnumerator> CreateMarathon( |
|
List<MediaItem> mediaItems, |
|
CollectionEnumeratorState state = null) |
|
{ |
|
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>(); |
|
var helper = new MarathonHelper(repo); |
|
|
|
Option<PlaylistEnumerator> maybeEnumerator = await helper.GetEnumerator( |
|
mediaItems, |
|
MarathonGroupBy.Show, |
|
marathonShuffleGroups: false, |
|
marathonShuffleItems: false, |
|
marathonBatchSize: Option<int>.None, |
|
state ?? new CollectionEnumeratorState { Seed = Seed, Index = 0 }, |
|
_cancellationToken); |
|
|
|
return maybeEnumerator.IfNone(() => throw new InvalidOperationException("no marathon enumerator")); |
|
} |
|
|
|
private static List<int> Advance(PlaylistEnumerator enumerator, int count) |
|
{ |
|
var indexes = new List<int>(); |
|
for (var i = 0; i < count; i++) |
|
{ |
|
enumerator.MoveNext(Option<DateTimeOffset>.None); |
|
indexes.Add(enumerator.State.Index); |
|
} |
|
|
|
return indexes; |
|
} |
|
|
|
private static List<MediaItem> Show(int showId, int seasonNumber, int episodes, int firstId) |
|
{ |
|
var seasonId = showId * 100 + seasonNumber; |
|
var season = new Season { Id = seasonId, ShowId = showId, SeasonNumber = seasonNumber }; |
|
|
|
return Enumerable.Range(0, episodes) |
|
.Map(i => (MediaItem)new Episode |
|
{ |
|
Id = firstId + i, |
|
Season = season, |
|
SeasonId = seasonId, |
|
EpisodeMetadata = [new EpisodeMetadata { EpisodeNumber = i + 1 }], |
|
MediaVersions = |
|
[ |
|
new MediaVersion |
|
{ |
|
Duration = TimeSpan.FromMinutes(30), |
|
MediaFiles = [new MediaFile { Path = $"/fake/path/{firstId + i}" }], |
|
Chapters = [] |
|
} |
|
] |
|
}) |
|
.ToList(); |
|
} |
|
}
|
|
|