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;
///
/// Автосборка сетки: раскладка по полосам профиля, подбор групп под рейтинг и тип, заполнение
/// только дыр и пересборка с нуля. Главное свойство — предпросмотр и создание считают одно и то же.
///
public class GenerateGridTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// Шоу с готовыми сериями: без длительности группа считалась бы пустой.
private static (Show Show, List 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();
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);
}
/// Несколько полнометражек: у одиночного шоу серия ровно одна, поэтому кино — это N шоу.
private static (List Shows, List Assets) Films(
int count,
int minutes,
ShowAudience? audience = null
)
{
var shows = new List();
var assets = new List();
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;
}
/// Канал с пустой сеткой: один фоновый слой без слотов.
private static async Task<(Guid ChannelId, ScheduleTemplate Template)> SeedChannelAsync(
TestDb fixture,
IEnumerable shows,
IEnumerable assets,
IEnumerable 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 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);
}
}