Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
634 lines
22 KiB
C#
634 lines
22 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,
|
|
IReadOnlyList<PadUnit>? 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);
|
|
}
|
|
}
|