378 lines
12 KiB
C#
378 lines
12 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
|
|
) =>
|
|
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);
|
|
}
|
|
}
|