Files
TeleWave/backend/tests/TeleWave.Application.Tests/Programming/GridJunctionPreviewTests.cs
T
Leonid Pershin 3d2ae20368
ci / build-backend (push) Successful in 1m27s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 10m43s
ci / sonar (push) Successful in 6m18s
Refactor CI workflow for improved test coverage reporting and enhance BumperPlaceholders logic
Updated the CI workflow to run tests for individual projects, ensuring accurate coverage reports are generated for each module. Enhanced the BumperPlaceholders class to handle dangling separators more effectively, improving text resolution logic and maintaining clarity in output.
2026-07-27 22:46:02 +03:00

258 lines
9.8 KiB
C#

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;
/// <summary>
/// Разворачивание стыка генератором: то, что происходит между шаблоном стыка в базе и чистым
/// планировщиком. Проверяется сухим прогоном — он ничего не пишет, но проходит тот же сбор входа.
/// </summary>
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<IBumperRenderQueue>(), 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;
}
/// <summary>Канал с одним слотом на сутки, группой из двух серий и группой роликов для врезок.</summary>
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<JunctionTemplate> JunctionWithElementsAsync(
AppDbContext db,
Guid junctionId
) => db.JunctionTemplates.Include(j => j.Elements).SingleAsync(j => j.Id == junctionId);
}