Enhance EffectiveGridBuilder to support background slots and improve scheduling logic
ci / build-backend (push) Successful in 2m37s
ci / build-frontend (push) Successful in 1m11s
ci / tests (push) Successful in 2m31s
ci / sonar (push) Successful in 6m52s

Updated the EffectiveGridBuilder to return an EffectiveGrid containing both scheduled and background slots, allowing for better management of overlapping content. Refactored the ResolveDay method to handle background slots separately, ensuring they fill gaps in the schedule appropriately. Adjusted related components, including GridScheduleGenerator and SchedulePlanner, to accommodate the new structure and improve overall scheduling accuracy. Enhanced documentation and comments for clarity.
This commit is contained in:
Leonid Pershin
2026-07-28 01:06:29 +03:00
parent e0b85807df
commit 7bee84c548
12 changed files with 512 additions and 58 deletions
@@ -139,12 +139,8 @@ public class LayerApplicabilityTests
)
{
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
return EffectiveGridBuilder.Build(
template,
utcOffsetMinutes: 180,
DayStart,
start,
start.AddHours(18)
);
return EffectiveGridBuilder
.Build(template, utcOffsetMinutes: 180, DayStart, start, start.AddHours(18))
.Slots;
}
}
@@ -74,7 +74,8 @@ public class SchedulePlannerTests
IReadOnlyList<PlanningSlot> slots,
DateTimeOffset? start = null,
int horizonHours = 6,
IReadOnlyList<PlanningUnit>? fallback = null
IReadOnlyList<PlanningUnit>? fallback = null,
IReadOnlyList<PlanningSlot>? background = null
) =>
new(
Guid.NewGuid(),
@@ -82,7 +83,9 @@ public class SchedulePlannerTests
(start ?? T0).AddHours(horizonHours),
slots,
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
SegmentSeconds: 2
SegmentSeconds: 2,
UtcOffsetMinutes: 0,
background ?? []
);
private static PlanningResult Run(PlanningInput input) =>
@@ -347,6 +350,184 @@ public class SchedulePlannerTests
Assert.Equal(element.ElementId, program.Trace.ElementId);
}
[Fact]
public void Background_PlaysInLeftoverInsteadOfEmergencyFiller()
{
// Слот на полчаса при часовом горизонте: остаток по замыслу принадлежит фоновому слою.
var backgroundElement = Element(4, 10);
var background = Slot(T0, 24 * 60, [backgroundElement]);
var result = Run(
Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background])
);
var filled = result
.Items.Where(i => i.SlotId == background.SlotId)
.OrderBy(i => i.StartsAtUtc)
.ToList();
Assert.Equal(3, filled.Count);
// Фон — это программа своего слота, а не аварийный запас.
Assert.All(filled, item => Assert.Equal(PlannedItemKind.Program, item.Kind));
Assert.All(
filled,
item => Assert.Equal(backgroundElement.ElementId, item.Trace!.ElementId)
);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
}
[Fact]
public void Background_ContinuesAcrossGaps()
{
var backgroundElement = Element(6, 10);
var background = Slot(T0, 24 * 60, [backgroundElement]);
var result = Run(
Input(
[Slot(T0, 20, [Element(1, 20)]), Slot(T0.AddMinutes(40), 20, [Element(1, 20)])],
horizonHours: 2,
background: [background]
)
);
var filled = result
.Items.Where(i => i.SlotId == background.SlotId)
.OrderBy(i => i.StartsAtUtc)
.Select(i => i.UnitIndex)
.ToList();
// Вторая пауза продолжает сериал с той серии, на которой остановилась первая; исчерпав
// элемент, последовательная стратегия начинает его заново — это её штатное поведение.
Assert.Equal([0, 1, 2, 3, 4, 5], filled.Take(6));
}
[Fact]
public void Background_FallsBackToEmergencyWhenEpisodeDoesNotFit()
{
var background = Slot(T0, 24 * 60, [Element(4, 30)]);
var result = Run(
Input(
[Slot(T0, 50, [Element(1, 50)])],
horizonHours: 1,
fallback: [Unit(2, Guid.Empty, 0)],
background: [background]
)
);
// Остаток — 10 минут, серия фона длиннее: хвост добирает аварийный запас, а не дыра.
Assert.DoesNotContain(result.Items, i => i.SlotId == background.SlotId);
Assert.Equal(5, result.Items.Count(i => i.Kind == PlannedItemKind.Fallback));
}
[Fact]
public void Background_FillsEmptyRepeatSlot()
{
var backgroundElement = Element(6, 10);
var background = Slot(T0, 24 * 60, [backgroundElement]);
var repeat = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] };
var result = Run(Input([repeat], horizonHours: 1, background: [background]));
// Свежему каналу повторять нечего — по замыслу это место отдаётся фоновому слою.
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
Assert.Equal(6, result.Items.Count(i => i.SlotId == background.SlotId));
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
}
[Fact]
public void Background_CursorIsSavedOnlyForOverlappedSlots()
{
var backgroundElement = Element(6, 10);
var background = Slot(T0, 24 * 60, [backgroundElement]);
var result = Run(
Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background])
);
var cursor = Assert.Single(result.Cursors, c => c.SlotId == background.SlotId);
Assert.Equal(backgroundElement.ElementId, cursor.ElementId);
Assert.Equal(3, cursor.NextUnitIndex);
}
[Fact]
public void Fallback_KeepsShowOfItsUnits()
{
var showId = Guid.NewGuid();
// Слот на 30 минут при часовом горизонте: остаток закрывает фон.
var result = Run(
Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, fallback: [Unit(5, showId, 3)])
);
var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList();
Assert.NotEmpty(fallback);
// Фон — это программы из группы: без шоу в ленте на их месте прочерк.
Assert.All(fallback, item => Assert.Equal(showId, item.ShowId));
Assert.All(fallback, item => Assert.Equal(3, item.UnitIndex));
}
[Fact]
public void Fallback_AnonymousFillerHasNoShow()
{
// Аварийный филлер канала приходит без шоу — подписывать его нечем и не нужно.
var result = Run(
Input(
[Slot(T0, 30, [Element(1, 30)])],
horizonHours: 1,
fallback: [Unit(5, Guid.Empty, 0)]
)
);
Assert.All(
result.Items.Where(i => i.Kind == PlannedItemKind.Fallback),
item => Assert.Null(item.ShowId)
);
}
[Fact]
public void Fallback_RotatesUnitsBetweenGaps()
{
var first = Unit(5, Guid.NewGuid(), 0);
var second = Unit(5, Guid.NewGuid(), 0);
var result = Run(
Input(
[Slot(T0, 30, [Element(1, 30)]), Slot(T0.AddMinutes(40), 30, [Element(1, 30)])],
horizonHours: 2,
fallback: [first, second]
)
);
var fallback = result
.Items.Where(i => i.Kind == PlannedItemKind.Fallback)
.OrderBy(i => i.StartsAtUtc)
.ToList();
// Пауза продолжает перебор с того места, где остановилась прошлая: иначе ночь выглядит
// одним зациклённым куском.
Assert.Equal(first.MediaAssetId, fallback[0].MediaAssetId);
Assert.Equal(second.MediaAssetId, fallback[1].MediaAssetId);
}
[Fact]
public void Fallback_TakesShorterUnitWhenNextDoesNotFit()
{
var showId = Guid.NewGuid();
// Список отсортирован по возрастанию, как его готовит оркестратор.
var result = Run(
Input(
[Slot(T0, 50, [Element(1, 50)])],
horizonHours: 1,
fallback: [Unit(2, showId, 0), Unit(30, showId, 1)]
)
);
// Остаток — 10 минут: длинная единица не влезает, и вместо дыры в ленте идут короткие.
var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList();
Assert.Equal(5, fallback.Count);
Assert.All(
fallback,
item => Assert.Equal(TimeSpan.FromMinutes(2), item.EndsAtUtc - item.StartsAtUtc)
);
}
[Fact]
public void NoFallbackUnits_ProducesWarning()
{