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:
@@ -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:00–12: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:00–01:00 пересекает полночь на пятницу.
|
||||
var day = (int)Epoch.UtcDateTime.DayOfWeek;
|
||||
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 1380, 60); // 23:00–01: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:00–01: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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user