Introduced the InterstitialGroups service to manage interstitial group logic, preventing their inclusion in slots and fallback groups. Updated the SlotWriter class to utilize this service, ensuring proper validation during slot creation and updates. Enhanced error handling for interstitial groups in the ImportGridCommandHandler and UpdateTemplateCommandHandler, providing warnings instead of failures when interstitials are detected. Updated related tests to verify the correct behavior of these changes, ensuring robust handling of interstitials in the scheduling process.
732 lines
30 KiB
C#
732 lines
30 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, Catalog(db));
|
||
|
||
private static GroupCatalog Catalog(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(14, 0)),
|
||
slot => Assert.Null(slot.Weekday)
|
||
);
|
||
// Дневной блок и прайм чередуются по дням недели: иначе неделя выглядит одним повторяющимся
|
||
// днём. У них дни проставлены явно.
|
||
Assert.All(
|
||
plan.Slots.Where(s => s.Start >= new TimeOnly(14, 0) && s.Start < new TimeOnly(18, 0)),
|
||
slot => Assert.NotNull(slot.Weekday)
|
||
);
|
||
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 NeighbouringSlots_DoNotShareTheSameGroup()
|
||
{
|
||
// Две группы и целые сутки полос: раскладка обязана их чередовать, а не ставить одну
|
||
// и ту же встык — два блока подряд из одной группы читаются как один длинный.
|
||
var fixture = new TestDb();
|
||
var first = Playable("Первый сериал", ShowKind.Series, 60, 25);
|
||
var second = Playable("Второй сериал", ShowKind.Series, 60, 25);
|
||
var (channelId, _) = await SeedChannelAsync(
|
||
fixture,
|
||
[first.Show, second.Show],
|
||
[.. first.Assets, .. second.Assets],
|
||
[GroupOf("Первые", first.Show.Id), GroupOf("Вторые", second.Show.Id)]
|
||
);
|
||
|
||
var plan = await PlanAsync(fixture, channelId);
|
||
|
||
var byDay = plan
|
||
.Slots.Where(s => s.GroupId is not null)
|
||
.GroupBy(s => s.Weekday)
|
||
.Select(g => g.OrderBy(s => s.Start).ToList());
|
||
|
||
foreach (var day in byDay)
|
||
{
|
||
for (var i = 1; i < day.Count; i++)
|
||
Assert.NotEqual(day[i - 1].GroupId, day[i].GroupId);
|
||
}
|
||
}
|
||
|
||
[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 InterstitialGroup_GoesNeitherIntoSlots_NorIntoFallback()
|
||
{
|
||
// Группа рекламы — материал для стыков. По размеру она бьёт любую библиотеку, поэтому
|
||
// раньше выигрывала каждую полосу без предпочтения по типу и становилась аварийной:
|
||
// в эфир уходила полоса роликов, подписанная как программа.
|
||
var fixture = new TestDb();
|
||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||
var films = Films(8, 100);
|
||
var ads = Enumerable
|
||
.Range(0, 60)
|
||
.Select(i => Playable($"Ролик {i}", ShowKind.Interstitial, 1, 1))
|
||
.ToList();
|
||
|
||
var adsGroup = GroupOf("Реклама", [.. ads.Select(a => a.Show.Id)]);
|
||
var (channelId, _) = await SeedChannelAsync(
|
||
fixture,
|
||
[series.Show, .. films.Shows, .. ads.Select(a => a.Show)],
|
||
[.. series.Assets, .. films.Assets, .. ads.SelectMany(a => a.Assets)],
|
||
[
|
||
GroupOf("Сериалы", series.Show.Id),
|
||
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
|
||
adsGroup,
|
||
]
|
||
);
|
||
|
||
var plan = await PlanAsync(
|
||
fixture,
|
||
channelId,
|
||
GridProfileKind.Mixed,
|
||
GridGenerationMode.Rebuild
|
||
);
|
||
|
||
Assert.DoesNotContain(plan.Slots, s => s.GroupId == adsGroup.Id);
|
||
Assert.NotNull(plan.FallbackGroupId);
|
||
Assert.NotEqual(adsGroup.Id, plan.FallbackGroupId);
|
||
}
|
||
|
||
[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 Rebuild_BuildsSeasonLayer_WithAnnualApplicability()
|
||
{
|
||
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)]),
|
||
]
|
||
);
|
||
|
||
await using (var db = fixture.New())
|
||
{
|
||
var result = await new GenerateGridCommandHandler(
|
||
db,
|
||
Planner(db),
|
||
GroupServices.Slots(db)
|
||
).Handle(
|
||
new GenerateGridCommand(
|
||
channelId,
|
||
GridProfileKind.Mixed,
|
||
GridGenerationMode.Rebuild
|
||
),
|
||
CancellationToken.None
|
||
);
|
||
|
||
Assert.True(result.IsSuccess);
|
||
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);
|
||
|
||
var season = GridProfiles.Get(GridProfileKind.Mixed).Season;
|
||
Assert.NotNull(season);
|
||
var layer = Assert.Single(stored.Layers, l => l.Name == season.Name);
|
||
|
||
// Праздничный слой обязан перекрывать и будни, и выходные — иначе 1 января пройдёт как
|
||
// обычное воскресенье.
|
||
var otherPriority = stored.Layers.Where(l => l.Id != layer.Id).Max(l => l.Priority);
|
||
Assert.True(layer.Priority > otherPriority);
|
||
Assert.NotEmpty(layer.Slots);
|
||
|
||
// Слой действует по ежегодному периоду: заводить его заново каждый декабрь не нужно.
|
||
var applicability = LayerApplicability.FromJson(layer.ApplicabilityJson);
|
||
Assert.NotNull(applicability);
|
||
Assert.Equal([season.Range], applicability.AnnualRanges);
|
||
Assert.True(applicability.Covers(new DateOnly(2027, 1, 1)));
|
||
Assert.False(applicability.Covers(new DateOnly(2027, 3, 1)));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Season_DoesNotSpendTheWeeksBudget()
|
||
{
|
||
// Праздничная неделя идёт вместо обычной, а не вдобавок к ней: её слоты не должны попадать
|
||
// в подсчёт закрытого времени, иначе генератор решит, что неделя перекрыта с запасом.
|
||
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,
|
||
GridProfileKind.Mixed,
|
||
GridGenerationMode.Rebuild
|
||
);
|
||
|
||
Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Season);
|
||
Assert.All(
|
||
plan.Slots.Where(s => s.Layer == GridPlanLayer.Season),
|
||
s => Assert.Null(s.Weekday)
|
||
);
|
||
Assert.True(plan.CoveredMinutes <= plan.FreeMinutes);
|
||
}
|
||
|
||
[Fact]
|
||
public void Sitcoms_AirInPairs()
|
||
{
|
||
// Ситком — это пара серий и рекламный шов между парами. Три-четыре подряд читаются как
|
||
// марафон, а марафон у ситкомового канала бывает по выходным, а не каждый будний день.
|
||
var sitcom = GridProfiles.Get(GridProfileKind.Sitcom);
|
||
var blocks = sitcom
|
||
.Weekdays.Where(b => b.PreferKind == ShowKind.Series && b.UnitsPerBlock > 0)
|
||
.ToList();
|
||
|
||
Assert.NotEmpty(blocks);
|
||
Assert.All(blocks, b => Assert.Equal(2, b.UnitsPerBlock));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SlotLength_FollowsContent_RoundedToTheGrid()
|
||
{
|
||
// Серии по 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 =>
|
||
{
|
||
var content = slot.BlockValue * 25;
|
||
// Слот не короче своего контента — иначе блок перелезет через объявленное время.
|
||
Assert.True(slot.DurationMinutes >= content);
|
||
// И не длиннее его больше, чем на шаг решётки: остаток — это шов, а не пустота.
|
||
Assert.True(slot.DurationMinutes - content < 15);
|
||
Assert.Equal(0, slot.DurationMinutes % 15);
|
||
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),
|
||
GroupServices.Slots(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),
|
||
GroupServices.Slots(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);
|
||
}
|
||
}
|