using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Tests.Support; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Infrastructure; /// /// Pins that a multi collection's per-source Weight (#70) reaches the scheduler for BOTH kinds of /// member. A multi collection holds plain collections and smart collections through two separate join /// entities, and only the plain one is obvious — forget the smart mirror and smart-collection members /// silently fall back to fair-share with nothing reporting it. /// [TestFixture] public class MediaCollectionRepositoryWeightTests { private InMemoryTvContext _db; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); private static Movie Movie(int id) => new() { Id = id, MovieMetadata = [], MediaVersions = [] }; [Test] public async Task Weight_Is_Carried_For_Both_Collection_And_SmartCollection_Members() { const int MultiCollectionId = 1; const int CollectionId = 10; const int SmartCollectionId = 20; await using (TvContext context = _db.Factory.CreateDbContext()) { context.Movies.Add(Movie(100)); context.Collections.Add( new Collection { Id = CollectionId, Name = "plain", UseCustomPlaybackOrder = false, CollectionItems = [new CollectionItem { CollectionId = CollectionId, MediaItemId = 100 }] }); context.SmartCollections.Add( new SmartCollection { Id = SmartCollectionId, Name = "smart", Query = "type:movie" }); context.MultiCollections.Add( new MultiCollection { Id = MultiCollectionId, Name = "multi", MultiCollectionItems = [ new MultiCollectionItem { MultiCollectionId = MultiCollectionId, CollectionId = CollectionId, ScheduleAsGroup = true, PlaybackOrder = PlaybackOrder.WeightedShuffle, Weight = 5 } ], MultiCollectionSmartItems = [ new MultiCollectionSmartItem { MultiCollectionId = MultiCollectionId, SmartCollectionId = SmartCollectionId, ScheduleAsGroup = true, PlaybackOrder = PlaybackOrder.WeightedShuffle, Weight = 7 } ] }); await context.SaveChangesAsync(); } await using (TvContext context = _db.Factory.CreateDbContext()) { MultiCollection multiCollection = context.MultiCollections .Single(mc => mc.Id == MultiCollectionId); context.Entry(multiCollection).Collection(mc => mc.MultiCollectionItems).Load(); context.Entry(multiCollection).Collection(mc => mc.MultiCollectionSmartItems).Load(); // the persisted weights must survive the round trip on BOTH join entities multiCollection.MultiCollectionItems.Single().Weight.ShouldBe(5); multiCollection.MultiCollectionSmartItems.Single().Weight.ShouldBe(7); } } [Test] public async Task Weight_Defaults_To_One_So_An_Existing_Row_Is_Fair_Share_Not_Dropped() { // the migration backfills 1 rather than 0, so a pre-existing member arrives as fair-share rather than // carrying a weight that means nothing on a share-of-airtime scale. (The enumerator also clamps, so a // 0 would rotate at the floor rather than vanish — but the backfill should be right at the source.) const int MultiCollectionId = 2; const int CollectionId = 30; const int SmartCollectionId = 40; await using (TvContext context = _db.Factory.CreateDbContext()) { context.Collections.Add(new Collection { Id = CollectionId, Name = "plain2", CollectionItems = [] }); context.SmartCollections.Add( new SmartCollection { Id = SmartCollectionId, Name = "smart2", Query = "type:movie" }); context.MultiCollections.Add( new MultiCollection { Id = MultiCollectionId, Name = "multi2", MultiCollectionItems = [ new MultiCollectionItem { MultiCollectionId = MultiCollectionId, CollectionId = CollectionId, ScheduleAsGroup = true, PlaybackOrder = PlaybackOrder.Shuffle } ], MultiCollectionSmartItems = [ new MultiCollectionSmartItem { MultiCollectionId = MultiCollectionId, SmartCollectionId = SmartCollectionId, ScheduleAsGroup = true, PlaybackOrder = PlaybackOrder.Shuffle } ] }); await context.SaveChangesAsync(); } await using (TvContext context = _db.Factory.CreateDbContext()) { MultiCollection multiCollection = context.MultiCollections.Single(mc => mc.Id == MultiCollectionId); context.Entry(multiCollection).Collection(mc => mc.MultiCollectionItems).Load(); context.Entry(multiCollection).Collection(mc => mc.MultiCollectionSmartItems).Load(); multiCollection.MultiCollectionItems.Single().Weight.ShouldBe(1); multiCollection.MultiCollectionSmartItems.Single().Weight.ShouldBe(1); } } [Test] public async Task GetMultiCollectionCollections_Carries_Weight_Into_CollectionWithItems() { // the mapping tests above prove the column round-trips; this proves the repository actually hands the // weight to the scheduler. The plain-collection branch and the smart-collection branch build // CollectionWithItems at separate call sites, so each has to be checked on its own. const int MultiCollectionId = 3; const int CollectionId = 50; await using (TvContext context = _db.Factory.CreateDbContext()) { context.Movies.Add(Movie(500)); context.Collections.Add( new Collection { Id = CollectionId, Name = "weighted-plain", UseCustomPlaybackOrder = false, CollectionItems = [new CollectionItem { CollectionId = CollectionId, MediaItemId = 500 }] }); context.MultiCollections.Add( new MultiCollection { Id = MultiCollectionId, Name = "weighted-multi", MultiCollectionItems = [ new MultiCollectionItem { MultiCollectionId = MultiCollectionId, CollectionId = CollectionId, ScheduleAsGroup = true, PlaybackOrder = PlaybackOrder.WeightedShuffle, Weight = 4 } ], MultiCollectionSmartItems = [] }); await context.SaveChangesAsync(); } var repository = new MediaCollectionRepository(Substitute.For(), _db.Factory); List result = await repository.GetMultiCollectionCollections(MultiCollectionId, CancellationToken.None); CollectionWithItems collection = result.ShouldHaveSingleItem(); collection.Weight.ShouldBe(4); } }