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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user