Added a DiscontinuityIndex property to ScheduleEntry and related DTOs to track the cumulative number of discontinuities in live streaming. Updated the LiveWindowCalculator to utilize this index for generating accurate EXT-X-DISCONTINUITY-SEQUENCE values, ensuring compatibility with ffmpeg-based players. Enhanced the WriteEntriesAsync method to calculate and persist the discontinuity index during entry creation. Updated relevant tests and documentation to reflect these changes, improving the reliability of live streaming functionality.
279 lines
12 KiB
C#
279 lines
12 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using TeleWave.Application.Programming.Groups;
|
||
using TeleWave.Application.Programming.Templates;
|
||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||
using TeleWave.Application.Programming.Templates.Reset;
|
||
using TeleWave.Application.Programming.Templates.Validate;
|
||
using TeleWave.Domain.Broadcast;
|
||
using TeleWave.Domain.Programming;
|
||
using TeleWave.Infrastructure.Persistence;
|
||
using Xunit;
|
||
|
||
namespace TeleWave.Integration.Tests;
|
||
|
||
/// <summary>
|
||
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
|
||
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
|
||
/// </summary>
|
||
[Collection("postgres")]
|
||
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
|
||
{
|
||
[SkippableFact]
|
||
public async Task Copy_MovesLayersAndSlots_AndReplacesTargetGrid()
|
||
{
|
||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||
|
||
await using var seedDb = fixture.CreateContext();
|
||
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
|
||
|
||
await using var db = fixture.CreateContext();
|
||
var result = await new CopyTemplateCommandHandler(db).Handle(
|
||
new CopyTemplateCommand(sourceId, targetId),
|
||
default
|
||
);
|
||
Assert.True(result.IsSuccess);
|
||
await db.SaveChangesAsync();
|
||
|
||
Assert.Equal(1, result.Value.Layers);
|
||
Assert.Equal(1, result.Value.Slots);
|
||
|
||
await using var verify = fixture.CreateContext();
|
||
var target = verify.Channels.Single(c => c.Id == targetId);
|
||
var copied = verify
|
||
.ScheduleTemplates.Include(t => t.Layers)
|
||
.ThenInclude(l => l.Slots)
|
||
.Single(t => t.ChannelId == targetId);
|
||
|
||
// У приёмника ровно один шаблон, и канал смотрит именно на него.
|
||
Assert.Equal(copied.Id, target.TemplateId);
|
||
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
|
||
|
||
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
|
||
// Группы общие — ссылка переносится как есть, а не копией группы.
|
||
Assert.Equal(groupId, slot.GroupId);
|
||
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
|
||
|
||
// Стыки общие — копия ссылается на тот же стык, а не заводит свой.
|
||
var sourceSlot = verify
|
||
.ScheduleTemplates.Include(t => t.Layers)
|
||
.ThenInclude(l => l.Slots)
|
||
.Single(t => t.ChannelId == sourceId)
|
||
.Layers.SelectMany(l => l.Slots)
|
||
.Single();
|
||
Assert.Equal(sourceSlot.JunctionAfterId, slot.JunctionAfterId);
|
||
Assert.NotNull(slot.JunctionAfterId);
|
||
}
|
||
|
||
[Fact]
|
||
public void Copy_ToItself_IsRejectedByValidator()
|
||
{
|
||
var validator = new CopyTemplateCommandValidator();
|
||
var id = Guid.NewGuid();
|
||
|
||
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
|
||
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task Validate_ReportsEmptyGroupAndGridGap()
|
||
{
|
||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||
|
||
await using var seedDb = fixture.CreateContext();
|
||
var (sourceId, _, _) = await SeedPairAsync(seedDb);
|
||
|
||
await using var db = fixture.CreateContext();
|
||
var result = await new ValidateTemplateQueryHandler(
|
||
db,
|
||
new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db))
|
||
).Handle(new ValidateTemplateQuery(sourceId), default);
|
||
|
||
Assert.True(result.IsSuccess);
|
||
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
|
||
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
|
||
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent()
|
||
{
|
||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||
|
||
// Канал из старой ротации: сетки нет, ссылки на неё тоже.
|
||
await using var seedDb = fixture.CreateContext();
|
||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||
var channel = Channel.Create(
|
||
$"Без сетки {suffix}",
|
||
$"nogrid-{suffix}",
|
||
DateTimeOffset.UtcNow
|
||
);
|
||
seedDb.Channels.Add(channel);
|
||
await seedDb.SaveChangesAsync();
|
||
|
||
await using var db = fixture.CreateContext();
|
||
var created = await new CreateChannelTemplateCommandHandler(db).Handle(
|
||
new CreateChannelTemplateCommand(channel.Id),
|
||
default
|
||
);
|
||
Assert.True(created.IsSuccess);
|
||
await db.SaveChangesAsync();
|
||
|
||
await using var again = fixture.CreateContext();
|
||
var second = await new CreateChannelTemplateCommandHandler(again).Handle(
|
||
new CreateChannelTemplateCommand(channel.Id),
|
||
default
|
||
);
|
||
await again.SaveChangesAsync();
|
||
|
||
// Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется.
|
||
Assert.True(second.IsSuccess);
|
||
Assert.Equal(created.Value, second.Value);
|
||
|
||
await using var verify = fixture.CreateContext();
|
||
var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id);
|
||
Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId);
|
||
// Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть.
|
||
Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Сброс: сетка снимается целиком, будущий эфир уходит, прошлое и идущая запись остаются.
|
||
/// Проверяется на настоящей БД — команда держит транзакцию с advisory-локом канала и сносит
|
||
/// ленту через ExecuteDelete, а этого InMemory не умеет.
|
||
/// </summary>
|
||
[SkippableFact]
|
||
public async Task Reset_ClearsGridAndFutureTape_KeepingPastAndCurrentEntry()
|
||
{
|
||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||
|
||
await using var seedDb = fixture.CreateContext();
|
||
var (channelId, _, _) = await SeedPairAsync(seedDb);
|
||
|
||
var now = DateTimeOffset.UtcNow;
|
||
var slotId = seedDb
|
||
.ScheduleTemplates.Include(t => t.Layers)
|
||
.ThenInclude(l => l.Slots)
|
||
.Single(t => t.ChannelId == channelId)
|
||
.Layers.SelectMany(l => l.Slots)
|
||
.Single()
|
||
.Id;
|
||
|
||
// Курсор слота: он обязан уйти вместе со слотом, иначе сериал продолжится с той же серии.
|
||
var state = SlotState.Create(slotId);
|
||
state.MoveTo(GroupElementKind.Show, Guid.NewGuid(), 3);
|
||
seedDb.SlotStates.Add(state);
|
||
|
||
Entry(seedDb, channelId, now.AddHours(-2), now.AddHours(-1));
|
||
Entry(seedDb, channelId, now.AddMinutes(-10), now.AddMinutes(20));
|
||
Entry(seedDb, channelId, now.AddHours(1), now.AddHours(2));
|
||
await seedDb.SaveChangesAsync();
|
||
|
||
await using var db = fixture.CreateContext();
|
||
var result = await new ResetTemplateCommandHandler(db).Handle(
|
||
new ResetTemplateCommand(channelId),
|
||
default
|
||
);
|
||
|
||
Assert.True(result.IsSuccess);
|
||
Assert.Equal(1, result.Value.Slots);
|
||
Assert.Equal(1, result.Value.Layers);
|
||
Assert.Equal(1, result.Value.Entries);
|
||
|
||
await using var verify = fixture.CreateContext();
|
||
var template = verify
|
||
.ScheduleTemplates.Include(t => t.Layers)
|
||
.ThenInclude(l => l.Slots)
|
||
.Single(t => t.ChannelId == channelId);
|
||
|
||
// Фоновый слой остаётся всегда — удалить его нельзя, и без него сетку нечем закрывать.
|
||
var layer = Assert.Single(template.Layers);
|
||
Assert.True(layer.IsBackground);
|
||
Assert.Empty(layer.Slots);
|
||
Assert.True(template.HasPendingChanges);
|
||
Assert.False(verify.SlotStates.Any(s => s.SlotId == slotId));
|
||
|
||
var entries = verify.ScheduleEntries.Where(e => e.ChannelId == channelId).ToList();
|
||
Assert.Equal(2, entries.Count);
|
||
Assert.All(entries, e => Assert.True(e.StartsAtUtc < now));
|
||
}
|
||
|
||
private static void Entry(
|
||
AppDbContext db,
|
||
Guid channelId,
|
||
DateTimeOffset from,
|
||
DateTimeOffset to
|
||
) =>
|
||
db.ScheduleEntries.Add(
|
||
ScheduleEntry.FromSlot(
|
||
channelId,
|
||
Guid.NewGuid(),
|
||
ScheduleEntryKind.Program,
|
||
from,
|
||
to,
|
||
new ScheduleEntryOrigin(null, null, null, null, null),
|
||
0
|
||
)
|
||
);
|
||
|
||
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
|
||
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(
|
||
AppDbContext db
|
||
)
|
||
{
|
||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||
|
||
var group = Group.Create($"Группа {suffix}");
|
||
db.Groups.Add(group);
|
||
|
||
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
|
||
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
|
||
|
||
var junction = JunctionTemplate.Create($"Прайм {suffix}");
|
||
var ad = junction.AddElement(JunctionElementKind.Ad);
|
||
ad.Update(
|
||
new JunctionElementSettings(
|
||
JunctionElementKind.Ad,
|
||
group.Id,
|
||
null,
|
||
null,
|
||
JunctionAmountMode.Count,
|
||
2,
|
||
true,
|
||
null,
|
||
JunctionElement.DefaultChoiceWeight,
|
||
null
|
||
)
|
||
);
|
||
db.JunctionTemplates.Add(junction);
|
||
|
||
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
|
||
var layer = sourceTemplate.AddLayer("Прайм", 10);
|
||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||
slot.UpdateContent(
|
||
new SlotContent(
|
||
slot.Title,
|
||
SlotKind.Content,
|
||
group.Id,
|
||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||
null,
|
||
SlotBlockMode.FillSlot,
|
||
1,
|
||
OverflowPolicy.ContinueNext,
|
||
JunctionAfterId: junction.Id
|
||
)
|
||
);
|
||
sourceTemplate.SetDefaultJunction(junction.Id);
|
||
source.SetTemplate(sourceTemplate.Id);
|
||
|
||
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
|
||
target.SetTemplate(targetTemplate.Id);
|
||
|
||
db.Channels.AddRange(source, target);
|
||
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
|
||
|
||
await db.SaveChangesAsync();
|
||
return (source.Id, target.Id, group.Id);
|
||
}
|
||
}
|