- CreateAutoTunedChannelsRequest.ToCommand(): guard null Channels (was NREing on a request body that omits "channels", causing HTTP 500). - CreateAutoTunedChannelsHandler.CreateOne: when CreateChannelFromLineup returns Left (Skipped/Failed), roll back the just-created SmartCollection via DeleteSmartCollection so retries don't fail on SmartCollection-name uniqueness. Best-effort; the delete result does not change the outcome. - PreviewAutoTuneChannelsHandler: filter out proposals whose generated name exceeds the 50-char Channel.Name limit before number allocation, so numbers aren't wasted on proposals that can never be created. - docs/superpowers/specs/2026-07-16-auto-tuning-design.md: fix field-name drift in JSON examples (proposedNumber -> number, error -> reason) to match the actual AutoTuneProposal/AutoTuneChannelOutcome DTOs. Refs #69
153 lines
5.9 KiB
C#
153 lines
5.9 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.Domain;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Application.Channels;
|
|
|
|
[TestFixture]
|
|
public class PreviewAutoTuneChannelsHandlerTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
[Test]
|
|
public async Task Enumerates_Shows_Above_MinItems_With_Counts_And_Numbers()
|
|
{
|
|
// Show 1 "The Office" with 3 episodes; Show 2 "Short" with 1 episode.
|
|
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102, 103 });
|
|
await SeedShow(showId: 2, title: "Short", seasonId: 22, episodeIds: new[] { 201 });
|
|
|
|
var result = await Handle(new PreviewAutoTuneChannels(
|
|
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 2, StartingNumber: 500));
|
|
|
|
List<AutoTuneProposal> proposals = RightOf(result);
|
|
proposals.Count.ShouldBe(1);
|
|
proposals[0].Value.ShouldBe("The Office");
|
|
proposals[0].Name.ShouldBe("The Office");
|
|
proposals[0].ItemCount.ShouldBe(3);
|
|
proposals[0].Number.ShouldBe("500");
|
|
proposals[0].AlreadyExists.ShouldBeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Flags_AlreadyExists_By_Channel_Name_And_Skips_Taken_Numbers()
|
|
{
|
|
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102 });
|
|
await SeedChannel(number: "500", name: "The Office");
|
|
|
|
var result = await Handle(new PreviewAutoTuneChannels(
|
|
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 1, StartingNumber: 500));
|
|
|
|
List<AutoTuneProposal> proposals = RightOf(result);
|
|
proposals[0].AlreadyExists.ShouldBeTrue();
|
|
proposals[0].Number.ShouldBe("501"); // 500 is taken
|
|
}
|
|
|
|
[Test]
|
|
public async Task Enumerates_Movie_Genres_With_Suffixed_Names()
|
|
{
|
|
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: "Action");
|
|
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: "Action");
|
|
await SeedMovieWithGenre(movieId: 3, metadataId: 3, genre: "Drama");
|
|
|
|
var result = await Handle(new PreviewAutoTuneChannels(
|
|
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
|
|
|
|
List<AutoTuneProposal> proposals = RightOf(result);
|
|
proposals.Count.ShouldBe(1); // Drama has only 1 movie, below minItems
|
|
proposals[0].Value.ShouldBe("Action");
|
|
proposals[0].Name.ShouldBe("Action Movies");
|
|
proposals[0].ItemCount.ShouldBe(2);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Excludes_Proposals_Whose_Generated_Name_Exceeds_50_Chars()
|
|
{
|
|
// "Movies" suffix (7 chars) pushes this over the 50-char Channel.Name limit.
|
|
const string longGenre = "A Really Really Long And Overly Descriptive Genre"; // 50 chars, +" Movies" = 57
|
|
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: longGenre);
|
|
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: longGenre);
|
|
|
|
var result = await Handle(new PreviewAutoTuneChannels(
|
|
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
|
|
|
|
List<AutoTuneProposal> proposals = RightOf(result);
|
|
proposals.ShouldNotContain(p => p.Value == longGenre);
|
|
proposals.ShouldBeEmpty();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Empty_Axes_Is_Error()
|
|
{
|
|
var result = await Handle(new PreviewAutoTuneChannels(
|
|
new List<AutoTuneAxis>(), MinItems: 5, StartingNumber: 500));
|
|
LeftOf(result).Value.ShouldContain("axis");
|
|
}
|
|
|
|
private Task<Either<BaseError, List<AutoTuneProposal>>> Handle(PreviewAutoTuneChannels request) =>
|
|
new PreviewAutoTuneChannelsHandler(_db.Factory).Handle(request, CancellationToken.None);
|
|
|
|
private async Task SeedShow(int showId, string title, int seasonId, int[] episodeIds)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
context.Shows.Add(new Show
|
|
{
|
|
Id = showId,
|
|
ShowMetadata = new List<ShowMetadata> { new() { ShowId = showId, Title = title } },
|
|
Seasons = new List<Season>
|
|
{
|
|
new()
|
|
{
|
|
Id = seasonId, ShowId = showId,
|
|
Episodes = episodeIds.Select(id => new Episode { Id = id, SeasonId = seasonId }).ToList()
|
|
}
|
|
}
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SeedMovieWithGenre(int movieId, int metadataId, string genre)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
context.Movies.Add(new Movie
|
|
{
|
|
Id = movieId,
|
|
MovieMetadata = new List<MovieMetadata>
|
|
{
|
|
new() { Id = metadataId, MovieId = movieId, Title = $"Movie {movieId}",
|
|
Genres = new List<Genre> { new() { Name = genre } } }
|
|
}
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SeedChannel(string number, string name)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
context.Channels.Add(new Channel(System.Guid.NewGuid())
|
|
{
|
|
Number = number, Name = name, Group = "Test", SortNumber = double.Parse(number)
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private static BaseError LeftOf<TR>(Either<BaseError, TR> e) =>
|
|
e.Match(Left: x => x, Right: _ => throw new AssertionException("Expected a Left result"));
|
|
private static TR RightOf<TR>(Either<BaseError, TR> e) =>
|
|
e.Match(Left: x => throw new AssertionException($"Expected Right, got {x.Value}"), Right: r => r);
|
|
}
|