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 sealed class LastAlways : IRandomSource
{
public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1);
}
///
/// Считает броски жребия. Жребий — единственный, кто спрашивает сотню, поэтому по этому числу
/// его и отличаем от выбора внутри развилки, который спрашивает сумму весов.
///
private sealed class CountingRandom : IRandomSource
{
public int Rolls { get; private set; }
public int Next(int maxExclusive)
{
if (maxExclusive == 100)
Rolls++;
return 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,
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 Choice_RollsChanceOnce_ForTheWholeFork()
{
// Три заставки в одной развилке — это один выбор, а не три независимых. Бросок жребия
// на каждую превратил бы «показать в 60 % случаев» в «показать хоть какую-то почти всегда».
var element = Series(2, 20, out _);
var fork = new PlanningJunction(
Guid.NewGuid(),
[
Ads(1, chance: 60, choiceKey: "fork"),
Ads(1, chance: 60, choiceKey: "fork"),
Ads(1, chance: 60, choiceKey: "fork"),
]
);
var random = new CountingRandom();
Run(Slot(element, 2, between: fork), random);
Assert.Equal(1, random.Rolls);
}
[Fact]
public void Choice_TakesChanceOfThePickedVariant()
{
// Жребий бросает уже выбранный вариант: у него шанс ноль — значит развилка молчит целиком,
// а не подставляет вместо него соседа с сотней.
var element = Series(2, 20, out _);
var fork = new PlanningJunction(
Guid.NewGuid(),
[Ads(1, chance: 0, choiceKey: "fork"), Ads(1, chance: 100, choiceKey: "fork")]
);
var result = Run(Slot(element, 2, between: fork), new FirstAlways());
Assert.DoesNotContain(result.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);
}
}