Files
ersatztv/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs
T
timothy f31476e012 fix(69): auto-tune review fixes — null channels, orphaned SmartCollection, oversized preview names, doc drift
- 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
2026-07-16 22:18:52 +02:00

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