190 lines
7.6 KiB
C#
190 lines
7.6 KiB
C#
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;
|
|
|
|
/// <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))
|
|
);
|
|
}
|
|
}
|