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.
459 lines
16 KiB
C#
459 lines
16 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;
|
|
}
|
|
|
|
/// <summary>Всегда последний вариант — по нему видно, что жребий вообще участвует в выборе.</summary>
|
|
private sealed class LastAlways : IRandomSource
|
|
{
|
|
public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1);
|
|
}
|
|
|
|
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,
|
|
int chance = 100,
|
|
PlanningTimeWindow? window = null,
|
|
string? choiceKey = null,
|
|
int choiceWeight = 1,
|
|
int poolSize = 8,
|
|
int nearHourMinutes = 0
|
|
) =>
|
|
new(
|
|
Guid.NewGuid(),
|
|
JunctionElementKind.Ad,
|
|
Enumerable.Range(0, poolSize).Select(_ => Unit(minutesEach)).ToList(),
|
|
JunctionAmountMode.Count,
|
|
count,
|
|
required,
|
|
onlyOnElementChange,
|
|
minMinutesBetween,
|
|
chance,
|
|
window,
|
|
nearHourMinutes,
|
|
choiceKey,
|
|
choiceWeight
|
|
);
|
|
|
|
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,
|
|
IRandomSource? random = null,
|
|
int utcOffsetMinutes = 0
|
|
) =>
|
|
GridPlanner.Plan(
|
|
new PlanningInput(
|
|
Guid.NewGuid(),
|
|
T0,
|
|
T0.AddHours(5),
|
|
[slot],
|
|
[Unit(1)],
|
|
SegmentSeconds: 2,
|
|
UtcOffsetMinutes: utcOffsetMinutes
|
|
),
|
|
random ?? 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(
|
|
Guid.NewGuid(),
|
|
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 MinInterval_IsPerElement_NotPerKind()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var frequent = Ads(1, minMinutesBetween: 0);
|
|
var rare = Ads(1, minMinutesBetween: 600);
|
|
|
|
var result = Run(
|
|
Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [frequent, rare]))
|
|
);
|
|
|
|
// Обе врезки рекламные, но ограничение у каждой своё — общий счётчик на вид склеил бы их.
|
|
Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
|
}
|
|
|
|
[Fact]
|
|
public void Units_RotateBetweenJunctions()
|
|
{
|
|
var element = Series(3, 20, out _);
|
|
var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1)])));
|
|
|
|
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
|
|
// Иначе каждый рекламный блок начинался бы с одного и того же ролика.
|
|
Assert.Equal(2, ads.Select(a => a.MediaAssetId).Distinct().Count());
|
|
}
|
|
|
|
[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 Chance_Zero_SkipsElement()
|
|
{
|
|
var element = Series(3, 20, out _);
|
|
var result = Run(
|
|
Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, chance: 0)]))
|
|
);
|
|
|
|
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
|
|
[Fact]
|
|
public void TimeWindow_SkipsOutsideChannelHours()
|
|
{
|
|
var element = Series(3, 20, out _);
|
|
// Стык идёт в 18:20 UTC, канал в UTC+3 — 21:20 по времени канала, окно 06:00–20:00 закрыто.
|
|
var window = new PlanningTimeWindow(new TimeOnly(6, 0), new TimeOnly(20, 0));
|
|
var result = Run(
|
|
Slot(
|
|
element,
|
|
3,
|
|
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, window: window)])
|
|
),
|
|
utcOffsetMinutes: 180
|
|
);
|
|
|
|
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
|
|
[Fact]
|
|
public void NearHour_SkipsAwayFromRoundHour()
|
|
{
|
|
// Серии по 20 минут: стыки приходятся на 18:20 и 18:40 — сигналу точного времени там не место.
|
|
var element = Series(3, 20, out _);
|
|
var result = Run(
|
|
Slot(
|
|
element,
|
|
3,
|
|
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
|
|
)
|
|
);
|
|
|
|
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
|
|
[Fact]
|
|
public void NearHour_PlacesOnTheHour()
|
|
{
|
|
// Часовые серии: стыки ровно в 19:00 и 20:00 — врезка с допуском срабатывает на каждом.
|
|
var element = Series(3, 60, out _);
|
|
var result = Run(
|
|
Slot(
|
|
element,
|
|
3,
|
|
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
|
|
)
|
|
);
|
|
|
|
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
|
|
Assert.Equal(2, ads.Count);
|
|
Assert.All(ads, ad => Assert.Equal(0, ad.StartsAtUtc.Minute));
|
|
}
|
|
|
|
[Fact]
|
|
public void NearHour_CountsChannelTime_NotUtc()
|
|
{
|
|
// Канал в UTC+3:30: круглый час канала приходится на :30 по UTC, и считать надо по каналу —
|
|
// «московское время» объявляют в эфире, а не в UTC.
|
|
var element = Series(3, 60, out _);
|
|
var result = Run(
|
|
Slot(
|
|
element,
|
|
3,
|
|
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
|
|
),
|
|
utcOffsetMinutes: 210
|
|
);
|
|
|
|
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
|
|
[Fact]
|
|
public void Choice_PlacesExactlyOneOfTheFork()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var first = Ads(1, choiceKey: "fork");
|
|
var second = new PlanningJunctionElement(
|
|
Guid.NewGuid(),
|
|
JunctionElementKind.Promo,
|
|
[Unit(0.5)],
|
|
JunctionAmountMode.Count,
|
|
1,
|
|
IsRequired: false,
|
|
ChoiceKey: "fork"
|
|
);
|
|
|
|
var byFirst = Run(
|
|
Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])),
|
|
new FirstAlways()
|
|
);
|
|
var byLast = Run(
|
|
Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])),
|
|
new LastAlways()
|
|
);
|
|
|
|
Assert.Single(byFirst.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
Assert.DoesNotContain(byFirst.Items, i => i.Kind == PlannedItemKind.Promo);
|
|
Assert.Single(byLast.Items, i => i.Kind == PlannedItemKind.Promo);
|
|
Assert.DoesNotContain(byLast.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
|
|
[Fact]
|
|
public void MaxTotal_CapsJunctionLength()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var junction = new PlanningJunction(
|
|
Guid.NewGuid(),
|
|
[Ads(6, minutesEach: 1)],
|
|
MaxTotal: TimeSpan.FromMinutes(2)
|
|
);
|
|
|
|
var result = Run(Slot(element, 2, between: junction));
|
|
|
|
Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
|
}
|
|
|
|
[Fact]
|
|
public void Bumper_ReservesDurationWithEmptyAssetAndShowPair()
|
|
{
|
|
var element = Series(2, 20, out var showId);
|
|
var variantId = Guid.NewGuid();
|
|
var bumper = new PlanningJunctionElement(
|
|
Guid.NewGuid(),
|
|
JunctionElementKind.Bumper,
|
|
[],
|
|
JunctionAmountMode.Count,
|
|
1,
|
|
IsRequired: true,
|
|
BumperTemplateId: Guid.NewGuid(),
|
|
BumperVariantId: variantId,
|
|
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(variantId, placed.BumperVariantId);
|
|
Assert.Equal(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderFollowsPosition_NotRequiredFlag()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var optional = Ads(1, minutesEach: 1);
|
|
var required = new PlanningJunctionElement(
|
|
Guid.NewGuid(),
|
|
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.Ad, junctionItems[0].Kind);
|
|
Assert.Equal(PlannedItemKind.Promo, junctionItems[1].Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void RequiredSurvives_WhenOptionalDoesNotFit()
|
|
{
|
|
var element = Series(2, 20, out _);
|
|
var optional = Ads(4, minutesEach: 1);
|
|
var required = new PlanningJunctionElement(
|
|
Guid.NewGuid(),
|
|
JunctionElementKind.Promo,
|
|
[Unit(1)],
|
|
JunctionAmountMode.Count,
|
|
1,
|
|
IsRequired: true
|
|
);
|
|
var junction = new PlanningJunction(
|
|
Guid.NewGuid(),
|
|
[optional, required],
|
|
MaxTotal: TimeSpan.FromMinutes(2)
|
|
);
|
|
|
|
var result = Run(Slot(element, 2, between: junction));
|
|
|
|
Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Promo);
|
|
Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
|
|
[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)
|
|
);
|
|
Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
|
}
|
|
}
|