Files
TeleWave/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs
T
Leonid Pershin ba3721eb92
ci / build-backend (push) Successful in 1m39s
ci / build-frontend (push) Failing after 26s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Implement BumperEndpoints and remove deprecated bumper-related functionality
Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
2026-07-27 22:01:56 +03:00

405 lines
14 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
) =>
new(
Guid.NewGuid(),
JunctionElementKind.Ad,
Enumerable.Range(0, poolSize).Select(_ => Unit(minutesEach)).ToList(),
JunctionAmountMode.Count,
count,
required,
onlyOnElementChange,
minMinutesBetween,
chance,
window,
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 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);
}
}