using System.Globalization;
using System.Text;
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;
///
/// Золотые тесты планировщика: для набора эталонных конфигураций фиксируется посуточная лента
/// целиком. Любая правка стратегий или подгонки сразу показывает построчный диф — что именно
/// поехало, — вместо «упало N тестов» через неделю после того, как это ушло в эфир.
///
/// Эталон обновляется осознанно: переписыванием ожидаемой строки в тесте, а не автоматически при
/// расхождении. Автообновление превратило бы их в тесты, которые всегда зелёные.
///
public class GoldenScheduleTests
{
private static readonly DateTimeOffset Day = new(2026, 3, 2, 6, 0, 0, TimeSpan.Zero);
/// Псевдослучайность с фиксированным зерном: жребий обязан быть воспроизводимым.
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;
}
}
/// Лента в компактном виде: время начала, тип, шоу и номер единицы — построчно.
private static List Render(
PlanningResult result,
IReadOnlyDictionary 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 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
{
[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))
);
}
}