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
@@ -1,89 +0,0 @@
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class ChannelShowTests
{
private static ChannelShow NewShow() =>
Channel
.Create("c", "c", DateTimeOffset.UnixEpoch)
.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1);
[Fact]
public void Create_HasDefaultPreferredMultiplier_AndNoWindows()
{
var cs = NewShow();
Assert.Equal(ChannelShow.DefaultPreferredWeightMultiplier, cs.PreferredWeightMultiplier);
Assert.Empty(cs.PreferredHours);
Assert.True(cs.IsEnabled);
Assert.Equal(0, cs.NextEpisodeIndex);
}
[Fact]
public void Update_ChangesCoreFields()
{
var cs = NewShow();
cs.Update(9, BlockMode.Duration, 45, false);
Assert.Equal(9, cs.Weight);
Assert.Equal(BlockMode.Duration, cs.BlockMode);
Assert.Equal(45, cs.BlockValue);
Assert.False(cs.IsEnabled);
}
[Fact]
public void SetPreferredHours_ClampsMultiplier_AndNormalizesWindows()
{
var cs = NewShow();
cs.SetPreferredHours(
0, // < 1 → должно стать 1
[
(18, 23), // валидное
(10, 10), // start == end → отбрасывается
(5, 25), // end > 24 → отбрасывается
(-1, 3), // start < 0 → отбрасывается
(18, 23), // дубликат → схлопывается
]
);
Assert.Equal(1, cs.PreferredWeightMultiplier);
var window = Assert.Single(cs.PreferredHours);
Assert.Equal(18, window.StartHour);
Assert.Equal(23, window.EndHour);
}
[Fact]
public void SetPreferredHours_ReplacesPreviousSet()
{
var cs = NewShow();
cs.SetPreferredHours(3, [(1, 2)]);
cs.SetPreferredHours(2, [(8, 9), (20, 22)]);
Assert.Equal(2, cs.PreferredWeightMultiplier);
Assert.Equal(2, cs.PreferredHours.Count);
}
[Fact]
public void IsPreferredAt_ChecksWindows()
{
var cs = NewShow();
cs.SetPreferredHours(3, [(18, 23)]);
Assert.True(cs.IsPreferredAt(18));
Assert.True(cs.IsPreferredAt(22));
Assert.False(cs.IsPreferredAt(23)); // конец исключается
Assert.False(cs.IsPreferredAt(9));
}
[Fact]
public void SetNextEpisodeIndex_MovesCursor()
{
var cs = NewShow();
cs.SetNextEpisodeIndex(5);
Assert.Equal(5, cs.NextEpisodeIndex);
}
}
@@ -1,167 +1,131 @@
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class ChannelTests
{
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
[Fact]
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
{
var channel = NewChannel();
Assert.NotEqual(Guid.Empty, channel.Id);
Assert.Equal("News", channel.Name);
Assert.Equal("news", channel.Slug);
Assert.True(channel.IsEnabled);
Assert.Equal(Epoch, channel.EpochUtc);
Assert.Equal(AdInsertion.BetweenBlocks, channel.AdInsertion);
Assert.False(channel.BumpersEnabled);
Assert.Equal(BumperSelection.Rotation, channel.BumperSelection);
Assert.Equal(1.0, channel.BumperShowChangeChance);
Assert.Equal(1.0, channel.BumperEpisodeChangeChance);
var template = Assert.Single(channel.BumperTemplates);
Assert.True(template.IsDefault);
Assert.Equal(0, template.Position);
Assert.Single(template.Variants); // дефолтный подблок
}
[Fact]
public void UpdateSettings_ChangesFields()
{
var channel = NewChannel();
var filler = Guid.NewGuid();
channel.UpdateSettings("N2", false, AdInsertion.BetweenEpisodes, 4, true, filler);
Assert.Equal("N2", channel.Name);
Assert.False(channel.IsEnabled);
Assert.Equal(AdInsertion.BetweenEpisodes, channel.AdInsertion);
Assert.Equal(4, channel.AdsPerBreak);
Assert.True(channel.BumpersEnabled);
Assert.Equal(filler, channel.FillerAssetId);
}
[Theory]
[InlineData(-1, 2.0, -5.0, 1.0, 0.0)]
[InlineData(30, 0.3, 0.9, 0.3, 0.9)]
public void UpdateBumperSettings_ClampsChancesAndInterval(
int interval,
double show,
double episode,
double expectedShow,
double expectedEpisode
)
{
var channel = NewChannel();
channel.UpdateBumperSettings(
BumperFont.Serif,
interval,
BumperSelection.WeightedRandom,
show,
episode
);
Assert.Equal(BumperFont.Serif, channel.BumperFont);
Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes);
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
Assert.Equal(expectedShow, channel.BumperShowChangeChance);
Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance);
}
[Fact]
public void AddBumperTemplate_AppendsWithNextPosition()
{
var channel = NewChannel();
var t1 = channel.AddBumperTemplate("Block 2");
var t2 = channel.AddBumperTemplate("Block 3");
Assert.Equal(1, t1.Position);
Assert.Equal(2, t2.Position);
Assert.False(t1.IsDefault);
Assert.Equal(3, channel.BumperTemplates.Count);
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
}
[Fact]
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
{
var channel = NewChannel();
var def = channel.BumperTemplates[0];
var extra = channel.AddBumperTemplate("Block 2");
Assert.False(channel.RemoveBumperTemplate(def.Id));
Assert.True(channel.RemoveBumperTemplate(extra.Id));
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
Assert.Single(channel.BumperTemplates);
}
[Fact]
public void Shows_AddFindRemoveHas()
{
var channel = NewChannel();
var showId = Guid.NewGuid();
var cs = channel.AddShow(showId, 5, BlockMode.Count, 3);
Assert.Equal(showId, cs.ShowId);
Assert.Equal(5, cs.Weight);
Assert.True(channel.HasShow(showId));
Assert.Equal(cs, channel.FindShow(cs.Id));
Assert.True(channel.RemoveShow(cs.Id));
Assert.False(channel.RemoveShow(cs.Id));
Assert.False(channel.HasShow(showId));
}
[Fact]
public void Ads_AppendWithPosition_AndRemove()
{
var channel = NewChannel();
var a1 = channel.AddAd(Guid.NewGuid());
var a2 = channel.AddAd(Guid.NewGuid());
Assert.Equal(0, a1.Position);
Assert.Equal(1, a2.Position);
Assert.True(channel.HasAd(a1.MediaAssetId));
Assert.True(channel.RemoveAd(a1.Id));
Assert.False(channel.RemoveAd(a1.Id));
}
[Fact]
public void AddOverride_OneTime_AndWeekly()
{
var channel = NewChannel();
var one = channel.AddOverride(OverrideMode.Exclusive, Epoch, Epoch.AddHours(2));
Assert.Equal(OverrideRecurrence.OneTime, one.Recurrence);
Assert.Equal(Epoch, one.StartsAtUtc);
var weekly = channel.AddWeeklyOverride(OverrideMode.Boost, 6, 600, 720);
Assert.Equal(OverrideRecurrence.Weekly, weekly.Recurrence);
Assert.Equal(6, weekly.DayOfWeek);
Assert.Equal(600, weekly.StartMinute);
Assert.Equal(2, channel.Overrides.Count);
Assert.True(channel.RemoveOverride(one.Id));
Assert.False(channel.RemoveOverride(Guid.NewGuid()));
}
[Fact]
public void Cursors_AreMovedBySetters()
{
var channel = NewChannel();
channel.SetNextAdIndex(7);
channel.SetNextBumperIndex(3);
Assert.Equal(7, channel.NextAdIndex);
Assert.Equal(3, channel.NextBumperIndex);
}
}
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class ChannelTests
{
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
[Fact]
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
{
var channel = NewChannel();
Assert.NotEqual(Guid.Empty, channel.Id);
Assert.Equal("News", channel.Name);
Assert.Equal("news", channel.Slug);
Assert.True(channel.IsEnabled);
Assert.Equal(Epoch, channel.EpochUtc);
Assert.False(channel.BumpersEnabled);
Assert.Equal(BumperSelection.Rotation, channel.BumperSelection);
Assert.Equal(1.0, channel.BumperShowChangeChance);
Assert.Equal(1.0, channel.BumperEpisodeChangeChance);
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
var template = Assert.Single(channel.BumperTemplates);
Assert.True(template.IsDefault);
Assert.Equal(0, template.Position);
Assert.Single(template.Variants); // дефолтный подблок
}
[Fact]
public void UpdateSettings_ChangesFields()
{
var channel = NewChannel();
var filler = Guid.NewGuid();
channel.UpdateSettings("N2", false, true, filler);
Assert.Equal("N2", channel.Name);
Assert.False(channel.IsEnabled);
Assert.True(channel.BumpersEnabled);
Assert.Equal(filler, channel.FillerAssetId);
}
[Theory]
[InlineData(-1, 2.0, -5.0, 1.0, 0.0)]
[InlineData(30, 0.3, 0.9, 0.3, 0.9)]
public void UpdateBumperSettings_ClampsChancesAndInterval(
int interval,
double show,
double episode,
double expectedShow,
double expectedEpisode
)
{
var channel = NewChannel();
channel.UpdateBumperSettings(
BumperFont.Serif,
interval,
BumperSelection.WeightedRandom,
show,
episode
);
Assert.Equal(BumperFont.Serif, channel.BumperFont);
Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes);
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
Assert.Equal(expectedShow, channel.BumperShowChangeChance);
Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance);
}
[Fact]
public void AddBumperTemplate_AppendsWithNextPosition()
{
var channel = NewChannel();
var t1 = channel.AddBumperTemplate("Block 2");
var t2 = channel.AddBumperTemplate("Block 3");
Assert.Equal(1, t1.Position);
Assert.Equal(2, t2.Position);
Assert.False(t1.IsDefault);
Assert.Equal(3, channel.BumperTemplates.Count);
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
}
[Fact]
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
{
var channel = NewChannel();
var def = channel.BumperTemplates[0];
var extra = channel.AddBumperTemplate("Block 2");
Assert.False(channel.RemoveBumperTemplate(def.Id));
Assert.True(channel.RemoveBumperTemplate(extra.Id));
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
Assert.Single(channel.BumperTemplates);
}
[Fact]
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
{
var channel = NewChannel();
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
Assert.Null(channel.Number);
Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
}
[Fact]
public void SetTemplate_LinksAndUnlinks()
{
var channel = NewChannel();
var templateId = Guid.NewGuid();
channel.SetTemplate(templateId);
Assert.Equal(templateId, channel.TemplateId);
channel.SetTemplate(null);
Assert.Null(channel.TemplateId);
}
}
@@ -1,56 +1,41 @@
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Settings;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
public class DomainRecordFactoryTests
{
[Fact]
public void ChannelShowHour_Contains_HalfOpenWindow()
{
var cs = Channel
.Create("c", "c", DateTimeOffset.UnixEpoch)
.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1);
cs.SetPreferredHours(2, [(9, 12)]);
var hour = Assert.Single(cs.PreferredHours);
Assert.False(hour.Contains(8));
Assert.True(hour.Contains(9));
Assert.True(hour.Contains(11));
Assert.False(hour.Contains(12));
}
[Fact]
public void BumperAsset_Create_StoresPair()
{
var channel = Guid.NewGuid();
var template = Guid.NewGuid();
var variant = Guid.NewGuid();
var from = Guid.NewGuid();
var to = Guid.NewGuid();
var asset = Guid.NewGuid();
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
Assert.Equal(channel, b.ChannelId);
Assert.Equal(template, b.TemplateId);
Assert.Equal(variant, b.VariantId);
Assert.Equal(from, b.FromShowId);
Assert.Equal(to, b.ToShowId);
Assert.Equal("sig", b.Signature);
Assert.Equal(asset, b.MediaAssetId);
}
[Fact]
public void AppSetting_CreateAndSetValue()
{
var s = AppSetting.Create("site.title", "TeleWave");
Assert.Equal("site.title", s.Key);
Assert.Equal("TeleWave", s.Value);
s.SetValue("New");
Assert.Equal("New", s.Value);
}
}
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Settings;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
public class DomainRecordFactoryTests
{
[Fact]
public void BumperAsset_Create_StoresPair()
{
var channel = Guid.NewGuid();
var template = Guid.NewGuid();
var variant = Guid.NewGuid();
var from = Guid.NewGuid();
var to = Guid.NewGuid();
var asset = Guid.NewGuid();
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
Assert.Equal(channel, b.ChannelId);
Assert.Equal(template, b.TemplateId);
Assert.Equal(variant, b.VariantId);
Assert.Equal(from, b.FromShowId);
Assert.Equal(to, b.ToShowId);
Assert.Equal("sig", b.Signature);
Assert.Equal(asset, b.MediaAssetId);
}
[Fact]
public void AppSetting_CreateAndSetValue()
{
var s = AppSetting.Create("site.title", "TeleWave");
Assert.Equal("site.title", s.Key);
Assert.Equal("TeleWave", s.Value);
s.SetValue("New");
Assert.Equal("New", s.Value);
}
}
@@ -1,85 +0,0 @@
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class ProgrammingOverrideTests
{
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Channel NewChannel() => Channel.Create("c", "c", Epoch);
[Fact]
public void OneTime_Covers_OnlyInsideWindow()
{
var ovr = NewChannel()
.AddOverride(OverrideMode.Exclusive, Epoch.AddHours(1), Epoch.AddHours(3));
Assert.False(ovr.Covers(Epoch.AddMinutes(59)));
Assert.True(ovr.Covers(Epoch.AddHours(1)));
Assert.True(ovr.Covers(Epoch.AddHours(2)));
Assert.False(ovr.Covers(Epoch.AddHours(3))); // конец исключается
}
[Fact]
public void AddShow_AttachesWithWeight()
{
var ovr = NewChannel().AddOverride(OverrideMode.Boost, Epoch, Epoch.AddHours(1));
var showId = Guid.NewGuid();
var os = ovr.AddShow(showId, 4);
Assert.Equal(showId, os.ShowId);
Assert.Equal(4, os.Weight);
Assert.Single(ovr.Shows);
}
[Fact]
public void Weekly_Covers_MatchingDayAndMinuteWindow()
{
// Epoch = 2026-01-01 — четверг (DayOfWeek=4).
var day = (int)Epoch.UtcDateTime.DayOfWeek;
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 720); // 10:0012:00
Assert.True(ovr.Covers(Epoch.AddMinutes(600))); // 10:00 того же дня
Assert.True(ovr.Covers(Epoch.AddMinutes(719))); // 11:59
Assert.False(ovr.Covers(Epoch.AddMinutes(720))); // 12:00 — конец исключается
Assert.False(ovr.Covers(Epoch.AddMinutes(599))); // 09:59
}
[Fact]
public void Weekly_Covers_NextWeekSameDay()
{
var day = (int)Epoch.UtcDateTime.DayOfWeek;
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 0, 1440);
Assert.True(ovr.Covers(Epoch.AddDays(7).AddHours(5)));
Assert.False(ovr.Covers(Epoch.AddDays(1).AddHours(5))); // другой день недели
}
[Fact]
public void Weekly_Covers_WindowCrossingMidnight()
{
// Epoch = 2026-01-01 — четверг (DayOfWeek=4). Окно 23:0001:00 пересекает полночь на пятницу.
var day = (int)Epoch.UtcDateTime.DayOfWeek;
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 1380, 60); // 23:0001:00
Assert.True(ovr.Covers(Epoch.AddMinutes(1380))); // чт 23:00 — начало
Assert.True(ovr.Covers(Epoch.AddMinutes(1439))); // чт 23:59
Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(0))); // пт 00:00 (следующий день)
Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(59))); // пт 00:59
Assert.False(ovr.Covers(Epoch.AddDays(1).AddMinutes(60))); // пт 01:00 — конец исключается
Assert.False(ovr.Covers(Epoch.AddMinutes(1379))); // чт 22:59 — до начала
Assert.False(ovr.Covers(Epoch.AddHours(12))); // чт полдень — вне окна
}
[Fact]
public void Weekly_EmptyWindow_WhenStartEqualsEnd_CoversNothing()
{
var day = (int)Epoch.UtcDateTime.DayOfWeek;
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 600);
Assert.False(ovr.Covers(Epoch.AddMinutes(600)));
Assert.False(ovr.Covers(Epoch.AddMinutes(700)));
}
}
@@ -1,877 +0,0 @@
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class SchedulePlannerTests
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>Источник случайности с фиксированной (циклической) последовательностью значений.</summary>
private sealed class FixedRandom(params int[] values) : IRandomSource
{
private int _i;
public int Next(int maxExclusive) =>
values.Length == 0 ? 0 : values[_i++ % values.Length] % maxExclusive;
}
private static Dictionary<Guid, TimeSpan> Durations(params (Guid Id, int Minutes)[] items) =>
items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes));
private static readonly Guid DefaultTemplate = Guid.NewGuid();
private static readonly Guid DefaultVariant = Guid.NewGuid();
/// <summary>Конфиг заставок с одним дефолтным подблоком (8с) заданного триггера, если варианты не заданы.</summary>
private static PlannerBumperConfig Bumper(
bool enabled,
BumperTrigger trigger,
TimeSpan minInterval,
BumperSelection selection = BumperSelection.Rotation,
params PlannerBumperVariant[] variants
) =>
new(
enabled,
minInterval,
selection,
variants.Length == 0
?
[
new PlannerBumperVariant(
DefaultVariant,
DefaultTemplate,
TimeSpan.FromSeconds(8),
trigger
),
]
: variants
);
[Fact]
public void Count_Block_ProducesConsecutiveEpisodes_BackToBack()
{
var cs = Guid.NewGuid();
var show = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
var input = new PlannerInput(
ChannelId: Guid.NewGuid(),
AdInsertion: AdInsertion.BetweenBlocks,
AdsPerBreak: 1,
NextAdIndex: 0,
Shows: [new PlannerShow(cs, show, 1, BlockMode.Count, 3, eps, 0)],
AdPool: [],
Durations: Durations((eps[0], 2), (eps[1], 2), (eps[2], 2), (eps[3], 2)),
Overrides: [],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(3, result.Entries.Count);
Assert.All(result.Entries, e => Assert.Equal(ScheduleEntryKind.Program, e.Kind));
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
Assert.Equal(Start, result.Entries[0].StartsAtUtc);
Assert.Equal(result.Entries[0].EndsAtUtc, result.Entries[1].StartsAtUtc);
Assert.Equal(result.Entries[1].EndsAtUtc, result.Entries[2].StartsAtUtc);
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
}
[Fact]
public void Count_Block_WrapsAtEndOfSeries()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
var input = BaseInput(
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 3, eps, 0)],
Durations((eps[0], 2), (eps[1], 2)),
horizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal([0, 1, 0], result.Entries.Select(e => e.EpisodeIndex));
Assert.Equal(1, result.NextEpisodeIndexByChannelShow[cs]);
}
[Fact]
public void Duration_Block_TakesEpisodesUntilBudgetReached()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
var input = BaseInput(
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Duration, 25, eps, 0)],
Durations((eps[0], 10), (eps[1], 10), (eps[2], 10), (eps[3], 10)),
horizonEnd: Start.AddMinutes(30)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
// 10+10+10 = 30 >= 25 → три серии.
Assert.Equal(3, result.Entries.Count);
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
}
[Fact]
public void AdsBetweenBlocks_InsertsAdAfterBlock()
{
var cs = Guid.NewGuid();
Guid ep = Guid.NewGuid(),
ad0 = Guid.NewGuid(),
ad1 = Guid.NewGuid();
var input = new PlannerInput(
Guid.NewGuid(),
AdInsertion.BetweenBlocks,
AdsPerBreak: 1,
NextAdIndex: 0,
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, [ep], 0)],
AdPool: [ad0, ad1],
Durations: Durations((ep, 20), (ad0, 1), (ad1, 1)),
Overrides: [],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(2, result.Entries.Count);
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
Assert.Equal(ScheduleEntryKind.Ad, result.Entries[1].Kind);
Assert.Equal(ad0, result.Entries[1].MediaAssetId);
Assert.Equal(1, result.NextAdIndex);
}
[Fact]
public void AdsBetweenEpisodes_InsertsAdAfterEachEpisode()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
Guid ad = Guid.NewGuid();
var input = new PlannerInput(
Guid.NewGuid(),
AdInsertion.BetweenEpisodes,
AdsPerBreak: 1,
NextAdIndex: 0,
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 2, eps, 0)],
AdPool: [ad],
Durations: Durations((eps[0], 20), (eps[1], 20), (ad, 1)),
Overrides: [],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(
[
ScheduleEntryKind.Program,
ScheduleEntryKind.Ad,
ScheduleEntryKind.Program,
ScheduleEntryKind.Ad,
],
result.Entries.Select(e => e.Kind)
);
Assert.Equal(2, result.NextAdIndex);
}
[Fact]
public void WeightedPick_RespectsRoll()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
3,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var pickA = SchedulePlanner.Plan(
BaseInput([a, b], durations, Start.AddSeconds(1)),
new FixedRandom(0)
);
var pickB = SchedulePlanner.Plan(
BaseInput([a, b], durations, Start.AddSeconds(1)),
new FixedRandom(3)
);
Assert.Equal(a.ShowId, pickA.Entries[0].ShowId);
Assert.Equal(b.ShowId, pickB.Entries[0].ShowId);
}
[Fact]
public void ExclusiveOverride_ForcesSingleShow()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
10,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = new PlannerInput(
Guid.NewGuid(),
AdInsertion.BetweenBlocks,
0,
0,
[a, b],
[],
durations,
Overrides:
[
new PlannerOverride(
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 1)],
StartsAtUtc: Start,
EndsAtUtc: Start.AddHours(1)
),
],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
}
[Fact]
public void Bumpers_InsertedOnTransitionBetweenDifferentShows_BackToBack()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero),
};
// Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2).
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
// Перед первым блоком заставки нет; далее по одной на каждый переход.
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.NotEmpty(bumpers);
var first = result.Entries[1];
Assert.Equal(ScheduleEntryKind.Bumper, first.Kind);
Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор
Assert.Equal(a.ShowId, first.FromShowId);
Assert.Equal(b.ShowId, first.ToShowId);
Assert.Equal(DefaultTemplate, first.BumperTemplateId);
Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc);
// Встык: программа → заставка → программа.
Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc);
Assert.Equal(first.EndsAtUtc, result.Entries[2].StartsAtUtc);
}
[Fact]
public void Bumpers_SkippedForSameShow_WhenOnlyBetweenDifferentShows()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
var input = BaseInput(
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
Durations((eps[0], 20), (eps[1], 20)),
horizonEnd: Start.AddMinutes(50)
) with
{
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_InsertedForSameShow_WhenNotRestrictedToDifferent()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
var input = BaseInput(
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
Durations((eps[0], 20), (eps[1], 20)),
horizonEnd: Start.AddMinutes(50)
) with
{
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Contains(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_MinInterval_SuppressesTooFrequentBumpers()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
// Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй.
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.FromMinutes(30)),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
Assert.Equal(1, result.Entries.Count(e => e.Kind == ScheduleEntryKind.Bumper));
}
[Fact]
public void Bumpers_Rotation_CyclesTemplatesAndAdvancesCursor()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
Guid t0 = Guid.NewGuid(),
t1 = Guid.NewGuid();
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
{
Bumpers = Bumper(
true,
BumperTrigger.Both,
TimeSpan.Zero,
BumperSelection.Rotation,
new PlannerBumperVariant(
Guid.NewGuid(),
t0,
TimeSpan.FromSeconds(8),
BumperTrigger.Both
),
new PlannerBumperVariant(
Guid.NewGuid(),
t1,
TimeSpan.FromSeconds(4),
BumperTrigger.Both
)
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.True(bumpers.Count >= 2);
// Ротация: первый подблок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу.
Assert.Equal(t0, bumpers[0].BumperTemplateId);
Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc);
Assert.Equal(t1, bumpers[1].BumperTemplateId);
Assert.Equal(TimeSpan.FromSeconds(4), bumpers[1].EndsAtUtc - bumpers[1].StartsAtUtc);
Assert.All(bumpers, e => Assert.Equal(Guid.Empty, e.MediaAssetId));
Assert.Equal(bumpers.Count, result.NextBumperIndex);
}
[Fact]
public void Bumpers_AlwaysFirst_AlwaysUsesFirstTemplate()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
Guid t0 = Guid.NewGuid(),
t1 = Guid.NewGuid();
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
{
Bumpers = Bumper(
true,
BumperTrigger.Both,
TimeSpan.Zero,
BumperSelection.AlwaysFirst,
new PlannerBumperVariant(
Guid.NewGuid(),
t0,
TimeSpan.FromSeconds(8),
BumperTrigger.Both
),
new PlannerBumperVariant(
Guid.NewGuid(),
t1,
TimeSpan.FromSeconds(4),
BumperTrigger.Both
)
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.True(bumpers.Count >= 2);
Assert.All(bumpers, e => Assert.Equal(t0, e.BumperTemplateId));
Assert.Equal(0, result.NextBumperIndex); // курсор ротации не двигается
}
[Fact]
public void Bumpers_NoTemplates_NoBumpers()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = new PlannerBumperConfig(
true,
MinInterval: TimeSpan.Zero,
Selection: BumperSelection.Rotation,
Variants: []
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_Disabled_ProduceNoBumperEntries()
{
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = Bumper(false, BumperTrigger.OnShowChange, TimeSpan.Zero),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_ShowChangeChanceZero_SuppressesBumpersOnShowChange()
{
// Два шоу, ротация выбора подблока (без random в пути заставки) → чередование гарантировано,
// каждый переход — смена шоу. ShowChangeChance=0 глушит их, EpisodeChangeChance=1 не при чём.
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(120)) with
{
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with
{
ShowChangeChance = 0.0,
},
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_EpisodeChangeChanceZero_SuppressesBumpersBetweenEpisodes()
{
// Одно шоу → каждый переход между блоками одного шоу. EpisodeChangeChance=0 глушит все заставки.
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20));
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
{
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with
{
EpisodeChangeChance = 0.0,
},
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_WeightedRandom_NeverPicksZeroWeightVariant()
{
// Взвешенный выбор: подблок с весом 0 не выбирается никогда, весь трафик уходит на t1 (вес 5).
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20));
Guid t0 = Guid.NewGuid(),
t1 = Guid.NewGuid();
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
{
Bumpers = Bumper(
true,
BumperTrigger.Both,
TimeSpan.Zero,
BumperSelection.WeightedRandom,
new PlannerBumperVariant(
Guid.NewGuid(),
t0,
TimeSpan.FromSeconds(8),
BumperTrigger.Both,
0
),
new PlannerBumperVariant(
Guid.NewGuid(),
t1,
TimeSpan.FromSeconds(8),
BumperTrigger.Both,
5
)
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1, 2, 3, 4));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.NotEmpty(bumpers);
Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId));
}
[Fact]
public void WeeklyOverride_AppliesOnMatchingDayAndTime()
{
// Start = 2026-01-01 (четверг, DayOfWeek=4), полночь. Еженедельный override на четверг 00:0001:00.
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
{
Overrides =
[
new PlannerOverride(
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 1)],
OverrideRecurrence.Weekly,
DayOfWeek: (int)Start.UtcDateTime.DayOfWeek,
StartMinute: 0,
EndMinute: 60
),
],
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
}
[Fact]
public void WeeklyOverride_IgnoredOnOtherDay()
{
// Override на другой день недели → базовая ротация (FixedRandom(0) берёт первое шоу — a).
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var otherDay = ((int)Start.UtcDateTime.DayOfWeek + 1) % 7;
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
{
Overrides =
[
new PlannerOverride(
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 1)],
OverrideRecurrence.Weekly,
DayOfWeek: otherDay,
StartMinute: 0,
EndMinute: 1440
),
],
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(a.ShowId, result.Entries[0].ShowId);
}
[Fact]
public void PreferredHours_BoostsWeight_InsideWindow()
{
// Час старта — 0 (UTC). Окно b покрывает его, множитель 100 → b доминирует, хотя базовые веса равны.
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0,
PreferredHours: [new PlannerHourWindow(0, 24)],
PreferredWeightMultiplier: 100
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(b.ShowId, e.ShowId));
}
[Fact]
public void PreferredHours_OutsideWindow_KeepsBaseWeight()
{
// Час старта — 0, окно b — [10,12): не покрывает → буста нет, при FixedRandom(50) берётся первое шоу (a).
var a = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0
);
var b = new PlannerShow(
Guid.NewGuid(),
Guid.NewGuid(),
1,
BlockMode.Count,
1,
[Guid.NewGuid()],
0,
PreferredHours: [new PlannerHourWindow(10, 12)],
PreferredWeightMultiplier: 100
);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(a.ShowId, e.ShowId));
}
[Fact]
public void NoPlayableShows_ReturnsEmpty()
{
var input = BaseInput([], new Dictionary<Guid, TimeSpan>(), Start.AddHours(1));
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Empty(result.Entries);
}
private static PlannerInput BaseInput(
IReadOnlyList<PlannerShow> shows,
IReadOnlyDictionary<Guid, TimeSpan> durations,
DateTimeOffset horizonEnd
) =>
new(
ChannelId: Guid.NewGuid(),
AdInsertion: AdInsertion.BetweenBlocks,
AdsPerBreak: 0,
NextAdIndex: 0,
Shows: shows,
AdPool: [],
Durations: durations,
Overrides: [],
StartTime: Start,
HorizonEnd: horizonEnd
);
}