Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Золотые тесты планировщика: для набора эталонных конфигураций фиксируется посуточная лента
|
||||
/// целиком. Любая правка стратегий или подгонки сразу показывает построчный диф — что именно
|
||||
/// поехало, — вместо «упало N тестов» через неделю после того, как это ушло в эфир.
|
||||
///
|
||||
/// Эталон обновляется осознанно: переписыванием ожидаемой строки в тесте, а не автоматически при
|
||||
/// расхождении. Автообновление превратило бы их в тесты, которые всегда зелёные.
|
||||
/// </summary>
|
||||
public class GoldenScheduleTests
|
||||
{
|
||||
private static readonly DateTimeOffset Day = new(2026, 3, 2, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Псевдослучайность с фиксированным зерном: жребий обязан быть воспроизводимым.</summary>
|
||||
private sealed class SeededRandom(int seed) : IRandomSource
|
||||
{
|
||||
private int _state = seed;
|
||||
|
||||
public int Next(int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= 0)
|
||||
return 0;
|
||||
_state = (int)((_state * 1103515245L + 12345) & 0x7FFFFFFF);
|
||||
return _state % maxExclusive;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Лента в компактном виде: время начала, тип, шоу и номер единицы — построчно.</summary>
|
||||
private static List<string> Render(
|
||||
PlanningResult result,
|
||||
IReadOnlyDictionary<Guid, string> names
|
||||
) =>
|
||||
result
|
||||
.Items.OrderBy(i => i.StartsAtUtc)
|
||||
.Select(item =>
|
||||
{
|
||||
var label = item.ShowId is { } showId && names.TryGetValue(showId, out var name)
|
||||
? $"{name}#{item.UnitIndex}"
|
||||
: item.Kind.ToString();
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{item.StartsAtUtc:HH:mm} {label}"
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static (PlanningElement Element, Guid ShowId) Series(int episodes, int minutes)
|
||||
{
|
||||
var showId = Guid.NewGuid();
|
||||
var units = Enumerable
|
||||
.Range(0, episodes)
|
||||
.Select(i => new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, i))
|
||||
.ToList();
|
||||
return (
|
||||
new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units),
|
||||
showId
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanningSlot ContentSlot(
|
||||
DateTimeOffset start,
|
||||
int durationMinutes,
|
||||
IReadOnlyList<PlanningElement> elements,
|
||||
SlotBlockMode blockMode,
|
||||
int blockValue,
|
||||
bool isAnchor = false,
|
||||
int? snap = null
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
start,
|
||||
durationMinutes,
|
||||
SlotKind.Content,
|
||||
isAnchor,
|
||||
MaxDriftMinutes: 15,
|
||||
snap,
|
||||
blockMode,
|
||||
blockValue,
|
||||
OverflowPolicy.ContinueNext,
|
||||
new PlanningStrategy(SlotStrategyKind.Sequential),
|
||||
elements,
|
||||
Cursor: null
|
||||
);
|
||||
|
||||
private static PlanningUnit Filler() =>
|
||||
new(Guid.NewGuid(), TimeSpan.FromMinutes(1), Guid.Empty, 0);
|
||||
|
||||
[Fact]
|
||||
public void Golden_MorningCartoonsThenPrimeMovie()
|
||||
{
|
||||
var (cartoons, cartoonShow) = Series(6, 20);
|
||||
var (movie, movieShow) = Series(1, 95);
|
||||
|
||||
var slots = new[]
|
||||
{
|
||||
// Утренний блок: три мультфильма подряд.
|
||||
ContentSlot(Day.AddHours(1), 60, [cartoons], SlotBlockMode.Count, 3),
|
||||
// Прайм с якорем: старт жёсткий, предыдущий блок через него не перелезает.
|
||||
ContentSlot(Day.AddHours(14), 120, [movie], SlotBlockMode.Count, 1, isAnchor: true),
|
||||
};
|
||||
|
||||
var input = new PlanningInput(
|
||||
Guid.NewGuid(),
|
||||
Day,
|
||||
Day.AddHours(17),
|
||||
slots,
|
||||
[Filler()],
|
||||
SegmentSeconds: 2
|
||||
);
|
||||
|
||||
var result = GridPlanner.Plan(input, new SeededRandom(42));
|
||||
var names = new Dictionary<Guid, string>
|
||||
{
|
||||
[cartoonShow] = "Мультфильмы",
|
||||
[movieShow] = "Кино",
|
||||
};
|
||||
|
||||
var tape = Render(result, names)
|
||||
.Where(line => !line.Contains("Fallback", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"07:00 Мультфильмы#0",
|
||||
"07:20 Мультфильмы#1",
|
||||
"07:40 Мультфильмы#2",
|
||||
"20:00 Кино#0",
|
||||
],
|
||||
tape
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Golden_DriftIsCarriedForward()
|
||||
{
|
||||
var (series, showId) = Series(6, 25);
|
||||
|
||||
// Три слота подряд по часу, блок — две серии по 25 минут: каждый слот отдаёт 50 минут,
|
||||
// и разбег накапливается, пока его не подберёт следующий целевой старт.
|
||||
var slots = Enumerable
|
||||
.Range(0, 3)
|
||||
.Select(i =>
|
||||
ContentSlot(Day.AddHours(i), 60, [series], SlotBlockMode.Count, 2)
|
||||
)
|
||||
.ToArray();
|
||||
|
||||
var input = new PlanningInput(
|
||||
Guid.NewGuid(),
|
||||
Day,
|
||||
Day.AddHours(4),
|
||||
slots,
|
||||
[Filler()],
|
||||
SegmentSeconds: 2
|
||||
);
|
||||
|
||||
var result = GridPlanner.Plan(input, new SeededRandom(7));
|
||||
var programs = result
|
||||
.Items.Where(i => i.Kind == PlannedItemKind.Program)
|
||||
.OrderBy(i => i.StartsAtUtc)
|
||||
.Select(i => i.StartsAtUtc.ToString("HH:mm", CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
|
||||
// Слот не начинается раньше своего целевого времени: недобор закрывает фон, а не сдвиг вперёд.
|
||||
Assert.Equal(["06:00", "06:25", "07:00", "07:25", "08:00", "08:25"], programs);
|
||||
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback);
|
||||
Assert.Equal(showId, result.Items.First(i => i.Kind == PlannedItemKind.Program).ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Golden_SameInputProducesSameTape()
|
||||
{
|
||||
var (series, _) = Series(10, 30);
|
||||
var slots = new[] { ContentSlot(Day, 180, [series], SlotBlockMode.FillSlot, 1) };
|
||||
// Один и тот же филлер на оба прогона: разные ассеты фона сделали бы сравнение бессмысленным.
|
||||
var filler = Filler();
|
||||
|
||||
PlanningInput MakeInput() =>
|
||||
new(Guid.NewGuid(), Day, Day.AddHours(4), slots, [filler], SegmentSeconds: 2);
|
||||
|
||||
var first = GridPlanner.Plan(MakeInput(), new SeededRandom(1));
|
||||
var second = GridPlanner.Plan(MakeInput(), new SeededRandom(1));
|
||||
|
||||
// Один вход и одно зерно обязаны давать одну ленту: иначе предпросмотр разойдётся с эфиром.
|
||||
Assert.Equal(
|
||||
first.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId)),
|
||||
second.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
public class GroupTests
|
||||
{
|
||||
private static IReadOnlyList<Guid> Order(Group group) =>
|
||||
group.Items.OrderBy(i => i.Position).Select(i => i.Id).ToList();
|
||||
|
||||
[Fact]
|
||||
public void AddElement_AppendsAndRejectsDuplicates()
|
||||
{
|
||||
var group = Group.Create("Боевики 90-х");
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
Assert.NotNull(group.AddElement(GroupElementKind.Show, showId));
|
||||
Assert.Null(group.AddElement(GroupElementKind.Show, showId));
|
||||
Assert.Single(group.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddElement_SameIdDifferentKind_AreDistinct()
|
||||
{
|
||||
var group = Group.Create("Смешанная");
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
group.AddElement(GroupElementKind.Show, id);
|
||||
group.AddElement(GroupElementKind.Collection, id);
|
||||
|
||||
Assert.Equal(2, group.Items.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveElement_DropsAllMatchingPositions()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var showId = Guid.NewGuid();
|
||||
group.AddElement(GroupElementKind.Show, showId);
|
||||
group.AddElement(GroupElementKind.Show, Guid.NewGuid());
|
||||
|
||||
var removed = group.RemoveElement(GroupElementKind.Show, showId);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Single(group.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetWeight_ClampsNegativeToZero()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var item = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
|
||||
group.SetWeight(item.Id, -5);
|
||||
|
||||
// Ноль — законный вес («исключить, не удаляя»), отрицательный сломал бы взвешенный выбор.
|
||||
Assert.Equal(0, item.Weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetWeight_UnknownItem_ReturnsFalse()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
|
||||
Assert.False(group.SetWeight(Guid.NewGuid(), 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_KeepsUnmentionedAfterRequested()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var a = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
var b = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
var c = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
|
||||
group.Reorder([c.Id]);
|
||||
|
||||
Assert.Equal([c.Id, a.Id, b.Id], Order(group));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetFilter_TreatsBlankAsAbsent()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
|
||||
group.SetFilter(" ");
|
||||
|
||||
Assert.Null(group.FilterJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStats_StoresSnapshot()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var at = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
group.UpdateStats(3, 42, TimeSpan.FromHours(18), at);
|
||||
|
||||
Assert.Equal(3, group.ItemCount);
|
||||
Assert.Equal(42, group.UnitCount);
|
||||
Assert.Equal(TimeSpan.FromHours(18), group.TotalDuration);
|
||||
Assert.Equal(at, group.StatsComputedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
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
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
start ?? T0,
|
||||
(start ?? T0).AddHours(horizonHours),
|
||||
slots,
|
||||
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
|
||||
SegmentSeconds: 2
|
||||
);
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
public class ScheduleTemplateTests
|
||||
{
|
||||
private static ScheduleTemplate NewTemplate() =>
|
||||
ScheduleTemplate.Create(Guid.NewGuid(), "Основная");
|
||||
|
||||
[Fact]
|
||||
public void Create_AddsBackgroundLayer()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
|
||||
var background = Assert.Single(template.Layers);
|
||||
Assert.True(background.IsBackground);
|
||||
Assert.Equal(GridLayer.BackgroundPriority, background.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLayer_RefusesBackground()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
var background = template.Background!;
|
||||
|
||||
Assert.False(template.RemoveLayer(background.Id));
|
||||
Assert.Single(template.Layers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLayer_KeepsPriorityAboveBackground()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
|
||||
var layer = template.AddLayer("Выходные", 0);
|
||||
|
||||
// Ноль занят фоном: слой с таким же приоритетом сделал бы выбор неоднозначным.
|
||||
Assert.True(layer.Priority > GridLayer.BackgroundPriority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackgroundLayer_KeepsPriority_WhenUpdated()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
var background = template.Background!;
|
||||
|
||||
background.Update("Фон", 99, null, isEnabled: true);
|
||||
|
||||
Assert.Equal(GridLayer.BackgroundPriority, background.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkChanged_MakesPendingUntilApplied()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
Assert.False(template.HasPendingChanges);
|
||||
|
||||
template.MarkChanged();
|
||||
Assert.True(template.HasPendingChanges);
|
||||
|
||||
template.MarkApplied();
|
||||
Assert.False(template.HasPendingChanges);
|
||||
}
|
||||
}
|
||||
|
||||
public class GridLayerOverlapTests
|
||||
{
|
||||
private static GridLayer NewLayer() =>
|
||||
ScheduleTemplate.Create(Guid.NewGuid(), "T").AddLayer("Базовая", 10);
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_DetectsIntersectionOnSameDay()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5);
|
||||
|
||||
Assert.True(layer.HasOverlap(5, new TimeOnly(21, 0), 30, null));
|
||||
Assert.False(layer.HasOverlap(5, new TimeOnly(21, 30), 30, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_IgnoresOtherWeekdays()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5);
|
||||
|
||||
Assert.False(layer.HasOverlap(3, new TimeOnly(20, 30), 30, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_EverydaySlotConflictsWithAnyDay()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
layer.AddSlot("Новости", new TimeOnly(18, 0), 30);
|
||||
|
||||
// Слот без дня недели идёт каждый день, поэтому конфликтует и с конкретным днём.
|
||||
Assert.True(layer.HasOverlap(2, new TimeOnly(18, 15), 30, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_SkipsSlotBeingEdited()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5);
|
||||
|
||||
Assert.False(layer.HasOverlap(5, new TimeOnly(20, 0), 120, slot.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateContent_ClearsFieldsForeignToSlotKind()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
var slot = layer.AddSlot("Кино", new TimeOnly(20, 0), 90);
|
||||
slot.UpdateContent(
|
||||
"Кино",
|
||||
SlotKind.Content,
|
||||
Guid.NewGuid(),
|
||||
"{\"type\":\"sequential\"}",
|
||||
null,
|
||||
SlotBlockMode.Count,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
);
|
||||
|
||||
slot.UpdateContent(
|
||||
"Конец вещания",
|
||||
SlotKind.SignOff,
|
||||
Guid.NewGuid(),
|
||||
"{\"type\":\"sequential\"}",
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
);
|
||||
|
||||
// Иначе генератор прочитал бы настройки, оставшиеся от прежнего типа слота.
|
||||
Assert.Null(slot.GroupId);
|
||||
Assert.Null(slot.StrategyJson);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user