using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
///
/// Витрина автосборки: каталог профилей, предпросмотр плана и его перевод в DTO. Предпросмотр —
/// то, по чему админ принимает решение, поэтому проверяется отдельно от самой раскладки.
///
public class GridPlanPreviewTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static PlannedSlot Slot(
string title,
SlotKind kind,
SlotBlockMode blockMode,
int blockValue,
int? weekday = null
) =>
new(
GridPlanLayer.Main,
weekday,
new TimeOnly(20, 0),
120,
title,
Daypart.Prime,
kind,
kind == SlotKind.Content ? Guid.NewGuid() : null,
kind == SlotKind.Content ? title : null,
blockMode,
blockValue,
null,
null,
IsAnchor: false
);
[Fact]
public async Task Profiles_AreListed_WithReferenceAndDescription()
{
var result = await new ListGridProfilesQueryHandler().Handle(
new ListGridProfilesQuery(),
CancellationToken.None
);
Assert.True(result.IsSuccess);
// Каждый профиль должен объяснять себя: имя, на что похоже и для какой библиотеки годится.
Assert.Equal(GridProfiles.All.Count, result.Value.Count);
Assert.All(
result.Value,
profile =>
{
Assert.False(string.IsNullOrWhiteSpace(profile.Name));
Assert.False(string.IsNullOrWhiteSpace(profile.Reference));
Assert.False(string.IsNullOrWhiteSpace(profile.Description));
}
);
Assert.Contains(result.Value, p => p.Kind == GridProfileKind.Animation);
}
[Fact]
public void Mapper_DescribesEveryBlockKind()
{
var plan = new GridPlan(
GridProfiles.Get(GridProfileKind.Mixed),
GridGenerationMode.Rebuild,
[
Slot("Сериалы", SlotKind.Content, SlotBlockMode.Count, 3),
Slot("Кино", SlotKind.Content, SlotBlockMode.FillSlot, 1),
Slot("Полоса", SlotKind.Content, SlotBlockMode.Duration, 90, weekday: 6),
Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1),
Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1),
],
[new PlanNote(false, "замечание")],
SlotsToRemove: 4,
FallbackGroupId: Guid.NewGuid(),
FallbackGroupName: "Фон",
FreeMinutes: 10080,
CoveredMinutes: 5040
);
var dto = plan.ToDto();
Assert.Equal(GridProfileKind.Mixed, dto.Profile);
Assert.Equal(plan.Profile.Name, dto.ProfileName);
Assert.Equal(4, dto.SlotsToRemove);
Assert.Equal("Фон", dto.FallbackGroupName);
Assert.Equal(["замечание"], dto.Notes.Select(n => n.Text));
// Описание блока — то, что читает админ вместо режима и числа.
Assert.Equal(
["3 подряд", "до конца слота", "90 мин", "повтор", "конец вещания"],
dto.Slots.Select(s => s.Block)
);
// Время приезжает строкой «ЧЧ:ММ»: в предпросмотре оно только показывается.
Assert.All(dto.Slots, slot => Assert.Equal("20:00", slot.Start));
Assert.Equal(6, dto.Slots[2].Weekday);
Assert.Null(dto.Slots[0].Weekday);
// У неконтентных слотов группы нет — показывать в колонке нечего.
Assert.Null(dto.Slots[3].GroupName);
Assert.Equal("Сериалы", dto.Slots[0].GroupName);
}
[Fact]
public async Task Preview_ReturnsPlanForProfile()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
await using var db = fixture.New();
var result = await new PreviewGeneratedGridQueryHandler(
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
).Handle(
new PreviewGeneratedGridQuery(
channelId,
GridProfileKind.Kids,
GridGenerationMode.Rebuild
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(GridProfileKind.Kids, result.Value.Profile);
Assert.NotEmpty(result.Value.Slots);
// Пересборка сносит то, что было, и это видно до нажатия.
Assert.True(result.Value.SlotsToRemove >= 0);
Assert.Equal(7 * GridCoverage.MinutesInDay, result.Value.FreeMinutes);
// У детского профиля ночь — конец вещания, и в предпросмотре он подписан именно так.
Assert.Contains(result.Value.Slots, s => s.Block == "конец вещания");
}
[Fact]
public async Task Preview_FailsWhenChannelIsGone()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new PreviewGeneratedGridQueryHandler(
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
).Handle(
new PreviewGeneratedGridQuery(
Guid.NewGuid(),
GridProfileKind.Mixed,
GridGenerationMode.FillGaps
),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public void Validator_RejectsUnknownProfileAndMode()
{
var validator = new GenerateGridCommandValidator();
Assert.True(
validator
.Validate(
new GenerateGridCommand(
Guid.NewGuid(),
GridProfileKind.Sitcom,
GridGenerationMode.Rebuild
)
)
.IsValid
);
// Значения enum приходят с клиента строкой — за пределами каталога они бессмысленны.
Assert.False(
validator
.Validate(
new GenerateGridCommand(
Guid.NewGuid(),
(GridProfileKind)42,
GridGenerationMode.FillGaps
)
)
.IsValid
);
}
/// Канал с одной группой мягкого рейтинга — чтобы детскому профилю было чем закрыться.
private static async Task SeedAsync(TestDb fixture)
{
var show = Show.Create("Мультсериал", ShowKind.Series);
show.SetAudience(ShowAudience.G);
var assets = new List();
for (var i = 0; i < 20; i++)
{
var asset = MediaAsset.Register($"e{i}.mkv", ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
TimeSpan.FromMinutes(22),
6,
220,
1920,
1080,
"h264",
"aac",
$"assets/e{i}"
)
);
assets.Add(asset);
show.AddEpisode(asset.Id);
}
var group = Group.Create("Мультфильмы");
group.AddElement(GroupElementKind.Show, show.Id);
var channel = Channel.Create("Детский", "kids", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.MediaAssets.AddRange(assets);
seed.Shows.Add(show);
seed.Groups.Add(group);
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return channel.Id;
}
}