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);
/// Всегда первый вариант — жребий в тестах не должен добавлять шума.
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 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 slots,
DateTimeOffset? start = null,
int horizonHours = 6,
IReadOnlyList? fallback = null,
IReadOnlyList? background = null,
IReadOnlyList? pad = null,
TimeSpan maxPad = default
) =>
new(
Guid.NewGuid(),
start ?? T0,
(start ?? T0).AddHours(horizonHours),
slots,
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
SegmentSeconds: 2,
UtcOffsetMinutes: 0,
background ?? [],
pad ?? [],
maxPad
);
private static PadUnit Ad(int minutes) => new(Unit(minutes, Guid.Empty, 0), PlannedItemKind.Ad);
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 Pad_FillsSeamBeforeSlotWithAds()
{
// Слот-якорь начинается через полчаса после конца предыдущего: шов до объявленного времени
// на настоящем ТВ занимает реклама, а не ещё одна серия.
var anchor = Slot(T0.AddMinutes(30), 30, [Element(1, 30)], isAnchor: true);
var result = Run(
Input(
[Slot(T0, 15, [Element(1, 15)]), anchor],
horizonHours: 1,
pad: [Ad(5)],
maxPad: TimeSpan.FromMinutes(30)
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(3, ads.Count);
Assert.Equal(T0.AddMinutes(15), ads[0].StartsAtUtc);
// К объявленному времени шов заканчивается: якорь начинается ровно в свои полчаса.
Assert.Equal(anchor.TargetStartUtc, ads[^1].EndsAtUtc);
}
[Fact]
public void Pad_IsCappedSoLongHolesStayProgramming()
{
// Дыра в час: рекламой закрывается только потолок одного добора, остальное — программы.
var background = Slot(T0, 24 * 60, [Element(6, 10)]);
var result = Run(
Input(
[Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)],
horizonHours: 2,
pad: [Ad(5)],
maxPad: TimeSpan.FromMinutes(10),
background: [background]
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(
TimeSpan.FromMinutes(10),
ads.Aggregate(TimeSpan.Zero, (sum, a) => sum + (a.EndsAtUtc - a.StartsAtUtc))
);
Assert.Contains(result.Items, i => i.SlotId == background.SlotId);
}
[Fact]
public void Pad_ClosesTailWhenProgrammeDoesNotFit()
{
// Остаток в пять минут: серия фона туда не влезает, и хвост занимает реклама, а не филлер.
var background = Slot(T0, 24 * 60, [Element(4, 30)]);
var result = Run(
Input(
[Slot(T0, 55, [Element(1, 55)])],
horizonHours: 1,
pad: [Ad(5)],
background: [background]
)
);
Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
}
[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);
}
}