Enhance GridPlanner and GenerateGridDialog for improved slot management and user experience
Refactored the GridPlanner class to optimize slot management during grid generation, ensuring more efficient handling of slots. Updated the GenerateGridDialog component to improve its layout and usability, enhancing the overall user experience when interacting with slot lists. These changes contribute to better functionality and accessibility in grid management.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Metadata.RefreshEpisodes;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Довыгрузка метаданных серий: что берётся за номер серии, что происходит с кадром и когда
|
||||
/// операция отказывается работать вовсе.
|
||||
/// </summary>
|
||||
public class RefreshEpisodesTests
|
||||
{
|
||||
private static EpisodeMetadata Meta(string title, string? stillUrl = null) =>
|
||||
new(title, "Описание", stillUrl, new DateOnly(2005, 3, 20));
|
||||
|
||||
private static (IMetadataProviderResolver Resolver, IMetadataProvider Provider) ProviderFor(
|
||||
string key
|
||||
)
|
||||
{
|
||||
var provider = Substitute.For<IMetadataProvider>();
|
||||
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||
resolver.Resolve(key).Returns(provider);
|
||||
return (resolver, provider);
|
||||
}
|
||||
|
||||
private static RefreshShowEpisodesMetadataCommandHandler Handler(
|
||||
IAppDbContext db,
|
||||
IMetadataProviderResolver resolver,
|
||||
IImageDownloader? downloader = null,
|
||||
IImageStore? store = null
|
||||
) =>
|
||||
new(
|
||||
db,
|
||||
resolver,
|
||||
downloader ?? Substitute.For<IImageDownloader>(),
|
||||
store ?? Substitute.For<IImageStore>()
|
||||
);
|
||||
|
||||
/// <summary>Сериал с привязкой к источнику и одной серией на готовом ассете.</summary>
|
||||
private static (Show Show, MediaAsset Asset) Linked(string fileName, bool withNumbers)
|
||||
{
|
||||
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
|
||||
var show = Show.Create("Симпсоны", ShowKind.Series);
|
||||
show.ApplyMetadata("tmdb", "456", null, 2005, null);
|
||||
var episode = show.AddEpisode(asset.Id);
|
||||
if (withNumbers)
|
||||
episode.SetNumbers(16, 21);
|
||||
return (show, asset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppliesMetadata_AndSavesStillToRegistry()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var (show, asset) = Linked("s16e21.mkv", withNumbers: true);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var (resolver, provider) = ProviderFor("tmdb");
|
||||
provider
|
||||
.GetEpisodeAsync("456", 16, 21, Arg.Any<CancellationToken>())
|
||||
.Returns(Meta("The Father, the Son", "https://img/still.jpg"));
|
||||
|
||||
var downloader = Substitute.For<IImageDownloader>();
|
||||
downloader
|
||||
.DownloadAsync("https://img/still.jpg", Arg.Any<CancellationToken>())
|
||||
.Returns(new DownloadedImage([1, 2, 3], ".jpg"));
|
||||
var store = Substitute.For<IImageStore>();
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, resolver, downloader, store)
|
||||
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = await check.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None);
|
||||
var episode = stored.Episodes.Single();
|
||||
Assert.Equal("The Father, the Son", episode.Title);
|
||||
Assert.NotNull(episode.StillImageId);
|
||||
// Кадр попадает в общий реестр изображений, а не в своё файловое поле.
|
||||
Assert.Equal(1, await check.Images.CountAsync(CancellationToken.None));
|
||||
await store
|
||||
.Received(1)
|
||||
.SaveAsync(
|
||||
episode.StillImageId!.Value,
|
||||
".jpg",
|
||||
Arg.Any<byte[]>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TakesNumbersFromFileName_WhenNotSet()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var (show, asset) = Linked("Simpsons.S16E21.1080p.mkv", withNumbers: false);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var (resolver, provider) = ProviderFor("tmdb");
|
||||
provider
|
||||
.GetEpisodeAsync("456", 16, 21, Arg.Any<CancellationToken>())
|
||||
.Returns(Meta("Разобрано по имени"));
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, resolver)
|
||||
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Value);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single();
|
||||
// Разобранные номера тут же сохраняются: второй раз имя файла разбирать незачем.
|
||||
Assert.Equal(16, episode.Season);
|
||||
Assert.Equal(21, episode.Episode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsEpisode_WhenNumbersUnknownOrSourceSilent()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var unparsable = MediaAsset.Register("случайное имя.mkv", ".mkv", MediaSource.Upload);
|
||||
var known = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload);
|
||||
var show = Show.Create("Сериал", ShowKind.Series);
|
||||
show.ApplyMetadata("tmdb", "1", null, null, null);
|
||||
show.AddEpisode(unparsable.Id);
|
||||
show.AddEpisode(known.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.AddRange(unparsable, known);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var (resolver, provider) = ProviderFor("tmdb");
|
||||
// Источник знает не всё: на распознанную серию он отвечает пустотой.
|
||||
provider
|
||||
.GetEpisodeAsync("1", Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((EpisodeMetadata?)null);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, resolver)
|
||||
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(0, result.Value);
|
||||
// К нераспознанной серии источник не ходит вовсе — незачем.
|
||||
await provider
|
||||
.Received(1)
|
||||
.GetEpisodeAsync("1", Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeepsMetadata_WhenStillCannotBeDownloaded()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var (show, asset) = Linked("s01e01.mkv", withNumbers: true);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var (resolver, provider) = ProviderFor("tmdb");
|
||||
provider
|
||||
.GetEpisodeAsync("456", Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Meta("Без кадра", "https://img/broken.jpg"));
|
||||
|
||||
var downloader = Substitute.For<IImageDownloader>();
|
||||
downloader
|
||||
.DownloadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((DownloadedImage?)null);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, resolver, downloader)
|
||||
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Недоступный кадр не повод отменять текст: серия обновлена, картинки просто нет.
|
||||
Assert.Equal(1, result.Value);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single();
|
||||
Assert.Equal("Без кадра", episode.Title);
|
||||
Assert.Null(episode.StillImageId);
|
||||
Assert.Equal(0, await check.Images.CountAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenShowIsMissingOrNotSeriesOrUnlinked()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var movie = Show.Create("Фильм", ShowKind.Single);
|
||||
var unlinked = Show.Create("Сериал без источника", ShowKind.Series);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.AddRange(movie, unlinked);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var (resolver, _) = ProviderFor("tmdb");
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = Handler(db, resolver);
|
||||
|
||||
var missing = await handler.Handle(
|
||||
new RefreshShowEpisodesMetadataCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
var notSeries = await handler.Handle(
|
||||
new RefreshShowEpisodesMetadataCommand(movie.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
var noSource = await handler.Handle(
|
||||
new RefreshShowEpisodesMetadataCommand(unlinked.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(missing.IsSuccess);
|
||||
Assert.Equal(MetadataErrors.EpisodesForSeriesOnly, notSeries.Error);
|
||||
Assert.Equal(MetadataErrors.NoLinkedSource, noSource.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenLinkedProviderIsNotConfigured()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("Сериал", ShowKind.Series);
|
||||
show.ApplyMetadata("omdb", "tt1", null, null, null);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||
// Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null.
|
||||
resolver.Resolve(Arg.Any<string>()).Returns((IMetadataProvider?)null);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, resolver)
|
||||
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Metadata.SearchShows;
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Поиск кандидатов в источнике. Внешняя сеть — единственная часть метаданных, которая падает
|
||||
/// регулярно, поэтому проверяется в первую очередь она.
|
||||
/// </summary>
|
||||
public class SearchShowMetadataTests
|
||||
{
|
||||
private static SearchShowMetadataQueryHandler Handler(IMetadataProvider? provider)
|
||||
{
|
||||
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||
// Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null.
|
||||
resolver.Resolve(Arg.Any<string>()).Returns(provider);
|
||||
return new SearchShowMetadataQueryHandler(resolver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsWhatProviderFound()
|
||||
{
|
||||
var provider = Substitute.For<IMetadataProvider>();
|
||||
provider
|
||||
.SearchShowsAsync("Симпсоны", ShowKind.Series, Arg.Any<CancellationToken>())
|
||||
.Returns([new MetadataCandidate("456", "The Simpsons", 1989, null, null)]);
|
||||
|
||||
var result = await Handler(provider)
|
||||
.Handle(
|
||||
new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("456", Assert.Single(result.Value).ExternalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsNothing_ForBlankQuery_WithoutCallingProvider()
|
||||
{
|
||||
var provider = Substitute.For<IMetadataProvider>();
|
||||
|
||||
var result = await Handler(provider)
|
||||
.Handle(
|
||||
new SearchShowMetadataQuery("tmdb", " ", ShowKind.Single),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Empty(result.Value);
|
||||
await provider
|
||||
.DidNotReceive()
|
||||
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenProviderIsNotConfigured()
|
||||
{
|
||||
var result = await Handler(null)
|
||||
.Handle(
|
||||
new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenSourceIsUnreachableOrRejectsTheKey()
|
||||
{
|
||||
var provider = Substitute.For<IMetadataProvider>();
|
||||
provider
|
||||
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
|
||||
.Returns<IReadOnlyList<MetadataCandidate>>(_ => throw new HttpRequestException("401"));
|
||||
|
||||
var result = await Handler(provider)
|
||||
.Handle(
|
||||
new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Сетевой сбой и неверный ключ — управляемая ошибка, а не исключение наружу.
|
||||
Assert.Equal(MetadataErrors.ProviderRequestFailed, result.Error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user