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,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