using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using MediatR; using Microsoft.EntityFrameworkCore; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; // #425: per-source rotation weights turn an auto-tune channel from a single fair-share SmartCollection into // a system-owned MultiCollection of per-source SmartCollections. CreateChannelFromLineup is mocked, so these // verify the plan/persistence/ownership; end-to-end weighted round-robin is covered by // WeightedShuffleCollectionEnumeratorTests + live-E2E. [TestFixture] public class CreateAutoTunedWeightedChannelsTests : ChannelHandlerTestBase { private ISender _mediator = null!; private ISmartCollectionCache _smartCollectionCache = null!; private CreateChannelFromLineup _sentLineup; [SetUp] public void WeightedSetUp() { _mediator = Substitute.For(); _smartCollectionCache = Substitute.For(); _sentLineup = null; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(ci => { _sentLineup = ci.Arg(); return (Either) new CreateChannelFromLineupResponseModel(88, null, 1, 2); }); } private void SeedMembers(params (int Id, string Title)[] members) => _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedLibraryBrowseItemsResponseModel( members.Length, members.Select(m => Item(m.Id, m.Title)).ToList())); private static LibraryBrowseItemResponseModel Item(int id, string title) => new(id, LibraryBrowseMediaType.TelevisionShow, title, null, null, string.Empty, null, null, null, CollectionType.SmartCollection, null, null, null, null, null, null); private CreateAutoTunedChannelsHandler MakeHandler() => new(_mediator, Db.Factory, SearchTargets, _smartCollectionCache); private async Task<(MultiCollection Mc, List Members)> LoadOnlyMultiCollection() { await using TvContext context = Db.CreateContext(); MultiCollection mc = await context.MultiCollections .Include(m => m.MultiCollectionSmartItems) .ThenInclude(i => i.SmartCollection) .SingleAsync(); List members = mc.MultiCollectionSmartItems .OrderBy(i => i.SmartCollection.Name) .Select(i => i.SmartCollection) .ToList(); return (mc, members); } [Test] public async Task TvGenre_Materializes_Every_Show_With_A_Live_Remainder() { SeedMembers((1, "Alpha"), (2, "Beta"), (3, "Gamma")); AutoTuneResult result = await MakeHandler().Handle( new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500", Sources: [new AutoTuneSourceWeight(1, Weight: 3)]) }), CancellationToken.None); result.CreatedCount.ShouldBe(1); (MultiCollection mc, List _) = await LoadOnlyMultiCollection(); // 3 shows + 1 remainder, all owned by the created channel. mc.MultiCollectionSmartItems.Count.ShouldBe(4); mc.OwnedByChannelId.ShouldBe(88); Dictionary weightByQuery = mc.MultiCollectionSmartItems .ToDictionary(i => i.SmartCollection.Query, i => i.Weight); weightByQuery["type:episode AND show_title:\"Alpha\""].ShouldBe(3); weightByQuery["type:episode AND show_title:\"Beta\""].ShouldBe(1); weightByQuery["type:episode AND show_title:\"Gamma\""].ShouldBe(1); weightByQuery[ "(type:episode AND genre:\"Comedy\") AND NOT (show_title:\"Alpha\" OR show_title:\"Beta\" OR show_title:\"Gamma\")"] .ShouldBe(1); // The channel points at the MultiCollection with WeightedShuffle. _sentLineup.Advanced.PlaybackOrder.ShouldBe(PlaybackOrder.WeightedShuffle); _sentLineup.Lineup.Count.ShouldBe(1); _sentLineup.Lineup[0].CollectionType.ShouldBe(CollectionType.MultiCollection); _sentLineup.Lineup[0].MultiCollectionId.ShouldBe(mc.Id); // Every member smart collection is stamped as owned (hidden from user lists, cleaned on delete). mc.MultiCollectionSmartItems.ShouldAllBe(i => i.SmartCollection.OwnedByChannelId == 88); } [Test] public async Task Excluded_Show_Is_Not_A_Member_But_Is_Subtracted_From_The_Remainder() { SeedMembers((1, "Alpha"), (2, "Beta"), (3, "Gamma")); await MakeHandler().Handle( new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500", Sources: [new AutoTuneSourceWeight(2, Excluded: true)]) }), CancellationToken.None); (MultiCollection mc, List members) = await LoadOnlyMultiCollection(); // Beta is dropped: 2 shows + 1 remainder. members.Select(m => m.Query).ShouldNotContain("type:episode AND show_title:\"Beta\""); mc.MultiCollectionSmartItems.Count(i => !i.SmartCollection.Query.StartsWith("(")).ShouldBe(2); // But Beta is still subtracted so its episodes don't leak into the remainder. string remainder = members.Single(m => m.Query.StartsWith("(")).Query; remainder.ShouldContain("show_title:\"Beta\""); } [Test] public async Task MovieGenre_Materializes_Only_Touched_Movies_With_A_Count_Weighted_Remainder() { // 4 movies; only movie 10 is re-weighted. The untouched three stay in one count-weighted remainder. _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedLibraryBrowseItemsResponseModel(4, new List { MovieItem(10), MovieItem(11), MovieItem(12), MovieItem(13) })); await MakeHandler().Handle( new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.MovieGenre, "Action", "Action Movies", "500", Sources: [new AutoTuneSourceWeight(10, Weight: 3)]) }), CancellationToken.None); (MultiCollection mc, List members) = await LoadOnlyMultiCollection(); // one materialized movie + one remainder mc.MultiCollectionSmartItems.Count.ShouldBe(2); MultiCollectionSmartItem movie = mc.MultiCollectionSmartItems .Single(i => i.SmartCollection.Query == "type:movie AND id:10"); movie.Weight.ShouldBe(3); // remainder weight = the 3 un-touched base movies; subtracts the materialized movie by id MultiCollectionSmartItem remainder = mc.MultiCollectionSmartItems .Single(i => i.SmartCollection.Query.StartsWith("(")); remainder.Weight.ShouldBe(3); remainder.SmartCollection.Query.ShouldBe("(type:movie AND genre:\"Action\") AND NOT (id:10)"); } [Test] public async Task All_Default_Weights_Fall_Back_To_A_Single_SmartCollection() { SeedMembers((1, "Alpha"), (2, "Beta")); // Sources present but every weight is the fair-share default and nothing is excluded/added: the // channel keeps the cheap single-SmartCollection shape (no MultiCollection created). _mediator.Send(Arg.Any(), Arg.Any()) .Returns(ci => (Either) new SmartCollectionViewModel(7, ci.Arg().Name, ci.Arg().Query)); AutoTuneResult result = await MakeHandler().Handle( new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500", Sources: [ new AutoTuneSourceWeight(1), new AutoTuneSourceWeight(2) ]) }), CancellationToken.None); result.CreatedCount.ShouldBe(1); await using TvContext context = Db.CreateContext(); (await context.MultiCollections.AnyAsync()).ShouldBeFalse(); _sentLineup.Lineup[0].CollectionType.ShouldBe(CollectionType.SmartCollection); _sentLineup.Lineup[0].SmartCollectionId.ShouldBe(7); } private static LibraryBrowseItemResponseModel MovieItem(int id) => new(id, LibraryBrowseMediaType.Movie, $"Movie {id}", null, null, string.Empty, null, null, null, CollectionType.SmartCollection, null, null, null, null, null, null); }