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
@@ -1,69 +1,69 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.CreateChannel;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
public class ChannelHandlersTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var ok = await new CreateChannelCommandHandler(db).Handle(
|
||||
new CreateChannelCommand("News", "news"),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var dup = await new CreateChannelCommandHandler(db).Handle(
|
||||
new CreateChannelCommand("Other", "news"),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(dup.IsSuccess);
|
||||
Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChannelSettings_UpdatesBumperChances()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
||||
new UpdateChannelSettingsCommand(
|
||||
channel.Id,
|
||||
"c",
|
||||
true,
|
||||
true,
|
||||
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
|
||||
null
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await using var verify = fixture.New();
|
||||
var stored = await verify.Channels.FindAsync(channel.Id);
|
||||
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.CreateChannel;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
public class ChannelHandlersTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var ok = await new CreateChannelCommandHandler(db).Handle(
|
||||
new CreateChannelCommand("News", "news"),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var dup = await new CreateChannelCommandHandler(db).Handle(
|
||||
new CreateChannelCommand("Other", "news"),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(dup.IsSuccess);
|
||||
Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChannelSettings_UpdatesBumperChances()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
||||
new UpdateChannelSettingsCommand(
|
||||
channel.Id,
|
||||
"c",
|
||||
true,
|
||||
true,
|
||||
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
|
||||
null
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await using var verify = fixture.New();
|
||||
var stored = await verify.Channels.FindAsync(channel.Id);
|
||||
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,201 +1,201 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.GetChannel;
|
||||
using TeleWave.Application.Broadcast.GetSchedule;
|
||||
using TeleWave.Application.Broadcast.ListChannels;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Library.DeleteShow;
|
||||
using TeleWave.Application.Library.GetShow;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
public class QueryHandlersTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
||||
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetChannelQueryHandler(db).Handle(
|
||||
new GetChannelQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListChannels_ReturnsSummaries()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(Channel.Create("A", "a", T0));
|
||||
seed.Channels.Add(Channel.Create("B", "b", T0));
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new ListChannelsQueryHandler(db).Handle(
|
||||
new ListChannelsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var show = Show.Create("Show A", ShowKind.Series);
|
||||
var variant = channel.BumperTemplates[0].Variants[0];
|
||||
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
|
||||
var program = ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
T0,
|
||||
T0.AddMinutes(20),
|
||||
show.Id,
|
||||
0
|
||||
);
|
||||
var bumper = ScheduleEntry.Bumper(
|
||||
channel.Id,
|
||||
Guid.NewGuid(),
|
||||
T0.AddMinutes(20),
|
||||
T0.AddMinutes(20).AddSeconds(8),
|
||||
show.Id,
|
||||
variant.Id
|
||||
);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.Channels.Add(channel);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.ScheduleEntries.Add(program);
|
||||
seed.ScheduleEntries.Add(bumper);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetChannelScheduleQueryHandler(db).Handle(
|
||||
new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
Assert.NotNull(bumperDto.BumperName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_ReturnsDtoOrNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var ok = await new GetShowQueryHandler(db).Handle(
|
||||
new GetShowQuery(show.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
|
||||
var missing = await new GetShowQueryHandler(db).Handle(
|
||||
new GetShowQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(missing.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteShow_RemovesOrNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
||||
new DeleteShowCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(missing.IsSuccess);
|
||||
|
||||
var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
||||
new DeleteShowCommand(show.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Guid templateId;
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var added = await new AddBumperTemplateCommandHandler(db).Handle(
|
||||
new AddBumperTemplateCommand(channel.Id, ""),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(added.IsSuccess);
|
||||
templateId = added.Value;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
||||
new RemoveBumperTemplateCommand(channel.Id, templateId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(removed.IsSuccess);
|
||||
}
|
||||
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
|
||||
|
||||
// дефолтный блок удалить нельзя
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var def = await db
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstAsync(c => c.Id == channel.Id);
|
||||
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
||||
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.GetChannel;
|
||||
using TeleWave.Application.Broadcast.GetSchedule;
|
||||
using TeleWave.Application.Broadcast.ListChannels;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Library.DeleteShow;
|
||||
using TeleWave.Application.Library.GetShow;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
public class QueryHandlersTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
||||
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetChannelQueryHandler(db).Handle(
|
||||
new GetChannelQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListChannels_ReturnsSummaries()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(Channel.Create("A", "a", T0));
|
||||
seed.Channels.Add(Channel.Create("B", "b", T0));
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new ListChannelsQueryHandler(db).Handle(
|
||||
new ListChannelsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var show = Show.Create("Show A", ShowKind.Series);
|
||||
var variant = channel.BumperTemplates[0].Variants[0];
|
||||
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
|
||||
var program = ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
T0,
|
||||
T0.AddMinutes(20),
|
||||
show.Id,
|
||||
0
|
||||
);
|
||||
var bumper = ScheduleEntry.Bumper(
|
||||
channel.Id,
|
||||
Guid.NewGuid(),
|
||||
T0.AddMinutes(20),
|
||||
T0.AddMinutes(20).AddSeconds(8),
|
||||
show.Id,
|
||||
variant.Id
|
||||
);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.Channels.Add(channel);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.ScheduleEntries.Add(program);
|
||||
seed.ScheduleEntries.Add(bumper);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetChannelScheduleQueryHandler(db).Handle(
|
||||
new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
Assert.NotNull(bumperDto.BumperName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_ReturnsDtoOrNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var ok = await new GetShowQueryHandler(db).Handle(
|
||||
new GetShowQuery(show.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
|
||||
var missing = await new GetShowQueryHandler(db).Handle(
|
||||
new GetShowQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(missing.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteShow_RemovesOrNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
||||
new DeleteShowCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(missing.IsSuccess);
|
||||
|
||||
var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
||||
new DeleteShowCommand(show.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Guid templateId;
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var added = await new AddBumperTemplateCommandHandler(db).Handle(
|
||||
new AddBumperTemplateCommand(channel.Id, ""),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(added.IsSuccess);
|
||||
templateId = added.Value;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
||||
new RemoveBumperTemplateCommand(channel.Id, templateId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(removed.IsSuccess);
|
||||
}
|
||||
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
|
||||
|
||||
// дефолтный блок удалить нельзя
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var def = await db
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstAsync(c => c.Id == channel.Id);
|
||||
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
||||
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
|
||||
/// </summary>
|
||||
public class LayerApplicabilityTests
|
||||
{
|
||||
private static readonly TimeOnly DayStart = new(6, 0);
|
||||
|
||||
[Fact]
|
||||
public void Empty_CoversEveryDate()
|
||||
{
|
||||
var applicability = new LayerApplicability();
|
||||
|
||||
Assert.True(applicability.IsEmpty);
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
|
||||
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
|
||||
[InlineData(2026, 12, 20, true)] // ровно начало
|
||||
[InlineData(2026, 1, 8, true)] // ровно конец
|
||||
[InlineData(2026, 12, 19, false)] // за день до начала
|
||||
[InlineData(2026, 1, 9, false)] // на следующий день после конца
|
||||
[InlineData(2026, 6, 15, false)] // середина года
|
||||
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
|
||||
int year,
|
||||
int month,
|
||||
int day,
|
||||
bool expected
|
||||
)
|
||||
{
|
||||
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
|
||||
// по паре (месяц, день), а не по датам.
|
||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]);
|
||||
|
||||
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnnualRange_WithinOneYear_DoesNotWrap()
|
||||
{
|
||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_AreCombinedWithOr()
|
||||
{
|
||||
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
|
||||
var applicability = new LayerApplicability(
|
||||
Weekdays: [1],
|
||||
SpecificDates: [new DateOnly(2026, 3, 21)]
|
||||
);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
|
||||
{
|
||||
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
|
||||
// половину настройки — своей группы и стратегии у половинки нет.
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var top = template.AddLayer("Прайм", 100);
|
||||
var bottom = template.AddLayer("Обычный", 50);
|
||||
|
||||
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
|
||||
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
|
||||
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
|
||||
|
||||
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
|
||||
|
||||
Assert.Contains("Кино", titles);
|
||||
Assert.Contains("Ночь", titles);
|
||||
Assert.DoesNotContain("Сериал", titles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DropsLayersThatDoNotApplyOnTheDate()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var newYear = template.AddLayer("Новогодний", 100);
|
||||
newYear.Update(
|
||||
newYear.Name,
|
||||
newYear.Priority,
|
||||
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
|
||||
isEnabled: true
|
||||
);
|
||||
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
|
||||
|
||||
var usual = template.AddLayer("Обычный", 50);
|
||||
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
|
||||
|
||||
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
|
||||
Assert.Contains("Вечерний сериал", march);
|
||||
Assert.DoesNotContain("Ирония судьбы", march);
|
||||
Assert.Contains("Ирония судьбы", december);
|
||||
Assert.DoesNotContain("Вечерний сериал", december);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DisabledLayerIsIgnored()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var layer = template.AddLayer("Выключённый", 100);
|
||||
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
|
||||
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
|
||||
|
||||
Assert.Empty(Build(template));
|
||||
}
|
||||
|
||||
private static void AddSlot(
|
||||
GridLayer layer,
|
||||
string title,
|
||||
TimeOnly start,
|
||||
int durationMinutes
|
||||
) => layer.AddSlot(title, start, durationMinutes);
|
||||
|
||||
private static IReadOnlyList<ScheduledSlot> Build(
|
||||
ScheduleTemplate template,
|
||||
DateTimeOffset? from = null
|
||||
)
|
||||
{
|
||||
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
return EffectiveGridBuilder.Build(
|
||||
template,
|
||||
utcOffsetMinutes: 180,
|
||||
DayStart,
|
||||
start,
|
||||
start.AddHours(18)
|
||||
);
|
||||
}
|
||||
}
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
|
||||
/// </summary>
|
||||
public class LayerApplicabilityTests
|
||||
{
|
||||
private static readonly TimeOnly DayStart = new(6, 0);
|
||||
|
||||
[Fact]
|
||||
public void Empty_CoversEveryDate()
|
||||
{
|
||||
var applicability = new LayerApplicability();
|
||||
|
||||
Assert.True(applicability.IsEmpty);
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
|
||||
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
|
||||
[InlineData(2026, 12, 20, true)] // ровно начало
|
||||
[InlineData(2026, 1, 8, true)] // ровно конец
|
||||
[InlineData(2026, 12, 19, false)] // за день до начала
|
||||
[InlineData(2026, 1, 9, false)] // на следующий день после конца
|
||||
[InlineData(2026, 6, 15, false)] // середина года
|
||||
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
|
||||
int year,
|
||||
int month,
|
||||
int day,
|
||||
bool expected
|
||||
)
|
||||
{
|
||||
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
|
||||
// по паре (месяц, день), а не по датам.
|
||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]);
|
||||
|
||||
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnnualRange_WithinOneYear_DoesNotWrap()
|
||||
{
|
||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_AreCombinedWithOr()
|
||||
{
|
||||
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
|
||||
var applicability = new LayerApplicability(
|
||||
Weekdays: [1],
|
||||
SpecificDates: [new DateOnly(2026, 3, 21)]
|
||||
);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
|
||||
{
|
||||
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
|
||||
// половину настройки — своей группы и стратегии у половинки нет.
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var top = template.AddLayer("Прайм", 100);
|
||||
var bottom = template.AddLayer("Обычный", 50);
|
||||
|
||||
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
|
||||
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
|
||||
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
|
||||
|
||||
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
|
||||
|
||||
Assert.Contains("Кино", titles);
|
||||
Assert.Contains("Ночь", titles);
|
||||
Assert.DoesNotContain("Сериал", titles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DropsLayersThatDoNotApplyOnTheDate()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var newYear = template.AddLayer("Новогодний", 100);
|
||||
newYear.Update(
|
||||
newYear.Name,
|
||||
newYear.Priority,
|
||||
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
|
||||
isEnabled: true
|
||||
);
|
||||
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
|
||||
|
||||
var usual = template.AddLayer("Обычный", 50);
|
||||
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
|
||||
|
||||
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
|
||||
Assert.Contains("Вечерний сериал", march);
|
||||
Assert.DoesNotContain("Ирония судьбы", march);
|
||||
Assert.Contains("Ирония судьбы", december);
|
||||
Assert.DoesNotContain("Вечерний сериал", december);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DisabledLayerIsIgnored()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var layer = template.AddLayer("Выключённый", 100);
|
||||
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
|
||||
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
|
||||
|
||||
Assert.Empty(Build(template));
|
||||
}
|
||||
|
||||
private static void AddSlot(
|
||||
GridLayer layer,
|
||||
string title,
|
||||
TimeOnly start,
|
||||
int durationMinutes
|
||||
) => layer.AddSlot(title, start, durationMinutes);
|
||||
|
||||
private static IReadOnlyList<ScheduledSlot> Build(
|
||||
ScheduleTemplate template,
|
||||
DateTimeOffset? from = null
|
||||
)
|
||||
{
|
||||
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
return EffectiveGridBuilder.Build(
|
||||
template,
|
||||
utcOffsetMinutes: 180,
|
||||
DayStart,
|
||||
start,
|
||||
start.AddHours(18)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
using TeleWave.Application.Admin.Users.CreateUser;
|
||||
using TeleWave.Application.Admin.Users.ResetPassword;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Validators;
|
||||
|
||||
public class ValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
|
||||
{
|
||||
var v = new UpdateBumperTextVariantCommandValidator();
|
||||
|
||||
var good = new UpdateBumperTextVariantCommand(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"Name",
|
||||
BumperTextKind.NowNext,
|
||||
"NOW",
|
||||
"NEXT",
|
||||
"l1",
|
||||
"l2",
|
||||
BumperTrigger.Both,
|
||||
3
|
||||
);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||
Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
|
||||
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateChannelSettings_ChecksRanges()
|
||||
{
|
||||
var v = new UpdateChannelSettingsCommandValidator();
|
||||
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
|
||||
|
||||
var good = new UpdateChannelSettingsCommand(
|
||||
Guid.NewGuid(),
|
||||
"Name",
|
||||
true,
|
||||
true,
|
||||
bumper,
|
||||
null
|
||||
);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetUserPassword_RequiresMinLength()
|
||||
{
|
||||
var v = new ResetUserPasswordCommandValidator();
|
||||
|
||||
Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid);
|
||||
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid);
|
||||
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUser_ChecksNameAndPassword()
|
||||
{
|
||||
var v = new CreateUserCommandValidator();
|
||||
|
||||
Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid);
|
||||
Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid);
|
||||
Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid);
|
||||
Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid);
|
||||
}
|
||||
}
|
||||
using TeleWave.Application.Admin.Users.CreateUser;
|
||||
using TeleWave.Application.Admin.Users.ResetPassword;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Validators;
|
||||
|
||||
public class ValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
|
||||
{
|
||||
var v = new UpdateBumperTextVariantCommandValidator();
|
||||
|
||||
var good = new UpdateBumperTextVariantCommand(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"Name",
|
||||
BumperTextKind.NowNext,
|
||||
"NOW",
|
||||
"NEXT",
|
||||
"l1",
|
||||
"l2",
|
||||
BumperTrigger.Both,
|
||||
3
|
||||
);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||
Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
|
||||
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateChannelSettings_ChecksRanges()
|
||||
{
|
||||
var v = new UpdateChannelSettingsCommandValidator();
|
||||
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
|
||||
|
||||
var good = new UpdateChannelSettingsCommand(
|
||||
Guid.NewGuid(),
|
||||
"Name",
|
||||
true,
|
||||
true,
|
||||
bumper,
|
||||
null
|
||||
);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetUserPassword_RequiresMinLength()
|
||||
{
|
||||
var v = new ResetUserPasswordCommandValidator();
|
||||
|
||||
Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid);
|
||||
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid);
|
||||
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUser_ChecksNameAndPassword()
|
||||
{
|
||||
var v = new CreateUserCommandValidator();
|
||||
|
||||
Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid);
|
||||
Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid);
|
||||
Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid);
|
||||
Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Broadcast;
|
||||
|
||||
public class ChannelTests
|
||||
{
|
||||
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
|
||||
|
||||
[Fact]
|
||||
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
Assert.NotEqual(Guid.Empty, channel.Id);
|
||||
Assert.Equal("News", channel.Name);
|
||||
Assert.Equal("news", channel.Slug);
|
||||
Assert.True(channel.IsEnabled);
|
||||
Assert.Equal(Epoch, channel.EpochUtc);
|
||||
Assert.False(channel.BumpersEnabled);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
||||
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
|
||||
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
|
||||
|
||||
var template = Assert.Single(channel.BumperTemplates);
|
||||
Assert.True(template.IsDefault);
|
||||
Assert.Equal(0, template.Position);
|
||||
Assert.Single(template.Variants); // дефолтный подблок
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSettings_ChangesFields()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var filler = Guid.NewGuid();
|
||||
|
||||
channel.UpdateSettings("N2", false, true, filler);
|
||||
|
||||
Assert.Equal("N2", channel.Name);
|
||||
Assert.False(channel.IsEnabled);
|
||||
Assert.True(channel.BumpersEnabled);
|
||||
Assert.Equal(filler, channel.FillerAssetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateBumperSettings_ChangesFontAndSelection()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
|
||||
|
||||
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
||||
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1.0, 2.0, 0.0, 1.0)]
|
||||
[InlineData(0.4, 0.25, 0.4, 0.25)]
|
||||
public void UpdateViewerSettings_ClampsStrengths(
|
||||
double opacity,
|
||||
double filter,
|
||||
double expectedOpacity,
|
||||
double expectedFilter
|
||||
)
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var logo = Guid.NewGuid();
|
||||
|
||||
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
|
||||
|
||||
Assert.Equal(logo, channel.LogoImageId);
|
||||
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
|
||||
Assert.Equal(expectedOpacity, channel.LogoOpacity);
|
||||
Assert.True(channel.ShowClock);
|
||||
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewChannel_HasViewerOverlaysOff()
|
||||
{
|
||||
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
|
||||
var channel = NewChannel();
|
||||
|
||||
Assert.Null(channel.LogoImageId);
|
||||
Assert.False(channel.ShowClock);
|
||||
Assert.Equal(0.0, channel.AnalogFilterStrength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBumperTemplate_AppendsWithNextPosition()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
var t1 = channel.AddBumperTemplate("Block 2");
|
||||
var t2 = channel.AddBumperTemplate("Block 3");
|
||||
|
||||
Assert.Equal(1, t1.Position);
|
||||
Assert.Equal(2, t2.Position);
|
||||
Assert.False(t1.IsDefault);
|
||||
Assert.Equal(3, channel.BumperTemplates.Count);
|
||||
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
|
||||
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var def = channel.BumperTemplates[0];
|
||||
var extra = channel.AddBumperTemplate("Block 2");
|
||||
|
||||
Assert.False(channel.RemoveBumperTemplate(def.Id));
|
||||
Assert.True(channel.RemoveBumperTemplate(extra.Id));
|
||||
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
|
||||
Assert.Single(channel.BumperTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
|
||||
|
||||
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
|
||||
Assert.Null(channel.Number);
|
||||
Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
|
||||
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTemplate_LinksAndUnlinks()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var templateId = Guid.NewGuid();
|
||||
|
||||
channel.SetTemplate(templateId);
|
||||
Assert.Equal(templateId, channel.TemplateId);
|
||||
|
||||
channel.SetTemplate(null);
|
||||
Assert.Null(channel.TemplateId);
|
||||
}
|
||||
}
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Broadcast;
|
||||
|
||||
public class ChannelTests
|
||||
{
|
||||
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
|
||||
|
||||
[Fact]
|
||||
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
Assert.NotEqual(Guid.Empty, channel.Id);
|
||||
Assert.Equal("News", channel.Name);
|
||||
Assert.Equal("news", channel.Slug);
|
||||
Assert.True(channel.IsEnabled);
|
||||
Assert.Equal(Epoch, channel.EpochUtc);
|
||||
Assert.False(channel.BumpersEnabled);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
||||
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
|
||||
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
|
||||
|
||||
var template = Assert.Single(channel.BumperTemplates);
|
||||
Assert.True(template.IsDefault);
|
||||
Assert.Equal(0, template.Position);
|
||||
Assert.Single(template.Variants); // дефолтный подблок
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSettings_ChangesFields()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var filler = Guid.NewGuid();
|
||||
|
||||
channel.UpdateSettings("N2", false, true, filler);
|
||||
|
||||
Assert.Equal("N2", channel.Name);
|
||||
Assert.False(channel.IsEnabled);
|
||||
Assert.True(channel.BumpersEnabled);
|
||||
Assert.Equal(filler, channel.FillerAssetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateBumperSettings_ChangesFontAndSelection()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
|
||||
|
||||
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
||||
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1.0, 2.0, 0.0, 1.0)]
|
||||
[InlineData(0.4, 0.25, 0.4, 0.25)]
|
||||
public void UpdateViewerSettings_ClampsStrengths(
|
||||
double opacity,
|
||||
double filter,
|
||||
double expectedOpacity,
|
||||
double expectedFilter
|
||||
)
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var logo = Guid.NewGuid();
|
||||
|
||||
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
|
||||
|
||||
Assert.Equal(logo, channel.LogoImageId);
|
||||
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
|
||||
Assert.Equal(expectedOpacity, channel.LogoOpacity);
|
||||
Assert.True(channel.ShowClock);
|
||||
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewChannel_HasViewerOverlaysOff()
|
||||
{
|
||||
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
|
||||
var channel = NewChannel();
|
||||
|
||||
Assert.Null(channel.LogoImageId);
|
||||
Assert.False(channel.ShowClock);
|
||||
Assert.Equal(0.0, channel.AnalogFilterStrength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBumperTemplate_AppendsWithNextPosition()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
var t1 = channel.AddBumperTemplate("Block 2");
|
||||
var t2 = channel.AddBumperTemplate("Block 3");
|
||||
|
||||
Assert.Equal(1, t1.Position);
|
||||
Assert.Equal(2, t2.Position);
|
||||
Assert.False(t1.IsDefault);
|
||||
Assert.Equal(3, channel.BumperTemplates.Count);
|
||||
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
|
||||
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var def = channel.BumperTemplates[0];
|
||||
var extra = channel.AddBumperTemplate("Block 2");
|
||||
|
||||
Assert.False(channel.RemoveBumperTemplate(def.Id));
|
||||
Assert.True(channel.RemoveBumperTemplate(extra.Id));
|
||||
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
|
||||
Assert.Single(channel.BumperTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
|
||||
|
||||
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
|
||||
Assert.Null(channel.Number);
|
||||
Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
|
||||
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTemplate_LinksAndUnlinks()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var templateId = Guid.NewGuid();
|
||||
|
||||
channel.SetTemplate(templateId);
|
||||
Assert.Equal(templateId, channel.TemplateId);
|
||||
|
||||
channel.SetTemplate(null);
|
||||
Assert.Null(channel.TemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Broadcast;
|
||||
|
||||
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
|
||||
public class DomainRecordFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void BumperAsset_Create_StoresPair()
|
||||
{
|
||||
var channel = Guid.NewGuid();
|
||||
var template = Guid.NewGuid();
|
||||
var variant = Guid.NewGuid();
|
||||
var from = Guid.NewGuid();
|
||||
var to = Guid.NewGuid();
|
||||
var asset = Guid.NewGuid();
|
||||
|
||||
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
|
||||
|
||||
Assert.Equal(channel, b.ChannelId);
|
||||
Assert.Equal(template, b.TemplateId);
|
||||
Assert.Equal(variant, b.VariantId);
|
||||
Assert.Equal(from, b.FromShowId);
|
||||
Assert.Equal(to, b.ToShowId);
|
||||
Assert.Equal("sig", b.Signature);
|
||||
Assert.Equal(asset, b.MediaAssetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSetting_CreateAndSetValue()
|
||||
{
|
||||
var s = AppSetting.Create("site.title", "TeleWave");
|
||||
Assert.Equal("site.title", s.Key);
|
||||
Assert.Equal("TeleWave", s.Value);
|
||||
|
||||
s.SetValue("New");
|
||||
Assert.Equal("New", s.Value);
|
||||
}
|
||||
}
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Broadcast;
|
||||
|
||||
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
|
||||
public class DomainRecordFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void BumperAsset_Create_StoresPair()
|
||||
{
|
||||
var channel = Guid.NewGuid();
|
||||
var template = Guid.NewGuid();
|
||||
var variant = Guid.NewGuid();
|
||||
var from = Guid.NewGuid();
|
||||
var to = Guid.NewGuid();
|
||||
var asset = Guid.NewGuid();
|
||||
|
||||
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
|
||||
|
||||
Assert.Equal(channel, b.ChannelId);
|
||||
Assert.Equal(template, b.TemplateId);
|
||||
Assert.Equal(variant, b.VariantId);
|
||||
Assert.Equal(from, b.FromShowId);
|
||||
Assert.Equal(to, b.ToShowId);
|
||||
Assert.Equal("sig", b.Signature);
|
||||
Assert.Equal(asset, b.MediaAssetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSetting_CreateAndSetValue()
|
||||
{
|
||||
var s = AppSetting.Create("site.title", "TeleWave");
|
||||
Assert.Equal("site.title", s.Key);
|
||||
Assert.Equal("TeleWave", s.Value);
|
||||
|
||||
s.SetValue("New");
|
||||
Assert.Equal("New", s.Value);
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Media.ManualInbox;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
|
||||
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
|
||||
/// </summary>
|
||||
[Collection("postgres")]
|
||||
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
[SkippableFact]
|
||||
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var showId = await SeedShowAsync(seedDb);
|
||||
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||
var first = $"{tag}-s01e01.mkv";
|
||||
var second = $"{tag}-s01e02.mkv";
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.ListManualInbox(Arg.Any<int>())
|
||||
.Returns([
|
||||
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
|
||||
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
|
||||
]);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ImportManualInboxCommandHandler(
|
||||
db,
|
||||
storage,
|
||||
Substitute.For<IMediaProcessingQueue>()
|
||||
).Handle(
|
||||
new ImportManualInboxCommand(
|
||||
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
|
||||
showId
|
||||
),
|
||||
default
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Imported);
|
||||
Assert.Empty(result.Value.Failed);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
|
||||
await storage
|
||||
.Received(2)
|
||||
.PromoteToOriginalAsync(
|
||||
MediaSource.ManualInbox,
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<Guid>(),
|
||||
".mkv",
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var episodes = verify
|
||||
.Shows.Where(s => s.Id == showId)
|
||||
.SelectMany(s => s.Episodes)
|
||||
.OrderBy(e => e.Position)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(2, episodes.Count);
|
||||
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
|
||||
Assert.Equal(1, episodes[0].Season);
|
||||
Assert.Equal(1, episodes[0].Episode);
|
||||
Assert.Equal(2, episodes[1].Episode);
|
||||
|
||||
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
|
||||
// заводит записи из ручного inbox.
|
||||
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
|
||||
Assert.Equal(2, assets.Count);
|
||||
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
|
||||
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var showId = await SeedShowAsync(seedDb);
|
||||
|
||||
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||
var good = $"{tag}-ok.mkv";
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.ListManualInbox(Arg.Any<int>())
|
||||
.Returns([
|
||||
new IMediaStorage.ManualInboxFile(good, good, 1000),
|
||||
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
|
||||
]);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ImportManualInboxCommandHandler(
|
||||
db,
|
||||
storage,
|
||||
Substitute.For<IMediaProcessingQueue>()
|
||||
).Handle(
|
||||
new ImportManualInboxCommand(
|
||||
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
|
||||
showId
|
||||
),
|
||||
default
|
||||
);
|
||||
|
||||
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value.Imported);
|
||||
Assert.Equal(2, result.Value.Failed.Count);
|
||||
Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt");
|
||||
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var showId = await SeedShowAsync(seedDb);
|
||||
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.ListManualInbox(Arg.Any<int>())
|
||||
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ImportManualInboxCommandHandler(
|
||||
db,
|
||||
storage,
|
||||
Substitute.For<IMediaProcessingQueue>()
|
||||
).Handle(
|
||||
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
|
||||
default
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
|
||||
|
||||
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
|
||||
Assert.Equal(4, episode.Season);
|
||||
Assert.Equal(12, episode.Episode);
|
||||
|
||||
// И спутники рядом с забранным файлом убираются.
|
||||
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
|
||||
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
|
||||
|
||||
private static async Task<Guid> SeedShowAsync(AppDbContext db)
|
||||
{
|
||||
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
|
||||
db.Shows.Add(show);
|
||||
await db.SaveChangesAsync();
|
||||
return show.Id;
|
||||
}
|
||||
}
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Media.ManualInbox;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
|
||||
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
|
||||
/// </summary>
|
||||
[Collection("postgres")]
|
||||
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
[SkippableFact]
|
||||
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var showId = await SeedShowAsync(seedDb);
|
||||
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||
var first = $"{tag}-s01e01.mkv";
|
||||
var second = $"{tag}-s01e02.mkv";
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.ListManualInbox(Arg.Any<int>())
|
||||
.Returns([
|
||||
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
|
||||
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
|
||||
]);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ImportManualInboxCommandHandler(
|
||||
db,
|
||||
storage,
|
||||
Substitute.For<IMediaProcessingQueue>()
|
||||
).Handle(
|
||||
new ImportManualInboxCommand(
|
||||
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
|
||||
showId
|
||||
),
|
||||
default
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Imported);
|
||||
Assert.Empty(result.Value.Failed);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
|
||||
await storage
|
||||
.Received(2)
|
||||
.PromoteToOriginalAsync(
|
||||
MediaSource.ManualInbox,
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<Guid>(),
|
||||
".mkv",
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var episodes = verify
|
||||
.Shows.Where(s => s.Id == showId)
|
||||
.SelectMany(s => s.Episodes)
|
||||
.OrderBy(e => e.Position)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(2, episodes.Count);
|
||||
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
|
||||
Assert.Equal(1, episodes[0].Season);
|
||||
Assert.Equal(1, episodes[0].Episode);
|
||||
Assert.Equal(2, episodes[1].Episode);
|
||||
|
||||
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
|
||||
// заводит записи из ручного inbox.
|
||||
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
|
||||
Assert.Equal(2, assets.Count);
|
||||
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
|
||||
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var showId = await SeedShowAsync(seedDb);
|
||||
|
||||
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||
var good = $"{tag}-ok.mkv";
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.ListManualInbox(Arg.Any<int>())
|
||||
.Returns([
|
||||
new IMediaStorage.ManualInboxFile(good, good, 1000),
|
||||
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
|
||||
]);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ImportManualInboxCommandHandler(
|
||||
db,
|
||||
storage,
|
||||
Substitute.For<IMediaProcessingQueue>()
|
||||
).Handle(
|
||||
new ImportManualInboxCommand(
|
||||
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
|
||||
showId
|
||||
),
|
||||
default
|
||||
);
|
||||
|
||||
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value.Imported);
|
||||
Assert.Equal(2, result.Value.Failed.Count);
|
||||
Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt");
|
||||
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var showId = await SeedShowAsync(seedDb);
|
||||
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.ListManualInbox(Arg.Any<int>())
|
||||
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ImportManualInboxCommandHandler(
|
||||
db,
|
||||
storage,
|
||||
Substitute.For<IMediaProcessingQueue>()
|
||||
).Handle(
|
||||
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
|
||||
default
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
|
||||
|
||||
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
|
||||
Assert.Equal(4, episode.Season);
|
||||
Assert.Equal(12, episode.Episode);
|
||||
|
||||
// И спутники рядом с забранным файлом убираются.
|
||||
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
|
||||
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
|
||||
|
||||
private static async Task<Guid> SeedShowAsync(AppDbContext db)
|
||||
{
|
||||
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
|
||||
db.Shows.Add(show);
|
||||
await db.SaveChangesAsync();
|
||||
return show.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
|
||||
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
|
||||
private PostgreSqlContainer? _container;
|
||||
|
||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public string ConnectionString =>
|
||||
_container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
|
||||
await _container.StartAsync();
|
||||
await using var db = CreateContext();
|
||||
await db.Database.MigrateAsync();
|
||||
Available = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
||||
Available = false;
|
||||
}
|
||||
}
|
||||
|
||||
public AppDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("postgres")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||
|
||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||
internal sealed class SequenceRandom(params int[] sequence)
|
||||
: Domain.Broadcast.Scheduling.IRandomSource
|
||||
{
|
||||
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||
private int _i;
|
||||
|
||||
public int Next(int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= 0)
|
||||
return 0;
|
||||
var value = _sequence[_i++ % _sequence.Length];
|
||||
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
|
||||
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
|
||||
private PostgreSqlContainer? _container;
|
||||
|
||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public string ConnectionString =>
|
||||
_container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
|
||||
await _container.StartAsync();
|
||||
await using var db = CreateContext();
|
||||
await db.Database.MigrateAsync();
|
||||
Available = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
||||
Available = false;
|
||||
}
|
||||
}
|
||||
|
||||
public AppDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("postgres")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||
|
||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||
internal sealed class SequenceRandom(params int[] sequence)
|
||||
: Domain.Broadcast.Scheduling.IRandomSource
|
||||
{
|
||||
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||
private int _i;
|
||||
|
||||
public int Next(int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= 0)
|
||||
return 0;
|
||||
var value = _sequence[_i++ % _sequence.Length];
|
||||
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||
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_MovesLayersSlotsAndJunctions_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);
|
||||
Assert.Equal(1, result.Value.Junctions);
|
||||
|
||||
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 junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
|
||||
Assert.Equal(junction.Id, slot.JunctionAfterId);
|
||||
Assert.NotEqual(
|
||||
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
|
||||
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).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>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</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(source.Id, "Прайм");
|
||||
var ad = junction.AddElement(JunctionElementKind.Ad);
|
||||
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, 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);
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||
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_MovesLayersSlotsAndJunctions_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);
|
||||
Assert.Equal(1, result.Value.Junctions);
|
||||
|
||||
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 junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
|
||||
Assert.Equal(junction.Id, slot.JunctionAfterId);
|
||||
Assert.NotEqual(
|
||||
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
|
||||
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).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>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</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(source.Id, "Прайм");
|
||||
var ad = junction.AddElement(JunctionElementKind.Ad);
|
||||
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user