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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Library;
|
||||
|
||||
public class CollectionTests
|
||||
{
|
||||
private static IReadOnlyList<Guid> Order(Collection collection) =>
|
||||
collection.Items.OrderBy(i => i.Position).Select(i => i.ShowId).ToList();
|
||||
|
||||
[Fact]
|
||||
public void AddShow_AppendsToEnd()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var first = Guid.NewGuid();
|
||||
var second = Guid.NewGuid();
|
||||
|
||||
collection.AddShow(first);
|
||||
collection.AddShow(second);
|
||||
|
||||
Assert.Equal([first, second], Order(collection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddShow_Duplicate_ReturnsNull()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
collection.AddShow(showId);
|
||||
|
||||
Assert.Null(collection.AddShow(showId));
|
||||
Assert.Single(collection.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveShow_ReturnsFalse_WhenNotPresent()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
|
||||
Assert.False(collection.RemoveShow(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_AppliesRequestedOrder()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
var c = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
collection.AddShow(b);
|
||||
collection.AddShow(c);
|
||||
|
||||
collection.Reorder([c, a, b]);
|
||||
|
||||
Assert.Equal([c, a, b], Order(collection));
|
||||
Assert.Equal([0, 1, 2], collection.Items.OrderBy(i => i.Position).Select(i => i.Position));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_KeepsUnmentionedAfterRequested_InOriginalOrder()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
var c = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
collection.AddShow(b);
|
||||
collection.AddShow(c);
|
||||
|
||||
// Перетащили только один элемент — остальные не должны потеряться или перемешаться.
|
||||
collection.Reorder([c]);
|
||||
|
||||
Assert.Equal([c, a, b], Order(collection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_IgnoresUnknownShows()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
|
||||
collection.Reorder([Guid.NewGuid(), a]);
|
||||
|
||||
Assert.Equal([a], Order(collection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveThenAdd_DoesNotReusePosition()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
collection.AddShow(b);
|
||||
|
||||
collection.RemoveShow(a);
|
||||
var c = Guid.NewGuid();
|
||||
collection.AddShow(c);
|
||||
|
||||
// Новая часть встаёт после существующих, а не занимает освободившийся номер.
|
||||
Assert.Equal([b, c], Order(collection));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Library;
|
||||
|
||||
public class ShowGenreTests
|
||||
{
|
||||
private static Show NewShow() => Show.Create("A", ShowKind.Series);
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_MarksRequestedPrimary()
|
||||
{
|
||||
var show = NewShow();
|
||||
var action = Guid.NewGuid();
|
||||
var comedy = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([action, comedy], comedy);
|
||||
|
||||
Assert.Equal(2, show.Genres.Count);
|
||||
Assert.Equal(comedy, show.PrimaryGenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_FallsBackToFirst_WhenPrimaryNotInSet()
|
||||
{
|
||||
var show = NewShow();
|
||||
var action = Guid.NewGuid();
|
||||
var comedy = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([action, comedy], Guid.NewGuid());
|
||||
|
||||
// Шоу с жанрами не должно оставаться без основного — иначе список показывал бы прочерк.
|
||||
Assert.Equal(action, show.PrimaryGenreId);
|
||||
Assert.Single(show.Genres, g => g.IsPrimary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_DropsDuplicatesAndEmpty()
|
||||
{
|
||||
var show = NewShow();
|
||||
var action = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([action, action, Guid.Empty]);
|
||||
|
||||
Assert.Single(show.Genres);
|
||||
Assert.Equal(action, show.PrimaryGenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_ReplacesPreviousSet()
|
||||
{
|
||||
var show = NewShow();
|
||||
var first = Guid.NewGuid();
|
||||
var second = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([first]);
|
||||
show.SetGenres([second]);
|
||||
|
||||
Assert.Single(show.Genres);
|
||||
Assert.Equal(second, show.PrimaryGenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_Empty_LeavesNoPrimary()
|
||||
{
|
||||
var show = NewShow();
|
||||
show.SetGenres([Guid.NewGuid()]);
|
||||
|
||||
show.SetGenres([]);
|
||||
|
||||
Assert.Empty(show.Genres);
|
||||
Assert.Null(show.PrimaryGenreId);
|
||||
}
|
||||
}
|
||||
|
||||
public class GenreTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(" Sci-Fi ", "sci-fi")]
|
||||
[InlineData("Action & Adventure", "action & adventure")]
|
||||
[InlineData("БОЕВИК", "боевик")]
|
||||
[InlineData(" ", "")]
|
||||
[InlineData(null, "")]
|
||||
public void Normalize_CollapsesCaseAndSpacing(string? input, string expected) =>
|
||||
Assert.Equal(expected, GenreAlias.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public void AddAlias_IgnoresDuplicatesRegardlessOfSpelling()
|
||||
{
|
||||
var genre = Genre.Create("Боевик", "action");
|
||||
|
||||
genre.AddAlias("Action");
|
||||
genre.AddAlias(" action ");
|
||||
genre.AddAlias("боевик");
|
||||
|
||||
Assert.Equal(2, genre.Aliases.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAliases_DropsOldOnes()
|
||||
{
|
||||
var genre = Genre.Create("Боевик", "action");
|
||||
genre.AddAlias("tmdb:28");
|
||||
|
||||
genre.ReplaceAliases(["tmdb:10759"]);
|
||||
|
||||
Assert.Single(genre.Aliases);
|
||||
Assert.Equal("tmdb:10759", genre.Aliases[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProviderKey_BuildsNormalizedPrefixedAlias() =>
|
||||
Assert.Equal("tmdb:28", GenreAlias.ProviderKey("TMDB", "28"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user