Files
ersatztv/ErsatzTV.Core.Tests/Scheduling/ShuffleSourceBuilderTests.cs
T
timothyandClaude Opus 4.8 8e7cba4c44 test(380): pin the keepMultiPartEpisodesTogether=true shuffle-source branch
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>
2026-07-17 08:48:05 +02:00

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) }
]
};
}