Add grid auto-build functionality with profile management and preview capabilities
Implemented new API endpoints and frontend components for grid auto-building based on predefined profiles. Added functionality to list grid profiles, preview generated grids, and generate grids with specified parameters. Enhanced the TemplateEndpoints and related services to support these features, improving the user experience for channel scheduling. Localization updates were made to accommodate new features in both English and Russian.
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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>
|
||||
/// Автосборка сетки: раскладка по полосам профиля, подбор групп под рейтинг и тип, заполнение
|
||||
/// только дыр и пересборка с нуля. Главное свойство — предпросмотр и создание считают одно и то же.
|
||||
/// </summary>
|
||||
public class GenerateGridTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Шоу с готовыми сериями: без длительности группа считалась бы пустой.</summary>
|
||||
private static (Show Show, List<MediaAsset> Assets) Playable(
|
||||
string name,
|
||||
ShowKind kind,
|
||||
int episodes,
|
||||
int minutes,
|
||||
ShowAudience? audience = null
|
||||
)
|
||||
{
|
||||
var show = Show.Create(name, kind);
|
||||
if (audience is { } value)
|
||||
show.SetAudience(value);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Несколько полнометражек: у одиночного шоу серия ровно одна, поэтому кино — это N шоу.</summary>
|
||||
private static (List<Show> Shows, List<MediaAsset> Assets) Films(
|
||||
int count,
|
||||
int minutes,
|
||||
ShowAudience? audience = null
|
||||
)
|
||||
{
|
||||
var shows = new List<Show>();
|
||||
var assets = new List<MediaAsset>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var (show, made) = Playable($"Фильм {i}", ShowKind.Single, 1, minutes, audience);
|
||||
shows.Add(show);
|
||||
assets.AddRange(made);
|
||||
}
|
||||
|
||||
return (shows, assets);
|
||||
}
|
||||
|
||||
private static Group GroupOf(string name, params Guid[] showIds)
|
||||
{
|
||||
var group = Group.Create(name);
|
||||
foreach (var id in showIds)
|
||||
group.AddElement(GroupElementKind.Show, id);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>Канал с пустой сеткой: один фоновый слой без слотов.</summary>
|
||||
private static async Task<(Guid ChannelId, ScheduleTemplate Template)> SeedChannelAsync(
|
||||
TestDb fixture,
|
||||
IEnumerable<Show> shows,
|
||||
IEnumerable<MediaAsset> assets,
|
||||
IEnumerable<Group> groups
|
||||
)
|
||||
{
|
||||
var channel = Channel.Create("Первый", "first", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
await using var seed = fixture.New();
|
||||
seed.MediaAssets.AddRange(assets);
|
||||
seed.Shows.AddRange(shows);
|
||||
seed.Groups.AddRange(groups);
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return (channel.Id, template);
|
||||
}
|
||||
|
||||
private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) =>
|
||||
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
|
||||
|
||||
private static async Task<GridPlan> PlanAsync(
|
||||
TestDb fixture,
|
||||
Guid channelId,
|
||||
GridProfileKind profile = GridProfileKind.Mixed,
|
||||
GridGenerationMode mode = GridGenerationMode.FillGaps
|
||||
)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await Planner(db).BuildAsync(channelId, profile, mode, CancellationToken.None);
|
||||
Assert.True(result.IsSuccess);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyGrid_IsCoveredEntirely_AndCollapsesIntoEveryDaySlots()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var films = Films(6, 100);
|
||||
// Ночному блоку 18+ нужен строгий рейтинг, иначе полосу закрыть будет нечем.
|
||||
var adult = Playable("Ночное", ShowKind.Series, 10, 45, ShowAudience.R);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show, adult.Show, .. films.Shows],
|
||||
[.. series.Assets, .. adult.Assets, .. films.Assets],
|
||||
[
|
||||
GroupOf("Сериалы", series.Show.Id),
|
||||
GroupOf("Ночное", adult.Show.Id),
|
||||
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
|
||||
]
|
||||
);
|
||||
|
||||
var plan = await PlanAsync(fixture, channelId);
|
||||
|
||||
// Пустая сетка свободна всю неделю, и профиль обязан закрыть её целиком.
|
||||
Assert.Equal(7 * GridCoverage.MinutesInDay, plan.FreeMinutes);
|
||||
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
|
||||
// Свободно во все семь дней — значит слоты идут «каждый день», а не семью копиями.
|
||||
Assert.All(plan.Slots, slot => Assert.Null(slot.Weekday));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrimeGetsFeature_AndMorningGetsSeries()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var films = Films(8, 100);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show, .. films.Shows],
|
||||
[.. series.Assets, .. films.Assets],
|
||||
[
|
||||
GroupOf("Сериалы", series.Show.Id),
|
||||
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
|
||||
]
|
||||
);
|
||||
|
||||
var plan = await PlanAsync(fixture, channelId);
|
||||
|
||||
var prime = plan.Slots.First(s => s.Start == new TimeOnly(20, 0));
|
||||
Assert.Equal("Фильмы", prime.GroupName);
|
||||
// Кино ставится по одной единице и с якорем: объявленное время начала — обещание зрителю.
|
||||
Assert.Equal(SlotBlockMode.Count, prime.BlockMode);
|
||||
Assert.Equal(1, prime.BlockValue);
|
||||
Assert.True(prime.IsAnchor);
|
||||
|
||||
var morning = plan.Slots.First(s => s.Daypart == Daypart.Morning);
|
||||
Assert.Equal("Сериалы", morning.GroupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChildrenBand_SkipsAdultGroup()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var adult = Playable("Взрослое", ShowKind.Series, 20, 25, ShowAudience.R);
|
||||
var kids = Playable("Детское", ShowKind.Series, 20, 25, ShowAudience.G);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[adult.Show, kids.Show],
|
||||
[.. adult.Assets, .. kids.Assets],
|
||||
[GroupOf("Взрослое", adult.Show.Id), GroupOf("Детское", kids.Show.Id)]
|
||||
);
|
||||
|
||||
var plan = await PlanAsync(fixture, channelId, GridProfileKind.Kids);
|
||||
|
||||
// Детский профиль не пускает строгое никуда, а ночь у него — конец вещания.
|
||||
Assert.DoesNotContain(plan.Slots, s => s.GroupName == "Взрослое");
|
||||
Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.SignOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdultNightBand_NeedsRatedContent()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
// Рейтинга нет ни у чего: ночной блок 18+ ставить не из чего, и это должно быть сказано.
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show],
|
||||
series.Assets,
|
||||
[GroupOf("Сериалы", series.Show.Id)]
|
||||
);
|
||||
|
||||
var plan = await PlanAsync(fixture, channelId);
|
||||
|
||||
Assert.Contains(plan.Notes, note => note.Contains("Ночной блок 18+"));
|
||||
Assert.True(plan.CoveredMinutes < plan.FreeMinutes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FillGaps_LeavesExistingSlotsAlone()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var group = GroupOf("Сериалы", series.Show.Id);
|
||||
var (channelId, template) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show],
|
||||
series.Assets,
|
||||
[group]
|
||||
);
|
||||
|
||||
// Занятая полоса: вторник с 20:00 на два часа.
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var tracked = await db
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstAsync(t => t.Id == template.Id, CancellationToken.None);
|
||||
var slot = tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
"Ручной",
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
null,
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var plan = await PlanAsync(fixture, channelId);
|
||||
|
||||
Assert.Equal(0, plan.SlotsToRemove);
|
||||
Assert.Equal(7 * GridCoverage.MinutesInDay - 120, plan.FreeMinutes);
|
||||
// Занятое время не застраивается: во вторник в 20:00 нового слота нет.
|
||||
Assert.DoesNotContain(
|
||||
plan.Slots,
|
||||
s => s.Weekday == 2 && s.Start >= new TimeOnly(20, 0) && s.Start < new TimeOnly(22, 0)
|
||||
);
|
||||
// Остальные дни в это время свободны, поэтому слот «каждый день» туда уже не схлопнется.
|
||||
Assert.Contains(plan.Slots, s => s.Weekday is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rebuild_CountsExistingSlotsAsRemoved_AndSeparatesWeekend()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var films = Films(8, 100);
|
||||
var group = GroupOf("Сериалы", series.Show.Id);
|
||||
var (channelId, template) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show, .. films.Shows],
|
||||
[.. series.Assets, .. films.Assets],
|
||||
[group, GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)])]
|
||||
);
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var tracked = await db
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstAsync(t => t.Id == template.Id, CancellationToken.None);
|
||||
tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var plan = await PlanAsync(
|
||||
fixture,
|
||||
channelId,
|
||||
GridProfileKind.Mixed,
|
||||
GridGenerationMode.Rebuild
|
||||
);
|
||||
|
||||
Assert.Equal(1, plan.SlotsToRemove);
|
||||
Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Weekend && s.Weekday == 6);
|
||||
Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Main && s.Weekday is null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenNothingToPlanFrom()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
// Группа есть, но её шоу без готового медиа — эфира из неё не получится.
|
||||
var show = Show.Create("Пусто", ShowKind.Series);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[show],
|
||||
[],
|
||||
[GroupOf("Пустая", show.Id)]
|
||||
);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Planner(db)
|
||||
.BuildAsync(
|
||||
channelId,
|
||||
GridProfileKind.Mixed,
|
||||
GridGenerationMode.FillGaps,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TemplateErrors.NoGroupsToGenerate, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_WritesSlots_AndMarksTemplateChanged()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var films = Films(8, 100);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show, .. films.Shows],
|
||||
[.. series.Assets, .. films.Assets],
|
||||
[
|
||||
GroupOf("Сериалы", series.Show.Id),
|
||||
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
|
||||
]
|
||||
);
|
||||
|
||||
int planned;
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var result = await new GenerateGridCommandHandler(
|
||||
db,
|
||||
Planner(db),
|
||||
new SlotWriter(db)
|
||||
).Handle(
|
||||
new GenerateGridCommand(
|
||||
channelId,
|
||||
GridProfileKind.Mixed,
|
||||
GridGenerationMode.FillGaps
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
planned = result.Value.Created;
|
||||
Assert.Equal(0, result.Value.Removed);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = await check
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(planned, stored.Layers.Sum(l => l.Slots.Count));
|
||||
Assert.Contains(stored.Layers, l => l.Name == GridPlanner.MainLayerName);
|
||||
// Правка правил эфир не двигает — она только помечает шаблон изменённым.
|
||||
Assert.True(stored.HasPendingChanges);
|
||||
// Аварийная группа проставляется, если своей не было: иначе паузы останутся пустыми.
|
||||
Assert.NotNull(stored.FallbackGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_Twice_ReusesLayer_AndAddsNothingNew()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var films = Films(8, 100);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show, .. films.Shows],
|
||||
[.. series.Assets, .. films.Assets],
|
||||
[
|
||||
GroupOf("Сериалы", series.Show.Id),
|
||||
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
|
||||
]
|
||||
);
|
||||
|
||||
for (var run = 0; run < 2; run++)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new GenerateGridCommandHandler(
|
||||
db,
|
||||
Planner(db),
|
||||
new SlotWriter(db)
|
||||
).Handle(
|
||||
new GenerateGridCommand(
|
||||
channelId,
|
||||
GridProfileKind.Mixed,
|
||||
GridGenerationMode.FillGaps
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
// Второй прогон видит сетку закрытой и не создаёт ничего: дыр больше нет.
|
||||
Assert.True(run == 0 ? result.Value.Created > 0 : result.Value.Created == 0);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var check = fixture.New();
|
||||
var layers = await check.ScheduleTemplates.Include(t => t.Layers).FirstAsync();
|
||||
Assert.Single(layers.Layers, l => l.Name == GridPlanner.MainLayerName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user