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.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -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);
}
}