Implement dynamic group management features with exclusions and mode handling
Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management.
This commit is contained in:
@@ -22,7 +22,7 @@ public class QueryHandlersTests
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
||||
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
|
||||
new(db, GroupServices.Stats(db));
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
||||
|
||||
@@ -25,7 +25,7 @@ public class DeleteGuardsTests
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
||||
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
|
||||
new(db, GroupServices.Stats(db));
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteShow_InFutureSchedule_Fails()
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
|
||||
using TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
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 DynamicGroupsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Composition_IsRuleResult_PinnedFirstThenAlphabetical()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var genre = Genre.Create("Мультфильм", "animation");
|
||||
var beta = WithGenre(Show.Create("Бета", ShowKind.Series), genre);
|
||||
var alpha = WithGenre(Show.Create("Альфа", ShowKind.Series), genre);
|
||||
// Закреплённое правилу не подходит, но остаётся в составе — и идёт первым.
|
||||
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(alpha, beta, pinned);
|
||||
seed.Groups.Add(group);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var composition = await ResolveAsync(fixture, group.Id);
|
||||
|
||||
Assert.Equal([pinned.Id, alpha.Id, beta.Id], composition.Select(e => e.Id));
|
||||
Assert.True(composition[0].Pinned);
|
||||
Assert.All(composition.Skip(1), e => Assert.False(e.Pinned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Excluded_DropsOutOfComposition()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var genre = Genre.Create("Мультфильм", "animation");
|
||||
var keep = WithGenre(Show.Create("Остаётся", ShowKind.Series), genre);
|
||||
var drop = WithGenre(Show.Create("Выкинуть", ShowKind.Series), genre);
|
||||
|
||||
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.AddRange(keep, drop);
|
||||
seed.Groups.Add(group);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var result = await new ExcludeGroupElementCommandHandler(
|
||||
db,
|
||||
GroupServices.Stats(db)
|
||||
).Handle(
|
||||
new ExcludeGroupElementCommand(group.Id, GroupElementKind.Show, drop.Id, true),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var composition = await ResolveAsync(fixture, group.Id);
|
||||
Assert.Equal([keep.Id], composition.Select(e => e.Id));
|
||||
|
||||
// Снятие исключения возвращает элемент правилу.
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
await new ExcludeGroupElementCommandHandler(db, GroupServices.Stats(db)).Handle(
|
||||
new ExcludeGroupElementCommand(group.Id, GroupElementKind.Show, drop.Id, false),
|
||||
CancellationToken.None
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(2, (await ResolveAsync(fixture, group.Id)).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchToDynamic_KeepsExistingItemsAsPinned()
|
||||
{
|
||||
// Смена режима не должна вымывать из эфира то, что уже стояло в слотах.
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", 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);
|
||||
}
|
||||
|
||||
await UpdateModeAsync(fixture, group.Id, GroupMode.Dynamic);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = check.Groups.Include(g => g.Items).Single();
|
||||
Assert.Equal(GroupMode.Dynamic, stored.Mode);
|
||||
Assert.Equal(GroupItemRole.Pinned, stored.Items.Single().Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchToStatic_MaterializesComputedComposition()
|
||||
{
|
||||
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 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.AddRange(first, second);
|
||||
seed.Groups.Add(group);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await UpdateModeAsync(fixture, group.Id, GroupMode.Static);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = check.Groups.Include(g => g.Items).Single();
|
||||
Assert.Equal(GroupMode.Static, stored.Mode);
|
||||
Assert.Equal(2, stored.Items.Count);
|
||||
Assert.All(stored.Items, item => Assert.Equal(GroupItemRole.Member, item.Role));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Planner_ExpandsDynamicGroupByRule()
|
||||
{
|
||||
// Главное свойство: правило применяется на самой генерации, а не когда-то до неё.
|
||||
var fixture = new TestDb();
|
||||
var genre = Genre.Create("Мультфильм", "animation");
|
||||
var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
TimeSpan.FromMinutes(22),
|
||||
2,
|
||||
660,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
"assets/a"
|
||||
)
|
||||
);
|
||||
var show = WithGenre(Show.Create("A", ShowKind.Series), genre);
|
||||
show.AddEpisode(asset.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.MediaAssets.Add(asset);
|
||||
seed.Shows.Add(show);
|
||||
seed.Groups.Add(group);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var expanded = await new GroupExpander(db, GroupServices.Dynamic(db)).ExpandAsync(
|
||||
[group.Id],
|
||||
Guid.NewGuid(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var element = Assert.Single(expanded[group.Id]);
|
||||
Assert.Equal(show.Id, element.ElementId);
|
||||
Assert.Single(element.Units);
|
||||
}
|
||||
|
||||
private static Show WithGenre(Show show, Genre genre)
|
||||
{
|
||||
show.SetGenres([genre.Id]);
|
||||
return show;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<ResolvedElement>> ResolveAsync(
|
||||
TestDb fixture,
|
||||
Guid groupId
|
||||
)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var group = await db.Groups.Include(g => g.Items).FirstAsync(g => g.Id == groupId);
|
||||
return await GroupServices.Dynamic(db).ResolveAsync(group, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task UpdateModeAsync(TestDb fixture, Guid groupId, GroupMode mode)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var group = await db.Groups.FirstAsync(g => g.Id == groupId);
|
||||
var result = await new UpdateGroupCommandHandler(
|
||||
db,
|
||||
GroupServices.Dynamic(db),
|
||||
GroupServices.Stats(db)
|
||||
).Handle(
|
||||
new UpdateGroupCommand(
|
||||
groupId,
|
||||
group.Name,
|
||||
group.Description,
|
||||
GroupFilter.FromJson(group.FilterJson),
|
||||
mode
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ public class GroupSuggestionsTests
|
||||
var handler = new CreateGroupFromSuggestionCommandHandler(
|
||||
db,
|
||||
Builder(db),
|
||||
new GroupStatsService(db, new GroupElementResolver(db))
|
||||
GroupServices.Stats(db)
|
||||
);
|
||||
|
||||
var created = await handler.Handle(
|
||||
@@ -168,7 +168,7 @@ public class GroupSuggestionsTests
|
||||
var result = await new CreateGroupFromSuggestionCommandHandler(
|
||||
db,
|
||||
Builder(db),
|
||||
new GroupStatsService(db, new GroupElementResolver(db))
|
||||
GroupServices.Stats(db)
|
||||
).Handle(new CreateGroupFromSuggestionCommand("genre:нет-такого"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
|
||||
namespace TeleWave.Application.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Сборка сервисов группы для тестов: их четыре и они цепляются друг за друга, а в тестах нужен
|
||||
/// каждый раз один и тот же набор. В приложении это делает контейнер.
|
||||
/// </summary>
|
||||
internal static class GroupServices
|
||||
{
|
||||
public static DynamicGroupResolver Dynamic(IAppDbContext db) =>
|
||||
new(new GroupFilterMatcher(db), new GroupElementResolver(db));
|
||||
|
||||
public static GroupStatsService Stats(IAppDbContext db) =>
|
||||
new(db, new GroupElementResolver(db), Dynamic(db));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Streaming;
|
||||
@@ -192,7 +193,10 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
||||
var random = new SequenceRandom(0);
|
||||
return new GridScheduleGenerator(
|
||||
db,
|
||||
new GroupExpander(db),
|
||||
new GroupExpander(
|
||||
db,
|
||||
new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db))
|
||||
),
|
||||
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
|
||||
new PostCheckRunner(db),
|
||||
random,
|
||||
|
||||
Reference in New Issue
Block a user