Add group suggestions feature with API endpoints and frontend integration
Implemented new API endpoints for suggesting groups based on the current library, allowing admins to retrieve and create groups from suggestions. Updated the backend to include error handling for suggestions and added necessary DTOs. Enhanced the frontend with new API functions and UI components to display suggestions, improving the user experience for group management. Localization updates were made to support new features in both English and Russian.
This commit is contained in:
@@ -10,6 +10,7 @@ using TeleWave.Application.Programming.Groups.ListGroups;
|
||||
using TeleWave.Application.Programming.Groups.RemoveGroupItem;
|
||||
using TeleWave.Application.Programming.Groups.ReorderGroup;
|
||||
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
|
||||
using TeleWave.Application.Programming.Groups.Suggest;
|
||||
using TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
@@ -34,6 +35,12 @@ public static class GroupEndpoints
|
||||
.MapPost("/{id:guid}/candidates", FindCandidates)
|
||||
.Produces<IReadOnlyList<GroupCandidateDto>>();
|
||||
|
||||
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
|
||||
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
|
||||
admin
|
||||
.MapPost("/suggestions", CreateFromSuggestion)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
|
||||
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
|
||||
admin
|
||||
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
|
||||
@@ -80,6 +87,33 @@ public static class GroupEndpoints
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Suggestions(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateFromSuggestion(
|
||||
CreateGroupFromSuggestionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateGroupFromSuggestionCommand(body.Key),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/groups/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateGroup(
|
||||
Guid id,
|
||||
UpdateGroupBody body,
|
||||
@@ -179,6 +213,8 @@ public sealed record UpdateGroupBody(string Name, string? Description, GroupFilt
|
||||
|
||||
public sealed record FindCandidatesBody(GroupFilter? Filter);
|
||||
|
||||
public sealed record CreateGroupFromSuggestionBody(string Key);
|
||||
|
||||
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
|
||||
|
||||
public sealed record GroupItemWeightBody(int Weight);
|
||||
|
||||
@@ -7,6 +7,7 @@ using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Behaviors;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Groups.Suggest;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
|
||||
@@ -37,6 +38,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<GenreMatcher>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupStatsService>();
|
||||
services.AddScoped<GroupSuggestionBuilder>();
|
||||
services.AddScoped<GroupMembershipCleaner>();
|
||||
services.AddScoped<SlotWriter>();
|
||||
services.AddScoped<GroupExpander>();
|
||||
|
||||
@@ -25,4 +25,14 @@ public static class GroupErrors
|
||||
"Groups.FilterNotSet",
|
||||
"У группы не задано правило набора."
|
||||
);
|
||||
|
||||
public static readonly Error SuggestionNotFound = Error.NotFound(
|
||||
"Groups.SuggestionNotFound",
|
||||
"Предложение устарело — библиотека изменилась. Обновите список предложений."
|
||||
);
|
||||
|
||||
public static readonly Error SuggestionAlreadyCreated = Error.Conflict(
|
||||
"Groups.SuggestionAlreadyCreated",
|
||||
"Группа с таким названием уже есть."
|
||||
);
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.Suggest;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт группу по предложению: имя, правило набора и состав берутся с сервера по ключу, а не
|
||||
/// приезжают с клиента. Так между показом предложения и нажатием кнопки библиотека может измениться —
|
||||
/// в группу попадёт актуальное, а не то, что успело устареть на экране.
|
||||
/// </summary>
|
||||
public sealed record CreateGroupFromSuggestionCommand(string Key) : ICommand<Result<Guid>>;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.Suggest;
|
||||
|
||||
public sealed class CreateGroupFromSuggestionCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupSuggestionBuilder builder,
|
||||
GroupStatsService stats
|
||||
) : ICommandHandler<CreateGroupFromSuggestionCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateGroupFromSuggestionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var suggestions = await builder.BuildAsync(cancellationToken);
|
||||
var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key);
|
||||
if (suggestion is null)
|
||||
return Result.Failure<Guid>(GroupErrors.SuggestionNotFound);
|
||||
|
||||
// Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы.
|
||||
var taken = await dbContext.Groups.AnyAsync(
|
||||
g => g.Name.ToLower() == suggestion.Name.ToLower(),
|
||||
cancellationToken
|
||||
);
|
||||
if (taken)
|
||||
return Result.Failure<Guid>(GroupErrors.SuggestionAlreadyCreated);
|
||||
|
||||
var group = Group.Create(suggestion.Name);
|
||||
group.SetFilter(suggestion.Filter?.ToJson());
|
||||
foreach (var showId in suggestion.ShowIds)
|
||||
group.AddElement(GroupElementKind.Show, showId);
|
||||
|
||||
dbContext.Groups.Add(group);
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
return Result.Success(group.Id);
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
using ElementStats = System.Collections.Generic.IReadOnlyDictionary<
|
||||
(TeleWave.Domain.Programming.GroupElementKind Kind, System.Guid Id),
|
||||
TeleWave.Application.Programming.Groups.GroupElementInfo
|
||||
>;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.Suggest;
|
||||
|
||||
/// <summary>
|
||||
/// Предложение: готовая к созданию группа — имя, правило набора и состав. Ключ самоописателен
|
||||
/// (<c>genre:{id}</c>, <c>kind:Series</c>, …), поэтому создание не тащит состав с клиента: команда
|
||||
/// пересобирает предложение по ключу на свежих данных.
|
||||
/// </summary>
|
||||
public sealed record GroupSuggestion(
|
||||
string Key,
|
||||
GroupSuggestionKind Kind,
|
||||
string Name,
|
||||
GroupFilter? Filter,
|
||||
IReadOnlyList<Guid> ShowIds,
|
||||
int UnitCount,
|
||||
TimeSpan TotalDuration
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает библиотеку и предлагает группы, которые имеет смысл завести. Правила намеренно простые
|
||||
/// и объяснимые: админ должен понимать, почему ему это показали, — «12 шоу с жанром Мультфильм»
|
||||
/// проверяемо, а рекомендательная магия нет.
|
||||
///
|
||||
/// Состав предложения — только шоу. Коллекции сюда не берутся: франшиза почти всегда состоит из тех
|
||||
/// же шоу, и добавление обеих форм задваивало бы эфир. Правило набора у предложения сохраняется,
|
||||
/// поэтому коллекции при желании добираются кнопкой «Подобрать» уже в самой группе.
|
||||
/// </summary>
|
||||
public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElementResolver resolver)
|
||||
{
|
||||
/// <summary>Сколько шоу должно набраться, чтобы предлагать сборную группу (жанр/тип/рейтинг).</summary>
|
||||
private const int MinShows = 2;
|
||||
|
||||
/// <summary>С какого числа серий сериал заслуживает собственной группы.</summary>
|
||||
private const int BigSeriesEpisodes = 20;
|
||||
|
||||
/// <summary>Пороги «мягких» рейтингов: до них группа читается как «детское»/«семейное».</summary>
|
||||
private static readonly (ShowAudience Max, string Name)[] AudienceTiers =
|
||||
[
|
||||
(ShowAudience.Pg, "Детское (не строже PG)"),
|
||||
(ShowAudience.Pg13, "Семейное (не строже PG-13)"),
|
||||
];
|
||||
|
||||
private static readonly (ShowKind Kind, string Name)[] ShowKindNames =
|
||||
[
|
||||
(ShowKind.Series, "Сериалы"),
|
||||
(ShowKind.Single, "Полнометражки"),
|
||||
];
|
||||
|
||||
public async Task<IReadOnlyList<GroupSuggestion>> BuildAsync(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Ролики-врезки в группы не идут: у рекламы и заставок своя подсистема.
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Kind != ShowKind.Interstitial)
|
||||
.Select(s => new ShowRow(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Genres.Select(g => g.GenreId).ToList(),
|
||||
s.Episodes.Count
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
if (shows.Count == 0)
|
||||
return [];
|
||||
|
||||
var stats = await resolver.ResolveAsync(
|
||||
shows.Select(s => (GroupElementKind.Show, s.Id)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var genres = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
var suggestions = new List<GroupSuggestion>();
|
||||
suggestions.AddRange(ByGenre(shows, genres, stats));
|
||||
suggestions.AddRange(ByShowKind(shows, stats));
|
||||
suggestions.AddRange(ByAudience(shows, stats));
|
||||
suggestions.AddRange(ByBigSeries(shows, stats));
|
||||
|
||||
// Внутри вида — сначала самые ёмкие: с них и начинают собирать сетку.
|
||||
return
|
||||
[
|
||||
.. suggestions
|
||||
.OrderBy(s => s.Kind)
|
||||
.ThenByDescending(s => s.UnitCount)
|
||||
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
|
||||
];
|
||||
}
|
||||
|
||||
private static IEnumerable<GroupSuggestion> ByGenre(
|
||||
List<ShowRow> shows,
|
||||
IReadOnlyDictionary<Guid, string> genres,
|
||||
ElementStats stats
|
||||
)
|
||||
{
|
||||
foreach (var (genreId, name) in genres)
|
||||
{
|
||||
var matched = shows.Where(s => s.GenreIds.Contains(genreId)).ToList();
|
||||
if (matched.Count < MinShows)
|
||||
continue;
|
||||
|
||||
yield return Build(
|
||||
$"genre:{genreId:N}",
|
||||
GroupSuggestionKind.Genre,
|
||||
name,
|
||||
new GroupFilter(GenreIds: [genreId]),
|
||||
matched,
|
||||
stats
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<GroupSuggestion> ByShowKind(List<ShowRow> shows, ElementStats stats)
|
||||
{
|
||||
foreach (var (kind, name) in ShowKindNames)
|
||||
{
|
||||
var matched = shows.Where(s => s.Kind == kind).ToList();
|
||||
if (matched.Count < MinShows)
|
||||
continue;
|
||||
|
||||
yield return Build(
|
||||
$"kind:{kind}",
|
||||
GroupSuggestionKind.ShowKind,
|
||||
name,
|
||||
new GroupFilter(ShowKinds: [kind]),
|
||||
matched,
|
||||
stats
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Не строже X» — ровно та форма, которую понимает правило набора. Шоу без рейтинга в выборке
|
||||
/// остаются (как в фильтре и в планировщике): источники проставляют рейтинг далеко не всему.
|
||||
/// </summary>
|
||||
private static IEnumerable<GroupSuggestion> ByAudience(List<ShowRow> shows, ElementStats stats)
|
||||
{
|
||||
foreach (var (max, name) in AudienceTiers)
|
||||
{
|
||||
var matched = shows.Where(s => s.Audience == null || s.Audience <= max).ToList();
|
||||
if (matched.Count < MinShows)
|
||||
continue;
|
||||
|
||||
// Если под порог попадает вообще всё, группа повторяет библиотеку — предлагать нечего.
|
||||
if (matched.Count == shows.Count)
|
||||
continue;
|
||||
|
||||
yield return Build(
|
||||
$"audience:{max}",
|
||||
GroupSuggestionKind.Audience,
|
||||
name,
|
||||
new GroupFilter(MaxAudience: max),
|
||||
matched,
|
||||
stats
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Крупный сериал ставят в слот целиком, поэтому ему нужна собственная группа. Правила набора
|
||||
/// у неё нет: «именно это шоу» фильтром не выражается, да и не нужно — состав из одной позиции.
|
||||
/// </summary>
|
||||
private static IEnumerable<GroupSuggestion> ByBigSeries(List<ShowRow> shows, ElementStats stats)
|
||||
{
|
||||
foreach (
|
||||
var show in shows.Where(s =>
|
||||
s.Kind == ShowKind.Series && s.EpisodeCount >= BigSeriesEpisodes
|
||||
)
|
||||
)
|
||||
yield return Build(
|
||||
$"show:{show.Id:N}",
|
||||
GroupSuggestionKind.BigSeries,
|
||||
show.Name,
|
||||
null,
|
||||
[show],
|
||||
stats
|
||||
);
|
||||
}
|
||||
|
||||
private static GroupSuggestion Build(
|
||||
string key,
|
||||
GroupSuggestionKind kind,
|
||||
string name,
|
||||
GroupFilter? filter,
|
||||
IReadOnlyList<ShowRow> matched,
|
||||
ElementStats stats
|
||||
)
|
||||
{
|
||||
var units = 0;
|
||||
var duration = TimeSpan.Zero;
|
||||
foreach (var show in matched)
|
||||
{
|
||||
if (!stats.TryGetValue((GroupElementKind.Show, show.Id), out var info))
|
||||
continue;
|
||||
units += info.UnitCount;
|
||||
duration += info.TotalDuration;
|
||||
}
|
||||
|
||||
return new GroupSuggestion(
|
||||
key,
|
||||
kind,
|
||||
name,
|
||||
filter,
|
||||
[.. matched.Select(s => s.Id)],
|
||||
units,
|
||||
duration
|
||||
);
|
||||
}
|
||||
|
||||
private sealed record ShowRow(
|
||||
Guid Id,
|
||||
string Name,
|
||||
ShowKind Kind,
|
||||
ShowAudience? Audience,
|
||||
List<Guid> GenreIds,
|
||||
int EpisodeCount
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TeleWave.Application.Programming.Groups.Suggest;
|
||||
|
||||
/// <summary>Откуда взялось предложение — UI по нему группирует список и подписывает происхождение.</summary>
|
||||
public enum GroupSuggestionKind
|
||||
{
|
||||
/// <summary>Всё, что помечено одним жанром.</summary>
|
||||
Genre = 0,
|
||||
|
||||
/// <summary>Всё одного типа: сериалы либо полнометражки.</summary>
|
||||
ShowKind = 1,
|
||||
|
||||
/// <summary>Всё не строже указанного возрастного рейтинга.</summary>
|
||||
Audience = 2,
|
||||
|
||||
/// <summary>Один крупный сериал, которого хватает на слот целиком.</summary>
|
||||
BigSeries = 3,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.Suggest;
|
||||
|
||||
/// <summary>Что имеет смысл завести группой при текущем составе библиотеки.</summary>
|
||||
public sealed record SuggestGroupsQuery : IQuery<IReadOnlyList<GroupSuggestionDto>>;
|
||||
|
||||
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param>
|
||||
/// <param name="ShowCount">Сколько шоу попадёт в состав.</param>
|
||||
/// <param name="UnitCount">Сколько единиц воспроизведения (серий/фильмов) они дают.</param>
|
||||
/// <param name="TotalDurationSeconds">Объём готового эфира — по обработанным ассетам.</param>
|
||||
/// <param name="AlreadyExists">
|
||||
/// Такая группа уже есть (по имени либо по совпадающему правилу набора) — создавать повторно нечего.
|
||||
/// </param>
|
||||
public sealed record GroupSuggestionDto(
|
||||
string Key,
|
||||
GroupSuggestionKind Kind,
|
||||
string Name,
|
||||
int ShowCount,
|
||||
int UnitCount,
|
||||
double TotalDurationSeconds,
|
||||
bool AlreadyExists
|
||||
);
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.Suggest;
|
||||
|
||||
public sealed class SuggestGroupsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupSuggestionBuilder builder
|
||||
) : IQueryHandler<SuggestGroupsQuery, IReadOnlyList<GroupSuggestionDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<GroupSuggestionDto>> Handle(
|
||||
SuggestGroupsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var suggestions = await builder.BuildAsync(cancellationToken);
|
||||
if (suggestions.Count == 0)
|
||||
return [];
|
||||
|
||||
// Уже созданное отмечаем, но из списка не убираем: пропавшее без следа предложение читается
|
||||
// как сбой, а «уже есть» объясняет, почему кнопки нет.
|
||||
var existing = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Select(g => new { g.Name, g.FilterJson })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var names = existing
|
||||
.Select(g => g.Name.Trim())
|
||||
.ToHashSet(StringComparer.CurrentCultureIgnoreCase);
|
||||
var filters = existing
|
||||
.Select(g => g.FilterJson)
|
||||
.Where(json => !string.IsNullOrWhiteSpace(json))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return
|
||||
[
|
||||
.. suggestions.Select(s => new GroupSuggestionDto(
|
||||
s.Key,
|
||||
s.Kind,
|
||||
s.Name,
|
||||
s.ShowIds.Count,
|
||||
s.UnitCount,
|
||||
s.TotalDuration.TotalSeconds,
|
||||
names.Contains(s.Name)
|
||||
|| (s.Filter is not null && filters.Contains(s.Filter.ToJson()))
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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 GroupSuggestionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SuggestsGenre_WhenAtLeastTwoShowsShareIt()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var cartoons = Genre.Create("Мультфильм", "animation");
|
||||
var drama = Genre.Create("Драма", "drama");
|
||||
var first = WithGenre(Show.Create("A", ShowKind.Series), cartoons);
|
||||
var second = WithGenre(Show.Create("B", ShowKind.Series), cartoons);
|
||||
// Один драматический — на группу не тянет.
|
||||
var lonely = WithGenre(Show.Create("C", ShowKind.Single), drama);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Genres.AddRange(cartoons, drama);
|
||||
seed.Shows.AddRange(first, second, lonely);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
var genre = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.Genre);
|
||||
Assert.Equal("Мультфильм", genre.Name);
|
||||
Assert.Equal(2, genre.ShowIds.Count);
|
||||
Assert.Equal([cartoons.Id], genre.Filter!.GenreIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestsBigSeries_Separately()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var big = Show.Create("Гравити Фолз", ShowKind.Series);
|
||||
for (var i = 0; i < 20; i++)
|
||||
big.AddEpisode(Guid.NewGuid());
|
||||
var small = Show.Create("Короткое", ShowKind.Series);
|
||||
small.AddEpisode(Guid.NewGuid());
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.AddRange(big, small);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
var series = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.BigSeries);
|
||||
Assert.Equal("Гравити Фолз", series.Name);
|
||||
Assert.Equal([big.Id], series.ShowIds);
|
||||
// Правила у одиночного шоу нет: «именно это шоу» фильтром не выражается.
|
||||
Assert.Null(series.Filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsAudienceTier_WhenItCoversEverything()
|
||||
{
|
||||
// Если под «не строже PG» попадает вся библиотека, группа повторяет её — предлагать нечего.
|
||||
var fixture = new TestDb();
|
||||
var first = Show.Create("A", ShowKind.Series);
|
||||
first.SetAudience(ShowAudience.G);
|
||||
var second = Show.Create("B", ShowKind.Series);
|
||||
second.SetAudience(ShowAudience.Pg);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.AddRange(first, second);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
Assert.DoesNotContain(suggestions, s => s.Kind == GroupSuggestionKind.Audience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestsAudienceTier_WhenStricterContentExists()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var kids = Show.Create("A", ShowKind.Series);
|
||||
kids.SetAudience(ShowAudience.G);
|
||||
var adult = Show.Create("B", ShowKind.Series);
|
||||
adult.SetAudience(ShowAudience.R);
|
||||
var unrated = Show.Create("C", ShowKind.Series);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.AddRange(kids, adult, unrated);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
var tier = suggestions.First(s => s.Kind == GroupSuggestionKind.Audience);
|
||||
// Непроставленный рейтинг остаётся в выборке — как в правиле набора и в планировщике.
|
||||
Assert.Equal(2, tier.ShowIds.Count);
|
||||
Assert.Contains(kids.Id, tier.ShowIds);
|
||||
Assert.Contains(unrated.Id, tier.ShowIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromSuggestion_SavesFilterAndComposition()
|
||||
{
|
||||
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);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Genres.Add(genre);
|
||||
seed.Shows.AddRange(first, second);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new CreateGroupFromSuggestionCommandHandler(
|
||||
db,
|
||||
Builder(db),
|
||||
new GroupStatsService(db, new GroupElementResolver(db))
|
||||
);
|
||||
|
||||
var created = await handler.Handle(
|
||||
new CreateGroupFromSuggestionCommand($"genre:{genre.Id:N}"),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(created.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var group = check.Groups.Include(g => g.Items).Single();
|
||||
Assert.Equal("Мультфильм", group.Name);
|
||||
Assert.Equal(2, group.Items.Count);
|
||||
Assert.All(group.Items, item => Assert.Equal(GroupElementKind.Show, item.ElementKind));
|
||||
Assert.Equal([genre.Id], GroupFilter.FromJson(group.FilterJson)!.GenreIds);
|
||||
|
||||
// Повторное создание того же — конфликт: две одноимённые группы в слоте неразличимы.
|
||||
var again = await handler.Handle(
|
||||
new CreateGroupFromSuggestionCommand($"genre:{genre.Id:N}"),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(again.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromSuggestion_FailsForUnknownKey()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(Show.Create("A", ShowKind.Series));
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new CreateGroupFromSuggestionCommandHandler(
|
||||
db,
|
||||
Builder(db),
|
||||
new GroupStatsService(db, new GroupElementResolver(db))
|
||||
).Handle(new CreateGroupFromSuggestionCommand("genre:нет-такого"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
private static Show WithGenre(Show show, Genre genre)
|
||||
{
|
||||
show.SetGenres([genre.Id]);
|
||||
return show;
|
||||
}
|
||||
|
||||
private static GroupSuggestionBuilder Builder(Infrastructure.Persistence.AppDbContext db) =>
|
||||
new(db, new GroupElementResolver(db));
|
||||
|
||||
private static async Task<IReadOnlyList<GroupSuggestion>> BuildAsync(TestDb fixture)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
return await Builder(db).BuildAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user