Add unit tests for dynamic group mode handling and exclusions
ci / build-backend (push) Successful in 1m36s
ci / build-frontend (push) Successful in 59s
ci / tests (push) Successful in 1m38s
ci / sonar (push) Successful in 4m5s

Implemented several unit tests to validate the behavior of dynamic group modes, including the preservation of pinned items when switching to dynamic mode, the dropping of exclusions when reverting to static mode, and the default pinning of new elements in dynamic groups. Additional tests ensure that exclusions are correctly applied and removed, and that static groups refuse exclusions. These tests enhance the coverage and reliability of the group management functionality.
This commit is contained in:
Leonid Pershin
2026-07-27 06:37:10 +03:00
parent 97fa77287f
commit f8923b5275
8 changed files with 1230 additions and 0 deletions
@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.EnrichShows;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Library;
/// <summary>Массовые операции над отмеченными шоу: раскладка по франшизам и обогащение.</summary>
public class AddShowsToCollectionTests
{
[Fact]
public async Task AddsToExistingCollection_SkippingThoseAlreadyIn()
{
var fixture = new TestDb();
var inside = Show.Create("Уже внутри", ShowKind.Single);
var outside = Show.Create("Ещё нет", ShowKind.Single);
var collection = Collection.Create("Франшиза");
collection.AddShow(inside.Id);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(inside, outside);
seed.Collections.Add(collection);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new AddShowsToCollectionCommandHandler(db).Handle(
new AddShowsToCollectionCommand([inside.Id, outside.Id], collection.Id),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
// Повторное добавление молча пропускается: падать из-за одного повтора бессмысленно.
Assert.Equal(1, result.Value.Added);
Assert.Equal("Франшиза", result.Value.Name);
await using var check = fixture.New();
Assert.Equal(2, check.Collections.Include(c => c.Items).Single().Items.Count);
}
[Fact]
public async Task Fails_WhenCollectionIsGone()
{
var fixture = new TestDb();
var show = Show.Create("Шоу", ShowKind.Single);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new AddShowsToCollectionCommandHandler(db).Handle(
new AddShowsToCollectionCommand([show.Id], Guid.NewGuid()),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Fails_WhenNoneOfTheShowsExist()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new AddShowsToCollectionCommandHandler(db).Handle(
new AddShowsToCollectionCommand([Guid.NewGuid()], NewCollectionName: "Франшиза"),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Enrich_FailsWhenProviderUnavailable()
{
var fixture = new TestDb();
await using var db = fixture.New();
var resolver = Substitute.For<IMetadataProviderResolver>();
// Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null, и без этого
// «источник не настроен» не воспроизводится.
resolver.Resolve(Arg.Any<string>()).Returns((IMetadataProvider?)null);
var result = await new EnrichShowsMetadataCommandHandler(
db,
resolver,
new ShowMetadataApplier(
db,
resolver,
Substitute.For<IImageDownloader>(),
Substitute.For<IImageStore>(),
new GenreMatcher(db)
)
).Handle(new EnrichShowsMetadataCommand([Guid.NewGuid()], "tmdb"), CancellationToken.None);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Enrich_IgnoresInterstitials()
{
// Ролики метаданными не обогащаются: у них нет ни года, ни постера.
var fixture = new TestDb();
var clip = Show.Create("Ролик", ShowKind.Interstitial);
await using (var seed = fixture.New())
{
seed.Shows.Add(clip);
await seed.SaveChangesAsync(CancellationToken.None);
}
var provider = Substitute.For<IMetadataProvider>();
var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve("tmdb").Returns(provider);
await using var db = fixture.New();
var result = await new EnrichShowsMetadataCommandHandler(
db,
resolver,
new ShowMetadataApplier(
db,
resolver,
Substitute.For<IImageDownloader>(),
Substitute.For<IImageStore>(),
new GenreMatcher(db)
)
).Handle(new EnrichShowsMetadataCommand([clip.Id], "tmdb"), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(0, result.Value.Applied);
Assert.Empty(result.Value.Skipped);
await provider
.DidNotReceive()
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,144 @@
using TeleWave.Application.Library.GetShowUsage;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Library;
/// <summary>
/// «Где используется» для шоу: удаление отбивается ошибкой «используется», и без этого списка
/// непонятно, кем именно.
/// </summary>
public class ShowUsageTests
{
[Fact]
public async Task ReportsExplicitGroupMembership()
{
var fixture = new TestDb();
var show = Show.Create("Шоу", ShowKind.Series);
var group = Group.Create("Явная");
group.AddElement(GroupElementKind.Show, show.Id);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
var usage = await GetAsync(fixture, show.Id);
var reference = Assert.Single(usage.Groups);
Assert.Equal("Явная", reference.Name);
Assert.False(reference.ViaRule);
}
[Fact]
public async Task ReportsMembershipByRule()
{
// У динамической группы шоу входит без своей позиции — для «можно ли удалять» это то же самое.
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var show = Show.Create("Шоу", ShowKind.Series);
show.SetGenres([genre.Id]);
var group = Group.Create("По правилу");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.SetMode(GroupMode.Dynamic);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.Add(show);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
var usage = await GetAsync(fixture, show.Id);
Assert.True(Assert.Single(usage.Groups).ViaRule);
}
[Fact]
public async Task ReportsCollectionsAndGroupsReachedThroughThem()
{
// В группу шоу попадает и через франшизу, целиком — это тоже использование.
var fixture = new TestDb();
var show = Show.Create("Часть", ShowKind.Single);
var collection = Collection.Create("Франшиза");
collection.AddShow(show.Id);
var group = Group.Create("С франшизой");
group.AddElement(GroupElementKind.Collection, collection.Id);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
seed.Collections.Add(collection);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
var usage = await GetAsync(fixture, show.Id);
Assert.Equal("Франшиза", Assert.Single(usage.Collections).Name);
Assert.Equal("С франшизой", Assert.Single(usage.Groups).Name);
}
[Fact]
public async Task ReportsChannelsWithScheduledEntries()
{
var fixture = new TestDb();
var show = Show.Create("Шоу", ShowKind.Series);
var channel = Channel.Create("Первый", "one", DateTimeOffset.UtcNow);
var start = new DateTimeOffset(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
seed.Channels.Add(channel);
seed.ScheduleEntries.Add(
ScheduleEntry.Program(
channel.Id,
Guid.NewGuid(),
start,
start.AddMinutes(30),
show.Id,
0
)
);
await seed.SaveChangesAsync(CancellationToken.None);
}
var usage = await GetAsync(fixture, show.Id);
Assert.Equal("Первый", Assert.Single(usage.Channels).Name);
}
[Fact]
public async Task UnknownShow_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new GetShowUsageQueryHandler(db, GroupServices.Dynamic(db)).Handle(
new GetShowUsageQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
private static async Task<ShowUsageDto> GetAsync(TestDb fixture, Guid showId)
{
await using var db = fixture.New();
var result = await new GetShowUsageQueryHandler(db, GroupServices.Dynamic(db)).Handle(
new GetShowUsageQuery(showId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
return result.Value;
}
}
@@ -0,0 +1,170 @@
using System.Text;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Metadata;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Metadata;
/// <summary>
/// Применение метаданных: постер в реестр изображений, жанры, франшиза и отказы. Общий путь для
/// точечного и массового применения — расхождение между ними отлаживать было бы нечем.
/// </summary>
public class ShowMetadataApplierTests
{
[Fact]
public async Task RegistersPoster_InImageRegistry()
{
var fixture = new TestDb();
var show = Show.Create("Шоу", ShowKind.Single);
await SeedAsync(fixture, show);
var downloader = Substitute.For<IImageDownloader>();
downloader
.DownloadAsync("https://poster", Arg.Any<CancellationToken>())
.Returns(new DownloadedImage(Encoding.UTF8.GetBytes("img"), ".jpg"));
var store = Substitute.For<IImageStore>();
await using var db = fixture.New();
var result = await Applier(db, Meta(posterUrl: "https://poster"), downloader, store)
.ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
await using var check = fixture.New();
var image = check.Images.Single();
Assert.Equal(ImageCategory.ShowPoster, image.Category);
Assert.Equal(image.Id, check.Shows.Single().PosterImageId);
await store
.Received(1)
.SaveAsync(image.Id, ".jpg", Arg.Any<byte[]>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task KeepsPoster_WhenDownloadFails()
{
// Источник отдал ссылку, но картинка не скачалась — это не повод валить применение.
var fixture = new TestDb();
var show = Show.Create("Шоу", ShowKind.Single);
await SeedAsync(fixture, show);
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 Applier(db, Meta(posterUrl: "https://poster"), downloader)
.ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Empty(db.Images);
}
[Fact]
public async Task RemembersFranchise_FromSource()
{
var fixture = new TestDb();
var show = Show.Create("Рокки", ShowKind.Single);
await SeedAsync(fixture, show);
await using var db = fixture.New();
await Applier(db, Meta(franchise: new MetadataFranchise("1241", "Рокки")))
.ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
await using var check = fixture.New();
var stored = check.Shows.Single();
Assert.Equal("1241", stored.FranchiseExternalId);
Assert.Equal("Рокки", stored.FranchiseName);
}
[Fact]
public async Task Fails_WhenProviderUnavailable()
{
var fixture = new TestDb();
var show = Show.Create("Шоу", ShowKind.Single);
await SeedAsync(fixture, show);
await using var db = fixture.New();
var resolver = Substitute.For<IMetadataProviderResolver>();
var applier = new ShowMetadataApplier(
db,
resolver,
Substitute.For<IImageDownloader>(),
Substitute.For<IImageStore>(),
new GenreMatcher(db)
);
var result = await applier.ApplyAsync(
await LoadAsync(db, show.Id),
"tmdb",
"42",
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Fails_WhenSourceKnowsNothing()
{
var fixture = new TestDb();
var show = Show.Create("Шоу", ShowKind.Single);
await SeedAsync(fixture, show);
await using var db = fixture.New();
var result = await Applier(db, meta: null)
.ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
Assert.False(result.IsSuccess);
}
private static ShowMetadata Meta(
string? posterUrl = null,
MetadataFranchise? franchise = null
) => new("42", "Шоу", 2000, "описание", posterUrl, [], null, franchise);
private static ShowMetadataApplier Applier(
Infrastructure.Persistence.AppDbContext db,
ShowMetadata? meta,
IImageDownloader? downloader = null,
IImageStore? store = null
)
{
var provider = Substitute.For<IMetadataProvider>();
provider
.GetShowAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
.Returns(meta);
var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve("tmdb").Returns(provider);
return new ShowMetadataApplier(
db,
resolver,
downloader ?? Substitute.For<IImageDownloader>(),
store ?? Substitute.For<IImageStore>(),
new GenreMatcher(db)
);
}
private static async Task SeedAsync(TestDb fixture, Show show)
{
await using var seed = fixture.New();
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
private static async Task<Show> LoadAsync(
Infrastructure.Persistence.AppDbContext db,
Guid showId
) =>
await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstAsync(
db.Shows,
s => s.Id == showId
);
}
@@ -0,0 +1,166 @@
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.GetGroup;
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 GetGroupTests
{
[Fact]
public async Task StaticGroup_ReturnsItemsWithIdsAndStats()
{
var fixture = new TestDb();
var asset = ReadyAsset(TimeSpan.FromMinutes(30));
var show = Show.Create("Шоу", ShowKind.Series);
show.AddEpisode(asset.Id);
var group = Group.Create("Статическая");
group.AddElement(GroupElementKind.Show, show.Id);
await SeedAsync(fixture, group, [show], [asset]);
var dto = await GetAsync(fixture, group.Id);
var item = Assert.Single(dto.Items);
// У позиции статической группы есть своя строка — её можно двигать и взвешивать.
Assert.NotNull(item.Id);
Assert.False(item.Pinned);
Assert.Equal("Шоу", item.ElementName);
Assert.Equal(1, dto.UnitCount);
Assert.Equal(TimeSpan.FromMinutes(30).TotalSeconds, dto.TotalDurationSeconds);
Assert.Empty(dto.Excluded);
}
[Fact]
public async Task DynamicGroup_ShowsRuleMatchesWithoutItemId()
{
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var byRule = Show.Create("По правилу", ShowKind.Series);
byRule.SetGenres([genre.Id]);
var pinned = Show.Create("Закреплённое", ShowKind.Single);
var group = Group.Create("Динамическая");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.AddElement(GroupElementKind.Show, pinned.Id);
group.SetMode(GroupMode.Dynamic);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.AddRange(byRule, pinned);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
var dto = await GetAsync(fixture, group.Id);
Assert.Equal(GroupMode.Dynamic, dto.Mode);
var first = dto.Items[0];
Assert.True(first.Pinned);
Assert.NotNull(first.Id);
var second = dto.Items[1];
Assert.False(second.Pinned);
// Найденному правилом строки в таблице не отвечает — значит, ни веса, ни порядка.
Assert.Null(second.Id);
}
[Fact]
public async Task DynamicGroup_ListsExclusionsSeparately()
{
// Иначе непонятно, почему шоу нет в составе, хотя правило его находит.
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var dropped = Show.Create("Исключённое", ShowKind.Series);
dropped.SetGenres([genre.Id]);
var group = Group.Create("Динамическая");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.SetMode(GroupMode.Dynamic);
group.Exclude(GroupElementKind.Show, dropped.Id);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.Add(dropped);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
var dto = await GetAsync(fixture, group.Id);
Assert.Empty(dto.Items);
var excluded = Assert.Single(dto.Excluded);
Assert.Equal("Исключённое", excluded.ElementName);
}
[Fact]
public async Task MissingElement_ShowsDashInsteadOfFailing()
{
// Элемент мог исчезнуть из библиотеки между чисткой и чтением — экран группы падать не должен.
var fixture = new TestDb();
var group = Group.Create("Со следом");
group.AddElement(GroupElementKind.Show, Guid.NewGuid());
await SeedAsync(fixture, group, [], []);
var dto = await GetAsync(fixture, group.Id);
Assert.Equal("—", Assert.Single(dto.Items).ElementName);
}
[Fact]
public async Task UnknownGroup_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await Handler(db)
.Handle(new GetGroupQuery(Guid.NewGuid()), CancellationToken.None);
Assert.False(result.IsSuccess);
}
private static MediaAsset ReadyAsset(TimeSpan duration)
{
var asset = MediaAsset.Register($"{Guid.NewGuid():N}.mkv", ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(duration, 2, 900, 1920, 1080, "h264", "aac", "assets/x")
);
return asset;
}
private static async Task SeedAsync(
TestDb fixture,
Group group,
Show[] shows,
MediaAsset[] assets
)
{
await using var seed = fixture.New();
seed.MediaAssets.AddRange(assets);
seed.Shows.AddRange(shows);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
private static GetGroupQueryHandler Handler(Infrastructure.Persistence.AppDbContext db) =>
new(db, new GroupElementResolver(db), GroupServices.Dynamic(db));
private static async Task<GroupDto> GetAsync(TestDb fixture, Guid groupId)
{
await using var db = fixture.New();
var result = await Handler(db).Handle(new GetGroupQuery(groupId), CancellationToken.None);
Assert.True(result.IsSuccess);
return result.Value;
}
}
@@ -0,0 +1,215 @@
using TeleWave.Application.Programming.Groups;
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 GroupFilterMatcherTests
{
[Fact]
public async Task ShowKinds_NarrowSelection()
{
var fixture = new TestDb();
var series = Show.Create("Сериал", ShowKind.Series);
var movie = Show.Create("Фильм", ShowKind.Single);
await SeedAsync(fixture, series, movie);
var matched = await MatchAsync(fixture, new GroupFilter(ShowKinds: [ShowKind.Single]));
Assert.Equal([(GroupElementKind.Show, movie.Id)], matched);
}
[Fact]
public async Task MaxAudience_KeepsUnrated()
{
// Источники проставляют рейтинг далеко не всему: выбрасывать непроставленное значило бы
// оставить от «мультики не строже PG» горстку голливудского.
var fixture = new TestDb();
var kids = Show.Create("Детское", ShowKind.Series);
kids.SetAudience(ShowAudience.G);
var adult = Show.Create("Взрослое", ShowKind.Series);
adult.SetAudience(ShowAudience.R);
var unrated = Show.Create("Без рейтинга", ShowKind.Series);
await SeedAsync(fixture, kids, adult, unrated);
var matched = await MatchAsync(fixture, new GroupFilter(MaxAudience: ShowAudience.Pg));
Assert.Contains((GroupElementKind.Show, kids.Id), matched);
Assert.Contains((GroupElementKind.Show, unrated.Id), matched);
Assert.DoesNotContain((GroupElementKind.Show, adult.Id), matched);
}
[Fact]
public async Task MinAudience_DropsUnrated()
{
// У нижней границы правило обратное: «R и старше» с неизвестным рейтингом означало бы
// мультики в ночном слоте — ровно ту ошибку, ради которой границу и заводят.
var fixture = new TestDb();
var adult = Show.Create("Взрослое", ShowKind.Series);
adult.SetAudience(ShowAudience.R);
var unrated = Show.Create("Без рейтинга", ShowKind.Series);
await SeedAsync(fixture, adult, unrated);
var matched = await MatchAsync(fixture, new GroupFilter(MinAudience: ShowAudience.R));
Assert.Equal([(GroupElementKind.Show, adult.Id)], matched);
}
[Fact]
public async Task Year_BoundsAreInclusive_AndDropUnknown()
{
var fixture = new TestDb();
var inRange = WithYear(Show.Create("В диапазоне", ShowKind.Single), 2000);
var older = WithYear(Show.Create("Старое", ShowKind.Single), 1999);
var unknown = Show.Create("Без года", ShowKind.Single);
await SeedAsync(fixture, inRange, older, unknown);
var matched = await MatchAsync(fixture, new GroupFilter(YearMin: 2000, YearMax: 2000));
Assert.Equal([(GroupElementKind.Show, inRange.Id)], matched);
}
[Fact]
public async Task Genres_MatchAnyOfSelected()
{
var fixture = new TestDb();
var action = Genre.Create("Боевик", "action");
var drama = Genre.Create("Драма", "drama");
var first = Show.Create("Боевик", ShowKind.Single);
first.SetGenres([action.Id]);
var second = Show.Create("Драма", ShowKind.Single);
second.SetGenres([drama.Id]);
await using (var seed = fixture.New())
{
seed.Genres.AddRange(action, drama);
seed.Shows.AddRange(first, second);
await seed.SaveChangesAsync(CancellationToken.None);
}
var matched = await MatchAsync(fixture, new GroupFilter(GenreIds: [action.Id]));
Assert.Equal([(GroupElementKind.Show, first.Id)], matched);
}
[Fact]
public async Task Collection_MatchesOnlyWhenEveryPartDoes()
{
// Иначе во «взрослую ночь» через франшизу просочилось бы детское.
var fixture = new TestDb();
var kids = Show.Create("Детская часть", ShowKind.Single);
kids.SetAudience(ShowAudience.G);
var adult = Show.Create("Взрослая часть", ShowKind.Single);
adult.SetAudience(ShowAudience.R);
var pure = Collection.Create("Только детское");
pure.AddShow(kids.Id);
var mixed = Collection.Create("Смешанная");
mixed.AddShow(kids.Id);
mixed.AddShow(adult.Id);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(kids, adult);
seed.Collections.AddRange(pure, mixed);
await seed.SaveChangesAsync(CancellationToken.None);
}
var matched = await MatchAsync(fixture, new GroupFilter(MaxAudience: ShowAudience.Pg));
Assert.Contains((GroupElementKind.Collection, pure.Id), matched);
Assert.DoesNotContain((GroupElementKind.Collection, mixed.Id), matched);
}
[Fact]
public async Task ElementKinds_LimitWhatIsSearched()
{
var fixture = new TestDb();
var show = Show.Create("Фильм", ShowKind.Single);
var collection = Collection.Create("Франшиза");
collection.AddShow(show.Id);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
seed.Collections.Add(collection);
await seed.SaveChangesAsync(CancellationToken.None);
}
var onlyCollections = await MatchAsync(
fixture,
new GroupFilter(ElementKinds: [GroupElementKind.Collection])
);
Assert.Equal([(GroupElementKind.Collection, collection.Id)], onlyCollections);
}
[Fact]
public void UnitDuration_ChecksAverageUnit_AndKeepsUnprocessed()
{
var filter = new GroupFilter(UnitMinutesMin: 20, UnitMinutesMax: 30);
// Сериал из двух серий по 22 минуты — подходит по средней длине единицы, а не по сумме.
Assert.True(
GroupFilterMatcher.MatchesUnitDuration(
filter,
Element(units: 2, total: TimeSpan.FromMinutes(44))
)
);
Assert.False(
GroupFilterMatcher.MatchesUnitDuration(
filter,
Element(units: 1, total: TimeSpan.FromMinutes(120))
)
);
Assert.False(
GroupFilterMatcher.MatchesUnitDuration(
filter,
Element(units: 1, total: TimeSpan.FromMinutes(5))
)
);
// Ещё не обработанное длительности не имеет — такое фильтр не отбрасывает.
Assert.True(
GroupFilterMatcher.MatchesUnitDuration(filter, Element(units: 0, total: TimeSpan.Zero))
);
// Без границ проверять нечего.
Assert.True(
GroupFilterMatcher.MatchesUnitDuration(
new GroupFilter(),
Element(units: 1, total: TimeSpan.FromMinutes(120))
)
);
}
private static GroupElementInfo Element(int units, TimeSpan total) =>
new("Элемент", units, total, ShowKind.Series, null, null, null);
private static Show WithYear(Show show, int year)
{
show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
return show;
}
private static async Task SeedAsync(TestDb fixture, params Show[] shows)
{
await using var seed = fixture.New();
seed.Shows.AddRange(shows);
await seed.SaveChangesAsync(CancellationToken.None);
}
private static async Task<IReadOnlyList<(GroupElementKind Kind, Guid Id)>> MatchAsync(
TestDb fixture,
GroupFilter filter
)
{
await using var db = fixture.New();
return await new GroupFilterMatcher(db).MatchAsync(filter, CancellationToken.None);
}
}
@@ -0,0 +1,203 @@
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Отдача предложений наружу: уже созданное помечается, но из списка не пропадает — исчезнувшее
/// без следа предложение читается как сбой, а «уже есть» объясняет, почему кнопки нет.
/// </summary>
public class SuggestionQueriesTests
{
[Fact]
public async Task Groups_MarkExistingByName()
{
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var first = WithGenre(Show.Create("A", ShowKind.Series), genre);
var second = WithGenre(Show.Create("B", ShowKind.Series), genre);
// Регистр не важен: в слоте две «мультфильм» всё равно неразличимы.
var existing = Group.Create("мультфильм");
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.AddRange(first, second);
seed.Groups.Add(existing);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var suggestions = await new SuggestGroupsQueryHandler(
db,
new GroupSuggestionBuilder(db, new GroupElementResolver(db))
).Handle(new SuggestGroupsQuery(), CancellationToken.None);
var genreSuggestion = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.Genre);
Assert.True(genreSuggestion.AlreadyExists);
Assert.Equal(2, genreSuggestion.ShowCount);
}
[Fact]
public async Task Groups_MarkExistingByFilter()
{
// Группу могли переименовать — правило всё равно то же самое.
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var first = WithGenre(Show.Create("A", ShowKind.Series), genre);
var second = WithGenre(Show.Create("B", ShowKind.Series), genre);
var renamed = Group.Create("Своё название");
renamed.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.AddRange(first, second);
seed.Groups.Add(renamed);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var suggestions = await new SuggestGroupsQueryHandler(
db,
new GroupSuggestionBuilder(db, new GroupElementResolver(db))
).Handle(new SuggestGroupsQuery(), CancellationToken.None);
Assert.True(
Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.Genre).AlreadyExists
);
}
[Fact]
public async Task Groups_EmptyLibrary_SuggestsNothing()
{
var fixture = new TestDb();
await using var db = fixture.New();
var suggestions = await new SuggestGroupsQueryHandler(
db,
new GroupSuggestionBuilder(db, new GroupElementResolver(db))
).Handle(new SuggestGroupsQuery(), CancellationToken.None);
Assert.Empty(suggestions);
}
[Fact]
public async Task Collections_MarkExistingAndCarryParts()
{
var fixture = new TestDb();
var first = Movie("Рокки", 1976);
var second = Movie("Рокки 2", 1979);
var existing = Collection.Create("рокки");
await using (var seed = fixture.New())
{
seed.Shows.AddRange(first, second);
seed.Collections.Add(existing);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var suggestions = await new SuggestCollectionsQueryHandler(
db,
new CollectionSuggestionBuilder(db)
).Handle(new SuggestCollectionsQuery(), CancellationToken.None);
var suggestion = Assert.Single(suggestions);
Assert.True(suggestion.AlreadyExists);
// Состав едет наружу целиком: по нему видно, промахнулась ли догадка.
Assert.Equal([first.Id, second.Id], suggestion.Parts.Select(p => p.ShowId));
Assert.Equal(1976, suggestion.Parts[0].Year);
}
[Fact]
public async Task Collections_EmptyLibrary_SuggestsNothing()
{
var fixture = new TestDb();
await using var db = fixture.New();
var suggestions = await new SuggestCollectionsQueryHandler(
db,
new CollectionSuggestionBuilder(db)
).Handle(new SuggestCollectionsQuery(), CancellationToken.None);
Assert.Empty(suggestions);
}
[Fact]
public async Task Collections_CreateFromSuggestion_FailsOnUnknownKey()
{
var fixture = new TestDb();
await using (var seed = fixture.New())
{
seed.Shows.AddRange(Movie("Рокки", 1976), Movie("Рокки 2", 1979));
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new CreateCollectionFromSuggestionCommandHandler(
db,
new CollectionSuggestionBuilder(db)
).Handle(
new CreateCollectionFromSuggestionCommand("name:нет-такого"),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Collections_CreateFromSuggestion_BuildsFranchiseAndRefusesDuplicate()
{
var fixture = new TestDb();
var first = Movie("Рокки", 1976);
var second = Movie("Рокки 2", 1979);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(first, second);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var handler = new CreateCollectionFromSuggestionCommandHandler(
db,
new CollectionSuggestionBuilder(db)
);
var created = await handler.Handle(
new CreateCollectionFromSuggestionCommand("name:рокки"),
CancellationToken.None
);
Assert.True(created.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
await using var check = fixture.New();
var collection = check.Collections.Single();
Assert.Equal("Рокки", collection.Name);
// Повторное создание — конфликт: одноимённые франшизы неразличимы.
var again = await handler.Handle(
new CreateCollectionFromSuggestionCommand("name:рокки"),
CancellationToken.None
);
Assert.False(again.IsSuccess);
}
private static Show WithGenre(Show show, Genre genre)
{
show.SetGenres([genre.Id]);
return show;
}
private static Show Movie(string name, int year)
{
var show = Show.Create(name, ShowKind.Single);
show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
return show;
}
}
@@ -0,0 +1,99 @@
using TeleWave.Application.Library.BulkTagShows;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Programming.Groups.UpdateGroup;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Validators;
/// <summary>
/// Валидаторы массовых операций. Они ловят не злой умысел, а недосмотр вызывающей стороны: команда,
/// которая ничего не меняет или не говорит куда складывать, — это ошибка UI, и падать она должна
/// понятно.
/// </summary>
public class BulkOperationValidatorsTests
{
[Fact]
public void BulkTag_RequiresShowsAndSomethingToApply()
{
var validator = new BulkTagShowsCommandValidator();
Assert.True(
validator
.Validate(new BulkTagShowsCommand([Guid.NewGuid()], GenreIds: [Guid.NewGuid()]))
.IsValid
);
Assert.True(
validator
.Validate(
new BulkTagShowsCommand(
[Guid.NewGuid()],
Audience: ShowAudience.Pg,
SetAudience: true
)
)
.IsValid
);
// Ни жанров, ни рейтинга — применять нечего.
Assert.False(validator.Validate(new BulkTagShowsCommand([Guid.NewGuid()])).IsValid);
// Пустой список отмеченных.
Assert.False(
validator.Validate(new BulkTagShowsCommand([], GenreIds: [Guid.NewGuid()])).IsValid
);
}
[Fact]
public void AddShowsToCollection_RequiresTarget()
{
var validator = new AddShowsToCollectionCommandValidator();
var shows = new[] { Guid.NewGuid() };
Assert.True(
validator.Validate(new AddShowsToCollectionCommand(shows, Guid.NewGuid())).IsValid
);
Assert.True(
validator
.Validate(new AddShowsToCollectionCommand(shows, NewCollectionName: "Франшиза"))
.IsValid
);
// Ни существующей коллекции, ни имени новой — складывать некуда.
Assert.False(validator.Validate(new AddShowsToCollectionCommand(shows)).IsValid);
Assert.False(
validator
.Validate(new AddShowsToCollectionCommand(shows, NewCollectionName: " "))
.IsValid
);
Assert.False(
validator.Validate(new AddShowsToCollectionCommand([], Guid.NewGuid())).IsValid
);
Assert.False(
validator
.Validate(
new AddShowsToCollectionCommand(shows, NewCollectionName: new string('x', 257))
)
.IsValid
);
}
[Fact]
public void UpdateGroup_ChecksNameAndDescription()
{
var validator = new UpdateGroupCommandValidator();
Assert.True(
validator
.Validate(new UpdateGroupCommand(Guid.NewGuid(), "Боевики", null, null))
.IsValid
);
Assert.False(
validator.Validate(new UpdateGroupCommand(Guid.NewGuid(), " ", null, null)).IsValid
);
Assert.False(
validator
.Validate(new UpdateGroupCommand(Guid.NewGuid(), new string('x', 257), null, null))
.IsValid
);
}
}
@@ -101,4 +101,93 @@ public class GroupTests
Assert.Equal(TimeSpan.FromHours(18), group.TotalDuration);
Assert.Equal(at, group.StatsComputedAt);
}
[Fact]
public void SetMode_ToDynamic_KeepsItemsAsPinned()
{
// Смена режима не должна вымывать из эфира то, что уже стояло в слотах.
var group = Group.Create("Боевики");
var show = Guid.NewGuid();
group.AddElement(GroupElementKind.Show, show);
group.SetMode(GroupMode.Dynamic);
Assert.Equal(GroupMode.Dynamic, group.Mode);
Assert.Equal(GroupItemRole.Pinned, group.Items.Single().Role);
}
[Fact]
public void SetMode_BackToStatic_DropsExclusionsAndUnpins()
{
var group = Group.Create("Боевики");
group.SetMode(GroupMode.Dynamic);
group.AddElement(GroupElementKind.Show, Guid.NewGuid());
group.Exclude(GroupElementKind.Show, Guid.NewGuid());
group.SetMode(GroupMode.Static);
// Исключать в статической группе нечего: состав и есть список.
var item = Assert.Single(group.Items);
Assert.Equal(GroupItemRole.Member, item.Role);
}
[Fact]
public void SetMode_SameMode_ChangesNothing()
{
var group = Group.Create("Боевики");
group.AddElement(GroupElementKind.Show, Guid.NewGuid());
group.SetMode(GroupMode.Static);
Assert.Equal(GroupItemRole.Member, group.Items.Single().Role);
}
[Fact]
public void AddElement_InDynamicGroup_PinsByDefault()
{
// Иначе правило вымело бы позицию на первом же обращении.
var group = Group.Create("Боевики");
group.SetMode(GroupMode.Dynamic);
var item = group.AddElement(GroupElementKind.Show, Guid.NewGuid());
Assert.Equal(GroupItemRole.Pinned, item!.Role);
}
[Fact]
public void Exclude_TurnsPinnedIntoExclusion()
{
// Держать на один элемент обе противоположные поправки нельзя.
var group = Group.Create("Боевики");
group.SetMode(GroupMode.Dynamic);
var show = Guid.NewGuid();
group.AddElement(GroupElementKind.Show, show);
Assert.True(group.Exclude(GroupElementKind.Show, show));
Assert.Equal(GroupItemRole.Excluded, group.Items.Single().Role);
}
[Fact]
public void Exclude_InStaticGroup_IsRefused()
{
var group = Group.Create("Боевики");
Assert.False(group.Exclude(GroupElementKind.Show, Guid.NewGuid()));
Assert.Empty(group.Items);
}
[Fact]
public void RemoveExclusion_ReturnsElementToTheRule()
{
var group = Group.Create("Боевики");
group.SetMode(GroupMode.Dynamic);
var show = Guid.NewGuid();
group.Exclude(GroupElementKind.Show, show);
Assert.True(group.RemoveExclusion(GroupElementKind.Show, show));
Assert.Empty(group.Items);
// Снимать нечего — второй раз это уже не поправка.
Assert.False(group.RemoveExclusion(GroupElementKind.Show, show));
}
}