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:
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user