using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Scheduling; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Core.Tests.Scheduling; // Direct unit coverage for the shuffle-source helper extracted from PlayoutBuilder (#380). The builder // goldens exercise the common non-multi-collection path; these pin the branch selection (multi-collection // vs fake-multi-collection lookup, multi-part grouping on/off) that the goldens don't reach, so the // "one well-tested place per family" the issue asks for is genuinely tested here. [TestFixture] public class ShuffleSourceBuilderTests { [Test] public async Task GetGroupedMediaItemsForShuffle_Single_Collection_No_MultiPart_Groups_Each_Item() { IMediaCollectionRepository repo = Substitute.For(); List items = [FakeMovie(1), FakeMovie(2), FakeMovie(3)]; var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 7 }; List result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle( repo, keepMultiPartEpisodesTogether: false, treatCollectionsAsShows: false, items, key, CancellationToken.None); // No multi-part grouping and no multi-collection => each item is its own group, no repo call. result.Count.ShouldBe(3); result.Select(g => g.First.Id).ShouldBe([1, 2, 3]); result.ShouldAllBe(g => g.Additional.Count == 0); await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default); } [Test] public async Task GetGroupedMediaItemsForShuffle_MultiCollection_Reads_From_Repo() { IMediaCollectionRepository repo = Substitute.For(); repo.GetMultiCollectionCollections(42, Arg.Any()) .Returns( new List { new( ShowId: 0, ArtistId: 0, Key: "mc", MediaItems: [FakeMovie(10), FakeMovie(11)], ScheduleAsGroup: false, PlaybackOrder: PlaybackOrder.Shuffle, UseCustomOrder: false) }); var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 42 }; List result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle( repo, keepMultiPartEpisodesTogether: false, treatCollectionsAsShows: false, // the passed items list is ignored on the multi-collection branch [], key, CancellationToken.None); // The multi-collection branch reads from the repo and feeds MultiCollectionGrouper (whose grouping // behavior is covered by its own tests); here we only pin the branch selection + passthrough. await repo.Received(1).GetMultiCollectionCollections(42, Arg.Any()); GroupedMediaItem.FlattenGroups(result, 2).Select(mi => mi.Id).OrderBy(id => id).ShouldBe([10, 11]); } [Test] public async Task GetCollectionItemsForShuffleInOrder_Single_Collection_Uses_Fake_MultiCollection_Lookup() { IMediaCollectionRepository repo = Substitute.For(); var expected = new List { new( ShowId: 0, ArtistId: 0, Key: "fake", MediaItems: [FakeMovie(20)], ScheduleAsGroup: false, PlaybackOrder: PlaybackOrder.ShuffleInOrder, UseCustomOrder: false) }; repo.GetFakeMultiCollectionCollections(5, null, Arg.Any()).Returns(expected); var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 5 }; List result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder( repo, key, CancellationToken.None); result.ShouldBe(expected); await repo.Received(1).GetFakeMultiCollectionCollections(5, null, Arg.Any()); await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default); } [Test] public async Task GetCollectionItemsForShuffleInOrder_MultiCollection_Uses_MultiCollection_Lookup() { IMediaCollectionRepository repo = Substitute.For(); var expected = new List { new( ShowId: 0, ArtistId: 0, Key: "mc", MediaItems: [FakeMovie(30), FakeMovie(31)], ScheduleAsGroup: true, PlaybackOrder: PlaybackOrder.ShuffleInOrder, UseCustomOrder: false) }; repo.GetMultiCollectionCollections(9, Arg.Any()).Returns(expected); var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 9 }; List result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder( repo, key, CancellationToken.None); result.ShouldBe(expected); await repo.Received(1).GetMultiCollectionCollections(9, Arg.Any()); await repo.DidNotReceiveWithAnyArgs().GetFakeMultiCollectionCollections(default, default, default); } [Test] public async Task GetGroupedMediaItemsForShuffle_KeepMultiPart_Groups_Adjacent_Parts() { IMediaCollectionRepository repo = Substitute.For(); // "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together. List items = [ NamedEpisode("Episode 1", 1), NamedEpisode("Episode 2 (1)", 2), NamedEpisode("Episode 3 (2)", 3), NamedEpisode("Episode 4", 4) ]; List kept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle( repo, keepMultiPartEpisodesTogether: true, treatCollectionsAsShows: false, items, new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 }, CancellationToken.None); // keep=true routes through MultiPartEpisodeGrouper -> the two parts collapse into one group (3 total). kept.Count.ShouldBe(3); List notKept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle( repo, keepMultiPartEpisodesTogether: false, treatCollectionsAsShows: false, items, new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 }, CancellationToken.None); // keep=false leaves each item its own group (4 total) — proves the flag observably selects the branch. notKept.Count.ShouldBe(4); await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default); } private static Episode NamedEpisode(string title, int id) => new() { Id = id, EpisodeMetadata = [new EpisodeMetadata { Title = title, EpisodeNumber = id }], Season = new Season { SeasonNumber = 1, Show = new Show { Id = 1 }, ShowId = 1 } }; private static Movie FakeMovie(int id) => new() { Id = id, MediaVersions = [], MovieMetadata = [ new MovieMetadata { ReleaseDate = new DateTime(2020, 1, id) } ] }; }