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
) =>
new(
Guid.NewGuid(),
start ?? T0,
(start ?? T0).AddHours(horizonHours),
slots,
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
SegmentSeconds: 2
);
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 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);
}
}