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