Addresses the one substantive nit from the cold adversarial review of PR #391: the true branch of ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle (routing through MultiPartEpisodeGrouper) had no direct regression net — the goldens and existing unit tests all use false. Add a test that observably distinguishes the branches: four episodes where two are a "(1)"/"(2)" multi-part pair collapse to 3 groups under keepMultiPartEpisodesTogether=true vs 4 groups under false. Test-only; no production change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
7.7 KiB
C#
186 lines
7.7 KiB
C#
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<IMediaCollectionRepository>();
|
|
List<MediaItem> items = [FakeMovie(1), FakeMovie(2), FakeMovie(3)];
|
|
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 7 };
|
|
|
|
List<GroupedMediaItem> 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<IMediaCollectionRepository>();
|
|
repo.GetMultiCollectionCollections(42, Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
new List<CollectionWithItems>
|
|
{
|
|
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<GroupedMediaItem> 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<CancellationToken>());
|
|
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<IMediaCollectionRepository>();
|
|
var expected = new List<CollectionWithItems>
|
|
{
|
|
new(
|
|
ShowId: 0,
|
|
ArtistId: 0,
|
|
Key: "fake",
|
|
MediaItems: [FakeMovie(20)],
|
|
ScheduleAsGroup: false,
|
|
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
|
|
UseCustomOrder: false)
|
|
};
|
|
repo.GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>()).Returns(expected);
|
|
|
|
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 5 };
|
|
|
|
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
|
repo,
|
|
key,
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(expected);
|
|
await repo.Received(1).GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>());
|
|
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetCollectionItemsForShuffleInOrder_MultiCollection_Uses_MultiCollection_Lookup()
|
|
{
|
|
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
|
var expected = new List<CollectionWithItems>
|
|
{
|
|
new(
|
|
ShowId: 0,
|
|
ArtistId: 0,
|
|
Key: "mc",
|
|
MediaItems: [FakeMovie(30), FakeMovie(31)],
|
|
ScheduleAsGroup: true,
|
|
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
|
|
UseCustomOrder: false)
|
|
};
|
|
repo.GetMultiCollectionCollections(9, Arg.Any<CancellationToken>()).Returns(expected);
|
|
|
|
var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 9 };
|
|
|
|
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
|
repo,
|
|
key,
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(expected);
|
|
await repo.Received(1).GetMultiCollectionCollections(9, Arg.Any<CancellationToken>());
|
|
await repo.DidNotReceiveWithAnyArgs().GetFakeMultiCollectionCollections(default, default, default);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetGroupedMediaItemsForShuffle_KeepMultiPart_Groups_Adjacent_Parts()
|
|
{
|
|
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
|
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together.
|
|
List<MediaItem> items =
|
|
[
|
|
NamedEpisode("Episode 1", 1),
|
|
NamedEpisode("Episode 2 (1)", 2),
|
|
NamedEpisode("Episode 3 (2)", 3),
|
|
NamedEpisode("Episode 4", 4)
|
|
];
|
|
|
|
List<GroupedMediaItem> 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<GroupedMediaItem> 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) }
|
|
]
|
|
};
|
|
}
|