253 lines
8.4 KiB
C#
253 lines
8.4 KiB
C#
using TeleWave.Domain.Broadcast.Scheduling;
|
|
using TeleWave.Domain.Programming;
|
|
using TeleWave.Domain.Programming.Planning;
|
|
using Xunit;
|
|
using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner;
|
|
|
|
namespace TeleWave.Domain.Tests.Programming;
|
|
|
|
public class JunctionFillerTests
|
|
{
|
|
private static readonly DateTimeOffset T0 = new(2026, 4, 6, 18, 0, 0, TimeSpan.Zero);
|
|
|
|
private sealed class FirstAlways : IRandomSource
|
|
{
|
|
public int Next(int maxExclusive) => 0;
|
|
}
|
|
|
|
private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) =>
|
|
new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index);
|
|
|
|
private static PlanningElement Series(int episodes, int minutes, out Guid showId)
|
|
{
|
|
showId = Guid.NewGuid();
|
|
var id = showId;
|
|
var units = Enumerable
|
|
.Range(0, episodes)
|
|
.Select(i => Unit(minutes, id, i))
|
|
.ToList();
|
|
return new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units);
|
|
}
|
|
|
|
private static PlanningJunctionElement Ads(
|
|
int count,
|
|
double minutesEach = 0.5,
|
|
bool required = false,
|
|
int minMinutesBetween = 0,
|
|
bool onlyOnElementChange = false
|
|
) =>
|
|
new(
|
|
JunctionElementKind.Ad,
|
|
Enumerable.Range(0, 8).Select(_ => Unit(minutesEach)).ToList(),
|
|
JunctionAmountMode.Count,
|
|
count,
|
|
required,
|
|
onlyOnElementChange,
|
|
minMinutesBetween
|
|
);
|
|
|
|
private static PlanningSlot Slot(
|
|
PlanningElement element,
|
|
int blockValue,
|
|
PlanningJunction? between = null,
|
|
PlanningJunction? after = null
|
|
) =>
|
|
new(
|
|
Guid.NewGuid(),
|
|
T0,
|
|
240,
|
|
SlotKind.Content,
|
|
IsAnchor: false,
|
|
MaxDriftMinutes: 30,
|
|
SnapToMinutes: null,
|
|
SlotBlockMode.Count,
|
|
blockValue,
|
|
OverflowPolicy.ContinueNext,
|
|
new PlanningStrategy(SlotStrategyKind.Sequential),
|
|
[element],
|
|
Cursor: null,
|
|
RepeatUnits: null,
|
|
JunctionBetween: between,
|
|
JunctionAfter: after
|
|
);
|
|
|
|
private static PlanningResult Run(PlanningSlot slot) =>
|
|
GridPlanner.Plan(
|
|
new PlanningInput(
|
|
Guid.NewGuid(),
|
|
T0,
|
|
T0.AddHours(5),
|
|
[slot],
|
|
[Unit(1)],
|
|
SegmentSeconds: 2
|
|
),
|
|
new FirstAlways()
|
|
);
|
|
|
|
[Fact]
|
|
public void BetweenJunction_PlacesAdsBetweenUnitsButNotBeforeFirst()
|
|
{
|
|
var element = Series(3, 20, out _);
|
|
var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(2)])));
|
|
|
|
var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList();
|
|
Assert.Equal(PlannedItemKind.Program, ordered[0].Kind);
|
|
// Между тремя сериями два стыка по две врезки.
|
|
Assert.Equal(4, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
|
}
|
|
|
|
[Fact]
|
|
public void AfterJunction_PlacesAdsAtBlockEnd()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var result = Run(Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(3)])));
|
|
|
|
var last = result
|
|
.Items.Where(i => i.Kind != PlannedItemKind.Fallback)
|
|
.OrderBy(i => i.StartsAtUtc)
|
|
.Last();
|
|
Assert.Equal(PlannedItemKind.Ad, last.Kind);
|
|
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
|
}
|
|
|
|
[Fact]
|
|
public void DurationMode_IncludesLastUnitWholly()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var ads = new PlanningJunctionElement(
|
|
JunctionElementKind.Ad,
|
|
Enumerable.Range(0, 6).Select(_ => Unit(0.5)).ToList(),
|
|
JunctionAmountMode.Duration,
|
|
1,
|
|
IsRequired: false
|
|
);
|
|
|
|
var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [ads])));
|
|
|
|
// Бюджет 1 минута, ролики по 30 секунд: две штуки добирают ровно минуту, третья не нужна.
|
|
Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
|
}
|
|
|
|
[Fact]
|
|
public void MinInterval_SkipsSecondJunction()
|
|
{
|
|
var element = Series(3, 20, out _);
|
|
var result = Run(
|
|
Slot(
|
|
element,
|
|
3,
|
|
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, minMinutesBetween: 60)])
|
|
)
|
|
);
|
|
|
|
// Серии по 20 минут: второй стык приходит через 20 минут после первого — раньше разрешённого.
|
|
Assert.Equal(1, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnlyOnElementChange_SkipsWithinSameShow()
|
|
{
|
|
var element = Series(3, 20, out _);
|
|
var result = Run(
|
|
Slot(
|
|
element,
|
|
3,
|
|
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, onlyOnElementChange: true)])
|
|
)
|
|
);
|
|
|
|
// Внутри одного сериала смены нет — врезка не ставится.
|
|
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bumper_ReservesDurationWithEmptyAssetAndShowPair()
|
|
{
|
|
var element = Series(2, 20, out var showId);
|
|
var bumper = new PlanningJunctionElement(
|
|
JunctionElementKind.Bumper,
|
|
[],
|
|
JunctionAmountMode.Count,
|
|
1,
|
|
IsRequired: true,
|
|
BumperTemplateId: Guid.NewGuid(),
|
|
BumperDuration: TimeSpan.FromSeconds(10)
|
|
);
|
|
|
|
var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [bumper])));
|
|
|
|
var placed = Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Bumper);
|
|
// Ассет подставит рендер после сборки ленты — до неё пара соседей неизвестна.
|
|
Assert.Equal(Guid.Empty, placed.MediaAssetId);
|
|
Assert.Equal(showId, placed.ToShowId);
|
|
Assert.Equal(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void RequiredElement_GoesBeforeOptional()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var optional = Ads(1, minutesEach: 1);
|
|
var required = new PlanningJunctionElement(
|
|
JunctionElementKind.Promo,
|
|
[Unit(0.5)],
|
|
JunctionAmountMode.Count,
|
|
1,
|
|
IsRequired: true
|
|
);
|
|
|
|
var result = Run(
|
|
Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [optional, required]))
|
|
);
|
|
|
|
var junctionItems = result
|
|
.Items.Where(i => i.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo)
|
|
.OrderBy(i => i.StartsAtUtc)
|
|
.ToList();
|
|
Assert.Equal(PlannedItemKind.Promo, junctionItems[0].Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void JunctionDoesNotCrossAnchor()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var slot = Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(4, 5)])) with
|
|
{
|
|
TargetDurationMinutes = 40,
|
|
};
|
|
var anchor = new PlanningSlot(
|
|
Guid.NewGuid(),
|
|
T0.AddMinutes(45),
|
|
60,
|
|
SlotKind.Content,
|
|
IsAnchor: true,
|
|
MaxDriftMinutes: 5,
|
|
SnapToMinutes: null,
|
|
SlotBlockMode.Count,
|
|
1,
|
|
OverflowPolicy.ContinueNext,
|
|
new PlanningStrategy(SlotStrategyKind.Sequential),
|
|
[Series(1, 30, out _)],
|
|
Cursor: null
|
|
);
|
|
|
|
var result = GridPlanner.Plan(
|
|
new PlanningInput(
|
|
Guid.NewGuid(),
|
|
T0,
|
|
T0.AddHours(3),
|
|
[slot, anchor],
|
|
[Unit(1)],
|
|
SegmentSeconds: 2
|
|
),
|
|
new FirstAlways()
|
|
);
|
|
|
|
// До якоря остаётся пять минут: влезает одна пятиминутная врезка, остальные не начинают.
|
|
Assert.All(
|
|
result.Items.Where(i => i.Kind == PlannedItemKind.Ad),
|
|
item => Assert.True(item.EndsAtUtc <= anchor.TargetStartUtc)
|
|
);
|
|
}
|
|
}
|