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,60 @@
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Admin.Users.ListUsers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Список пользователей: хендлер только перекладывает параметры запроса в порт — Identity живёт
|
||||
/// вне <c>IAppDbContext</c>. Проверяется именно перекладка: потерянный фильтр здесь означал бы
|
||||
/// заблокированных вперемешку с активными.
|
||||
/// </summary>
|
||||
public class ListUsersTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PassesEveryFilterToIdentityService()
|
||||
{
|
||||
var identity = Substitute.For<IIdentityService>();
|
||||
var roleId = Guid.NewGuid();
|
||||
var page = new PagedList<UserSummaryDto>([], 0, 2, 25);
|
||||
identity
|
||||
.ListUsersAsync(Arg.Any<UserListFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(page);
|
||||
|
||||
var result = await new ListUsersQueryHandler(identity).Handle(
|
||||
new ListUsersQuery(2, 25, "иван", roleId, IsBlocked: true, Sort: "created", Desc: true),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Same(page, result);
|
||||
await identity
|
||||
.Received(1)
|
||||
.ListUsersAsync(
|
||||
new UserListFilter(2, 25, "иван", roleId, true, "created", true),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultsSortAndDirection_WhenNotAsked()
|
||||
{
|
||||
var identity = Substitute.For<IIdentityService>();
|
||||
identity
|
||||
.ListUsersAsync(Arg.Any<UserListFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedList<UserSummaryDto>([], 0, 1, 20));
|
||||
|
||||
await new ListUsersQueryHandler(identity).Handle(
|
||||
new ListUsersQuery(1, 20, null, null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await identity
|
||||
.Received(1)
|
||||
.ListUsersAsync(
|
||||
Arg.Is<UserListFilter>(f => f.Sort == null && !f.Desc && f.Search == null),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using TeleWave.Application.Library.Collections.GetCollection;
|
||||
using TeleWave.Application.Library.Collections.ListCollections;
|
||||
using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
|
||||
using TeleWave.Application.Library.Interstitials.ListInterstitials;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Справочные списки библиотеки: коллекции и ролики. Считают они немного, но каждое число на экране
|
||||
/// админа приходит отсюда — сериал внутри франшизы, длительность блока рекламы, статус обработки.
|
||||
/// </summary>
|
||||
public class LibraryQueriesTests
|
||||
{
|
||||
/// <summary>Шоу с готовыми ассетами: без длительностей в списках нечего показывать.</summary>
|
||||
private static (Show Show, List<MediaAsset> Assets) Playable(
|
||||
string name,
|
||||
ShowKind kind,
|
||||
int episodes,
|
||||
int minutes
|
||||
)
|
||||
{
|
||||
var show = Show.Create(name, kind);
|
||||
var assets = new List<MediaAsset>();
|
||||
|
||||
for (var i = 0; i < episodes; i++)
|
||||
{
|
||||
var asset = MediaAsset.Register($"{name}-{i}.mkv", ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
TimeSpan.FromMinutes(minutes),
|
||||
6,
|
||||
minutes * 10,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
$"assets/{name}-{i}"
|
||||
)
|
||||
);
|
||||
assets.Add(asset);
|
||||
show.AddEpisode(asset.Id);
|
||||
}
|
||||
|
||||
return (show, assets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Collections_CountUnitsNotParts()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 10, 25);
|
||||
var film = Playable("Фильм", ShowKind.Single, 1, 100);
|
||||
|
||||
var collection = Collection.Create("Франшиза", "Описание");
|
||||
collection.AddShow(film.Show.Id);
|
||||
collection.AddShow(series.Show.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.AddRange([.. series.Assets, .. film.Assets]);
|
||||
seed.Shows.AddRange(series.Show, film.Show);
|
||||
seed.Collections.Add(collection);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var list = await new ListCollectionsQueryHandler(db).Handle(
|
||||
new ListCollectionsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var summary = Assert.Single(list);
|
||||
Assert.Equal("Франшиза", summary.Name);
|
||||
Assert.Equal(2, summary.ItemCount);
|
||||
// Единиц больше, чем частей: сериал внутри коллекции разворачивается в свои серии.
|
||||
Assert.Equal(11, summary.UnitCount);
|
||||
|
||||
var detail = await new GetCollectionQueryHandler(db).Handle(
|
||||
new GetCollectionQuery(collection.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(detail.IsSuccess);
|
||||
// Порядок частей — заданный, а не алфавитный: франшиза смотрится по порядку выхода.
|
||||
Assert.Equal(["Фильм", "Сериал"], detail.Value.Items.Select(i => i.ShowName));
|
||||
Assert.Equal(10, detail.Value.Items[1].EpisodeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCollection_FailsForUnknownId()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new GetCollectionQueryHandler(db).Handle(
|
||||
new GetCollectionQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interstitials_ShowDurationAndStatus()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var ready = Playable("Реклама", ShowKind.Interstitial, 1, 1);
|
||||
// Ролик без медиа: заведён, но файла к нему ещё не приложили.
|
||||
var empty = Show.Create("Пустой ролик", ShowKind.Interstitial);
|
||||
var notAClip = Playable("Кино", ShowKind.Single, 1, 100);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.AddRange([.. ready.Assets, .. notAClip.Assets]);
|
||||
seed.Shows.AddRange(ready.Show, empty, notAClip.Show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var list = await new ListInterstitialsQueryHandler(db).Handle(
|
||||
new ListInterstitialsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// В списке роликов только ролики — полнометражка сюда не попадает.
|
||||
Assert.Equal(2, list.Count);
|
||||
|
||||
var withMedia = list.Single(i => i.Name == "Реклама");
|
||||
Assert.Equal(MediaAssetStatus.Ready, withMedia.AssetStatus);
|
||||
Assert.Equal(60, withMedia.DurationSeconds);
|
||||
|
||||
var withoutMedia = list.Single(i => i.Name == "Пустой ролик");
|
||||
Assert.Null(withoutMedia.MediaAssetId);
|
||||
Assert.Null(withoutMedia.AssetStatus);
|
||||
Assert.Null(withoutMedia.DurationSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blocks_AreCollectionsMadeOfClipsOnly()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var first = Playable("Ролик 1", ShowKind.Interstitial, 1, 1);
|
||||
var second = Playable("Ролик 2", ShowKind.Interstitial, 1, 2);
|
||||
var film = Playable("Кино", ShowKind.Single, 1, 100);
|
||||
|
||||
var block = Collection.Create("Рекламный блок");
|
||||
block.AddShow(first.Show.Id);
|
||||
block.AddShow(second.Show.Id);
|
||||
|
||||
// Смешанная коллекция — франшиза, а не блок: она живёт на своём экране.
|
||||
var mixed = Collection.Create("Смешанная");
|
||||
mixed.AddShow(first.Show.Id);
|
||||
mixed.AddShow(film.Show.Id);
|
||||
|
||||
// Пустая коллекция блоком тоже не считается: ставить в эфир из неё нечего.
|
||||
var empty = Collection.Create("Пустая");
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.AddRange([.. first.Assets, .. second.Assets, .. film.Assets]);
|
||||
seed.Shows.AddRange(first.Show, second.Show, film.Show);
|
||||
seed.Collections.AddRange(block, mixed, empty);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var blocks = await new ListInterstitialBlocksQueryHandler(db).Handle(
|
||||
new ListInterstitialBlocksQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var only = Assert.Single(blocks);
|
||||
Assert.Equal("Рекламный блок", only.Name);
|
||||
Assert.Equal(2, only.ItemCount);
|
||||
Assert.Equal(180, only.DurationSeconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Сигнальная очередь конвейера и предел параллельности. Мелочь, но на первой держится реакция
|
||||
/// обработчика на новый файл, а на втором — оценка «сколько ещё ждать».
|
||||
/// </summary>
|
||||
public class MediaQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Queue_WakesUpOnce_ForAWholeBurstOfSignals()
|
||||
{
|
||||
var queue = new MediaProcessingQueue();
|
||||
|
||||
queue.Enqueue(Guid.NewGuid());
|
||||
queue.Enqueue(Guid.NewGuid());
|
||||
queue.Enqueue(Guid.NewGuid());
|
||||
|
||||
// Первый вызов возвращается сразу и дренирует накопленное: работу обработчик всё равно
|
||||
// берёт из БД пачкой, и три сигнала не должны означать три круга.
|
||||
await queue.WaitAsync(CancellationToken.None);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
// Очередь пуста — следующий вызов ждёт нового сигнала, а не возвращается снова.
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await queue.WaitAsync(cts.Token)
|
||||
);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4, 4)]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-2, 1)]
|
||||
public void Limits_NeverReportLessThanOneTranscode(int configured, int expected)
|
||||
{
|
||||
var limits = new MediaProcessingLimits(
|
||||
Options.Create(new MediaOptions { MaxParallelTranscodes = configured })
|
||||
);
|
||||
|
||||
// Ноль и отрицательное в настройках — не повод делить оценку очереди на ноль.
|
||||
Assert.Equal(expected, limits.MaxParallelTranscodes);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Подбор позиций по правилу набора: то, что редактор группы показывает кнопкой «Подобрать».
|
||||
/// Важна не только выборка, но и отметка «уже в группе» — по ней видно, что добавлять нечего.
|
||||
/// </summary>
|
||||
public class FindGroupCandidatesTests
|
||||
{
|
||||
private static (Show Show, MediaAsset Asset) Playable(string name, int minutes)
|
||||
{
|
||||
var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
TimeSpan.FromMinutes(minutes),
|
||||
6,
|
||||
minutes * 10,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
$"assets/{name}"
|
||||
)
|
||||
);
|
||||
var show = Show.Create(name, ShowKind.Single);
|
||||
show.AddEpisode(asset.Id);
|
||||
return (show, asset);
|
||||
}
|
||||
|
||||
private static async Task<TeleWave.Application.Common.Models.Result<
|
||||
IReadOnlyList<GroupCandidateDto>
|
||||
>> FindAsync(TestDb fixture, FindGroupCandidatesQuery query)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
return await new FindGroupCandidatesQueryHandler(
|
||||
db,
|
||||
new GroupFilterMatcher(db),
|
||||
new GroupElementResolver(db)
|
||||
).Handle(query, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarksWhatIsAlreadyInGroup_AndFiltersByUnitDuration()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var shortFilm = Playable("Короткий", 20);
|
||||
var longFilm = Playable("Длинный", 120);
|
||||
var inside = Playable("Уже внутри", 100);
|
||||
|
||||
var group = Group.Create("Кино");
|
||||
group.SetFilter(new GroupFilter(UnitMinutesMin: 60).ToJson());
|
||||
group.AddElement(GroupElementKind.Show, inside.Show.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.AddRange(shortFilm.Asset, longFilm.Asset, inside.Asset);
|
||||
seed.Shows.AddRange(shortFilm.Show, longFilm.Show, inside.Show);
|
||||
seed.Groups.Add(group);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var result = await FindAsync(fixture, new FindGroupCandidatesQuery(group.Id));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Короткий отсечён по длине единицы: правило меряет саму серию, а не сумму по шоу.
|
||||
Assert.Equal(["Длинный", "Уже внутри"], result.Value.Select(c => c.ElementName));
|
||||
Assert.False(result.Value.Single(c => c.ElementName == "Длинный").AlreadyInGroup);
|
||||
Assert.True(result.Value.Single(c => c.ElementName == "Уже внутри").AlreadyInGroup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsesFilterFromQuery_WhenGivenExplicitly()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var film = Playable("Кино", 100);
|
||||
// У группы своего правила нет — редактор крутит фильтр до сохранения.
|
||||
var group = Group.Create("Черновик");
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.Add(film.Asset);
|
||||
seed.Shows.Add(film.Show);
|
||||
seed.Groups.Add(group);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var withFilter = await FindAsync(
|
||||
fixture,
|
||||
new FindGroupCandidatesQuery(group.Id, new GroupFilter(ShowKinds: [ShowKind.Single]))
|
||||
);
|
||||
var withoutFilter = await FindAsync(fixture, new FindGroupCandidatesQuery(group.Id));
|
||||
|
||||
Assert.Single(withFilter.Value);
|
||||
// Без правила подбирать не из чего — и это отказ, а не пустой список.
|
||||
Assert.False(withoutFilter.IsSuccess);
|
||||
Assert.Equal(GroupErrors.FilterNotSet, withoutFilter.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenGroupIsGone()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
|
||||
var result = await FindAsync(fixture, new FindGroupCandidatesQuery(Guid.NewGuid()));
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(GroupErrors.NotFound, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Сетка глазами редактора: слои по приоритету, слоты с именами групп и признаки состояния —
|
||||
/// есть ли неприменённые правки и есть ли к чему откатываться.
|
||||
/// </summary>
|
||||
public class GetTemplateTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsLayersByPriority_WithGroupNamesAndState()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var group = Group.Create("Кино");
|
||||
var channel = Channel.Create("Первый", "first", T0);
|
||||
channel.UpdateTimeSettings(1, 180, new TimeOnly(6, 0));
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
channel.SetTemplate(template.Id);
|
||||
var top = template.AddLayer("Выходные", 20);
|
||||
var slot = top.AddSlot("Кино в прайм", new TimeOnly(20, 0), 120, Daypart.Prime, weekday: 6);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.Count,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
template.MarkChanged();
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Groups.Add(group);
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetChannelTemplateQueryHandler(db).Handle(
|
||||
new GetChannelTemplateQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var dto = result.Value;
|
||||
|
||||
// Слои идут сверху вниз: первым тот, что перекрывает остальные.
|
||||
Assert.Equal(["Выходные", "Фон"], dto.Layers.Select(l => l.Name));
|
||||
Assert.Equal(new TimeOnly(6, 0), dto.DayStartTime);
|
||||
Assert.Equal(180, dto.UtcOffsetMinutes);
|
||||
|
||||
var stored = Assert.Single(dto.Layers[0].Slots);
|
||||
// Имя группы приезжает вместе со слотом: инспектор показывает его без второго запроса.
|
||||
Assert.Equal("Кино", stored.GroupName);
|
||||
Assert.Equal(6, stored.Weekday);
|
||||
Assert.Equal(SlotStrategyType.RandomWithCooldown, stored.Strategy!.Type);
|
||||
Assert.Equal(14, stored.Strategy.CooldownDays);
|
||||
|
||||
// Правка эфир не двигает — она поднимает ревизию, и плашка об этом видна в шапке канала.
|
||||
Assert.True(dto.HasPendingChanges);
|
||||
Assert.Equal(1, dto.Revision);
|
||||
Assert.Equal(0, dto.AppliedRevision);
|
||||
// Сетку ни разу не применяли — откатывать не к чему, и кнопки быть не должно.
|
||||
Assert.False(dto.CanRestore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanRestore_AfterFirstApply()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "first", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
channel.SetTemplate(template.Id);
|
||||
template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetChannelTemplateQueryHandler(db).Handle(
|
||||
new GetChannelTemplateQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.Value.CanRestore);
|
||||
Assert.False(result.Value.HasPendingChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenChannelOrTemplateIsMissing()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Без сетки", "no-grid", T0);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new GetChannelTemplateQueryHandler(db);
|
||||
|
||||
var noChannel = await handler.Handle(
|
||||
new GetChannelTemplateQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
var noTemplate = await handler.Handle(
|
||||
new GetChannelTemplateQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(noChannel.IsSuccess);
|
||||
Assert.False(noTemplate.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ElementResolver_ExpandsCollectionIntoItsParts()
|
||||
{
|
||||
// Коллекция в группе — одна позиция, но эфира она даёт столько же, сколько все её части.
|
||||
var fixture = new TestDb();
|
||||
var film = Show.Create("Часть 1", ShowKind.Single);
|
||||
film.SetAudience(ShowAudience.Pg13);
|
||||
var sequel = Show.Create("Часть 2", ShowKind.Single);
|
||||
sequel.SetAudience(ShowAudience.R);
|
||||
var assets = new List<MediaAsset>();
|
||||
|
||||
foreach (var show in new[] { film, sequel })
|
||||
{
|
||||
var asset = MediaAsset.Register($"{show.Name}.mkv", ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
TimeSpan.FromMinutes(90),
|
||||
6,
|
||||
900,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
$"assets/{show.Name}"
|
||||
)
|
||||
);
|
||||
assets.Add(asset);
|
||||
show.AddEpisode(asset.Id);
|
||||
}
|
||||
|
||||
var collection = Collection.Create("Франшиза");
|
||||
collection.AddShow(film.Id);
|
||||
collection.AddShow(sequel.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.AddRange(assets);
|
||||
seed.Shows.AddRange(film, sequel);
|
||||
seed.Collections.Add(collection);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var info = await new GroupElementResolver(db).ResolveAsync(
|
||||
[(GroupElementKind.Collection, collection.Id), (GroupElementKind.Show, film.Id)],
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var franchise = info[(GroupElementKind.Collection, collection.Id)];
|
||||
Assert.Equal("Франшиза", franchise.Name);
|
||||
Assert.Equal(2, franchise.UnitCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(180), franchise.TotalDuration);
|
||||
// Рейтинг франшизы — самый строгий среди частей: по нему её отсеивают в детское время.
|
||||
Assert.Equal(ShowAudience.R, franchise.Audience);
|
||||
|
||||
var single = info[(GroupElementKind.Show, film.Id)];
|
||||
Assert.Equal(1, single.UnitCount);
|
||||
Assert.Equal(ShowKind.Single, single.ShowKind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.Generate;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Витрина автосборки: каталог профилей, предпросмотр плана и его перевод в DTO. Предпросмотр —
|
||||
/// то, по чему админ принимает решение, поэтому проверяется отдельно от самой раскладки.
|
||||
/// </summary>
|
||||
public class GridPlanPreviewTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static PlannedSlot Slot(
|
||||
string title,
|
||||
SlotKind kind,
|
||||
SlotBlockMode blockMode,
|
||||
int blockValue,
|
||||
int? weekday = null
|
||||
) =>
|
||||
new(
|
||||
GridPlanLayer.Main,
|
||||
weekday,
|
||||
new TimeOnly(20, 0),
|
||||
120,
|
||||
title,
|
||||
Daypart.Prime,
|
||||
kind,
|
||||
kind == SlotKind.Content ? Guid.NewGuid() : null,
|
||||
kind == SlotKind.Content ? title : null,
|
||||
blockMode,
|
||||
blockValue,
|
||||
null,
|
||||
null,
|
||||
IsAnchor: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Profiles_AreListed_WithReferenceAndDescription()
|
||||
{
|
||||
var result = await new ListGridProfilesQueryHandler().Handle(
|
||||
new ListGridProfilesQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Каждый профиль должен объяснять себя: имя, на что похоже и для какой библиотеки годится.
|
||||
Assert.Equal(GridProfiles.All.Count, result.Value.Count);
|
||||
Assert.All(
|
||||
result.Value,
|
||||
profile =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.Name));
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.Reference));
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.Description));
|
||||
}
|
||||
);
|
||||
Assert.Contains(result.Value, p => p.Kind == GridProfileKind.Animation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mapper_DescribesEveryBlockKind()
|
||||
{
|
||||
var plan = new GridPlan(
|
||||
GridProfiles.Get(GridProfileKind.Mixed),
|
||||
GridGenerationMode.Rebuild,
|
||||
[
|
||||
Slot("Сериалы", SlotKind.Content, SlotBlockMode.Count, 3),
|
||||
Slot("Кино", SlotKind.Content, SlotBlockMode.FillSlot, 1),
|
||||
Slot("Полоса", SlotKind.Content, SlotBlockMode.Duration, 90, weekday: 6),
|
||||
Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1),
|
||||
Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1),
|
||||
],
|
||||
["замечание"],
|
||||
SlotsToRemove: 4,
|
||||
FallbackGroupId: Guid.NewGuid(),
|
||||
FallbackGroupName: "Фон",
|
||||
FreeMinutes: 10080,
|
||||
CoveredMinutes: 5040
|
||||
);
|
||||
|
||||
var dto = plan.ToDto();
|
||||
|
||||
Assert.Equal(GridProfileKind.Mixed, dto.Profile);
|
||||
Assert.Equal(plan.Profile.Name, dto.ProfileName);
|
||||
Assert.Equal(4, dto.SlotsToRemove);
|
||||
Assert.Equal("Фон", dto.FallbackGroupName);
|
||||
Assert.Equal(["замечание"], dto.Notes);
|
||||
|
||||
// Описание блока — то, что читает админ вместо режима и числа.
|
||||
Assert.Equal(
|
||||
["3 подряд", "до конца слота", "90 мин", "повтор", "конец вещания"],
|
||||
dto.Slots.Select(s => s.Block)
|
||||
);
|
||||
// Время приезжает строкой «ЧЧ:ММ»: в предпросмотре оно только показывается.
|
||||
Assert.All(dto.Slots, slot => Assert.Equal("20:00", slot.Start));
|
||||
Assert.Equal(6, dto.Slots[2].Weekday);
|
||||
Assert.Null(dto.Slots[0].Weekday);
|
||||
// У неконтентных слотов группы нет — показывать в колонке нечего.
|
||||
Assert.Null(dto.Slots[3].GroupName);
|
||||
Assert.Equal("Сериалы", dto.Slots[0].GroupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_ReturnsPlanForProfile()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channelId = await SeedAsync(fixture);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new PreviewGeneratedGridQueryHandler(
|
||||
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
|
||||
).Handle(
|
||||
new PreviewGeneratedGridQuery(
|
||||
channelId,
|
||||
GridProfileKind.Kids,
|
||||
GridGenerationMode.Rebuild
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(GridProfileKind.Kids, result.Value.Profile);
|
||||
Assert.NotEmpty(result.Value.Slots);
|
||||
// Пересборка сносит то, что было, и это видно до нажатия.
|
||||
Assert.True(result.Value.SlotsToRemove >= 0);
|
||||
Assert.Equal(7 * GridCoverage.MinutesInDay, result.Value.FreeMinutes);
|
||||
// У детского профиля ночь — конец вещания, и в предпросмотре он подписан именно так.
|
||||
Assert.Contains(result.Value.Slots, s => s.Block == "конец вещания");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_FailsWhenChannelIsGone()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new PreviewGeneratedGridQueryHandler(
|
||||
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
|
||||
).Handle(
|
||||
new PreviewGeneratedGridQuery(
|
||||
Guid.NewGuid(),
|
||||
GridProfileKind.Mixed,
|
||||
GridGenerationMode.FillGaps
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_RejectsUnknownProfileAndMode()
|
||||
{
|
||||
var validator = new GenerateGridCommandValidator();
|
||||
|
||||
Assert.True(
|
||||
validator
|
||||
.Validate(
|
||||
new GenerateGridCommand(
|
||||
Guid.NewGuid(),
|
||||
GridProfileKind.Sitcom,
|
||||
GridGenerationMode.Rebuild
|
||||
)
|
||||
)
|
||||
.IsValid
|
||||
);
|
||||
// Значения enum приходят с клиента строкой — за пределами каталога они бессмысленны.
|
||||
Assert.False(
|
||||
validator
|
||||
.Validate(
|
||||
new GenerateGridCommand(
|
||||
Guid.NewGuid(),
|
||||
(GridProfileKind)42,
|
||||
GridGenerationMode.FillGaps
|
||||
)
|
||||
)
|
||||
.IsValid
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Канал с одной группой мягкого рейтинга — чтобы детскому профилю было чем закрыться.</summary>
|
||||
private static async Task<Guid> SeedAsync(TestDb fixture)
|
||||
{
|
||||
var show = Show.Create("Мультсериал", ShowKind.Series);
|
||||
show.SetAudience(ShowAudience.G);
|
||||
var assets = new List<MediaAsset>();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var asset = MediaAsset.Register($"e{i}.mkv", ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
TimeSpan.FromMinutes(22),
|
||||
6,
|
||||
220,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
$"assets/e{i}"
|
||||
)
|
||||
);
|
||||
assets.Add(asset);
|
||||
show.AddEpisode(asset.Id);
|
||||
}
|
||||
|
||||
var group = Group.Create("Мультфильмы");
|
||||
group.AddElement(GroupElementKind.Show, show.Id);
|
||||
|
||||
var channel = Channel.Create("Детский", "kids", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
await using var seed = fixture.New();
|
||||
seed.MediaAssets.AddRange(assets);
|
||||
seed.Shows.Add(show);
|
||||
seed.Groups.Add(group);
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return channel.Id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user