Added new methods for intersecting and subtracting time intervals in the GridCoverage class to enhance scheduling capabilities. Updated the GridPlan and GridPlanDto classes to replace string notes with a structured PlanNote type, improving note management. Enhanced the GridPlanner class to utilize these new features, ensuring better handling of scheduling conflicts and notes during grid generation. Adjusted related tests to validate the new functionality.
547 lines
22 KiB
C#
547 lines
22 KiB
C#
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.Where(s => s.Start < new TimeOnly(20, 0)),
|
||
slot => Assert.Null(slot.Weekday)
|
||
);
|
||
// Прайм (с 20:00) чередуется по дням недели, поэтому у него дни проставлены явно.
|
||
Assert.All(
|
||
plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)),
|
||
slot => Assert.NotNull(slot.Weekday)
|
||
);
|
||
Assert.Equal(
|
||
5,
|
||
plan.Slots.Count(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5)
|
||
);
|
||
}
|
||
|
||
[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.Text.Contains("Ночной блок 18+"));
|
||
Assert.Contains(
|
||
plan.Slots,
|
||
s => s.SlotKind == SlotKind.Repeat && s.Title == "Ночной блок 18+"
|
||
);
|
||
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
|
||
}
|
||
|
||
[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 SlotLength_FollowsContent_NotProfileConstant()
|
||
{
|
||
// Серии по 25 минут: двухчасовая полоса должна разложиться на блоки, кратные серии,
|
||
// а не отдать десятки минут фону.
|
||
var fixture = new TestDb();
|
||
var series = Playable("Сериал", ShowKind.Series, 60, 25);
|
||
var (channelId, _) = await SeedChannelAsync(
|
||
fixture,
|
||
[series.Show],
|
||
series.Assets,
|
||
[GroupOf("Сериалы", series.Show.Id)]
|
||
);
|
||
|
||
var plan = await PlanAsync(fixture, channelId);
|
||
|
||
var morning = plan.Slots.Where(s => s.Daypart == Daypart.Morning && s.GroupId is not null);
|
||
Assert.All(
|
||
morning,
|
||
slot =>
|
||
{
|
||
// Слот вмещает ровно столько серий, сколько в нём заявлено, и с точностью до пяти минут.
|
||
Assert.Equal(slot.DurationMinutes, slot.BlockValue * 25);
|
||
Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
|
||
}
|
||
);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExhaustedGroup_IsReplacedByRepeatOfPrime()
|
||
{
|
||
// Контента на неделю заведомо мало: сетка должна закрыть день повторами, а не крутить
|
||
// одно и то же по третьему кругу молча.
|
||
var fixture = new TestDb();
|
||
var tiny = Playable("Мало", ShowKind.Series, 3, 25);
|
||
var (channelId, _) = await SeedChannelAsync(
|
||
fixture,
|
||
[tiny.Show],
|
||
tiny.Assets,
|
||
[GroupOf("Мало", tiny.Show.Id)]
|
||
);
|
||
|
||
var plan = await PlanAsync(fixture, channelId);
|
||
|
||
Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.Repeat);
|
||
Assert.Contains(plan.Notes, n => n.Text.Contains("повтор вечернего блока"));
|
||
// Повтор ссылается на вечерний блок прошлых суток — с начала прайма профиля.
|
||
var repeat = plan.Slots.First(s => s.RepeatSource is not null);
|
||
Assert.Equal(1, repeat.RepeatSource!.DaysAgo);
|
||
Assert.Equal(new TimeOnly(18, 0), repeat.RepeatSource.Time);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PrimeRotatesAcrossWeekdays()
|
||
{
|
||
var fixture = new TestDb();
|
||
var first = Films(6, 100);
|
||
var second = Playable("Сериал", ShowKind.Series, 40, 25);
|
||
var (channelId, _) = await SeedChannelAsync(
|
||
fixture,
|
||
[second.Show, .. first.Shows],
|
||
[.. second.Assets, .. first.Assets],
|
||
[
|
||
GroupOf("Кино А", first.Shows[0].Id, first.Shows[1].Id, first.Shows[2].Id),
|
||
GroupOf("Кино Б", first.Shows[3].Id, first.Shows[4].Id, first.Shows[5].Id),
|
||
GroupOf("Сериалы", second.Show.Id),
|
||
]
|
||
);
|
||
|
||
var plan = await PlanAsync(
|
||
fixture,
|
||
channelId,
|
||
GridProfileKind.Mixed,
|
||
GridGenerationMode.Rebuild
|
||
);
|
||
|
||
var prime = plan
|
||
.Slots.Where(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5)
|
||
.ToList();
|
||
|
||
Assert.Equal(5, prime.Count);
|
||
// Всю неделю один и тот же фильм в прайме — это не сетка, а один повторяющийся день.
|
||
Assert.True(prime.Select(s => s.GroupName).Distinct().Count() > 1);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task WeeklyShortage_IsReportedWithNumbers()
|
||
{
|
||
var fixture = new TestDb();
|
||
var series = Playable("Сериал", ShowKind.Series, 4, 25);
|
||
var (channelId, _) = await SeedChannelAsync(
|
||
fixture,
|
||
[series.Show],
|
||
series.Assets,
|
||
[GroupOf("Сериалы", series.Show.Id)]
|
||
);
|
||
|
||
var plan = await PlanAsync(fixture, channelId);
|
||
|
||
// Проверки считаются по тому же бюджету, по которому раскладывалось, — до нажатия кнопки.
|
||
Assert.Contains(plan.Notes, n => n.Text.Contains("выходах в неделю"));
|
||
}
|
||
|
||
[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);
|
||
}
|
||
}
|