using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
///
/// Разворачивание стыка генератором: то, что происходит между шаблоном стыка в базе и чистым
/// планировщиком. Проверяется сухим прогоном — он ничего не пишет, но проходит тот же сбор входа.
///
public class GridJunctionPreviewTests
{
private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero);
private sealed class FirstAlways : IRandomSource
{
public int Next(int maxExclusive) => 0;
}
private static GridScheduleGenerator Generator(AppDbContext db) =>
new(
db,
new GroupExpander(db, GroupServices.Dynamic(db)),
new BumperResolver(db, Substitute.For(), new FirstAlways()),
new PostCheckRunner(db),
new FirstAlways(),
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
private static MediaAsset ReadyAsset(string name, TimeSpan duration)
{
var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
duration,
2,
(int)(duration.TotalSeconds / 2),
1920,
1080,
"h264",
"aac",
"assets/x"
)
);
return asset;
}
/// Канал с одним слотом на сутки, группой из двух серий и группой роликов для врезок.
private static async Task<(
TestDb Fixture,
Guid ChannelId,
JunctionTemplate Junction
)> SeedAsync(Daypart daypart = Daypart.Prime)
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var show = Show.Create("Симпсоны", ShowKind.Series);
var episodes = new[]
{
ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)),
ReadyAsset("s01e02.mkv", TimeSpan.FromMinutes(20)),
};
foreach (var episode in episodes)
show.AddEpisode(episode.Id);
var spot = Show.Create("Ролик", ShowKind.Interstitial);
var spotAsset = ReadyAsset("spot.mkv", TimeSpan.FromSeconds(30));
spot.AddEpisode(spotAsset.Id);
var content = Group.Create("Мультсериалы");
content.AddElement(GroupElementKind.Show, show.Id);
var ads = Group.Create("Реклама");
ads.AddElement(GroupElementKind.Show, spot.Id);
var bumper = BumperTemplate.Create("Блок", "Текст 1");
bumper.SetAudio(".mp3", 8);
bumper
.Variants[0]
.SetLines([
BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
]);
var junction = JunctionTemplate.Create("Прайм");
junction.Update("Прайм", 600);
junction
.AddElement(JunctionElementKind.Ad)
.Update(
new JunctionElementSettings(
JunctionElementKind.Ad,
ads.Id,
null,
null,
JunctionAmountMode.Count,
1,
true,
null,
1,
null
)
);
junction
.AddElement(JunctionElementKind.Bumper)
.Update(
new JunctionElementSettings(
JunctionElementKind.Bumper,
null,
bumper.Id,
null,
JunctionAmountMode.Count,
1,
false,
null,
1,
// Только в прайме: дейпарт — свойство слота, и отсекается он при сборке входа.
new JunctionConditions(Dayparts: [Daypart.Prime]).ToJson()
)
);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateTiming(null, new TimeOnly(6, 0), 24 * 60, daypart, false, 30, null);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
content.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.Count,
2,
OverflowPolicy.ContinueNext,
junction.Id
)
);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.Shows.AddRange(show, spot);
seed.MediaAssets.AddRange([.. episodes, spotAsset]);
seed.Groups.AddRange(content, ads);
seed.BumperTemplates.Add(bumper);
seed.JunctionTemplates.Add(junction);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return (fixture, channel.Id, junction);
}
[Fact]
public async Task Preview_ExpandsJunctionWithAdsAndBumperReservation()
{
var (fixture, channelId, _) = await SeedAsync();
await using var db = fixture.New();
var result = await Generator(db).PreviewAsync(channelId, T0, 1, CancellationToken.None);
Assert.NotNull(result);
// Слот выходит в каждые вещательные сутки горизонта — врезки повторяются вместе с ним.
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
var bumpers = result.Items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
Assert.NotEmpty(ads);
Assert.Equal(ads.Count, bumpers.Count);
var ad = ads[0];
var bumper = bumpers[0];
// Заставка резервирует длину по звуку (8 с), выровненную на сегмент.
Assert.Equal(TimeSpan.FromSeconds(8), bumper.EndsAtUtc - bumper.StartsAtUtc);
Assert.Equal(TimeSpan.FromSeconds(30), ad.EndsAtUtc - ad.StartsAtUtc);
// Ассет заставки в сухом прогоне не рендерится — только место под него.
Assert.Equal(Guid.Empty, bumper.MediaAssetId);
Assert.NotNull(bumper.BumperTemplateId);
}
[Fact]
public async Task Preview_DropsBreaksOutsideTheirDayparts()
{
var (fixture, channelId, _) = await SeedAsync(Daypart.Night);
await using var db = fixture.New();
var result = await Generator(db).PreviewAsync(channelId, T0, 1, CancellationToken.None);
Assert.NotNull(result);
// Заставка разрешена только в прайме, а слот ночной — в ленте её быть не должно.
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Bumper);
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Ad);
}
[Fact]
public async Task Preview_SkipsBreaksWithoutSource()
{
var (fixture, channelId, junction) = await SeedAsync();
await using (var db = fixture.New())
{
// Врезка ссылается на блок, которого нет: настроена не до конца — играть ей нечем.
var stored = await JunctionWithElementsAsync(db, junction.Id);
var element = stored.Elements.Single(e => e.Kind == JunctionElementKind.Bumper);
element.Update(
new JunctionElementSettings(
JunctionElementKind.Bumper,
null,
Guid.NewGuid(),
null,
JunctionAmountMode.Count,
1,
false,
null,
1,
null
)
);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var context = fixture.New();
var result = await Generator(context)
.PreviewAsync(channelId, T0, 1, CancellationToken.None);
Assert.NotNull(result);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Bumper);
}
[Fact]
public async Task Preview_UnknownChannel_ReturnsNull()
{
var fixture = new TestDb();
await using var db = fixture.New();
Assert.Null(
await Generator(db).PreviewAsync(Guid.NewGuid(), T0, 1, CancellationToken.None)
);
}
private static Task JunctionWithElementsAsync(
AppDbContext db,
Guid junctionId
) => db.JunctionTemplates.Include(j => j.Elements).SingleAsync(j => j.Id == junctionId);
}