Implement template export/import endpoints and enhance grid generation logic
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 3m54s
ci / sonar (push) Successful in 4m40s

Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
This commit is contained in:
Leonid Pershin
2026-07-28 02:15:04 +03:00
parent 7bee84c548
commit f7e7b5f7c3
45 changed files with 2929 additions and 242 deletions
@@ -107,7 +107,9 @@ public class GenerateGridTests
return (channel.Id, template);
}
private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) =>
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(
@@ -149,10 +151,15 @@ public class GenerateGridTests
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
// Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями.
Assert.All(
plan.Slots.Where(s => s.Start < new TimeOnly(20, 0)),
plan.Slots.Where(s => s.Start < new TimeOnly(14, 0)),
slot => Assert.Null(slot.Weekday)
);
// Прайм (с 20:00) чередуется по дням недели, поэтому у него дни проставлены явно.
// Дневной блок и прайм чередуются по дням недели: иначе неделя выглядит одним повторяющимся
// днём. У них дни проставлены явно.
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)
@@ -163,6 +170,35 @@ public class GenerateGridTests
);
}
[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()
{
@@ -322,10 +358,117 @@ public class GenerateGridTests
}
[Fact]
public async Task SlotLength_FollowsContent_NotProfileConstant()
public async Task Rebuild_BuildsSeasonLayer_WithAnnualApplicability()
{
// Серии по 25 минут: двухчасовая полоса должна разложиться на блоки, кратные серии,
// а не отдать десятки минут фону.
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),
new SlotWriter(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(
@@ -342,8 +485,12 @@ public class GenerateGridTests
morning,
slot =>
{
// Слот вмещает ровно столько серий, сколько в нём заявлено, и с точностью до пяти минут.
Assert.Equal(slot.DurationMinutes, slot.BlockValue * 25);
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);
}
);