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