Files
ersatztv/ErsatzTV.Tests/Application/Channels/CreateAutoTunedWeightedChannelsTests.cs
T
timothyandClaude Opus 4.8 e364b338e6 feat(425): per-source rotation weights + query corrections for auto-tune channels
Auto-tune channels can now carry per-content-source rotation weights (weighted
round-robin, e.g. 3x Show A / 1x Show B) and query corrections (exclude /
add-untagged), supplied at bulk-create time via an optional
`sources: [{sourceId, weight, excluded}]` on each AutoTunedChannelRequest.

Design (Option A, reuse #70): when a source is customized the channel is backed
by a system-owned MultiCollection of per-source SmartCollections carrying the
weights, with PlaybackOrder.WeightedShuffle -- the exact path
WeightedShuffleCollectionEnumerator already consumes. All-default weights keep
the #69 single-SmartCollection fair-share shape.

- Discriminators: TV -> live show_title:"X" (episodes carry no parent-show id in
  the index); movies -> stable id:{mediaItemId}.
- Materialization is axis-dependent: TV materializes every base show individually
  (un-weighted shows keep per-show fair-share) + a live remainder at weight 1;
  MovieGenre materializes only touched movies + one count-weighted remainder.
- Remainder = (base) AND NOT (materialized union excluded) -- a partition.
- New nullable OwnedByChannelId on SmartCollection + MultiCollection
  (dual-provider migration); owned rows are hidden from the collection lists and
  cascade-cleaned on channel delete.

Tests: AutoTuneAxisMap query/partition units; DB-backed weighted-path handler
tests (TV materialize-all, movie count-remainder, exclusion, no-customization
fallback); delete-cleanup. Docs: decisions.md, domain-model.md, api-conventions.md;
OpenAPI trio regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:52:06 +02:00

213 lines
9.3 KiB
C#

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<ISender>();
_smartCollectionCache = Substitute.For<ISmartCollectionCache>();
_sentLineup = null;
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
_sentLineup = ci.Arg<CreateChannelFromLineup>();
return (Either<BaseError, CreateChannelFromLineupResponseModel>)
new CreateChannelFromLineupResponseModel(88, null, 1, 2);
});
}
private void SeedMembers(params (int Id, string Title)[] members) =>
_mediator.Send(Arg.Any<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
.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<SmartCollection> Members)> LoadOnlyMultiCollection()
{
await using TvContext context = Db.CreateContext();
MultiCollection mc = await context.MultiCollections
.Include(m => m.MultiCollectionSmartItems)
.ThenInclude(i => i.SmartCollection)
.SingleAsync();
List<SmartCollection> 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<AutoTuneChannelSelection>
{
new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500",
Sources: [new AutoTuneSourceWeight(1, Weight: 3)])
}),
CancellationToken.None);
result.CreatedCount.ShouldBe(1);
(MultiCollection mc, List<SmartCollection> _) = await LoadOnlyMultiCollection();
// 3 shows + 1 remainder, all owned by the created channel.
mc.MultiCollectionSmartItems.Count.ShouldBe(4);
mc.OwnedByChannelId.ShouldBe(88);
Dictionary<string, int> 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<AutoTuneChannelSelection>
{
new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500",
Sources: [new AutoTuneSourceWeight(2, Excluded: true)])
}),
CancellationToken.None);
(MultiCollection mc, List<SmartCollection> 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<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
.Returns(new PagedLibraryBrowseItemsResponseModel(4, new List<LibraryBrowseItemResponseModel>
{
MovieItem(10), MovieItem(11), MovieItem(12), MovieItem(13)
}));
await MakeHandler().Handle(
new CreateAutoTunedChannels(3, "Auto-Tuned", new List<AutoTuneChannelSelection>
{
new(AutoTuneAxis.MovieGenre, "Action", "Action Movies", "500",
Sources: [new AutoTuneSourceWeight(10, Weight: 3)])
}),
CancellationToken.None);
(MultiCollection mc, List<SmartCollection> 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<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(ci => (Either<BaseError, SmartCollectionViewModel>)
new SmartCollectionViewModel(7, ci.Arg<CreateSmartCollection>().Name,
ci.Arg<CreateSmartCollection>().Query));
AutoTuneResult result = await MakeHandler().Handle(
new CreateAutoTunedChannels(3, "Auto-Tuned", new List<AutoTuneChannelSelection>
{
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);
}