Implement interval intersection and subtraction methods in GridCoverage, update GridPlan and related DTOs for PlanNote integration
ci / build-backend (push) Successful in 2m45s
ci / build-frontend (push) Successful in 1m12s
ci / tests (push) Successful in 2m52s
ci / sonar (push) Successful in 8m13s

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.
This commit is contained in:
Leonid Pershin
2026-07-27 11:36:06 +03:00
parent 062f4aa200
commit 96ff2cc7ab
12 changed files with 742 additions and 176 deletions
@@ -52,7 +52,7 @@ public sealed record GridPlan(
GridProfile Profile, GridProfile Profile,
GridGenerationMode Mode, GridGenerationMode Mode,
IReadOnlyList<PlannedSlot> Slots, IReadOnlyList<PlannedSlot> Slots,
IReadOnlyList<string> Notes, IReadOnlyList<PlanNote> Notes,
int SlotsToRemove, int SlotsToRemove,
Guid? FallbackGroupId, Guid? FallbackGroupId,
string? FallbackGroupName, string? FallbackGroupName,
@@ -32,7 +32,7 @@ public sealed record GridPlanDto(
GridProfileKind Profile, GridProfileKind Profile,
string ProfileName, string ProfileName,
IReadOnlyList<GridPlanSlotDto> Slots, IReadOnlyList<GridPlanSlotDto> Slots,
IReadOnlyList<string> Notes, IReadOnlyList<PlanNote> Notes,
int SlotsToRemove, int SlotsToRemove,
string? FallbackGroupName, string? FallbackGroupName,
int FreeMinutes, int FreeMinutes,
@@ -14,9 +14,12 @@ namespace TeleWave.Application.Programming.Templates.Generate;
/// потом создают слоты. Один и тот же расчёт для обеих операций — иначе показанное и созданное /// потом создают слоты. Один и тот же расчёт для обеих операций — иначе показанное и созданное
/// разошлись бы. /// разошлись бы.
/// ///
/// Подбор намеренно объяснимый: жёстко отсекается неподходящее по рейтингу, дальше баллы за тип /// Что делает раскладку осмысленной, а не просто заполненной:
/// контента, длину единицы и запас серий, минус за повторное использование в тех же сутках. Админ /// длина слота считается из длительности единицы группы (иначе трёхчасовой прайм с одним фильмом
/// должен понимать, почему в прайм встала именно эта группа. /// на 100 минут отдаёт 80 минут фону); ёмкость групп считается на неделю, а не на сутки (слот
/// «каждый день» выходит семь раз); исчерпанный контент закрывается повтором вечернего блока, как
/// на настоящем ТВ; жанр не занимает подряд две полосы. Подбор при этом остаётся объяснимым —
/// жёсткий отсев по рейтингу, дальше баллы.
/// </summary> /// </summary>
public sealed class GridPlanner( public sealed class GridPlanner(
IAppDbContext dbContext, IAppDbContext dbContext,
@@ -36,6 +39,9 @@ public sealed class GridPlanner(
/// <summary>Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.</summary> /// <summary>Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.</summary>
private const int FeatureLengthMinutes = 60; private const int FeatureLengthMinutes = 60;
/// <summary>Дни, по которым идёт ротация в основном слое. Выходные живут своим слоем.</summary>
private static readonly int[] RotationDays = [1, 2, 3, 4, 5];
public async Task<Result<GridPlan>> BuildAsync( public async Task<Result<GridPlan>> BuildAsync(
Guid channelId, Guid channelId,
GridProfileKind profileKind, GridProfileKind profileKind,
@@ -63,7 +69,12 @@ public sealed class GridPlanner(
return Result.Failure<GridPlan>(TemplateErrors.NoGroupsToGenerate); return Result.Failure<GridPlan>(TemplateErrors.NoGroupsToGenerate);
var profile = GridProfiles.Get(profileKind); var profile = GridProfiles.Get(profileKind);
var run = new PlanRun(profile, groups, channel.DayStartTime); var run = new PlanRun(
profile,
groups,
channel.DayStartTime,
PlanningRules.FromJson(template.RulesJson)
);
// Занятым считается время слотов всех слоёв, включая выключенные: место, которое админ // Занятым считается время слотов всех слоёв, включая выключенные: место, которое админ
// отвёл под слот и временно погасил, — не дыра, и застраивать его генератору нечего. // отвёл под слот и временно погасил, — не дыра, и застраивать его генератору нечего.
@@ -84,15 +95,27 @@ public sealed class GridPlanner(
var fallback = groups.MaxBy(g => g.UnitCount); var fallback = groups.MaxBy(g => g.UnitCount);
var needsFallback = template.FallbackGroupId is null && fallback is not null; var needsFallback = template.FallbackGroupId is null && fallback is not null;
var everyDaySpan = mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7; var everyDaySpan = EveryDaySpan(mode, profile);
var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1)); var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1));
var notes = new List<PlanNote>(run.Notes);
notes.AddRange(run.WeeklyChecks());
notes.AddRange(AudienceChecks(slots, run));
if (covered < freeMinutes)
notes.Add(
new PlanNote(
true,
$"После сборки останется незакрытым {Hours(freeMinutes - covered)} недели — "
+ "это место в эфире закроет фон."
)
);
return Result.Success( return Result.Success(
new GridPlan( new GridPlan(
profile, profile,
mode, mode,
slots, slots,
run.Notes, notes,
mode == GridGenerationMode.Rebuild ? existing.Count : 0, mode == GridGenerationMode.Rebuild ? existing.Count : 0,
needsFallback ? fallback!.Id : null, needsFallback ? fallback!.Id : null,
needsFallback ? fallback!.Name : null, needsFallback ? fallback!.Name : null,
@@ -102,26 +125,44 @@ public sealed class GridPlanner(
); );
} }
/// <summary>Сколько дней в неделю действует слот «каждый день»: выходные могут быть перекрыты.</summary>
private static int EveryDaySpan(GridGenerationMode mode, GridProfile profile) =>
mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7;
private static string Hours(int minutes) =>
minutes >= 60 ? $"{minutes / 60} ч {minutes % 60:00} мин" : $"{minutes} мин";
/// <summary> /// <summary>
/// Неделя с нуля: будни ложатся слотами «каждый день» в основной слой, выходные — отдельным /// Неделя с нуля: будни ложатся слотами «каждый день» в основной слой, выходные — отдельным
/// слоем поверх. Слой выходных перекрывает будний целиком, поэтому расхождение по субботе /// слоем поверх. Слой выходных перекрывает будний целиком, поэтому расхождение по субботе
/// и воскресенью получается без дублирования всей недели по дням. /// и воскресенью получается без дублирования всей недели по дням.
///
/// Полосы с ротацией — исключение: они раскладываются по дням недели поимённо, иначе в прайме
/// всю неделю стоял бы один и тот же фильм.
/// </summary> /// </summary>
private static List<PlannedSlot> BuildWeek(PlanRun run) private static List<PlannedSlot> BuildWeek(PlanRun run)
{ {
var wholeDay = new List<(int From, int To)> { (0, GridCoverage.MinutesInDay) }; var wholeDay = new List<(int From, int To)> { (0, GridCoverage.MinutesInDay) };
var slots = FillDay( var plain = run.Profile.Weekdays.Where(b => !b.Rotate).ToList();
run.Profile.Weekdays, var rotating = run.Profile.Weekdays.Where(b => b.Rotate).ToList();
wholeDay,
new SlotTarget(null, GridPlanLayer.Main), run.BeginDay();
run var slots = FillDay(plain, wholeDay, new SlotTarget(null, GridPlanLayer.Main), run);
); run.SnapshotDay();
foreach (var weekday in RotationDays)
{
run.BeginDay(fromBaseline: true);
var target = new SlotTarget(weekday, GridPlanLayer.Main);
slots.AddRange(FillDay(rotating, wholeDay, target, run));
}
if (run.Profile.Weekend.Count == 0) if (run.Profile.Weekend.Count == 0)
return slots; return slots;
foreach (var weekday in new[] { 6, 0 }) foreach (var weekday in new[] { 6, 0 })
{ {
run.BeginDay();
var target = new SlotTarget(weekday, GridPlanLayer.Weekend); var target = new SlotTarget(weekday, GridPlanLayer.Weekend);
slots.AddRange(FillDay(run.Profile.Weekend, wholeDay, target, run)); slots.AddRange(FillDay(run.Profile.Weekend, wholeDay, target, run));
} }
@@ -140,34 +181,48 @@ public sealed class GridPlanner(
/// </summary> /// </summary>
private static List<PlannedSlot> FillExistingGaps(PlanRun run, IReadOnlyList<Slot> existing) private static List<PlannedSlot> FillExistingGaps(PlanRun run, IReadOnlyList<Slot> existing)
{ {
var entries = new List<(int Weekday, PlannedSlot Slot)>(); var perDay = GridCoverage.Week.ToDictionary(
day => day,
day => (IReadOnlyList<(int From, int To)>)GridCoverage.Gaps(existing, day, run.DayStart)
);
// Дыры, общие для всей недели, закрываются слотами «каждый день»: так сетка остаётся
// читаемой. Полосы с ротацией сюда не идут — им нужен свой контент на каждый день.
run.BeginDay();
var common = FillDay(
run.Profile.Weekdays.Where(b => !b.Rotate).ToList(),
GridCoverage.Intersect(perDay.Values),
new SlotTarget(null, GridPlanLayer.Main),
run
);
run.SnapshotDay();
var taken = Spans(common, run.DayStart);
var slots = new List<PlannedSlot>(common);
foreach (var weekday in GridCoverage.Week) foreach (var weekday in GridCoverage.Week)
{ {
var gaps = GridCoverage.Gaps(existing, weekday, run.DayStart); run.BeginDay(fromBaseline: true);
var rest = GridCoverage.Subtract(perDay[weekday], taken);
var target = new SlotTarget(weekday, GridPlanLayer.Main); var target = new SlotTarget(weekday, GridPlanLayer.Main);
foreach (var slot in FillDay(run.Profile.Weekdays, gaps, target, run)) slots.AddRange(FillDay(run.Profile.Weekdays, rest, target, run));
entries.Add((weekday, slot));
} }
return Collapse(entries); return slots;
} }
private static List<PlannedSlot> Collapse(List<(int Weekday, PlannedSlot Slot)> entries) /// <summary>Занятые слотами интервалы суток — по ним считается, что дню ещё осталось закрыть.</summary>
private static List<(int From, int To)> Spans(
IEnumerable<PlannedSlot> slots,
TimeOnly dayStart
) =>
[
.. slots.Select(s =>
{ {
var result = new List<PlannedSlot>(); var from = GridCoverage.OffsetInDay(s.Start, dayStart);
return (From: from, To: from + s.DurationMinutes);
foreach (var same in entries.GroupBy(e => e.Slot with { Weekday = null })) }),
{ ];
var days = same.Select(e => e.Weekday).Distinct().Count();
if (days == GridCoverage.Week.Length)
result.Add(same.Key);
else
result.AddRange(same.Select(e => e.Slot));
}
return result;
}
/// <summary>Раскладывает свободные интервалы одних суток по полосам профиля.</summary> /// <summary>Раскладывает свободные интервалы одних суток по полосам профиля.</summary>
private static List<PlannedSlot> FillDay( private static List<PlannedSlot> FillDay(
@@ -177,9 +232,6 @@ public sealed class GridPlanner(
PlanRun run PlanRun run
) )
{ {
// Счётчик использований живёт внутри суток: группа, занятая утром, уступает вечером другой,
// но на следующий день снова доступна — иначе раскладка зависела бы от порядка дней.
var used = new Dictionary<Guid, int>();
var slots = new List<PlannedSlot>(); var slots = new List<PlannedSlot>();
// Что реально предстоит застроить: полосы профиля, пересечённые со свободным временем. // Что реально предстоит застроить: полосы профиля, пересечённые со свободным временем.
@@ -190,16 +242,21 @@ public sealed class GridPlanner(
let start = Math.Max(piece.From, gap.From) let start = Math.Max(piece.From, gap.From)
let end = Math.Min(piece.To, gap.To) let end = Math.Min(piece.To, gap.To)
where end - start >= MinSlotMinutes where end - start >= MinSlotMinutes
orderby start
select (Band: band, From: start, To: end); select (Band: band, From: start, To: end);
foreach (var piece in pieces) foreach (var piece in pieces)
slots.AddRange(FillPiece(piece.Band, piece.From, piece.To, target, run, used)); slots.AddRange(FillPiece(piece.Band, piece.From, piece.To, target, run));
return [.. slots.OrderBy(s => GridCoverage.OffsetInDay(s.Start, run.DayStart))]; return [.. slots.OrderBy(s => GridCoverage.OffsetInDay(s.Start, run.DayStart))];
} }
/// <summary>Куда ложится слот: день недели и слой. Пара ходит вместе через всю раскладку.</summary> /// <summary>Куда ложится слот: день недели и слой. Пара ходит вместе через всю раскладку.</summary>
private readonly record struct SlotTarget(int? Weekday, GridPlanLayer Layer); private readonly record struct SlotTarget(int? Weekday, GridPlanLayer Layer)
{
/// <summary>Сколько раз слот выходит за неделю: без дня недели — каждый день.</summary>
public int AiringsPerWeek => Weekday is null ? 7 : 1;
}
/// <summary> /// <summary>
/// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому /// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому
@@ -220,48 +277,32 @@ public sealed class GridPlanner(
yield return (0, to - GridCoverage.MinutesInDay); yield return (0, to - GridCoverage.MinutesInDay);
} }
/// <summary>Режет кусок полосы на слоты целевой длины и подбирает каждому наполнение.</summary> /// <summary>
/// Режет кусок полосы на слоты. Длину слота задаёт не профиль, а контент: блок из N единиц
/// занимает столько, сколько эти единицы идут. Профильный <c>BlockMinutes</c> остаётся целью
/// для полос, которые меряются временем, а не штуками.
/// </summary>
private static IEnumerable<PlannedSlot> FillPiece( private static IEnumerable<PlannedSlot> FillPiece(
GridBand band, GridBand band,
int from, int from,
int to, int to,
SlotTarget target, SlotTarget target,
PlanRun run, PlanRun run
Dictionary<Guid, int> used
) )
{ {
var length = to - from;
var count =
band.BlockMinutes <= 0
? 1
: Math.Max(
1,
(int)
Math.Round(
length / (double)band.BlockMinutes,
MidpointRounding.AwayFromZero
)
);
var step = Math.Max(MinSlotMinutes, length / count / SlotStepMinutes * SlotStepMinutes);
var cursor = from; var cursor = from;
while (cursor < to) while (to - cursor >= MinSlotMinutes)
{ {
var duration = Math.Min(step, to - cursor);
// Хвост короче минимального слота приклеиваем к текущему: два слота по семь минут
// читаются как ошибка сетки, а не как решение.
if (to - cursor - duration < MinSlotMinutes)
duration = to - cursor;
// Якорь — только на первом слоте прайма: жёсткий старт у каждого слота подряд заставил // Якорь — только на первом слоте прайма: жёсткий старт у каждого слота подряд заставил
// бы генератор крутить фон перед каждым из них. // бы генератор крутить фон перед каждым из них.
var anchor = cursor == from && band.Daypart == Daypart.Prime; var anchor = cursor == from && band.Daypart == Daypart.Prime;
var slot = Compose(band, target, cursor, duration, anchor, run, used); var slot = Compose(band, target, cursor, to - cursor, anchor, run);
if (slot is not null) if (slot is null)
yield return slot; yield break;
cursor += duration; yield return slot;
cursor += slot.DurationMinutes;
} }
} }
@@ -269,53 +310,81 @@ public sealed class GridPlanner(
GridBand band, GridBand band,
SlotTarget target, SlotTarget target,
int offset, int offset,
int duration, int available,
bool anchor, bool anchor,
PlanRun run, PlanRun run
Dictionary<Guid, int> used
) )
{ {
var start = GridCoverage.AtOffset(offset, run.DayStart);
if (band.SlotKind == SlotKind.SignOff) if (band.SlotKind == SlotKind.SignOff)
return New(band.Title, null, SlotBlockMode.FillSlot, 1, null, null); return Build(band.Title, null, available, SlotBlockMode.FillSlot, 1, null, null);
if (band.SlotKind == SlotKind.Repeat) if (band.SlotKind == SlotKind.Repeat)
return New( return RepeatSlot(band.Title, band.RepeatFrom, available);
var group = Pick(band, target, run, available);
// Ставить нечего — либо подходящей группы нет вовсе, либо свежий контент на неделю кончился.
// И то и другое закрывается повтором вечернего блока, как на настоящем ТВ: крутить по
// третьему кругу одно и то же хуже — зритель видит то же самое, но без подписи «повтор».
if (group is null || run.Exhausted(group))
{
if (!CanRepeat(band, run))
{
run.Note(Unfillable(band, group), isError: group is null);
return group is null ? null : ContentSlot(group);
}
run.Note(
group is null
? $"Для полосы «{band.Title}» подходящей группы нет — её закрывает повтор "
+ "вечернего блока."
: $"Полосу «{band.Title}» закрывает повтор вечернего блока: свежего контента "
+ "на неделю не хватает."
);
return RepeatSlot(
band.Title, band.Title,
run.PrimeStart!.Value,
Math.Min(available, RepeatMinutes(band))
);
}
return ContentSlot(group);
PlannedSlot ContentSlot(GroupCandidate picked)
{
var (duration, units) = Measure(band, picked, available);
run.Reserve(picked, units, duration, target.AiringsPerWeek);
return Build(
picked.Name,
picked.Id,
duration,
band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot,
Math.Max(1, units),
new SlotStrategy(
band.Strategy,
RestartOnEnd: true,
CooldownDays: band.CooldownDays
),
null
);
}
PlannedSlot RepeatSlot(string title, TimeOnly repeatFrom, int minutes) =>
Build(
title,
null, null,
minutes,
SlotBlockMode.FillSlot, SlotBlockMode.FillSlot,
1, 1,
null, null,
new RepeatSource(band.RepeatDaysAgo, band.RepeatFrom, duration) new RepeatSource(band.RepeatDaysAgo, repeatFrom, minutes)
); );
var group = Pick(band, run, used); PlannedSlot Build(
if (group is null)
{
run.Note(
$"Полоса «{band.Title}» ({band.From:HH\\:mm}{band.To:HH\\:mm}): подходящей группы "
+ "нет — интервал останется незакрытым."
);
return null;
}
used[group.Id] = used.GetValueOrDefault(group.Id) + 1;
run.WarnIfThin(band, group, target.Weekday);
var blockMode = band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot;
return New(
group.Name,
group.Id,
blockMode,
Math.Max(1, band.UnitsPerBlock),
new SlotStrategy(band.Strategy, RestartOnEnd: true, CooldownDays: band.CooldownDays),
null
);
PlannedSlot New(
string title, string title,
Guid? groupId, Guid? groupId,
int duration,
SlotBlockMode blockMode, SlotBlockMode blockMode,
int blockValue, int blockValue,
SlotStrategy? strategy, SlotStrategy? strategy,
@@ -324,11 +393,11 @@ public sealed class GridPlanner(
new( new(
target.Layer, target.Layer,
target.Weekday, target.Weekday,
start, GridCoverage.AtOffset(offset, run.DayStart),
duration, duration,
title, title,
band.Daypart, band.Daypart,
band.SlotKind, repeat is null ? band.SlotKind : SlotKind.Repeat,
groupId, groupId,
groupId is null ? null : title, groupId is null ? null : title,
blockMode, blockMode,
@@ -339,16 +408,91 @@ public sealed class GridPlanner(
); );
} }
/// <summary>Почему полоса осталась без свежего контента — текст для предпросмотра.</summary>
private static string Unfillable(GridBand band, GroupCandidate? group) =>
group is null
? $"Полоса «{band.Title}» ({band.From:HH\\:mm}{band.To:HH\\:mm}): подходящей группы "
+ "нет — интервал останется незакрытым."
: $"В полосе «{band.Title}» контент пойдёт по второму кругу: свежего на неделю не хватает.";
/// <summary>Повторять вечерний блок можно везде, кроме самого прайма, и только если он есть.</summary>
private static bool CanRepeat(GridBand band, PlanRun run) =>
band.Daypart != Daypart.Prime && run.PrimeStart is not null;
private static int RepeatMinutes(GridBand band) =>
band.BlockMinutes > 0 ? band.BlockMinutes : 120;
/// <summary>
/// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти
/// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается
/// к текущему, чтобы в сетке не появлялось огрызков.
/// </summary>
private static (int Duration, int Units) Measure(
GridBand band,
GroupCandidate group,
int available
)
{
var unit = UnitMinutes(group);
// Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком.
if (band.UnitsPerBlock <= 0 || unit == 0)
return (Fit(band.BlockMinutes > 0 ? band.BlockMinutes : available, available), 0);
var duration = Fit(Math.Max(1, band.UnitsPerBlock) * unit, available);
// Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло,
// решает контент.
return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit)));
}
/// <summary>
/// Длина слота: желаемая, но не больше свободного места. Остаток короче минимального слота
/// приклеивается к текущему — огрызок в сетке читается как ошибка, а не как решение. Остаток
/// побольше достанется следующему слоту, и под него подберут группу подходящей длины.
/// </summary>
private static int Fit(int wanted, int available)
{
var duration = Math.Min(Round(wanted), available);
return available - duration < MinSlotMinutes
? available
: Math.Max(duration, MinSlotMinutes);
}
private static int Round(int minutes) =>
Math.Max(SlotStepMinutes, minutes / SlotStepMinutes * SlotStepMinutes);
/// <summary>Средняя длина единицы группы, округлённая до пяти минут (0 — длительностей нет).</summary>
private static int UnitMinutes(GroupCandidate group) =>
group.AverageUnitMinutes <= 0 ? 0 : Round((int)Math.Round(group.AverageUnitMinutes));
/// <summary> /// <summary>
/// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной /// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной
/// взрослый блок без взрослого содержимого не имеет смысла. Остальное — баллы. /// взрослый блок без взрослого содержимого не имеет смысла. Остальное — баллы.
///
/// У полосы с ротацией берётся не лучший кандидат, а следующий по кругу: в прайме понедельника
/// и вторника должно стоять разное, иначе неделя выглядит одним повторяющимся днём.
/// </summary> /// </summary>
private static GroupCandidate? Pick(GridBand band, PlanRun run, Dictionary<Guid, int> used) => private static GroupCandidate? Pick(
run GridBand band,
SlotTarget target,
PlanRun run,
int available
)
{
var ranked = run
.Groups.Where(g => Allowed(band, g)) .Groups.Where(g => Allowed(band, g))
.OrderByDescending(g => Score(band, g, used)) .OrderByDescending(g => Score(band, g, run, available))
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase) .ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)
.FirstOrDefault(); .ToList();
if (ranked.Count == 0)
return null;
if (!band.Rotate || target.Weekday is not { } weekday)
return ranked[0];
var day = Array.IndexOf(GridCoverage.Week, weekday);
return ranked[Math.Max(0, day) % ranked.Count];
}
private static bool Allowed(GridBand band, GroupCandidate group) private static bool Allowed(GridBand band, GroupCandidate group)
{ {
@@ -361,10 +505,15 @@ public sealed class GridPlanner(
|| (group.Strictest is { } audience && audience >= min); || (group.Strictest is { } audience && audience >= min);
} }
private static int Score(GridBand band, GroupCandidate group, Dictionary<Guid, int> used) private static int Score(GridBand band, GroupCandidate group, PlanRun run, int available)
{ {
var score = Math.Min(group.UnitCount, 60); var score = Math.Min(group.UnitCount, 60);
// Единица длиннее оставшегося места — до ближайшего якоря она не влезет, и место закроет
// фон. Такой кандидат хуже любого, который поместится.
if (UnitMinutes(group) > available)
score -= 80;
if (band.PreferKind is { } kind) if (band.PreferKind is { } kind)
score += group.DominantKind == kind ? 100 : -40; score += group.DominantKind == kind ? 100 : -40;
@@ -373,8 +522,52 @@ public sealed class GridPlanner(
if (band.PreferKind is not null && wantsFeature == isFeature) if (band.PreferKind is not null && wantsFeature == isFeature)
score += 30; score += 30;
// Ёмкость на неделю: группа, уже разобранная под другие слоты, уступает нетронутой.
if (run.Exhausted(group))
score -= 70;
// Два блока одного жанра подряд читаются как один длинный — канал теряет ритм.
if (run.FollowsSameGenre(group))
score -= 25;
if (run.Rules?.MaxGenreSharePercent is { } maxShare && run.GenreShare(group) >= maxShare)
score -= 60;
// Одна и та же группа во всех полосах суток — формально валидная, но бессмысленная сетка. // Одна и та же группа во всех полосах суток — формально валидная, но бессмысленная сетка.
return score - used.GetValueOrDefault(group.Id) * 50; return score - run.UsedToday(group.Id) * 50;
}
/// <summary>
/// Конфликты с детским временем: слот стоит в окне, где потолок строже, чем контент группы.
/// Проверка та же, что делают проверки сетки, но по плану — до того, как слоты созданы.
/// </summary>
private static IEnumerable<PlanNote> AudienceChecks(
IReadOnlyList<PlannedSlot> slots,
PlanRun run
)
{
if (run.Rules is not { } rules)
yield break;
var seen = new HashSet<(Guid, ShowAudience)>();
foreach (var slot in slots)
{
if (slot.GroupId is not { } groupId)
continue;
if (run.Groups.FirstOrDefault(g => g.Id == groupId) is not { Strictest: { } audience })
continue;
if (rules.AudienceAt(slot.Start) is not { } max || audience <= max)
continue;
if (!seen.Add((groupId, max)))
continue;
yield return new PlanNote(
false,
$"В группе «{slot.Title}» есть контент категории «{audience}», а слот "
+ $"{slot.Start:HH\\:mm} стоит во времени не строже «{max}»."
);
}
} }
private async Task<List<GroupCandidate>> LoadGroupsAsync(CancellationToken cancellationToken) private async Task<List<GroupCandidate>> LoadGroupsAsync(CancellationToken cancellationToken)
@@ -398,14 +591,57 @@ public sealed class GridPlanner(
composition.Select(e => (e.Kind, e.Id)), composition.Select(e => (e.Kind, e.Id)),
cancellationToken cancellationToken
); );
var genreId = await PrimaryGenreAsync(composition, cancellationToken);
if (Describe(group, composition, info) is { } candidate) if (Describe(group, composition, info, genreId) is { } candidate)
candidates.Add(candidate); candidates.Add(candidate);
} }
return candidates; return candidates;
} }
/// <summary>
/// Основной жанр группы — самый частый среди её шоу. Нужен, чтобы не ставить подряд две полосы
/// одного жанра и не выйти за потолок доли жанра из правил канала.
/// </summary>
private async Task<Guid?> PrimaryGenreAsync(
IReadOnlyList<ResolvedElement> composition,
CancellationToken cancellationToken
)
{
var showIds = composition
.Where(e => e.Kind == GroupElementKind.Show)
.Select(e => e.Id)
.ToList();
var collectionIds = composition
.Where(e => e.Kind == GroupElementKind.Collection)
.Select(e => e.Id)
.ToList();
if (collectionIds.Count > 0)
{
var parts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => i.ShowId)
.ToListAsync(cancellationToken);
showIds.AddRange(parts);
}
if (showIds.Count == 0)
return null;
var genres = await dbContext
.ShowGenres.AsNoTracking()
.Where(g => showIds.Contains(g.ShowId) && g.IsPrimary)
.Select(g => g.GenreId)
.ToListAsync(cancellationToken);
return genres.Count == 0
? null
: genres.GroupBy(id => id).OrderByDescending(g => g.Count()).First().Key;
}
/// <summary> /// <summary>
/// Сводит вычисленный состав к тому, что нужно раскладке: сколько эфира группа даёт, какой она /// Сводит вычисленный состав к тому, что нужно раскладке: сколько эфира группа даёт, какой она
/// в основном природы и насколько строгий в ней контент. Группа без готовых единиц не годится /// в основном природы и насколько строгий в ней контент. Группа без готовых единиц не годится
@@ -414,7 +650,8 @@ public sealed class GridPlanner(
private static GroupCandidate? Describe( private static GroupCandidate? Describe(
Group group, Group group,
IReadOnlyList<ResolvedElement> composition, IReadOnlyList<ResolvedElement> composition,
IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info,
Guid? primaryGenreId
) )
{ {
var resolved = composition var resolved = composition
@@ -429,63 +666,16 @@ public sealed class GridPlanner(
// У коллекции своего типа нет: франшиза — это почти всегда полнометражки. // У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount); var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount);
var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration); var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration);
var strictest = resolved.Max(r => r.Audience);
return new GroupCandidate( return new GroupCandidate(
group.Id, group.Id,
group.Name, group.Name,
composition.Count,
units, units,
strictest, resolved.Max(r => r.Audience),
seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single, seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single,
duration.TotalMinutes / units duration.TotalMinutes / units,
primaryGenreId
); );
} }
/// <summary>Группа глазами раскладки: чем её можно закрыть и насколько её хватит.</summary>
private sealed record GroupCandidate(
Guid Id,
string Name,
int UnitCount,
ShowAudience? Strictest,
ShowKind DominantKind,
double AverageUnitMinutes
);
/// <summary>Общее состояние одного разбора: профиль, кандидаты и накопленные замечания.</summary>
private sealed class PlanRun(
GridProfile profile,
IReadOnlyList<GroupCandidate> groups,
TimeOnly dayStart
)
{
private readonly List<string> _notes = [];
private readonly HashSet<string> _seen = [];
public GridProfile Profile { get; } = profile;
public IReadOnlyList<GroupCandidate> Groups { get; } = groups;
public TimeOnly DayStart { get; } = dayStart;
public IReadOnlyList<string> Notes => _notes;
public void Note(string text)
{
if (_seen.Add(text))
_notes.Add(text);
}
/// <summary>
/// Предупреждение о нехватке состава — то же, о чём потом скажут проверки сетки. Показать
/// это до создания слотов честнее, чем дать админу нажать кнопку и получить список ошибок.
/// </summary>
public void WarnIfThin(GridBand band, GroupCandidate group, int? weekday)
{
var perWeek = (weekday is null ? 7 : 1) * Math.Max(1, band.UnitsPerBlock);
if (group.UnitCount >= perWeek)
return;
Note(
$"В группе «{group.Name}» {group.UnitCount} единиц при {perWeek} выходах в неделю "
+ $"в полосе «{band.Title}» — повторы пойдут чаще, чем раз в неделю."
);
}
}
} }
@@ -38,7 +38,9 @@ public sealed record GridBand(
int CooldownDays = 0, int CooldownDays = 0,
SlotKind SlotKind = SlotKind.Content, SlotKind SlotKind = SlotKind.Content,
int RepeatDaysAgo = 1, int RepeatDaysAgo = 1,
TimeOnly RepeatFrom = default TimeOnly RepeatFrom = default,
/// <summary>Раскладывать по дням недели с чередованием групп — для прайма, где нужен разный контент.</summary>
bool Rotate = false
) )
{ {
/// <summary>Длина полосы в минутах; полоса через полночь считается вперёд, а не назад.</summary> /// <summary>Длина полосы в минутах; полоса через полночь считается вперёд, а не назад.</summary>
@@ -105,7 +105,8 @@ public static class GridProfiles
BlockMinutes: 150, BlockMinutes: 150,
UnitsPerBlock: 1, UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown, Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 14 CooldownDays: 14,
Rotate: true
), ),
AdultNight(At(23), At(2)), AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240), new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240),
@@ -151,7 +152,8 @@ public static class GridProfiles
BlockMinutes: 150, BlockMinutes: 150,
UnitsPerBlock: 1, UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown, Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 21 CooldownDays: 21,
Rotate: true
), ),
AdultNight(At(23), At(2)), AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240), new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240),
@@ -291,7 +293,8 @@ public static class GridProfiles
BlockMinutes: 120, BlockMinutes: 120,
UnitsPerBlock: 1, UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown, Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 14 CooldownDays: 14,
Rotate: true
), ),
AdultNight(At(0), At(6)), AdultNight(At(0), At(6)),
], ],
@@ -437,7 +440,8 @@ public static class GridProfiles
BlockMinutes: 150, BlockMinutes: 150,
UnitsPerBlock: 1, UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown, Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 30 CooldownDays: 30,
Rotate: true
), ),
new( new(
At(23), At(23),
@@ -0,0 +1,147 @@
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Generate;
/// <summary>Группа глазами раскладки: чем её можно закрыть, надолго ли хватит и какого она жанра.</summary>
/// <param name="ElementCount">Позиций в составе — по ним считается «повторы чаще раза в неделю».</param>
/// <param name="UnitCount">Единиц воспроизведения: столько эфира группа даёт без повторов.</param>
public sealed record GroupCandidate(
Guid Id,
string Name,
int ElementCount,
int UnitCount,
ShowAudience? Strictest,
ShowKind DominantKind,
double AverageUnitMinutes,
Guid? PrimaryGenreId
);
/// <summary>Замечание к плану: <c>IsError</c> — то, что после сборки останется дырой в эфире.</summary>
public sealed record PlanNote(bool IsError, string Text);
/// <summary>
/// Состояние одного разбора: кандидаты, бюджет недели, дневные счётчики и накопленные замечания.
///
/// Бюджет недельный намеренно: слот «каждый день» выходит семь раз, и группа из двенадцати серий
/// закрывает им максимум полторы недели. Считать это внутри суток бессмысленно — там всё всегда
/// сходится, а на неделе не сходится ничего.
/// </summary>
public sealed class PlanRun(
GridProfile profile,
IReadOnlyList<GroupCandidate> groups,
TimeOnly dayStart,
PlanningRules? rules
)
{
private readonly Dictionary<Guid, int> _weekUnits = [];
private readonly Dictionary<Guid, int> _weekAirings = [];
private Dictionary<Guid, int> _dayUsed = [];
private Dictionary<Guid, int> _dayGenreMinutes = [];
private Dictionary<Guid, int> _baselineUsed = [];
private Dictionary<Guid, int> _baselineGenre = [];
private int _dayMinutes;
private int _baselineMinutes;
private Guid? _lastGenreId;
private readonly List<PlanNote> _notes = [];
private readonly HashSet<string> _seen = [];
public GridProfile Profile { get; } = profile;
public IReadOnlyList<GroupCandidate> Groups { get; } = groups;
public TimeOnly DayStart { get; } = dayStart;
public PlanningRules? Rules { get; } = rules;
/// <summary>Начало прайма профиля — точка, на которую ссылаются утренние повторы.</summary>
public TimeOnly? PrimeStart =>
Profile.Weekdays.FirstOrDefault(b => b.Daypart == Daypart.Prime)?.From;
public IReadOnlyList<PlanNote> Notes => _notes;
public void Note(string text, bool isError = false)
{
if (_seen.Add(text))
_notes.Add(new PlanNote(isError, text));
}
// ── Сутки ────────────────────────────────────────────────────────────
/// <summary>Начать сутки. С <paramref name="fromBaseline"/> — продолжить с отложенного среза.</summary>
public void BeginDay(bool fromBaseline = false)
{
_dayUsed = fromBaseline ? new Dictionary<Guid, int>(_baselineUsed) : [];
_dayGenreMinutes = fromBaseline ? new Dictionary<Guid, int>(_baselineGenre) : [];
_dayMinutes = fromBaseline ? _baselineMinutes : 0;
_lastGenreId = null;
}
/// <summary>
/// Запомнить состояние суток. Нужно, когда день строится в два прохода: сначала общие полосы
/// «каждый день», потом ротация по дням недели — второй проход обязан видеть первый, иначе
/// вечером встанет то же, что стояло утром.
/// </summary>
public void SnapshotDay()
{
_baselineUsed = new Dictionary<Guid, int>(_dayUsed);
_baselineGenre = new Dictionary<Guid, int>(_dayGenreMinutes);
_baselineMinutes = _dayMinutes;
}
public int UsedToday(Guid groupId) => _dayUsed.GetValueOrDefault(groupId);
/// <summary>Доля суток, уже отданная жанру группы, в процентах.</summary>
public int GenreShare(GroupCandidate group)
{
if (group.PrimaryGenreId is not { } genreId || _dayMinutes == 0)
return 0;
return 100 * _dayGenreMinutes.GetValueOrDefault(genreId) / _dayMinutes;
}
public bool FollowsSameGenre(GroupCandidate group) =>
group.PrimaryGenreId is { } genreId && _lastGenreId == genreId;
// ── Неделя ───────────────────────────────────────────────────────────
/// <summary>Единиц группы, ещё не разобранных под слоты этой недели (может уйти в минус).</summary>
public int RemainingUnits(GroupCandidate group) =>
group.UnitCount - _weekUnits.GetValueOrDefault(group.Id);
/// <summary>Ёмкость исчерпана: дальше группа пойдёт по второму кругу.</summary>
public bool Exhausted(GroupCandidate group) => RemainingUnits(group) <= 0;
/// <summary>Учесть поставленный слот: единицы недели, выходы, минуты суток и жанр.</summary>
public void Reserve(GroupCandidate group, int units, int minutes, int airingsPerWeek)
{
_weekUnits[group.Id] = _weekUnits.GetValueOrDefault(group.Id) + units * airingsPerWeek;
_weekAirings[group.Id] = _weekAirings.GetValueOrDefault(group.Id) + airingsPerWeek;
_dayUsed[group.Id] = _dayUsed.GetValueOrDefault(group.Id) + 1;
_dayMinutes += minutes;
if (group.PrimaryGenreId is not { } genreId)
return;
_dayGenreMinutes[genreId] = _dayGenreMinutes.GetValueOrDefault(genreId) + minutes;
_lastGenreId = genreId;
}
/// <summary>
/// Что останется не так после сборки. Считается по тому же бюджету, по которому раскладывалось,
/// поэтому цифры точные: «84 выхода при 40 позициях» — это ровно то, что потом скажут проверки
/// сетки, но сказанное до нажатия кнопки.
/// </summary>
public IEnumerable<PlanNote> WeeklyChecks()
{
foreach (var (groupId, airings) in _weekAirings.OrderBy(p => p.Value))
{
var group = Groups.FirstOrDefault(g => g.Id == groupId);
if (group is null || group.ElementCount >= airings)
continue;
yield return new PlanNote(
false,
$"В группе «{group.Name}» {group.ElementCount} позиций при {airings} выходах "
+ "в неделю — повторы пойдут чаще, чем раз в неделю."
);
}
}
}
@@ -69,4 +69,72 @@ public static class GridCoverage
return gaps; return gaps;
} }
/// <summary>Интервалы, свободные во всех наборах сразу.</summary>
public static List<(int From, int To)> Intersect(
IEnumerable<IReadOnlyList<(int From, int To)>> sets
)
{
var mask = new bool[MinutesInDay];
Array.Fill(mask, true);
foreach (var set in sets)
{
var current = ToMask(set);
for (var minute = 0; minute < MinutesInDay; minute++)
mask[minute] &= current[minute];
}
return FromMask(mask);
}
/// <summary>Что останется от интервалов, если вычесть из них другие.</summary>
public static List<(int From, int To)> Subtract(
IReadOnlyList<(int From, int To)> from,
IReadOnlyList<(int From, int To)> minus
)
{
var mask = ToMask(from);
var cut = ToMask(minus);
for (var minute = 0; minute < MinutesInDay; minute++)
mask[minute] &= !cut[minute];
return FromMask(mask);
}
/// <summary>
/// Интервалы считаются поминутной маской: суток всего 1440 минут, а очевидная правильность
/// здесь дороже экономии — на этих операциях держится то, какие куски сетки застраиваются.
/// </summary>
private static bool[] ToMask(IReadOnlyList<(int From, int To)> intervals)
{
var mask = new bool[MinutesInDay];
foreach (var (from, to) in intervals)
for (var minute = Math.Max(0, from); minute < Math.Min(to, MinutesInDay); minute++)
mask[minute] = true;
return mask;
}
private static List<(int From, int To)> FromMask(bool[] mask)
{
var result = new List<(int From, int To)>();
var start = -1;
for (var minute = 0; minute <= MinutesInDay; minute++)
{
var covered = minute < MinutesInDay && mask[minute];
if (covered && start < 0)
start = minute;
else if (!covered && start >= 0)
{
result.Add((start, minute));
start = -1;
}
}
return result;
}
} }
@@ -147,8 +147,20 @@ public class GenerateGridTests
// Пустая сетка свободна всю неделю, и профиль обязан закрыть её целиком. // Пустая сетка свободна всю неделю, и профиль обязан закрыть её целиком.
Assert.Equal(7 * GridCoverage.MinutesInDay, plan.FreeMinutes); Assert.Equal(7 * GridCoverage.MinutesInDay, plan.FreeMinutes);
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes); Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
// Свободно во все семь дней — значит слоты идут «каждый день», а не семью копиями. // Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями.
Assert.All(plan.Slots, slot => Assert.Null(slot.Weekday)); 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] [Fact]
@@ -215,8 +227,13 @@ public class GenerateGridTests
var plan = await PlanAsync(fixture, channelId); var plan = await PlanAsync(fixture, channelId);
Assert.Contains(plan.Notes, note => note.Contains("Ночной блок 18+")); // Взрослого контента нет — ночной блок закрывается повтором вечернего, а не остаётся дырой.
Assert.True(plan.CoveredMinutes < plan.FreeMinutes); 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] [Fact]
@@ -304,6 +321,109 @@ public class GenerateGridTests
Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Main && s.Weekday is null); 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] [Fact]
public async Task Fails_WhenNothingToPlanFrom() public async Task Fails_WhenNothingToPlanFrom()
{ {
@@ -78,7 +78,7 @@ public class GridPlanPreviewTests
Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1), Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1),
Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1), Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1),
], ],
["замечание"], [new PlanNote(false, "замечание")],
SlotsToRemove: 4, SlotsToRemove: 4,
FallbackGroupId: Guid.NewGuid(), FallbackGroupId: Guid.NewGuid(),
FallbackGroupName: "Фон", FallbackGroupName: "Фон",
@@ -92,7 +92,7 @@ public class GridPlanPreviewTests
Assert.Equal(plan.Profile.Name, dto.ProfileName); Assert.Equal(plan.Profile.Name, dto.ProfileName);
Assert.Equal(4, dto.SlotsToRemove); Assert.Equal(4, dto.SlotsToRemove);
Assert.Equal("Фон", dto.FallbackGroupName); Assert.Equal("Фон", dto.FallbackGroupName);
Assert.Equal(["замечание"], dto.Notes); Assert.Equal(["замечание"], dto.Notes.Select(n => n.Text));
// Описание блока — то, что читает админ вместо режима и числа. // Описание блока — то, что читает админ вместо режима и числа.
Assert.Equal( Assert.Equal(
+27 -2
View File
@@ -860,9 +860,34 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
будни слоем `Основная сетка`, а выходные — слоем `Выходные` поверх: слой имеет смысл, только когда будни слоем `Основная сетка`, а выходные — слоем `Выходные` поверх: слой имеет смысл, только когда
покрывает день целиком, поэтому отдельные выходные бывают лишь в этом режиме. покрывает день целиком, поэтому отдельные выходные бывают лишь в этом режиме.
**Длину слота задаёт контент, а не профиль.** `BlockMinutes`/`UnitsPerBlock` — замысел полосы;
фактическая длина считается из средней длительности единицы выбранной группы. Иначе двухчасовой
слот с «тремя сериями по 22 минуты» отдаёт 54 минуты фону, а трёхчасовой прайм с одним фильмом на
100 минут — восемьдесят. Остаток, не влезающий под ещё одну единицу, достаётся следующему слоту, и
под него подбирается группа подходящей длины: кандидат, чья единица длиннее оставшегося места,
штрафуется — до ближайшего якоря он всё равно не влезет.
**Ёмкость считается на неделю.** Слот «каждый день» выходит семь раз, поэтому бюджет группы
(`PlanRun`) списывается сразу на все выходы. Исчерпанная группа уступает нетронутой, а когда
свежего контента не остаётся вовсе — полоса закрывается **повтором вечернего блока**
(`SlotKind.Repeat` на начало прайма прошлых суток), как это делают настоящие каналы. Тем же
повтором закрывается полоса, под которую подходящей группы нет вовсе (например ночной блок 18+
на канале без взрослого контента) — это честнее дыры в эфире.
**Прайм чередуется по дням недели.** Полосы с `Rotate` раскладываются поимённо по будням и берут
не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём.
Остальные полосы остаются слотами «каждый день» — сетка должна читаться.
**Жанр не занимает две полосы подряд**, а при заданном `MaxGenreSharePercent` — и больше своей
доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый
на раскладке, а не констатируемый после.
**Предпросмотр обязателен**: он считается тем же `GridPlanner`, что и создание, поэтому показанное **Предпросмотр обязателен**: он считается тем же `GridPlanner`, что и создание, поэтому показанное
и созданное совпадают по построению. Эфир генерация не двигает — как любая правка сетки, она только и созданное совпадают по построению. Там же показываются замечания — незакрытое время (красным),
поднимает ревизию шаблона, а хвост пересобирает кнопка применения. нехватка состава с точными числами («84 выхода в неделю при 40 позициях») и конфликты с детским
временем: всё это считается по тому же бюджету, по которому раскладывалось, и потому совпадает
с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она
только поднимает ревизию шаблона, а хвост пересобирает кнопка применения.
--- ---
@@ -143,10 +143,17 @@ export function GenerateGridDialog({
</p> </p>
)} )}
{/* Замечания разделены по цвету: красное останется дырой в эфире, янтарное
повод посмотреть, но сетка соберётся. */}
{plan.notes.length > 0 && ( {plan.notes.length > 0 && (
<ul className="flex flex-col gap-0.5 text-xs text-amber-500"> <ul className="flex flex-col gap-0.5 text-xs">
{plan.notes.map((note) => ( {plan.notes.map((note) => (
<li key={note}>{note}</li> <li
key={note.text}
className={note.isError ? 'text-red-500' : 'text-amber-500'}
>
{note.text}
</li>
))} ))}
</ul> </ul>
)} )}
+4 -1
View File
@@ -726,11 +726,14 @@ export type GridPlanSlotDto = {
block: string block: string
} }
/** Замечание к плану: `isError` — то, что после сборки останется дырой в эфире. */
export type PlanNoteDto = { isError: boolean; text: string }
export type GridPlanDto = { export type GridPlanDto = {
profile: GridProfileKind profile: GridProfileKind
profileName: string profileName: string
slots: GridPlanSlotDto[] slots: GridPlanSlotDto[]
notes: string[] notes: PlanNoteDto[]
/** Больше нуля только при пересборке — столько слотов будет снесено. */ /** Больше нуля только при пересборке — столько слотов будет снесено. */
slotsToRemove: number slotsToRemove: number
fallbackGroupName: string | null fallbackGroupName: string | null