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.
562 lines
20 KiB
C#
562 lines
20 KiB
C#
using TeleWave.Domain.Broadcast.Scheduling;
|
|
using TeleWave.Domain.Programming;
|
|
using TeleWave.Domain.Programming.Planning;
|
|
using Xunit;
|
|
|
|
namespace TeleWave.Domain.Tests.Programming;
|
|
|
|
public class SchedulePlannerTests
|
|
{
|
|
private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
/// <summary>Всегда первый вариант — жребий в тестах не должен добавлять шума.</summary>
|
|
private sealed class FirstAlways : IRandomSource
|
|
{
|
|
public int Next(int maxExclusive) => 0;
|
|
}
|
|
|
|
private static PlanningUnit Unit(int minutes, Guid showId, int index) =>
|
|
new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, index);
|
|
|
|
private static PlanningElement Element(
|
|
int unitCount,
|
|
int minutesEach,
|
|
int weight = 1,
|
|
int position = 0,
|
|
DateTimeOffset? lastPlayed = null
|
|
)
|
|
{
|
|
var showId = Guid.NewGuid();
|
|
var units = Enumerable
|
|
.Range(0, unitCount)
|
|
.Select(i => Unit(minutesEach, showId, i))
|
|
.ToList();
|
|
return new PlanningElement(
|
|
GroupElementKind.Show,
|
|
Guid.NewGuid(),
|
|
weight,
|
|
position,
|
|
units,
|
|
lastPlayed
|
|
);
|
|
}
|
|
|
|
private static PlanningSlot Slot(
|
|
DateTimeOffset startUtc,
|
|
int durationMinutes,
|
|
IReadOnlyList<PlanningElement> elements,
|
|
SlotBlockMode blockMode = SlotBlockMode.FillSlot,
|
|
int blockValue = 1,
|
|
OverflowPolicy overflow = OverflowPolicy.ContinueNext,
|
|
bool isAnchor = false,
|
|
int? snapToMinutes = null,
|
|
PlanningCursor? cursor = null,
|
|
SlotStrategyKind strategy = SlotStrategyKind.Sequential,
|
|
int cooldownDays = 0
|
|
) =>
|
|
new(
|
|
Guid.NewGuid(),
|
|
startUtc,
|
|
durationMinutes,
|
|
SlotKind.Content,
|
|
isAnchor,
|
|
MaxDriftMinutes: 30,
|
|
snapToMinutes,
|
|
blockMode,
|
|
blockValue,
|
|
overflow,
|
|
new PlanningStrategy(strategy, CooldownDays: cooldownDays),
|
|
elements,
|
|
cursor
|
|
);
|
|
|
|
private static PlanningInput Input(
|
|
IReadOnlyList<PlanningSlot> slots,
|
|
DateTimeOffset? start = null,
|
|
int horizonHours = 6,
|
|
IReadOnlyList<PlanningUnit>? fallback = null,
|
|
IReadOnlyList<PlanningSlot>? background = null
|
|
) =>
|
|
new(
|
|
Guid.NewGuid(),
|
|
start ?? T0,
|
|
(start ?? T0).AddHours(horizonHours),
|
|
slots,
|
|
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
|
|
SegmentSeconds: 2,
|
|
UtcOffsetMinutes: 0,
|
|
background ?? []
|
|
);
|
|
|
|
private static PlanningResult Run(PlanningInput input) =>
|
|
TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways());
|
|
|
|
[Fact]
|
|
public void ContinuousTape_HasNoGapsBetweenItems()
|
|
{
|
|
var element = Element(4, 30);
|
|
var result = Run(Input([Slot(T0, 120, [element])]));
|
|
|
|
var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList();
|
|
Assert.NotEmpty(ordered);
|
|
for (var i = 1; i < ordered.Count; i++)
|
|
Assert.Equal(ordered[i - 1].EndsAtUtc, ordered[i].StartsAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void BlockModeCount_PlacesExactlyRequestedUnits()
|
|
{
|
|
var element = Element(10, 20);
|
|
var result = Run(
|
|
Input([Slot(T0, 300, [element], SlotBlockMode.Count, blockValue: 3)], horizonHours: 1)
|
|
);
|
|
|
|
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
|
}
|
|
|
|
[Fact]
|
|
public void BlockModeDuration_IncludesLastUnitWholly()
|
|
{
|
|
var element = Element(10, 20);
|
|
var result = Run(
|
|
Input(
|
|
[Slot(T0, 300, [element], SlotBlockMode.Duration, blockValue: 50)],
|
|
horizonHours: 2
|
|
)
|
|
);
|
|
|
|
// 20+20 < 50, третья добирает до 60 — обрезать видеофайл нельзя.
|
|
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
|
}
|
|
|
|
[Fact]
|
|
public void Anchor_DoesNotStartUnitThatCrossesIt()
|
|
{
|
|
var first = Element(10, 25);
|
|
var second = Element(1, 30);
|
|
|
|
var slots = new[]
|
|
{
|
|
Slot(T0, 60, [first]),
|
|
Slot(T0.AddMinutes(60), 60, [second], isAnchor: true),
|
|
};
|
|
var result = Run(Input(slots));
|
|
|
|
var anchored = result.Items.First(i => i.SlotId == slots[1].SlotId);
|
|
// Третья 25-минутная серия перелезла бы через якорь — её не начинают.
|
|
Assert.True(anchored.StartsAtUtc >= slots[1].TargetStartUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snap_MovesStartToRoundTime()
|
|
{
|
|
// Первый слот кончается в 12:47 — второй с округлением до 15 минут стартует в 13:00.
|
|
var first = Element(1, 47);
|
|
var second = Element(1, 30);
|
|
|
|
var slots = new[]
|
|
{
|
|
Slot(T0, 47, [first]),
|
|
Slot(T0.AddMinutes(40), 60, [second], snapToMinutes: 15),
|
|
};
|
|
var result = Run(Input(slots));
|
|
|
|
var secondItem = result.Items.First(i =>
|
|
i.SlotId == slots[1].SlotId && i.Kind == PlannedItemKind.Program
|
|
);
|
|
Assert.Equal(0, secondItem.StartsAtUtc.Minute % 15);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snap_SkippedWhenDriftWouldExceedAllowance()
|
|
{
|
|
var element = Element(1, 30);
|
|
var slot = Slot(T0.AddMinutes(-1), 60, [element], snapToMinutes: 30) with
|
|
{
|
|
MaxDriftMinutes = 2,
|
|
};
|
|
|
|
var result = Run(Input([slot]));
|
|
|
|
// Округление потребовало бы почти 30 минут фона — лучше начать не в круглое время.
|
|
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
|
Assert.Equal(T0, program.StartsAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void GapBeforeSlot_IsFilledWithFallback()
|
|
{
|
|
var element = Element(1, 30);
|
|
var result = Run(Input([Slot(T0.AddMinutes(10), 30, [element])]));
|
|
|
|
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback);
|
|
Assert.Equal(T0, result.Items.Min(i => i.StartsAtUtc));
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptySlot_WarnsAndFallsBack()
|
|
{
|
|
var result = Run(Input([Slot(T0, 30, [])]));
|
|
|
|
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.SlotEmpty);
|
|
Assert.All(result.Items, i => Assert.NotEqual(PlannedItemKind.Program, i.Kind));
|
|
}
|
|
|
|
[Fact]
|
|
public void Cursor_AdvancesToNextUnit()
|
|
{
|
|
var element = Element(5, 20);
|
|
var slot = Slot(T0, 60, [element], SlotBlockMode.Count, blockValue: 2);
|
|
|
|
var result = Run(Input([slot], horizonHours: 1));
|
|
|
|
var cursor = Assert.Single(result.Cursors);
|
|
Assert.Equal(element.ElementId, cursor.ElementId);
|
|
Assert.Equal(2, cursor.NextUnitIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cursor_ResumesMidElement()
|
|
{
|
|
var element = Element(5, 20);
|
|
var slot = Slot(
|
|
T0,
|
|
60,
|
|
[element],
|
|
SlotBlockMode.Count,
|
|
blockValue: 1,
|
|
cursor: new PlanningCursor(GroupElementKind.Show, element.ElementId, 3)
|
|
);
|
|
|
|
var result = Run(Input([slot], horizonHours: 1));
|
|
|
|
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
|
Assert.Equal(3, program.UnitIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public void SkipIfNotFits_LeavesSlotToFallback()
|
|
{
|
|
// Элемент из трёх часовых частей в часовой слот целиком не влезает.
|
|
var element = Element(3, 60);
|
|
var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.SkipIfNotFits);
|
|
|
|
var result = Run(Input([slot], horizonHours: 2));
|
|
|
|
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Program);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtendSlot_PlaysElementBeyondBudget()
|
|
{
|
|
var element = Element(3, 60);
|
|
var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.ExtendSlot);
|
|
|
|
var result = Run(Input([slot], horizonHours: 5));
|
|
|
|
// Трилогия доигрывается целиком, слот залезает на соседей — так и задумано.
|
|
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
|
}
|
|
|
|
[Fact]
|
|
public void Cooldown_PrefersElementOutsideWindow()
|
|
{
|
|
var hot = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-1));
|
|
var cold = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-40));
|
|
|
|
var slot = Slot(
|
|
T0,
|
|
30,
|
|
[hot, cold],
|
|
SlotBlockMode.Count,
|
|
blockValue: 1,
|
|
strategy: SlotStrategyKind.RandomWithCooldown,
|
|
cooldownDays: 28
|
|
);
|
|
|
|
var result = Run(Input([slot], horizonHours: 1));
|
|
|
|
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
|
Assert.Equal(cold.Units[0].ShowId, program.ShowId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cooldown_Exhausted_WarnsAndTakesOldest()
|
|
{
|
|
var recentA = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-2));
|
|
var recentB = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-5));
|
|
|
|
var slot = Slot(
|
|
T0,
|
|
30,
|
|
[recentA, recentB],
|
|
SlotBlockMode.Count,
|
|
blockValue: 1,
|
|
strategy: SlotStrategyKind.RandomWithCooldown,
|
|
cooldownDays: 28
|
|
);
|
|
|
|
var result = Run(Input([slot], horizonHours: 1));
|
|
|
|
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.CooldownExhausted);
|
|
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
|
Assert.Equal(recentB.Units[0].ShowId, program.ShowId);
|
|
}
|
|
|
|
[Fact]
|
|
public void SignOffSlot_MarksItemsAccordingly()
|
|
{
|
|
var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.SignOff };
|
|
|
|
var result = Run(Input([slot], horizonHours: 2));
|
|
|
|
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.SignOff);
|
|
}
|
|
|
|
[Fact]
|
|
public void RepeatSlot_ReplaysSourceUnits()
|
|
{
|
|
var showId = Guid.NewGuid();
|
|
var repeat = new[] { Unit(30, showId, 0), Unit(30, showId, 1) };
|
|
var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = repeat };
|
|
|
|
var result = Run(Input([slot], horizonHours: 2));
|
|
|
|
Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
|
}
|
|
|
|
[Fact]
|
|
public void RepeatSlot_WithEmptySource_WarnsAndFallsBack()
|
|
{
|
|
var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] };
|
|
|
|
var result = Run(Input([slot], horizonHours: 2));
|
|
|
|
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public void Trace_IsWrittenWithSlotAndStrategy()
|
|
{
|
|
var element = Element(1, 30);
|
|
var slot = Slot(T0, 30, [element], SlotBlockMode.Count, blockValue: 1);
|
|
|
|
var result = Run(Input([slot], horizonHours: 1));
|
|
|
|
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
|
Assert.NotNull(program.Trace);
|
|
Assert.Equal(slot.SlotId, program.Trace!.SlotId);
|
|
Assert.Equal(SlotStrategyKind.Sequential, program.Trace.Strategy);
|
|
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()
|
|
{
|
|
var input = Input([], fallback: []);
|
|
|
|
var result = Run(input);
|
|
|
|
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.FallbackEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public void SequentialStrategy_MovesToNextElementWhenCurrentExhausted()
|
|
{
|
|
var first = Element(1, 30, position: 0);
|
|
var second = Element(1, 30, position: 1);
|
|
|
|
var slot = Slot(
|
|
T0,
|
|
30,
|
|
[first, second],
|
|
SlotBlockMode.Count,
|
|
blockValue: 1,
|
|
cursor: new PlanningCursor(GroupElementKind.Show, first.ElementId, 1)
|
|
);
|
|
|
|
var result = Run(Input([slot], horizonHours: 1));
|
|
|
|
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
|
Assert.Equal(second.Units[0].ShowId, program.ShowId);
|
|
}
|
|
}
|