Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
+298
-298
@@ -1,298 +1,298 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
|
||||
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
|
||||
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
|
||||
/// </summary>
|
||||
[Collection("postgres")]
|
||||
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
Assert.False(report.ChannelSkipped);
|
||||
Assert.True(report.Added > 0);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = verify
|
||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(entries);
|
||||
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
|
||||
for (var i = 1; i < entries.Count; i++)
|
||||
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
|
||||
|
||||
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
||||
Assert.NotEmpty(programs);
|
||||
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
|
||||
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
|
||||
|
||||
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
|
||||
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
|
||||
Assert.Equal(world.ShowId, state.CurrentElementId);
|
||||
Assert.True(state.NextUnitIndex > 0);
|
||||
|
||||
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
|
||||
Assert.False(
|
||||
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
||||
);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_IsIdempotentWithinHorizon()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var first = fixture.CreateContext();
|
||||
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
await using var second = fixture.CreateContext();
|
||||
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
|
||||
Assert.Equal(0, again.Added);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var first = fixture.CreateContext();
|
||||
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
|
||||
|
||||
await using var beforeDb = fixture.CreateContext();
|
||||
var past = beforeDb
|
||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
Assert.NotEmpty(past);
|
||||
|
||||
await using var rebuild = fixture.CreateContext();
|
||||
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var survived = verify
|
||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
|
||||
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
|
||||
Assert.Equal(past.Count, survived.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var programs = verify
|
||||
.ScheduleEntries.Where(e =>
|
||||
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
|
||||
)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(programs);
|
||||
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
|
||||
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Preview_WritesNothing()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Items);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
|
||||
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
|
||||
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
|
||||
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
|
||||
Assert.True(
|
||||
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
||||
);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_EmptyGroup_FallsBackAndWarns()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 0);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
|
||||
Assert.Contains(
|
||||
report.Warnings,
|
||||
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
|
||||
);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
Assert.All(
|
||||
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
|
||||
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Обвязка ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static GridScheduleGenerator Generator(AppDbContext db)
|
||||
{
|
||||
var random = new SequenceRandom(0);
|
||||
return new GridScheduleGenerator(
|
||||
db,
|
||||
new GroupExpander(db),
|
||||
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
|
||||
new PostCheckRunner(db),
|
||||
random,
|
||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
|
||||
Options.Create(new StreamingOptions())
|
||||
);
|
||||
}
|
||||
|
||||
private sealed record World(
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
Guid SlotId,
|
||||
Guid ShowId,
|
||||
Guid? CollectionId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
|
||||
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
|
||||
/// </summary>
|
||||
private static async Task<World> SeedAsync(
|
||||
AppDbContext db,
|
||||
int episodes,
|
||||
bool asCollection = false
|
||||
)
|
||||
{
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
|
||||
for (var i = 0; i < episodes; i++)
|
||||
{
|
||||
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
|
||||
show.AddEpisode(asset.Id);
|
||||
}
|
||||
db.Shows.Add(show);
|
||||
|
||||
Collection? collection = null;
|
||||
if (asCollection)
|
||||
{
|
||||
collection = Collection.Create($"Франшиза {suffix}");
|
||||
collection.AddShow(show.Id);
|
||||
db.Collections.Add(collection);
|
||||
}
|
||||
|
||||
var group = Group.Create($"Группа {suffix}");
|
||||
group.AddElement(
|
||||
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
|
||||
asCollection ? collection!.Id : show.Id
|
||||
);
|
||||
db.Groups.Add(group);
|
||||
|
||||
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
|
||||
|
||||
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var layer = template.AddLayer("Базовый", 10);
|
||||
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
channel.SetTemplate(template.Id);
|
||||
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
|
||||
template.MarkChanged();
|
||||
|
||||
db.Channels.Add(channel);
|
||||
db.ScheduleTemplates.Add(template);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
|
||||
}
|
||||
|
||||
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
|
||||
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
|
||||
{
|
||||
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
duration,
|
||||
SegmentSeconds: 2,
|
||||
SegmentCount: (int)(duration.TotalSeconds / 2),
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
RelativePath: $"segments/{asset.Id}"
|
||||
)
|
||||
);
|
||||
db.MediaAssets.Add(asset);
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
|
||||
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
|
||||
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
|
||||
/// </summary>
|
||||
[Collection("postgres")]
|
||||
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
Assert.False(report.ChannelSkipped);
|
||||
Assert.True(report.Added > 0);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = verify
|
||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(entries);
|
||||
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
|
||||
for (var i = 1; i < entries.Count; i++)
|
||||
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
|
||||
|
||||
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
||||
Assert.NotEmpty(programs);
|
||||
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
|
||||
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
|
||||
|
||||
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
|
||||
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
|
||||
Assert.Equal(world.ShowId, state.CurrentElementId);
|
||||
Assert.True(state.NextUnitIndex > 0);
|
||||
|
||||
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
|
||||
Assert.False(
|
||||
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
||||
);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_IsIdempotentWithinHorizon()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var first = fixture.CreateContext();
|
||||
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
await using var second = fixture.CreateContext();
|
||||
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
|
||||
Assert.Equal(0, again.Added);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var first = fixture.CreateContext();
|
||||
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
|
||||
|
||||
await using var beforeDb = fixture.CreateContext();
|
||||
var past = beforeDb
|
||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
Assert.NotEmpty(past);
|
||||
|
||||
await using var rebuild = fixture.CreateContext();
|
||||
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var survived = verify
|
||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
|
||||
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
|
||||
Assert.Equal(past.Count, survived.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var programs = verify
|
||||
.ScheduleEntries.Where(e =>
|
||||
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
|
||||
)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(programs);
|
||||
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
|
||||
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Preview_WritesNothing()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 3);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Items);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
|
||||
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
|
||||
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
|
||||
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
|
||||
Assert.True(
|
||||
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
||||
);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_EmptyGroup_FallsBackAndWarns()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 0);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
|
||||
Assert.Contains(
|
||||
report.Warnings,
|
||||
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
|
||||
);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
Assert.All(
|
||||
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
|
||||
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Обвязка ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static GridScheduleGenerator Generator(AppDbContext db)
|
||||
{
|
||||
var random = new SequenceRandom(0);
|
||||
return new GridScheduleGenerator(
|
||||
db,
|
||||
new GroupExpander(db),
|
||||
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
|
||||
new PostCheckRunner(db),
|
||||
random,
|
||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
|
||||
Options.Create(new StreamingOptions())
|
||||
);
|
||||
}
|
||||
|
||||
private sealed record World(
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
Guid SlotId,
|
||||
Guid ShowId,
|
||||
Guid? CollectionId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
|
||||
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
|
||||
/// </summary>
|
||||
private static async Task<World> SeedAsync(
|
||||
AppDbContext db,
|
||||
int episodes,
|
||||
bool asCollection = false
|
||||
)
|
||||
{
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
|
||||
for (var i = 0; i < episodes; i++)
|
||||
{
|
||||
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
|
||||
show.AddEpisode(asset.Id);
|
||||
}
|
||||
db.Shows.Add(show);
|
||||
|
||||
Collection? collection = null;
|
||||
if (asCollection)
|
||||
{
|
||||
collection = Collection.Create($"Франшиза {suffix}");
|
||||
collection.AddShow(show.Id);
|
||||
db.Collections.Add(collection);
|
||||
}
|
||||
|
||||
var group = Group.Create($"Группа {suffix}");
|
||||
group.AddElement(
|
||||
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
|
||||
asCollection ? collection!.Id : show.Id
|
||||
);
|
||||
db.Groups.Add(group);
|
||||
|
||||
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
|
||||
|
||||
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var layer = template.AddLayer("Базовый", 10);
|
||||
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
channel.SetTemplate(template.Id);
|
||||
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
|
||||
template.MarkChanged();
|
||||
|
||||
db.Channels.Add(channel);
|
||||
db.ScheduleTemplates.Add(template);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
|
||||
}
|
||||
|
||||
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
|
||||
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
|
||||
{
|
||||
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
duration,
|
||||
SegmentSeconds: 2,
|
||||
SegmentCount: (int)(duration.TotalSeconds / 2),
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
RelativePath: $"segments/{asset.Id}"
|
||||
)
|
||||
);
|
||||
db.MediaAssets.Add(asset);
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user