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);
/// Источник случайности с фиксированной (циклической) последовательностью значений.
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 Durations(params (Guid Id, int Minutes)[] items) =>
items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes));
[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(
Start,
Start.AddHours(1),
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 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 = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: 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(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 = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: 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 = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: false, MinInterval: 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 = new PlannerBumperConfig(
true,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: 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_StaticMode_UsesJinglesFromPoolInRotation()
{
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);
Guid j0 = Guid.NewGuid(),
j1 = Guid.NewGuid();
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1), (j1, 1));
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = new PlannerBumperConfig(
true,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: TimeSpan.Zero,
Mode: BumperMode.Static,
JinglePool: [j0, j1]
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.Equal(2, bumpers.Count);
Assert.Equal([j0, j1], bumpers.Select(e => e.MediaAssetId)); // ротация пула
Assert.All(bumpers, e => Assert.Null(e.FromShowId)); // статик — не по паре шоу
Assert.Equal(2, result.NextJingleIndex);
}
[Fact]
public void Bumpers_StaticMode_EmptyPool_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,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: TimeSpan.Zero,
Mode: BumperMode.Static,
JinglePool: []
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_BothMode_AlternatesDynamicAndStatic()
{
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 j0 = Guid.NewGuid();
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1));
// Три перехода: динамика, джингл, динамика.
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
{
Bumpers = new PlannerBumperConfig(
true,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: TimeSpan.Zero,
Mode: BumperMode.Both,
JinglePool: [j0]
),
};
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.Equal(Guid.Empty, bumpers[0].MediaAssetId); // первый — динамический (плейсхолдер)
Assert.Equal(j0, bumpers[1].MediaAssetId); // второй — статичный джингл
}
[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 = new PlannerBumperConfig(false, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void NoPlayableShows_ReturnsEmpty()
{
var input = BaseInput([], new Dictionary(), Start.AddHours(1));
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Empty(result.Entries);
}
private static PlannerInput BaseInput(
IReadOnlyList shows,
IReadOnlyDictionary durations,
DateTimeOffset horizonEnd
) =>
new(
ChannelId: Guid.NewGuid(),
AdInsertion: AdInsertion.BetweenBlocks,
AdsPerBreak: 0,
NextAdIndex: 0,
Shows: shows,
AdPool: [],
Durations: durations,
Overrides: [],
StartTime: Start,
HorizonEnd: horizonEnd
);
}