Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
|
||||
/// </summary>
|
||||
public class LayerApplicabilityTests
|
||||
{
|
||||
private static readonly TimeOnly DayStart = new(6, 0);
|
||||
|
||||
[Fact]
|
||||
public void Empty_CoversEveryDate()
|
||||
{
|
||||
var applicability = new LayerApplicability();
|
||||
|
||||
Assert.True(applicability.IsEmpty);
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
|
||||
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
|
||||
[InlineData(2026, 12, 20, true)] // ровно начало
|
||||
[InlineData(2026, 1, 8, true)] // ровно конец
|
||||
[InlineData(2026, 12, 19, false)] // за день до начала
|
||||
[InlineData(2026, 1, 9, false)] // на следующий день после конца
|
||||
[InlineData(2026, 6, 15, false)] // середина года
|
||||
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
|
||||
int year,
|
||||
int month,
|
||||
int day,
|
||||
bool expected
|
||||
)
|
||||
{
|
||||
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
|
||||
// по паре (месяц, день), а не по датам.
|
||||
var applicability = new LayerApplicability(
|
||||
AnnualRanges: [new AnnualRange(12, 20, 1, 8)]
|
||||
);
|
||||
|
||||
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnnualRange_WithinOneYear_DoesNotWrap()
|
||||
{
|
||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_AreCombinedWithOr()
|
||||
{
|
||||
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
|
||||
var applicability = new LayerApplicability(
|
||||
Weekdays: [1],
|
||||
SpecificDates: [new DateOnly(2026, 3, 21)]
|
||||
);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
|
||||
{
|
||||
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
|
||||
// половину настройки — своей группы и стратегии у половинки нет.
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var top = template.AddLayer("Прайм", 100);
|
||||
var bottom = template.AddLayer("Обычный", 50);
|
||||
|
||||
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
|
||||
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
|
||||
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
|
||||
|
||||
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
|
||||
|
||||
Assert.Contains("Кино", titles);
|
||||
Assert.Contains("Ночь", titles);
|
||||
Assert.DoesNotContain("Сериал", titles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DropsLayersThatDoNotApplyOnTheDate()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var newYear = template.AddLayer("Новогодний", 100);
|
||||
newYear.Update(
|
||||
newYear.Name,
|
||||
newYear.Priority,
|
||||
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
|
||||
isEnabled: true
|
||||
);
|
||||
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
|
||||
|
||||
var usual = template.AddLayer("Обычный", 50);
|
||||
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
|
||||
|
||||
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
|
||||
Assert.Contains("Вечерний сериал", march);
|
||||
Assert.DoesNotContain("Ирония судьбы", march);
|
||||
Assert.Contains("Ирония судьбы", december);
|
||||
Assert.DoesNotContain("Вечерний сериал", december);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DisabledLayerIsIgnored()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var layer = template.AddLayer("Выключённый", 100);
|
||||
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
|
||||
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
|
||||
|
||||
Assert.Empty(Build(template));
|
||||
}
|
||||
|
||||
private static void AddSlot(GridLayer layer, string title, TimeOnly start, int durationMinutes) =>
|
||||
layer.AddSlot(title, start, durationMinutes);
|
||||
|
||||
private static IReadOnlyList<ScheduledSlot> Build(
|
||||
ScheduleTemplate template,
|
||||
DateTimeOffset? from = null
|
||||
)
|
||||
{
|
||||
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
return EffectiveGridBuilder.Build(
|
||||
template,
|
||||
utcOffsetMinutes: 180,
|
||||
DayStart,
|
||||
start,
|
||||
start.AddHours(18)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Жёсткие фильтры кандидатов (см. 3.8): детское время и потолок повторов. Оба отсекают до жребия,
|
||||
/// поэтому проверяются на выборе элемента, а не на готовой ленте.
|
||||
/// </summary>
|
||||
public class CandidateFilterTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class FirstAlways : IRandomSource
|
||||
{
|
||||
public int Next(int maxExclusive) => 0;
|
||||
}
|
||||
|
||||
private static PlanningElement Element(
|
||||
ShowAudience? audience = null,
|
||||
int position = 0,
|
||||
DateTimeOffset? lastPlayed = null,
|
||||
IReadOnlyList<DateTimeOffset>? recentPlays = null
|
||||
)
|
||||
{
|
||||
var showId = Guid.NewGuid();
|
||||
return new PlanningElement(
|
||||
GroupElementKind.Show,
|
||||
Guid.NewGuid(),
|
||||
Weight: 1,
|
||||
position,
|
||||
[new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(30), showId, 0)],
|
||||
lastPlayed,
|
||||
audience,
|
||||
recentPlays
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanningSlot Slot(
|
||||
IReadOnlyList<PlanningElement> elements,
|
||||
ShowAudience? maxAudience = null,
|
||||
RepeatLimit? repeatLimit = null,
|
||||
SlotStrategyKind strategy = SlotStrategyKind.RandomWithCooldown
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
T0,
|
||||
TargetDurationMinutes: 60,
|
||||
SlotKind.Content,
|
||||
IsAnchor: false,
|
||||
MaxDriftMinutes: 30,
|
||||
SnapToMinutes: null,
|
||||
SlotBlockMode.FillSlot,
|
||||
BlockValue: 1,
|
||||
OverflowPolicy.ContinueNext,
|
||||
new PlanningStrategy(strategy),
|
||||
elements,
|
||||
Cursor: null,
|
||||
MaxAudience: maxAudience,
|
||||
RepeatLimit: repeatLimit
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void Audience_FiltersOutStricterContent()
|
||||
{
|
||||
var kids = Element(ShowAudience.Kids);
|
||||
var adult = Element(ShowAudience.Adult, position: 1);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([adult, kids], maxAudience: ShowAudience.Family),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(kids.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_WithoutLimit_KeepsEverything()
|
||||
{
|
||||
var adult = Element(ShowAudience.Adult);
|
||||
|
||||
var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways());
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(adult.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_UnknownCategory_IsNotDropped()
|
||||
{
|
||||
// Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча.
|
||||
var unknown = Element(audience: null);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([unknown], maxAudience: ShowAudience.Kids),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(unknown.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_AllFilteredOut_ReturnsNothing()
|
||||
{
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.Null(pick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_AppliesToSequentialStrategyToo()
|
||||
{
|
||||
var adult = Element(ShowAudience.Adult, position: 0);
|
||||
var teen = Element(ShowAudience.Teen, position: 1);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(teen.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_DropsElementsOverTheCap()
|
||||
{
|
||||
var overCap = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||
var fresh = Element(position: 1, recentPlays: [T0.AddDays(-1)]);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([overCap, fresh], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(fresh.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_IgnoresPlaysOutsideTheWindow()
|
||||
{
|
||||
// Показы старше окна не считаются — иначе правило «не чаще двух раз в неделю» запирало бы
|
||||
// элемент навсегда.
|
||||
var old = Element(recentPlays: [T0.AddDays(-30), T0.AddDays(-31)]);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([old], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(old.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_WhenEveryoneIsOverTheCap_StillPicksSomething()
|
||||
{
|
||||
// Пустой эфир хуже раннего повтора — как и при исчерпанном остывании.
|
||||
var a = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||
var b = Element(position: 1, recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([a, b], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 1)),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_AppliesBeforeCooldown()
|
||||
{
|
||||
// Порядок проверяется через исчерпание: потолок повторов выбрасывает «давний» элемент ещё
|
||||
// до остывания, поэтому в вырожденном случае остаётся свежий. В обратном порядке остывание
|
||||
// оставило бы давний, и выбор был бы другим.
|
||||
var overCap = Element(
|
||||
lastPlayed: T0.AddDays(-10),
|
||||
recentPlays: [T0.AddDays(-10), T0.AddDays(-9), T0.AddDays(-8)]
|
||||
);
|
||||
var recent = Element(position: 1, lastPlayed: T0.AddHours(-1), recentPlays: [T0.AddHours(-1)]);
|
||||
|
||||
var slot = Slot([overCap, recent], repeatLimit: new RepeatLimit(WindowDays: 30, Max: 2)) with
|
||||
{
|
||||
Strategy = new PlanningStrategy(SlotStrategyKind.RandomWithCooldown, CooldownDays: 2),
|
||||
};
|
||||
|
||||
var pick = ElementSelector.Select(slot, T0, new FirstAlways());
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(recent.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user