Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
This commit is contained in:
@@ -1,324 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.AddChannelAd;
|
||||
using TeleWave.Application.Broadcast.AddChannelShow;
|
||||
using TeleWave.Application.Broadcast.CreateChannel;
|
||||
using TeleWave.Application.Broadcast.CreateOverride;
|
||||
using TeleWave.Application.Broadcast.DeleteOverride;
|
||||
using TeleWave.Application.Broadcast.RemoveChannelShow;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelShow;
|
||||
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 AddChannelShow_HappyPath_AndErrors()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var show = Show.Create("S", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new AddChannelShowCommandHandler(db);
|
||||
|
||||
// неизвестный канал
|
||||
var noChannel = await handler.Handle(
|
||||
new AddChannelShowCommand(Guid.NewGuid(), show.Id, 1, BlockMode.Count, 1),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.NotFound, noChannel.Error);
|
||||
|
||||
// неизвестное шоу
|
||||
var noShow = await handler.Handle(
|
||||
new AddChannelShowCommand(channel.Id, Guid.NewGuid(), 1, BlockMode.Count, 1),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.ShowNotFound, noShow.Error);
|
||||
|
||||
// ок
|
||||
var ok = await handler.Handle(
|
||||
new AddChannelShowCommand(channel.Id, show.Id, 2, BlockMode.Count, 3),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// повторно — уже добавлено
|
||||
await using var db2 = fixture.New();
|
||||
var dup = await new AddChannelShowCommandHandler(db2).Handle(
|
||||
new AddChannelShowCommand(channel.Id, show.Id, 1, BlockMode.Count, 1),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.ShowAlreadyAdded, dup.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveChannelShow_RemovesOrReportsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var cs = channel.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new RemoveChannelShowCommandHandler(db);
|
||||
|
||||
var missing = await handler.Handle(
|
||||
new RemoveChannelShowCommand(channel.Id, Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.ChannelShowNotFound, missing.Error);
|
||||
|
||||
var ok = await handler.Handle(
|
||||
new RemoveChannelShowCommand(channel.Id, cs.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddChannelAd_HappyPath_AndAssetNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var asset = MediaAsset.Register("clip.mp4", ".mp4", MediaSource.Upload);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.MediaAssets.Add(asset);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new AddChannelAdCommandHandler(db);
|
||||
|
||||
var noAsset = await handler.Handle(
|
||||
new AddChannelAdCommand(channel.Id, Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.AssetNotFound, noAsset.Error);
|
||||
|
||||
var ok = await handler.Handle(
|
||||
new AddChannelAdCommand(channel.Id, asset.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
}
|
||||
|
||||
[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,
|
||||
AdInsertion.BetweenBlocks,
|
||||
1,
|
||||
true,
|
||||
new BumperSettingsInput(
|
||||
BumperFont.Sans,
|
||||
5,
|
||||
BumperSelection.WeightedRandom,
|
||||
0.5,
|
||||
0.2
|
||||
),
|
||||
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(0.5, stored!.BumperShowChangeChance);
|
||||
Assert.Equal(0.2, stored.BumperEpisodeChangeChance);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChannelShow_SetsPreferredHours()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var cs = channel.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1);
|
||||
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 UpdateChannelShowCommandHandler(db).Handle(
|
||||
new UpdateChannelShowCommand(
|
||||
channel.Id,
|
||||
cs.Id,
|
||||
3,
|
||||
BlockMode.Count,
|
||||
2,
|
||||
true,
|
||||
4,
|
||||
[new HourWindowInput(18, 23)]
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await using var verify = fixture.New();
|
||||
var stored = await verify
|
||||
.Channels.Include(c => c.Shows)
|
||||
.ThenInclude(s => s.PreferredHours)
|
||||
.FirstAsync(c => c.Id == channel.Id);
|
||||
var show = Assert.Single(stored.Shows);
|
||||
Assert.Equal(4, show.PreferredWeightMultiplier);
|
||||
Assert.Single(show.PreferredHours);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverride_OneTime_Weekly_AndValidation()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var show = Show.Create("S", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new CreateProgrammingOverrideCommandHandler(db);
|
||||
OverrideShowInput[] shows = [new OverrideShowInput(show.Id, 1)];
|
||||
|
||||
// Разовое окно — ок.
|
||||
var oneTime = await handler.Handle(
|
||||
new CreateProgrammingOverrideCommand(
|
||||
channel.Id,
|
||||
OverrideMode.Exclusive,
|
||||
OverrideRecurrence.OneTime,
|
||||
T0,
|
||||
T0.AddHours(2),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
shows
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(oneTime.IsSuccess);
|
||||
|
||||
// Еженедельное — ок.
|
||||
var weekly = await handler.Handle(
|
||||
new CreateProgrammingOverrideCommand(
|
||||
channel.Id,
|
||||
OverrideMode.Boost,
|
||||
OverrideRecurrence.Weekly,
|
||||
null,
|
||||
null,
|
||||
6,
|
||||
600,
|
||||
720,
|
||||
shows
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(weekly.IsSuccess);
|
||||
|
||||
// Невалидное окно (end <= start).
|
||||
var bad = await handler.Handle(
|
||||
new CreateProgrammingOverrideCommand(
|
||||
channel.Id,
|
||||
OverrideMode.Exclusive,
|
||||
OverrideRecurrence.OneTime,
|
||||
T0.AddHours(2),
|
||||
T0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
shows
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.InvalidOverrideWindow, bad.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOverride_RemovesOrReportsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var ovr = channel.AddOverride(OverrideMode.Exclusive, T0, T0.AddHours(1));
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new DeleteProgrammingOverrideCommandHandler(db);
|
||||
|
||||
var missing = await handler.Handle(
|
||||
new DeleteProgrammingOverrideCommand(channel.Id, Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ChannelErrors.OverrideNotFound, missing.Error);
|
||||
|
||||
var ok = await handler.Handle(
|
||||
new DeleteProgrammingOverrideCommand(channel.Id, ovr.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
}
|
||||
}
|
||||
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,
|
||||
5,
|
||||
BumperSelection.WeightedRandom,
|
||||
0.5,
|
||||
0.2
|
||||
),
|
||||
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(0.5, stored!.BumperShowChangeChance);
|
||||
Assert.Equal(0.2, stored.BumperEpisodeChangeChance);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,260 +1,203 @@
|
||||
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.Broadcast.RemoveChannelAd;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Library.DeleteShow;
|
||||
using TeleWave.Application.Library.GetShow;
|
||||
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);
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannel_ReturnsDto_WithBumperAndOverride()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("News", "news", T0);
|
||||
var show = Show.Create("S", ShowKind.Series);
|
||||
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||
channel.AddWeeklyOverride(OverrideMode.Exclusive, 6, 600, 720).AddShow(show.Id, 1);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetChannelQueryHandler(db).Handle(
|
||||
new GetChannelQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("News", result.Value.Name);
|
||||
Assert.Single(result.Value.BumperTemplates);
|
||||
Assert.Single(result.Value.Overrides);
|
||||
Assert.Equal(OverrideRecurrence.Weekly, result.Value.Overrides[0].Recurrence);
|
||||
}
|
||||
|
||||
[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).Handle(
|
||||
new DeleteShowCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(missing.IsSuccess);
|
||||
|
||||
var ok = await new DeleteShowCommandHandler(db).Handle(
|
||||
new DeleteShowCommand(show.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveChannelAd_RemovesOrNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
var ad = channel.AddAd(Guid.NewGuid());
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var handler = new RemoveChannelAdCommandHandler(db);
|
||||
|
||||
Assert.Equal(
|
||||
ChannelErrors.AdNotFound,
|
||||
(
|
||||
await handler.Handle(
|
||||
new RemoveChannelAdCommand(channel.Id, Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
)
|
||||
).Error
|
||||
);
|
||||
Assert.True(
|
||||
(
|
||||
await handler.Handle(
|
||||
new RemoveChannelAdCommand(channel.Id, ad.Id),
|
||||
CancellationToken.None
|
||||
)
|
||||
).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,142 +1,88 @@
|
||||
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.CreateOverride;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelShow;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Validators;
|
||||
|
||||
public class ValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpdateChannelShow_RejectsBadWeightMultiplierAndWindows()
|
||||
{
|
||||
var v = new UpdateChannelShowCommandValidator();
|
||||
|
||||
var good = new UpdateChannelShowCommand(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
5,
|
||||
BlockMode.Count,
|
||||
3,
|
||||
true,
|
||||
4,
|
||||
[new HourWindowInput(18, 23)]
|
||||
);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
var badMultiplier = good with { PreferredWeightMultiplier = 0 };
|
||||
Assert.False(v.Validate(badMultiplier).IsValid);
|
||||
|
||||
var badWindow = good with { PreferredHours = [new HourWindowInput(23, 20)] };
|
||||
Assert.False(v.Validate(badWindow).IsValid);
|
||||
|
||||
var badWeight = good with { Weight = 0 };
|
||||
Assert.False(v.Validate(badWeight).IsValid);
|
||||
}
|
||||
|
||||
[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,
|
||||
10,
|
||||
BumperSelection.Rotation,
|
||||
0.5,
|
||||
0.5
|
||||
);
|
||||
|
||||
var good = new UpdateChannelSettingsCommand(
|
||||
Guid.NewGuid(),
|
||||
"Name",
|
||||
true,
|
||||
AdInsertion.BetweenBlocks,
|
||||
2,
|
||||
true,
|
||||
bumper,
|
||||
null
|
||||
);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||
Assert.False(v.Validate(good with { AdsPerBreak = 99 }).IsValid);
|
||||
Assert.False(
|
||||
v.Validate(good with { Bumper = bumper with { ShowChangeChance = 2 } }).IsValid
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateOverride_RequiresShows_WithValidWeights()
|
||||
{
|
||||
var v = new CreateProgrammingOverrideCommandValidator();
|
||||
|
||||
var good = new CreateProgrammingOverrideCommand(
|
||||
Guid.NewGuid(),
|
||||
OverrideMode.Boost,
|
||||
OverrideRecurrence.OneTime,
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow.AddHours(1),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[new OverrideShowInput(Guid.NewGuid(), 3)]
|
||||
);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
Assert.False(v.Validate(good with { Shows = [] }).IsValid);
|
||||
Assert.False(
|
||||
v.Validate(good with { Shows = [new OverrideShowInput(Guid.NewGuid(), 0)] }).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,
|
||||
10,
|
||||
BumperSelection.Rotation,
|
||||
0.5,
|
||||
0.5
|
||||
);
|
||||
|
||||
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);
|
||||
Assert.False(
|
||||
v.Validate(good with { Bumper = bumper with { ShowChangeChance = 2 } }).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,89 +0,0 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Broadcast;
|
||||
|
||||
public class ChannelShowTests
|
||||
{
|
||||
private static ChannelShow NewShow() =>
|
||||
Channel
|
||||
.Create("c", "c", DateTimeOffset.UnixEpoch)
|
||||
.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1);
|
||||
|
||||
[Fact]
|
||||
public void Create_HasDefaultPreferredMultiplier_AndNoWindows()
|
||||
{
|
||||
var cs = NewShow();
|
||||
|
||||
Assert.Equal(ChannelShow.DefaultPreferredWeightMultiplier, cs.PreferredWeightMultiplier);
|
||||
Assert.Empty(cs.PreferredHours);
|
||||
Assert.True(cs.IsEnabled);
|
||||
Assert.Equal(0, cs.NextEpisodeIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ChangesCoreFields()
|
||||
{
|
||||
var cs = NewShow();
|
||||
|
||||
cs.Update(9, BlockMode.Duration, 45, false);
|
||||
|
||||
Assert.Equal(9, cs.Weight);
|
||||
Assert.Equal(BlockMode.Duration, cs.BlockMode);
|
||||
Assert.Equal(45, cs.BlockValue);
|
||||
Assert.False(cs.IsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPreferredHours_ClampsMultiplier_AndNormalizesWindows()
|
||||
{
|
||||
var cs = NewShow();
|
||||
|
||||
cs.SetPreferredHours(
|
||||
0, // < 1 → должно стать 1
|
||||
[
|
||||
(18, 23), // валидное
|
||||
(10, 10), // start == end → отбрасывается
|
||||
(5, 25), // end > 24 → отбрасывается
|
||||
(-1, 3), // start < 0 → отбрасывается
|
||||
(18, 23), // дубликат → схлопывается
|
||||
]
|
||||
);
|
||||
|
||||
Assert.Equal(1, cs.PreferredWeightMultiplier);
|
||||
var window = Assert.Single(cs.PreferredHours);
|
||||
Assert.Equal(18, window.StartHour);
|
||||
Assert.Equal(23, window.EndHour);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPreferredHours_ReplacesPreviousSet()
|
||||
{
|
||||
var cs = NewShow();
|
||||
cs.SetPreferredHours(3, [(1, 2)]);
|
||||
cs.SetPreferredHours(2, [(8, 9), (20, 22)]);
|
||||
|
||||
Assert.Equal(2, cs.PreferredWeightMultiplier);
|
||||
Assert.Equal(2, cs.PreferredHours.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPreferredAt_ChecksWindows()
|
||||
{
|
||||
var cs = NewShow();
|
||||
cs.SetPreferredHours(3, [(18, 23)]);
|
||||
|
||||
Assert.True(cs.IsPreferredAt(18));
|
||||
Assert.True(cs.IsPreferredAt(22));
|
||||
Assert.False(cs.IsPreferredAt(23)); // конец исключается
|
||||
Assert.False(cs.IsPreferredAt(9));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetNextEpisodeIndex_MovesCursor()
|
||||
{
|
||||
var cs = NewShow();
|
||||
cs.SetNextEpisodeIndex(5);
|
||||
Assert.Equal(5, cs.NextEpisodeIndex);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,131 @@
|
||||
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.Equal(AdInsertion.BetweenBlocks, channel.AdInsertion);
|
||||
Assert.False(channel.BumpersEnabled);
|
||||
Assert.Equal(BumperSelection.Rotation, channel.BumperSelection);
|
||||
Assert.Equal(1.0, channel.BumperShowChangeChance);
|
||||
Assert.Equal(1.0, channel.BumperEpisodeChangeChance);
|
||||
|
||||
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, AdInsertion.BetweenEpisodes, 4, true, filler);
|
||||
|
||||
Assert.Equal("N2", channel.Name);
|
||||
Assert.False(channel.IsEnabled);
|
||||
Assert.Equal(AdInsertion.BetweenEpisodes, channel.AdInsertion);
|
||||
Assert.Equal(4, channel.AdsPerBreak);
|
||||
Assert.True(channel.BumpersEnabled);
|
||||
Assert.Equal(filler, channel.FillerAssetId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1, 2.0, -5.0, 1.0, 0.0)]
|
||||
[InlineData(30, 0.3, 0.9, 0.3, 0.9)]
|
||||
public void UpdateBumperSettings_ClampsChancesAndInterval(
|
||||
int interval,
|
||||
double show,
|
||||
double episode,
|
||||
double expectedShow,
|
||||
double expectedEpisode
|
||||
)
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
channel.UpdateBumperSettings(
|
||||
BumperFont.Serif,
|
||||
interval,
|
||||
BumperSelection.WeightedRandom,
|
||||
show,
|
||||
episode
|
||||
);
|
||||
|
||||
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
||||
Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
||||
Assert.Equal(expectedShow, channel.BumperShowChangeChance);
|
||||
Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance);
|
||||
}
|
||||
|
||||
[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 Shows_AddFindRemoveHas()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
var cs = channel.AddShow(showId, 5, BlockMode.Count, 3);
|
||||
|
||||
Assert.Equal(showId, cs.ShowId);
|
||||
Assert.Equal(5, cs.Weight);
|
||||
Assert.True(channel.HasShow(showId));
|
||||
Assert.Equal(cs, channel.FindShow(cs.Id));
|
||||
Assert.True(channel.RemoveShow(cs.Id));
|
||||
Assert.False(channel.RemoveShow(cs.Id));
|
||||
Assert.False(channel.HasShow(showId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ads_AppendWithPosition_AndRemove()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
var a1 = channel.AddAd(Guid.NewGuid());
|
||||
var a2 = channel.AddAd(Guid.NewGuid());
|
||||
|
||||
Assert.Equal(0, a1.Position);
|
||||
Assert.Equal(1, a2.Position);
|
||||
Assert.True(channel.HasAd(a1.MediaAssetId));
|
||||
Assert.True(channel.RemoveAd(a1.Id));
|
||||
Assert.False(channel.RemoveAd(a1.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOverride_OneTime_AndWeekly()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
var one = channel.AddOverride(OverrideMode.Exclusive, Epoch, Epoch.AddHours(2));
|
||||
Assert.Equal(OverrideRecurrence.OneTime, one.Recurrence);
|
||||
Assert.Equal(Epoch, one.StartsAtUtc);
|
||||
|
||||
var weekly = channel.AddWeeklyOverride(OverrideMode.Boost, 6, 600, 720);
|
||||
Assert.Equal(OverrideRecurrence.Weekly, weekly.Recurrence);
|
||||
Assert.Equal(6, weekly.DayOfWeek);
|
||||
Assert.Equal(600, weekly.StartMinute);
|
||||
|
||||
Assert.Equal(2, channel.Overrides.Count);
|
||||
Assert.True(channel.RemoveOverride(one.Id));
|
||||
Assert.False(channel.RemoveOverride(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cursors_AreMovedBySetters()
|
||||
{
|
||||
var channel = NewChannel();
|
||||
channel.SetNextAdIndex(7);
|
||||
channel.SetNextBumperIndex(3);
|
||||
|
||||
Assert.Equal(7, channel.NextAdIndex);
|
||||
Assert.Equal(3, channel.NextBumperIndex);
|
||||
}
|
||||
}
|
||||
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.Rotation, channel.BumperSelection);
|
||||
Assert.Equal(1.0, channel.BumperShowChangeChance);
|
||||
Assert.Equal(1.0, channel.BumperEpisodeChangeChance);
|
||||
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);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1, 2.0, -5.0, 1.0, 0.0)]
|
||||
[InlineData(30, 0.3, 0.9, 0.3, 0.9)]
|
||||
public void UpdateBumperSettings_ClampsChancesAndInterval(
|
||||
int interval,
|
||||
double show,
|
||||
double episode,
|
||||
double expectedShow,
|
||||
double expectedEpisode
|
||||
)
|
||||
{
|
||||
var channel = NewChannel();
|
||||
|
||||
channel.UpdateBumperSettings(
|
||||
BumperFont.Serif,
|
||||
interval,
|
||||
BumperSelection.WeightedRandom,
|
||||
show,
|
||||
episode
|
||||
);
|
||||
|
||||
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
||||
Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes);
|
||||
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
||||
Assert.Equal(expectedShow, channel.BumperShowChangeChance);
|
||||
Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance);
|
||||
}
|
||||
|
||||
[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,56 +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 ChannelShowHour_Contains_HalfOpenWindow()
|
||||
{
|
||||
var cs = Channel
|
||||
.Create("c", "c", DateTimeOffset.UnixEpoch)
|
||||
.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1);
|
||||
cs.SetPreferredHours(2, [(9, 12)]);
|
||||
var hour = Assert.Single(cs.PreferredHours);
|
||||
|
||||
Assert.False(hour.Contains(8));
|
||||
Assert.True(hour.Contains(9));
|
||||
Assert.True(hour.Contains(11));
|
||||
Assert.False(hour.Contains(12));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Broadcast;
|
||||
|
||||
public class ProgrammingOverrideTests
|
||||
{
|
||||
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static Channel NewChannel() => Channel.Create("c", "c", Epoch);
|
||||
|
||||
[Fact]
|
||||
public void OneTime_Covers_OnlyInsideWindow()
|
||||
{
|
||||
var ovr = NewChannel()
|
||||
.AddOverride(OverrideMode.Exclusive, Epoch.AddHours(1), Epoch.AddHours(3));
|
||||
|
||||
Assert.False(ovr.Covers(Epoch.AddMinutes(59)));
|
||||
Assert.True(ovr.Covers(Epoch.AddHours(1)));
|
||||
Assert.True(ovr.Covers(Epoch.AddHours(2)));
|
||||
Assert.False(ovr.Covers(Epoch.AddHours(3))); // конец исключается
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddShow_AttachesWithWeight()
|
||||
{
|
||||
var ovr = NewChannel().AddOverride(OverrideMode.Boost, Epoch, Epoch.AddHours(1));
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
var os = ovr.AddShow(showId, 4);
|
||||
|
||||
Assert.Equal(showId, os.ShowId);
|
||||
Assert.Equal(4, os.Weight);
|
||||
Assert.Single(ovr.Shows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weekly_Covers_MatchingDayAndMinuteWindow()
|
||||
{
|
||||
// Epoch = 2026-01-01 — четверг (DayOfWeek=4).
|
||||
var day = (int)Epoch.UtcDateTime.DayOfWeek;
|
||||
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 720); // 10:00–12:00
|
||||
|
||||
Assert.True(ovr.Covers(Epoch.AddMinutes(600))); // 10:00 того же дня
|
||||
Assert.True(ovr.Covers(Epoch.AddMinutes(719))); // 11:59
|
||||
Assert.False(ovr.Covers(Epoch.AddMinutes(720))); // 12:00 — конец исключается
|
||||
Assert.False(ovr.Covers(Epoch.AddMinutes(599))); // 09:59
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weekly_Covers_NextWeekSameDay()
|
||||
{
|
||||
var day = (int)Epoch.UtcDateTime.DayOfWeek;
|
||||
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 0, 1440);
|
||||
|
||||
Assert.True(ovr.Covers(Epoch.AddDays(7).AddHours(5)));
|
||||
Assert.False(ovr.Covers(Epoch.AddDays(1).AddHours(5))); // другой день недели
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weekly_Covers_WindowCrossingMidnight()
|
||||
{
|
||||
// Epoch = 2026-01-01 — четверг (DayOfWeek=4). Окно 23:00–01:00 пересекает полночь на пятницу.
|
||||
var day = (int)Epoch.UtcDateTime.DayOfWeek;
|
||||
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 1380, 60); // 23:00–01:00
|
||||
|
||||
Assert.True(ovr.Covers(Epoch.AddMinutes(1380))); // чт 23:00 — начало
|
||||
Assert.True(ovr.Covers(Epoch.AddMinutes(1439))); // чт 23:59
|
||||
Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(0))); // пт 00:00 (следующий день)
|
||||
Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(59))); // пт 00:59
|
||||
Assert.False(ovr.Covers(Epoch.AddDays(1).AddMinutes(60))); // пт 01:00 — конец исключается
|
||||
Assert.False(ovr.Covers(Epoch.AddMinutes(1379))); // чт 22:59 — до начала
|
||||
Assert.False(ovr.Covers(Epoch.AddHours(12))); // чт полдень — вне окна
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weekly_EmptyWindow_WhenStartEqualsEnd_CoversNothing()
|
||||
{
|
||||
var day = (int)Epoch.UtcDateTime.DayOfWeek;
|
||||
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 600);
|
||||
|
||||
Assert.False(ovr.Covers(Epoch.AddMinutes(600)));
|
||||
Assert.False(ovr.Covers(Epoch.AddMinutes(700)));
|
||||
}
|
||||
}
|
||||
@@ -1,877 +0,0 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Broadcast;
|
||||
|
||||
public class SchedulePlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Источник случайности с фиксированной (циклической) последовательностью значений.</summary>
|
||||
private sealed class FixedRandom(params int[] values) : IRandomSource
|
||||
{
|
||||
private int _i;
|
||||
|
||||
public int Next(int maxExclusive) =>
|
||||
values.Length == 0 ? 0 : values[_i++ % values.Length] % maxExclusive;
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, TimeSpan> Durations(params (Guid Id, int Minutes)[] items) =>
|
||||
items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes));
|
||||
|
||||
private static readonly Guid DefaultTemplate = Guid.NewGuid();
|
||||
private static readonly Guid DefaultVariant = Guid.NewGuid();
|
||||
|
||||
/// <summary>Конфиг заставок с одним дефолтным подблоком (8с) заданного триггера, если варианты не заданы.</summary>
|
||||
private static PlannerBumperConfig Bumper(
|
||||
bool enabled,
|
||||
BumperTrigger trigger,
|
||||
TimeSpan minInterval,
|
||||
BumperSelection selection = BumperSelection.Rotation,
|
||||
params PlannerBumperVariant[] variants
|
||||
) =>
|
||||
new(
|
||||
enabled,
|
||||
minInterval,
|
||||
selection,
|
||||
variants.Length == 0
|
||||
?
|
||||
[
|
||||
new PlannerBumperVariant(
|
||||
DefaultVariant,
|
||||
DefaultTemplate,
|
||||
TimeSpan.FromSeconds(8),
|
||||
trigger
|
||||
),
|
||||
]
|
||||
: variants
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void Count_Block_ProducesConsecutiveEpisodes_BackToBack()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
var show = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var input = new PlannerInput(
|
||||
ChannelId: Guid.NewGuid(),
|
||||
AdInsertion: AdInsertion.BetweenBlocks,
|
||||
AdsPerBreak: 1,
|
||||
NextAdIndex: 0,
|
||||
Shows: [new PlannerShow(cs, show, 1, BlockMode.Count, 3, eps, 0)],
|
||||
AdPool: [],
|
||||
Durations: Durations((eps[0], 2), (eps[1], 2), (eps[2], 2), (eps[3], 2)),
|
||||
Overrides: [],
|
||||
StartTime: Start,
|
||||
HorizonEnd: Start.AddSeconds(1)
|
||||
);
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Equal(3, result.Entries.Count);
|
||||
Assert.All(result.Entries, e => Assert.Equal(ScheduleEntryKind.Program, e.Kind));
|
||||
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
|
||||
Assert.Equal(Start, result.Entries[0].StartsAtUtc);
|
||||
Assert.Equal(result.Entries[0].EndsAtUtc, result.Entries[1].StartsAtUtc);
|
||||
Assert.Equal(result.Entries[1].EndsAtUtc, result.Entries[2].StartsAtUtc);
|
||||
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_Block_WrapsAtEndOfSeries()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var input = BaseInput(
|
||||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 3, eps, 0)],
|
||||
Durations((eps[0], 2), (eps[1], 2)),
|
||||
horizonEnd: Start.AddSeconds(1)
|
||||
);
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Equal([0, 1, 0], result.Entries.Select(e => e.EpisodeIndex));
|
||||
Assert.Equal(1, result.NextEpisodeIndexByChannelShow[cs]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duration_Block_TakesEpisodesUntilBudgetReached()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var input = BaseInput(
|
||||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Duration, 25, eps, 0)],
|
||||
Durations((eps[0], 10), (eps[1], 10), (eps[2], 10), (eps[3], 10)),
|
||||
horizonEnd: Start.AddMinutes(30)
|
||||
);
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
// 10+10+10 = 30 >= 25 → три серии.
|
||||
Assert.Equal(3, result.Entries.Count);
|
||||
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
|
||||
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdsBetweenBlocks_InsertsAdAfterBlock()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid ep = Guid.NewGuid(),
|
||||
ad0 = Guid.NewGuid(),
|
||||
ad1 = Guid.NewGuid();
|
||||
|
||||
var input = new PlannerInput(
|
||||
Guid.NewGuid(),
|
||||
AdInsertion.BetweenBlocks,
|
||||
AdsPerBreak: 1,
|
||||
NextAdIndex: 0,
|
||||
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, [ep], 0)],
|
||||
AdPool: [ad0, ad1],
|
||||
Durations: Durations((ep, 20), (ad0, 1), (ad1, 1)),
|
||||
Overrides: [],
|
||||
StartTime: Start,
|
||||
HorizonEnd: Start.AddSeconds(1)
|
||||
);
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Equal(2, result.Entries.Count);
|
||||
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
|
||||
Assert.Equal(ScheduleEntryKind.Ad, result.Entries[1].Kind);
|
||||
Assert.Equal(ad0, result.Entries[1].MediaAssetId);
|
||||
Assert.Equal(1, result.NextAdIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdsBetweenEpisodes_InsertsAdAfterEachEpisode()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
Guid ad = Guid.NewGuid();
|
||||
|
||||
var input = new PlannerInput(
|
||||
Guid.NewGuid(),
|
||||
AdInsertion.BetweenEpisodes,
|
||||
AdsPerBreak: 1,
|
||||
NextAdIndex: 0,
|
||||
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 2, eps, 0)],
|
||||
AdPool: [ad],
|
||||
Durations: Durations((eps[0], 20), (eps[1], 20), (ad, 1)),
|
||||
Overrides: [],
|
||||
StartTime: Start,
|
||||
HorizonEnd: Start.AddSeconds(1)
|
||||
);
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
ScheduleEntryKind.Program,
|
||||
ScheduleEntryKind.Ad,
|
||||
ScheduleEntryKind.Program,
|
||||
ScheduleEntryKind.Ad,
|
||||
],
|
||||
result.Entries.Select(e => e.Kind)
|
||||
);
|
||||
Assert.Equal(2, result.NextAdIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedPick_RespectsRoll()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
3,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var pickA = SchedulePlanner.Plan(
|
||||
BaseInput([a, b], durations, Start.AddSeconds(1)),
|
||||
new FixedRandom(0)
|
||||
);
|
||||
var pickB = SchedulePlanner.Plan(
|
||||
BaseInput([a, b], durations, Start.AddSeconds(1)),
|
||||
new FixedRandom(3)
|
||||
);
|
||||
|
||||
Assert.Equal(a.ShowId, pickA.Entries[0].ShowId);
|
||||
Assert.Equal(b.ShowId, pickB.Entries[0].ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExclusiveOverride_ForcesSingleShow()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
10,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = new PlannerInput(
|
||||
Guid.NewGuid(),
|
||||
AdInsertion.BetweenBlocks,
|
||||
0,
|
||||
0,
|
||||
[a, b],
|
||||
[],
|
||||
durations,
|
||||
Overrides:
|
||||
[
|
||||
new PlannerOverride(
|
||||
OverrideMode.Exclusive,
|
||||
[new PlannerOverrideShow(b.ShowId, 1)],
|
||||
StartsAtUtc: Start,
|
||||
EndsAtUtc: Start.AddHours(1)
|
||||
),
|
||||
],
|
||||
StartTime: Start,
|
||||
HorizonEnd: Start.AddSeconds(1)
|
||||
);
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_InsertedOnTransitionBetweenDifferentShows_BackToBack()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
// Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2).
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
// Перед первым блоком заставки нет; далее по одной на каждый переход.
|
||||
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.NotEmpty(bumpers);
|
||||
|
||||
var first = result.Entries[1];
|
||||
Assert.Equal(ScheduleEntryKind.Bumper, first.Kind);
|
||||
Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор
|
||||
Assert.Equal(a.ShowId, first.FromShowId);
|
||||
Assert.Equal(b.ShowId, first.ToShowId);
|
||||
Assert.Equal(DefaultTemplate, first.BumperTemplateId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc);
|
||||
// Встык: программа → заставка → программа.
|
||||
Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc);
|
||||
Assert.Equal(first.EndsAtUtc, result.Entries[2].StartsAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_SkippedForSameShow_WhenOnlyBetweenDifferentShows()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var input = BaseInput(
|
||||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
|
||||
Durations((eps[0], 20), (eps[1], 20)),
|
||||
horizonEnd: Start.AddMinutes(50)
|
||||
) with
|
||||
{
|
||||
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_InsertedForSameShow_WhenNotRestrictedToDifferent()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var input = BaseInput(
|
||||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
|
||||
Durations((eps[0], 20), (eps[1], 20)),
|
||||
horizonEnd: Start.AddMinutes(50)
|
||||
) with
|
||||
{
|
||||
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Contains(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_MinInterval_SuppressesTooFrequentBumpers()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
// Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй.
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.FromMinutes(30)),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
Assert.Equal(1, result.Entries.Count(e => e.Kind == ScheduleEntryKind.Bumper));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_Rotation_CyclesTemplatesAndAdvancesCursor()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
Guid t0 = Guid.NewGuid(),
|
||||
t1 = Guid.NewGuid();
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
|
||||
{
|
||||
Bumpers = Bumper(
|
||||
true,
|
||||
BumperTrigger.Both,
|
||||
TimeSpan.Zero,
|
||||
BumperSelection.Rotation,
|
||||
new PlannerBumperVariant(
|
||||
Guid.NewGuid(),
|
||||
t0,
|
||||
TimeSpan.FromSeconds(8),
|
||||
BumperTrigger.Both
|
||||
),
|
||||
new PlannerBumperVariant(
|
||||
Guid.NewGuid(),
|
||||
t1,
|
||||
TimeSpan.FromSeconds(4),
|
||||
BumperTrigger.Both
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.True(bumpers.Count >= 2);
|
||||
// Ротация: первый подблок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу.
|
||||
Assert.Equal(t0, bumpers[0].BumperTemplateId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc);
|
||||
Assert.Equal(t1, bumpers[1].BumperTemplateId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(4), bumpers[1].EndsAtUtc - bumpers[1].StartsAtUtc);
|
||||
Assert.All(bumpers, e => Assert.Equal(Guid.Empty, e.MediaAssetId));
|
||||
Assert.Equal(bumpers.Count, result.NextBumperIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_AlwaysFirst_AlwaysUsesFirstTemplate()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
Guid t0 = Guid.NewGuid(),
|
||||
t1 = Guid.NewGuid();
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
|
||||
{
|
||||
Bumpers = Bumper(
|
||||
true,
|
||||
BumperTrigger.Both,
|
||||
TimeSpan.Zero,
|
||||
BumperSelection.AlwaysFirst,
|
||||
new PlannerBumperVariant(
|
||||
Guid.NewGuid(),
|
||||
t0,
|
||||
TimeSpan.FromSeconds(8),
|
||||
BumperTrigger.Both
|
||||
),
|
||||
new PlannerBumperVariant(
|
||||
Guid.NewGuid(),
|
||||
t1,
|
||||
TimeSpan.FromSeconds(4),
|
||||
BumperTrigger.Both
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.True(bumpers.Count >= 2);
|
||||
Assert.All(bumpers, e => Assert.Equal(t0, e.BumperTemplateId));
|
||||
Assert.Equal(0, result.NextBumperIndex); // курсор ротации не двигается
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_NoTemplates_NoBumpers()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(
|
||||
true,
|
||||
MinInterval: TimeSpan.Zero,
|
||||
Selection: BumperSelection.Rotation,
|
||||
Variants: []
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_Disabled_ProduceNoBumperEntries()
|
||||
{
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = Bumper(false, BumperTrigger.OnShowChange, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_ShowChangeChanceZero_SuppressesBumpersOnShowChange()
|
||||
{
|
||||
// Два шоу, ротация выбора подблока (без random в пути заставки) → чередование гарантировано,
|
||||
// каждый переход — смена шоу. ShowChangeChance=0 глушит их, EpisodeChangeChance=1 не при чём.
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(120)) with
|
||||
{
|
||||
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with
|
||||
{
|
||||
ShowChangeChance = 0.0,
|
||||
},
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_EpisodeChangeChanceZero_SuppressesBumpersBetweenEpisodes()
|
||||
{
|
||||
// Одно шоу → каждый переход между блоками одного шоу. EpisodeChangeChance=0 глушит все заставки.
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
|
||||
{
|
||||
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with
|
||||
{
|
||||
EpisodeChangeChance = 0.0,
|
||||
},
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_WeightedRandom_NeverPicksZeroWeightVariant()
|
||||
{
|
||||
// Взвешенный выбор: подблок с весом 0 не выбирается никогда, весь трафик уходит на t1 (вес 5).
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20));
|
||||
Guid t0 = Guid.NewGuid(),
|
||||
t1 = Guid.NewGuid();
|
||||
|
||||
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
|
||||
{
|
||||
Bumpers = Bumper(
|
||||
true,
|
||||
BumperTrigger.Both,
|
||||
TimeSpan.Zero,
|
||||
BumperSelection.WeightedRandom,
|
||||
new PlannerBumperVariant(
|
||||
Guid.NewGuid(),
|
||||
t0,
|
||||
TimeSpan.FromSeconds(8),
|
||||
BumperTrigger.Both,
|
||||
0
|
||||
),
|
||||
new PlannerBumperVariant(
|
||||
Guid.NewGuid(),
|
||||
t1,
|
||||
TimeSpan.FromSeconds(8),
|
||||
BumperTrigger.Both,
|
||||
5
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1, 2, 3, 4));
|
||||
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.NotEmpty(bumpers);
|
||||
Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeeklyOverride_AppliesOnMatchingDayAndTime()
|
||||
{
|
||||
// Start = 2026-01-01 (четверг, DayOfWeek=4), полночь. Еженедельный override на четверг 00:00–01:00.
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
|
||||
{
|
||||
Overrides =
|
||||
[
|
||||
new PlannerOverride(
|
||||
OverrideMode.Exclusive,
|
||||
[new PlannerOverrideShow(b.ShowId, 1)],
|
||||
OverrideRecurrence.Weekly,
|
||||
DayOfWeek: (int)Start.UtcDateTime.DayOfWeek,
|
||||
StartMinute: 0,
|
||||
EndMinute: 60
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeeklyOverride_IgnoredOnOtherDay()
|
||||
{
|
||||
// Override на другой день недели → базовая ротация (FixedRandom(0) берёт первое шоу — a).
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
var otherDay = ((int)Start.UtcDateTime.DayOfWeek + 1) % 7;
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
|
||||
{
|
||||
Overrides =
|
||||
[
|
||||
new PlannerOverride(
|
||||
OverrideMode.Exclusive,
|
||||
[new PlannerOverrideShow(b.ShowId, 1)],
|
||||
OverrideRecurrence.Weekly,
|
||||
DayOfWeek: otherDay,
|
||||
StartMinute: 0,
|
||||
EndMinute: 1440
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Equal(a.ShowId, result.Entries[0].ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferredHours_BoostsWeight_InsideWindow()
|
||||
{
|
||||
// Час старта — 0 (UTC). Окно b покрывает его, множитель 100 → b доминирует, хотя базовые веса равны.
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0,
|
||||
PreferredHours: [new PlannerHourWindow(0, 24)],
|
||||
PreferredWeightMultiplier: 100
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
|
||||
|
||||
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
||||
Assert.NotEmpty(programs);
|
||||
Assert.All(programs, e => Assert.Equal(b.ShowId, e.ShowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferredHours_OutsideWindow_KeepsBaseWeight()
|
||||
{
|
||||
// Час старта — 0, окно b — [10,12): не покрывает → буста нет, при FixedRandom(50) берётся первое шоу (a).
|
||||
var a = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0
|
||||
);
|
||||
var b = new PlannerShow(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
BlockMode.Count,
|
||||
1,
|
||||
[Guid.NewGuid()],
|
||||
0,
|
||||
PreferredHours: [new PlannerHourWindow(10, 12)],
|
||||
PreferredWeightMultiplier: 100
|
||||
);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
|
||||
|
||||
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
||||
Assert.NotEmpty(programs);
|
||||
Assert.All(programs, e => Assert.Equal(a.ShowId, e.ShowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoPlayableShows_ReturnsEmpty()
|
||||
{
|
||||
var input = BaseInput([], new Dictionary<Guid, TimeSpan>(), Start.AddHours(1));
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Empty(result.Entries);
|
||||
}
|
||||
|
||||
private static PlannerInput BaseInput(
|
||||
IReadOnlyList<PlannerShow> shows,
|
||||
IReadOnlyDictionary<Guid, TimeSpan> durations,
|
||||
DateTimeOffset horizonEnd
|
||||
) =>
|
||||
new(
|
||||
ChannelId: Guid.NewGuid(),
|
||||
AdInsertion: AdInsertion.BetweenBlocks,
|
||||
AdsPerBreak: 0,
|
||||
NextAdIndex: 0,
|
||||
Shows: shows,
|
||||
AdPool: [],
|
||||
Durations: durations,
|
||||
Overrides: [],
|
||||
StartTime: Start,
|
||||
HorizonEnd: horizonEnd
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Library;
|
||||
|
||||
public class CollectionTests
|
||||
{
|
||||
private static IReadOnlyList<Guid> Order(Collection collection) =>
|
||||
collection.Items.OrderBy(i => i.Position).Select(i => i.ShowId).ToList();
|
||||
|
||||
[Fact]
|
||||
public void AddShow_AppendsToEnd()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var first = Guid.NewGuid();
|
||||
var second = Guid.NewGuid();
|
||||
|
||||
collection.AddShow(first);
|
||||
collection.AddShow(second);
|
||||
|
||||
Assert.Equal([first, second], Order(collection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddShow_Duplicate_ReturnsNull()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
collection.AddShow(showId);
|
||||
|
||||
Assert.Null(collection.AddShow(showId));
|
||||
Assert.Single(collection.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveShow_ReturnsFalse_WhenNotPresent()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
|
||||
Assert.False(collection.RemoveShow(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_AppliesRequestedOrder()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
var c = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
collection.AddShow(b);
|
||||
collection.AddShow(c);
|
||||
|
||||
collection.Reorder([c, a, b]);
|
||||
|
||||
Assert.Equal([c, a, b], Order(collection));
|
||||
Assert.Equal([0, 1, 2], collection.Items.OrderBy(i => i.Position).Select(i => i.Position));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_KeepsUnmentionedAfterRequested_InOriginalOrder()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
var c = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
collection.AddShow(b);
|
||||
collection.AddShow(c);
|
||||
|
||||
// Перетащили только один элемент — остальные не должны потеряться или перемешаться.
|
||||
collection.Reorder([c]);
|
||||
|
||||
Assert.Equal([c, a, b], Order(collection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_IgnoresUnknownShows()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
|
||||
collection.Reorder([Guid.NewGuid(), a]);
|
||||
|
||||
Assert.Equal([a], Order(collection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveThenAdd_DoesNotReusePosition()
|
||||
{
|
||||
var collection = Collection.Create("Терминатор");
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
collection.AddShow(a);
|
||||
collection.AddShow(b);
|
||||
|
||||
collection.RemoveShow(a);
|
||||
var c = Guid.NewGuid();
|
||||
collection.AddShow(c);
|
||||
|
||||
// Новая часть встаёт после существующих, а не занимает освободившийся номер.
|
||||
Assert.Equal([b, c], Order(collection));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Library;
|
||||
|
||||
public class ShowGenreTests
|
||||
{
|
||||
private static Show NewShow() => Show.Create("A", ShowKind.Series);
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_MarksRequestedPrimary()
|
||||
{
|
||||
var show = NewShow();
|
||||
var action = Guid.NewGuid();
|
||||
var comedy = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([action, comedy], comedy);
|
||||
|
||||
Assert.Equal(2, show.Genres.Count);
|
||||
Assert.Equal(comedy, show.PrimaryGenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_FallsBackToFirst_WhenPrimaryNotInSet()
|
||||
{
|
||||
var show = NewShow();
|
||||
var action = Guid.NewGuid();
|
||||
var comedy = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([action, comedy], Guid.NewGuid());
|
||||
|
||||
// Шоу с жанрами не должно оставаться без основного — иначе список показывал бы прочерк.
|
||||
Assert.Equal(action, show.PrimaryGenreId);
|
||||
Assert.Single(show.Genres, g => g.IsPrimary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_DropsDuplicatesAndEmpty()
|
||||
{
|
||||
var show = NewShow();
|
||||
var action = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([action, action, Guid.Empty]);
|
||||
|
||||
Assert.Single(show.Genres);
|
||||
Assert.Equal(action, show.PrimaryGenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_ReplacesPreviousSet()
|
||||
{
|
||||
var show = NewShow();
|
||||
var first = Guid.NewGuid();
|
||||
var second = Guid.NewGuid();
|
||||
|
||||
show.SetGenres([first]);
|
||||
show.SetGenres([second]);
|
||||
|
||||
Assert.Single(show.Genres);
|
||||
Assert.Equal(second, show.PrimaryGenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGenres_Empty_LeavesNoPrimary()
|
||||
{
|
||||
var show = NewShow();
|
||||
show.SetGenres([Guid.NewGuid()]);
|
||||
|
||||
show.SetGenres([]);
|
||||
|
||||
Assert.Empty(show.Genres);
|
||||
Assert.Null(show.PrimaryGenreId);
|
||||
}
|
||||
}
|
||||
|
||||
public class GenreTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(" Sci-Fi ", "sci-fi")]
|
||||
[InlineData("Action & Adventure", "action & adventure")]
|
||||
[InlineData("БОЕВИК", "боевик")]
|
||||
[InlineData(" ", "")]
|
||||
[InlineData(null, "")]
|
||||
public void Normalize_CollapsesCaseAndSpacing(string? input, string expected) =>
|
||||
Assert.Equal(expected, GenreAlias.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public void AddAlias_IgnoresDuplicatesRegardlessOfSpelling()
|
||||
{
|
||||
var genre = Genre.Create("Боевик", "action");
|
||||
|
||||
genre.AddAlias("Action");
|
||||
genre.AddAlias(" action ");
|
||||
genre.AddAlias("боевик");
|
||||
|
||||
Assert.Equal(2, genre.Aliases.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAliases_DropsOldOnes()
|
||||
{
|
||||
var genre = Genre.Create("Боевик", "action");
|
||||
genre.AddAlias("tmdb:28");
|
||||
|
||||
genre.ReplaceAliases(["tmdb:10759"]);
|
||||
|
||||
Assert.Single(genre.Aliases);
|
||||
Assert.Equal("tmdb:10759", genre.Aliases[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProviderKey_BuildsNormalizedPrefixedAlias() =>
|
||||
Assert.Equal("tmdb:28", GenreAlias.ProviderKey("TMDB", "28"));
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Золотые тесты планировщика: для набора эталонных конфигураций фиксируется посуточная лента
|
||||
/// целиком. Любая правка стратегий или подгонки сразу показывает построчный диф — что именно
|
||||
/// поехало, — вместо «упало N тестов» через неделю после того, как это ушло в эфир.
|
||||
///
|
||||
/// Эталон обновляется осознанно: переписыванием ожидаемой строки в тесте, а не автоматически при
|
||||
/// расхождении. Автообновление превратило бы их в тесты, которые всегда зелёные.
|
||||
/// </summary>
|
||||
public class GoldenScheduleTests
|
||||
{
|
||||
private static readonly DateTimeOffset Day = new(2026, 3, 2, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Псевдослучайность с фиксированным зерном: жребий обязан быть воспроизводимым.</summary>
|
||||
private sealed class SeededRandom(int seed) : IRandomSource
|
||||
{
|
||||
private int _state = seed;
|
||||
|
||||
public int Next(int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= 0)
|
||||
return 0;
|
||||
_state = (int)((_state * 1103515245L + 12345) & 0x7FFFFFFF);
|
||||
return _state % maxExclusive;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Лента в компактном виде: время начала, тип, шоу и номер единицы — построчно.</summary>
|
||||
private static List<string> Render(
|
||||
PlanningResult result,
|
||||
IReadOnlyDictionary<Guid, string> names
|
||||
) =>
|
||||
result
|
||||
.Items.OrderBy(i => i.StartsAtUtc)
|
||||
.Select(item =>
|
||||
{
|
||||
var label = item.ShowId is { } showId && names.TryGetValue(showId, out var name)
|
||||
? $"{name}#{item.UnitIndex}"
|
||||
: item.Kind.ToString();
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{item.StartsAtUtc:HH:mm} {label}"
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static (PlanningElement Element, Guid ShowId) Series(int episodes, int minutes)
|
||||
{
|
||||
var showId = Guid.NewGuid();
|
||||
var units = Enumerable
|
||||
.Range(0, episodes)
|
||||
.Select(i => new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, i))
|
||||
.ToList();
|
||||
return (
|
||||
new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units),
|
||||
showId
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanningSlot ContentSlot(
|
||||
DateTimeOffset start,
|
||||
int durationMinutes,
|
||||
IReadOnlyList<PlanningElement> elements,
|
||||
SlotBlockMode blockMode,
|
||||
int blockValue,
|
||||
bool isAnchor = false,
|
||||
int? snap = null
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
start,
|
||||
durationMinutes,
|
||||
SlotKind.Content,
|
||||
isAnchor,
|
||||
MaxDriftMinutes: 15,
|
||||
snap,
|
||||
blockMode,
|
||||
blockValue,
|
||||
OverflowPolicy.ContinueNext,
|
||||
new PlanningStrategy(SlotStrategyKind.Sequential),
|
||||
elements,
|
||||
Cursor: null
|
||||
);
|
||||
|
||||
private static PlanningUnit Filler() =>
|
||||
new(Guid.NewGuid(), TimeSpan.FromMinutes(1), Guid.Empty, 0);
|
||||
|
||||
[Fact]
|
||||
public void Golden_MorningCartoonsThenPrimeMovie()
|
||||
{
|
||||
var (cartoons, cartoonShow) = Series(6, 20);
|
||||
var (movie, movieShow) = Series(1, 95);
|
||||
|
||||
var slots = new[]
|
||||
{
|
||||
// Утренний блок: три мультфильма подряд.
|
||||
ContentSlot(Day.AddHours(1), 60, [cartoons], SlotBlockMode.Count, 3),
|
||||
// Прайм с якорем: старт жёсткий, предыдущий блок через него не перелезает.
|
||||
ContentSlot(Day.AddHours(14), 120, [movie], SlotBlockMode.Count, 1, isAnchor: true),
|
||||
};
|
||||
|
||||
var input = new PlanningInput(
|
||||
Guid.NewGuid(),
|
||||
Day,
|
||||
Day.AddHours(17),
|
||||
slots,
|
||||
[Filler()],
|
||||
SegmentSeconds: 2
|
||||
);
|
||||
|
||||
var result = GridPlanner.Plan(input, new SeededRandom(42));
|
||||
var names = new Dictionary<Guid, string>
|
||||
{
|
||||
[cartoonShow] = "Мультфильмы",
|
||||
[movieShow] = "Кино",
|
||||
};
|
||||
|
||||
var tape = Render(result, names)
|
||||
.Where(line => !line.Contains("Fallback", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"07:00 Мультфильмы#0",
|
||||
"07:20 Мультфильмы#1",
|
||||
"07:40 Мультфильмы#2",
|
||||
"20:00 Кино#0",
|
||||
],
|
||||
tape
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Golden_DriftIsCarriedForward()
|
||||
{
|
||||
var (series, showId) = Series(6, 25);
|
||||
|
||||
// Три слота подряд по часу, блок — две серии по 25 минут: каждый слот отдаёт 50 минут,
|
||||
// и разбег накапливается, пока его не подберёт следующий целевой старт.
|
||||
var slots = Enumerable
|
||||
.Range(0, 3)
|
||||
.Select(i =>
|
||||
ContentSlot(Day.AddHours(i), 60, [series], SlotBlockMode.Count, 2)
|
||||
)
|
||||
.ToArray();
|
||||
|
||||
var input = new PlanningInput(
|
||||
Guid.NewGuid(),
|
||||
Day,
|
||||
Day.AddHours(4),
|
||||
slots,
|
||||
[Filler()],
|
||||
SegmentSeconds: 2
|
||||
);
|
||||
|
||||
var result = GridPlanner.Plan(input, new SeededRandom(7));
|
||||
var programs = result
|
||||
.Items.Where(i => i.Kind == PlannedItemKind.Program)
|
||||
.OrderBy(i => i.StartsAtUtc)
|
||||
.Select(i => i.StartsAtUtc.ToString("HH:mm", CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
|
||||
// Слот не начинается раньше своего целевого времени: недобор закрывает фон, а не сдвиг вперёд.
|
||||
Assert.Equal(["06:00", "06:25", "07:00", "07:25", "08:00", "08:25"], programs);
|
||||
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback);
|
||||
Assert.Equal(showId, result.Items.First(i => i.Kind == PlannedItemKind.Program).ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Golden_SameInputProducesSameTape()
|
||||
{
|
||||
var (series, _) = Series(10, 30);
|
||||
var slots = new[] { ContentSlot(Day, 180, [series], SlotBlockMode.FillSlot, 1) };
|
||||
// Один и тот же филлер на оба прогона: разные ассеты фона сделали бы сравнение бессмысленным.
|
||||
var filler = Filler();
|
||||
|
||||
PlanningInput MakeInput() =>
|
||||
new(Guid.NewGuid(), Day, Day.AddHours(4), slots, [filler], SegmentSeconds: 2);
|
||||
|
||||
var first = GridPlanner.Plan(MakeInput(), new SeededRandom(1));
|
||||
var second = GridPlanner.Plan(MakeInput(), new SeededRandom(1));
|
||||
|
||||
// Один вход и одно зерно обязаны давать одну ленту: иначе предпросмотр разойдётся с эфиром.
|
||||
Assert.Equal(
|
||||
first.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId)),
|
||||
second.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
public class GroupTests
|
||||
{
|
||||
private static IReadOnlyList<Guid> Order(Group group) =>
|
||||
group.Items.OrderBy(i => i.Position).Select(i => i.Id).ToList();
|
||||
|
||||
[Fact]
|
||||
public void AddElement_AppendsAndRejectsDuplicates()
|
||||
{
|
||||
var group = Group.Create("Боевики 90-х");
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
Assert.NotNull(group.AddElement(GroupElementKind.Show, showId));
|
||||
Assert.Null(group.AddElement(GroupElementKind.Show, showId));
|
||||
Assert.Single(group.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddElement_SameIdDifferentKind_AreDistinct()
|
||||
{
|
||||
var group = Group.Create("Смешанная");
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
group.AddElement(GroupElementKind.Show, id);
|
||||
group.AddElement(GroupElementKind.Collection, id);
|
||||
|
||||
Assert.Equal(2, group.Items.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveElement_DropsAllMatchingPositions()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var showId = Guid.NewGuid();
|
||||
group.AddElement(GroupElementKind.Show, showId);
|
||||
group.AddElement(GroupElementKind.Show, Guid.NewGuid());
|
||||
|
||||
var removed = group.RemoveElement(GroupElementKind.Show, showId);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Single(group.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetWeight_ClampsNegativeToZero()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var item = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
|
||||
group.SetWeight(item.Id, -5);
|
||||
|
||||
// Ноль — законный вес («исключить, не удаляя»), отрицательный сломал бы взвешенный выбор.
|
||||
Assert.Equal(0, item.Weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetWeight_UnknownItem_ReturnsFalse()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
|
||||
Assert.False(group.SetWeight(Guid.NewGuid(), 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_KeepsUnmentionedAfterRequested()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var a = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
var b = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
var c = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!;
|
||||
|
||||
group.Reorder([c.Id]);
|
||||
|
||||
Assert.Equal([c.Id, a.Id, b.Id], Order(group));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetFilter_TreatsBlankAsAbsent()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
|
||||
group.SetFilter(" ");
|
||||
|
||||
Assert.Null(group.FilterJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStats_StoresSnapshot()
|
||||
{
|
||||
var group = Group.Create("Боевики");
|
||||
var at = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
group.UpdateStats(3, 42, TimeSpan.FromHours(18), at);
|
||||
|
||||
Assert.Equal(3, group.ItemCount);
|
||||
Assert.Equal(42, group.UnitCount);
|
||||
Assert.Equal(TimeSpan.FromHours(18), group.TotalDuration);
|
||||
Assert.Equal(at, group.StatsComputedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
using Xunit;
|
||||
using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
public class JunctionFillerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 4, 6, 18, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class FirstAlways : IRandomSource
|
||||
{
|
||||
public int Next(int maxExclusive) => 0;
|
||||
}
|
||||
|
||||
private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) =>
|
||||
new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index);
|
||||
|
||||
private static PlanningElement Series(int episodes, int minutes, out Guid showId)
|
||||
{
|
||||
showId = Guid.NewGuid();
|
||||
var id = showId;
|
||||
var units = Enumerable
|
||||
.Range(0, episodes)
|
||||
.Select(i => Unit(minutes, id, i))
|
||||
.ToList();
|
||||
return new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units);
|
||||
}
|
||||
|
||||
private static PlanningJunctionElement Ads(
|
||||
int count,
|
||||
double minutesEach = 0.5,
|
||||
bool required = false,
|
||||
int minMinutesBetween = 0,
|
||||
bool onlyOnElementChange = false
|
||||
) =>
|
||||
new(
|
||||
JunctionElementKind.Ad,
|
||||
Enumerable.Range(0, 8).Select(_ => Unit(minutesEach)).ToList(),
|
||||
JunctionAmountMode.Count,
|
||||
count,
|
||||
required,
|
||||
onlyOnElementChange,
|
||||
minMinutesBetween
|
||||
);
|
||||
|
||||
private static PlanningSlot Slot(
|
||||
PlanningElement element,
|
||||
int blockValue,
|
||||
PlanningJunction? between = null,
|
||||
PlanningJunction? after = null
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
T0,
|
||||
240,
|
||||
SlotKind.Content,
|
||||
IsAnchor: false,
|
||||
MaxDriftMinutes: 30,
|
||||
SnapToMinutes: null,
|
||||
SlotBlockMode.Count,
|
||||
blockValue,
|
||||
OverflowPolicy.ContinueNext,
|
||||
new PlanningStrategy(SlotStrategyKind.Sequential),
|
||||
[element],
|
||||
Cursor: null,
|
||||
RepeatUnits: null,
|
||||
JunctionBetween: between,
|
||||
JunctionAfter: after
|
||||
);
|
||||
|
||||
private static PlanningResult Run(PlanningSlot slot) =>
|
||||
GridPlanner.Plan(
|
||||
new PlanningInput(
|
||||
Guid.NewGuid(),
|
||||
T0,
|
||||
T0.AddHours(5),
|
||||
[slot],
|
||||
[Unit(1)],
|
||||
SegmentSeconds: 2
|
||||
),
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void BetweenJunction_PlacesAdsBetweenUnitsButNotBeforeFirst()
|
||||
{
|
||||
var element = Series(3, 20, out _);
|
||||
var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(2)])));
|
||||
|
||||
var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList();
|
||||
Assert.Equal(PlannedItemKind.Program, ordered[0].Kind);
|
||||
// Между тремя сериями два стыка по две врезки.
|
||||
Assert.Equal(4, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterJunction_PlacesAdsAtBlockEnd()
|
||||
{
|
||||
var element = Series(2, 20, out _);
|
||||
var result = Run(Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(3)])));
|
||||
|
||||
var last = result
|
||||
.Items.Where(i => i.Kind != PlannedItemKind.Fallback)
|
||||
.OrderBy(i => i.StartsAtUtc)
|
||||
.Last();
|
||||
Assert.Equal(PlannedItemKind.Ad, last.Kind);
|
||||
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DurationMode_IncludesLastUnitWholly()
|
||||
{
|
||||
var element = Series(2, 20, out _);
|
||||
var ads = new PlanningJunctionElement(
|
||||
JunctionElementKind.Ad,
|
||||
Enumerable.Range(0, 6).Select(_ => Unit(0.5)).ToList(),
|
||||
JunctionAmountMode.Duration,
|
||||
1,
|
||||
IsRequired: false
|
||||
);
|
||||
|
||||
var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [ads])));
|
||||
|
||||
// Бюджет 1 минута, ролики по 30 секунд: две штуки добирают ровно минуту, третья не нужна.
|
||||
Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinInterval_SkipsSecondJunction()
|
||||
{
|
||||
var element = Series(3, 20, out _);
|
||||
var result = Run(
|
||||
Slot(
|
||||
element,
|
||||
3,
|
||||
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, minMinutesBetween: 60)])
|
||||
)
|
||||
);
|
||||
|
||||
// Серии по 20 минут: второй стык приходит через 20 минут после первого — раньше разрешённого.
|
||||
Assert.Equal(1, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyOnElementChange_SkipsWithinSameShow()
|
||||
{
|
||||
var element = Series(3, 20, out _);
|
||||
var result = Run(
|
||||
Slot(
|
||||
element,
|
||||
3,
|
||||
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, onlyOnElementChange: true)])
|
||||
)
|
||||
);
|
||||
|
||||
// Внутри одного сериала смены нет — врезка не ставится.
|
||||
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumper_ReservesDurationWithEmptyAssetAndShowPair()
|
||||
{
|
||||
var element = Series(2, 20, out var showId);
|
||||
var bumper = new PlanningJunctionElement(
|
||||
JunctionElementKind.Bumper,
|
||||
[],
|
||||
JunctionAmountMode.Count,
|
||||
1,
|
||||
IsRequired: true,
|
||||
BumperTemplateId: Guid.NewGuid(),
|
||||
BumperDuration: TimeSpan.FromSeconds(10)
|
||||
);
|
||||
|
||||
var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [bumper])));
|
||||
|
||||
var placed = Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Bumper);
|
||||
// Ассет подставит рендер после сборки ленты — до неё пара соседей неизвестна.
|
||||
Assert.Equal(Guid.Empty, placed.MediaAssetId);
|
||||
Assert.Equal(showId, placed.ToShowId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredElement_GoesBeforeOptional()
|
||||
{
|
||||
var element = Series(2, 20, out _);
|
||||
var optional = Ads(1, minutesEach: 1);
|
||||
var required = new PlanningJunctionElement(
|
||||
JunctionElementKind.Promo,
|
||||
[Unit(0.5)],
|
||||
JunctionAmountMode.Count,
|
||||
1,
|
||||
IsRequired: true
|
||||
);
|
||||
|
||||
var result = Run(
|
||||
Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [optional, required]))
|
||||
);
|
||||
|
||||
var junctionItems = result
|
||||
.Items.Where(i => i.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo)
|
||||
.OrderBy(i => i.StartsAtUtc)
|
||||
.ToList();
|
||||
Assert.Equal(PlannedItemKind.Promo, junctionItems[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JunctionDoesNotCrossAnchor()
|
||||
{
|
||||
var element = Series(2, 20, out _);
|
||||
var slot = Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(4, 5)])) with
|
||||
{
|
||||
TargetDurationMinutes = 40,
|
||||
};
|
||||
var anchor = new PlanningSlot(
|
||||
Guid.NewGuid(),
|
||||
T0.AddMinutes(45),
|
||||
60,
|
||||
SlotKind.Content,
|
||||
IsAnchor: true,
|
||||
MaxDriftMinutes: 5,
|
||||
SnapToMinutes: null,
|
||||
SlotBlockMode.Count,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext,
|
||||
new PlanningStrategy(SlotStrategyKind.Sequential),
|
||||
[Series(1, 30, out _)],
|
||||
Cursor: null
|
||||
);
|
||||
|
||||
var result = GridPlanner.Plan(
|
||||
new PlanningInput(
|
||||
Guid.NewGuid(),
|
||||
T0,
|
||||
T0.AddHours(3),
|
||||
[slot, anchor],
|
||||
[Unit(1)],
|
||||
SegmentSeconds: 2
|
||||
),
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
// До якоря остаётся пять минут: влезает одна пятиминутная врезка, остальные не начинают.
|
||||
Assert.All(
|
||||
result.Items.Where(i => i.Kind == PlannedItemKind.Ad),
|
||||
item => Assert.True(item.EndsAtUtc <= anchor.TargetStartUtc)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
public class SchedulePlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Всегда первый вариант — жребий в тестах не должен добавлять шума.</summary>
|
||||
private sealed class FirstAlways : IRandomSource
|
||||
{
|
||||
public int Next(int maxExclusive) => 0;
|
||||
}
|
||||
|
||||
private static PlanningUnit Unit(int minutes, Guid showId, int index) =>
|
||||
new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, index);
|
||||
|
||||
private static PlanningElement Element(
|
||||
int unitCount,
|
||||
int minutesEach,
|
||||
int weight = 1,
|
||||
int position = 0,
|
||||
DateTimeOffset? lastPlayed = null
|
||||
)
|
||||
{
|
||||
var showId = Guid.NewGuid();
|
||||
var units = Enumerable
|
||||
.Range(0, unitCount)
|
||||
.Select(i => Unit(minutesEach, showId, i))
|
||||
.ToList();
|
||||
return new PlanningElement(
|
||||
GroupElementKind.Show,
|
||||
Guid.NewGuid(),
|
||||
weight,
|
||||
position,
|
||||
units,
|
||||
lastPlayed
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanningSlot Slot(
|
||||
DateTimeOffset startUtc,
|
||||
int durationMinutes,
|
||||
IReadOnlyList<PlanningElement> elements,
|
||||
SlotBlockMode blockMode = SlotBlockMode.FillSlot,
|
||||
int blockValue = 1,
|
||||
OverflowPolicy overflow = OverflowPolicy.ContinueNext,
|
||||
bool isAnchor = false,
|
||||
int? snapToMinutes = null,
|
||||
PlanningCursor? cursor = null,
|
||||
SlotStrategyKind strategy = SlotStrategyKind.Sequential,
|
||||
int cooldownDays = 0
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
startUtc,
|
||||
durationMinutes,
|
||||
SlotKind.Content,
|
||||
isAnchor,
|
||||
MaxDriftMinutes: 30,
|
||||
snapToMinutes,
|
||||
blockMode,
|
||||
blockValue,
|
||||
overflow,
|
||||
new PlanningStrategy(strategy, CooldownDays: cooldownDays),
|
||||
elements,
|
||||
cursor
|
||||
);
|
||||
|
||||
private static PlanningInput Input(
|
||||
IReadOnlyList<PlanningSlot> slots,
|
||||
DateTimeOffset? start = null,
|
||||
int horizonHours = 6,
|
||||
IReadOnlyList<PlanningUnit>? fallback = null
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
start ?? T0,
|
||||
(start ?? T0).AddHours(horizonHours),
|
||||
slots,
|
||||
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
|
||||
SegmentSeconds: 2
|
||||
);
|
||||
|
||||
private static PlanningResult Run(PlanningInput input) =>
|
||||
TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways());
|
||||
|
||||
[Fact]
|
||||
public void ContinuousTape_HasNoGapsBetweenItems()
|
||||
{
|
||||
var element = Element(4, 30);
|
||||
var result = Run(Input([Slot(T0, 120, [element])]));
|
||||
|
||||
var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList();
|
||||
Assert.NotEmpty(ordered);
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
Assert.Equal(ordered[i - 1].EndsAtUtc, ordered[i].StartsAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockModeCount_PlacesExactlyRequestedUnits()
|
||||
{
|
||||
var element = Element(10, 20);
|
||||
var result = Run(
|
||||
Input([Slot(T0, 300, [element], SlotBlockMode.Count, blockValue: 3)], horizonHours: 1)
|
||||
);
|
||||
|
||||
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockModeDuration_IncludesLastUnitWholly()
|
||||
{
|
||||
var element = Element(10, 20);
|
||||
var result = Run(
|
||||
Input([Slot(T0, 300, [element], SlotBlockMode.Duration, blockValue: 50)], horizonHours: 2)
|
||||
);
|
||||
|
||||
// 20+20 < 50, третья добирает до 60 — обрезать видеофайл нельзя.
|
||||
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anchor_DoesNotStartUnitThatCrossesIt()
|
||||
{
|
||||
var first = Element(10, 25);
|
||||
var second = Element(1, 30);
|
||||
|
||||
var slots = new[]
|
||||
{
|
||||
Slot(T0, 60, [first]),
|
||||
Slot(T0.AddMinutes(60), 60, [second], isAnchor: true),
|
||||
};
|
||||
var result = Run(Input(slots));
|
||||
|
||||
var anchored = result.Items.First(i => i.SlotId == slots[1].SlotId);
|
||||
// Третья 25-минутная серия перелезла бы через якорь — её не начинают.
|
||||
Assert.True(anchored.StartsAtUtc >= slots[1].TargetStartUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snap_MovesStartToRoundTime()
|
||||
{
|
||||
// Первый слот кончается в 12:47 — второй с округлением до 15 минут стартует в 13:00.
|
||||
var first = Element(1, 47);
|
||||
var second = Element(1, 30);
|
||||
|
||||
var slots = new[]
|
||||
{
|
||||
Slot(T0, 47, [first]),
|
||||
Slot(T0.AddMinutes(40), 60, [second], snapToMinutes: 15),
|
||||
};
|
||||
var result = Run(Input(slots));
|
||||
|
||||
var secondItem = result.Items.First(i =>
|
||||
i.SlotId == slots[1].SlotId && i.Kind == PlannedItemKind.Program
|
||||
);
|
||||
Assert.Equal(0, secondItem.StartsAtUtc.Minute % 15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snap_SkippedWhenDriftWouldExceedAllowance()
|
||||
{
|
||||
var element = Element(1, 30);
|
||||
var slot = Slot(T0.AddMinutes(-1), 60, [element], snapToMinutes: 30) with
|
||||
{
|
||||
MaxDriftMinutes = 2,
|
||||
};
|
||||
|
||||
var result = Run(Input([slot]));
|
||||
|
||||
// Округление потребовало бы почти 30 минут фона — лучше начать не в круглое время.
|
||||
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
||||
Assert.Equal(T0, program.StartsAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GapBeforeSlot_IsFilledWithFallback()
|
||||
{
|
||||
var element = Element(1, 30);
|
||||
var result = Run(Input([Slot(T0.AddMinutes(10), 30, [element])]));
|
||||
|
||||
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback);
|
||||
Assert.Equal(T0, result.Items.Min(i => i.StartsAtUtc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySlot_WarnsAndFallsBack()
|
||||
{
|
||||
var result = Run(Input([Slot(T0, 30, [])]));
|
||||
|
||||
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.SlotEmpty);
|
||||
Assert.All(result.Items, i => Assert.NotEqual(PlannedItemKind.Program, i.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cursor_AdvancesToNextUnit()
|
||||
{
|
||||
var element = Element(5, 20);
|
||||
var slot = Slot(T0, 60, [element], SlotBlockMode.Count, blockValue: 2);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 1));
|
||||
|
||||
var cursor = Assert.Single(result.Cursors);
|
||||
Assert.Equal(element.ElementId, cursor.ElementId);
|
||||
Assert.Equal(2, cursor.NextUnitIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cursor_ResumesMidElement()
|
||||
{
|
||||
var element = Element(5, 20);
|
||||
var slot = Slot(
|
||||
T0,
|
||||
60,
|
||||
[element],
|
||||
SlotBlockMode.Count,
|
||||
blockValue: 1,
|
||||
cursor: new PlanningCursor(GroupElementKind.Show, element.ElementId, 3)
|
||||
);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 1));
|
||||
|
||||
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
||||
Assert.Equal(3, program.UnitIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipIfNotFits_LeavesSlotToFallback()
|
||||
{
|
||||
// Элемент из трёх часовых частей в часовой слот целиком не влезает.
|
||||
var element = Element(3, 60);
|
||||
var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.SkipIfNotFits);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 2));
|
||||
|
||||
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtendSlot_PlaysElementBeyondBudget()
|
||||
{
|
||||
var element = Element(3, 60);
|
||||
var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.ExtendSlot);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 5));
|
||||
|
||||
// Трилогия доигрывается целиком, слот залезает на соседей — так и задумано.
|
||||
Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cooldown_PrefersElementOutsideWindow()
|
||||
{
|
||||
var hot = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-1));
|
||||
var cold = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-40));
|
||||
|
||||
var slot = Slot(
|
||||
T0,
|
||||
30,
|
||||
[hot, cold],
|
||||
SlotBlockMode.Count,
|
||||
blockValue: 1,
|
||||
strategy: SlotStrategyKind.RandomWithCooldown,
|
||||
cooldownDays: 28
|
||||
);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 1));
|
||||
|
||||
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
||||
Assert.Equal(cold.Units[0].ShowId, program.ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cooldown_Exhausted_WarnsAndTakesOldest()
|
||||
{
|
||||
var recentA = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-2));
|
||||
var recentB = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-5));
|
||||
|
||||
var slot = Slot(
|
||||
T0,
|
||||
30,
|
||||
[recentA, recentB],
|
||||
SlotBlockMode.Count,
|
||||
blockValue: 1,
|
||||
strategy: SlotStrategyKind.RandomWithCooldown,
|
||||
cooldownDays: 28
|
||||
);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 1));
|
||||
|
||||
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.CooldownExhausted);
|
||||
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
||||
Assert.Equal(recentB.Units[0].ShowId, program.ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignOffSlot_MarksItemsAccordingly()
|
||||
{
|
||||
var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.SignOff };
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 2));
|
||||
|
||||
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.SignOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatSlot_ReplaysSourceUnits()
|
||||
{
|
||||
var showId = Guid.NewGuid();
|
||||
var repeat = new[] { Unit(30, showId, 0), Unit(30, showId, 1) };
|
||||
var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = repeat };
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 2));
|
||||
|
||||
Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatSlot_WithEmptySource_WarnsAndFallsBack()
|
||||
{
|
||||
var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] };
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 2));
|
||||
|
||||
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trace_IsWrittenWithSlotAndStrategy()
|
||||
{
|
||||
var element = Element(1, 30);
|
||||
var slot = Slot(T0, 30, [element], SlotBlockMode.Count, blockValue: 1);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 1));
|
||||
|
||||
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
||||
Assert.NotNull(program.Trace);
|
||||
Assert.Equal(slot.SlotId, program.Trace!.SlotId);
|
||||
Assert.Equal(SlotStrategyKind.Sequential, program.Trace.Strategy);
|
||||
Assert.Equal(element.ElementId, program.Trace.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoFallbackUnits_ProducesWarning()
|
||||
{
|
||||
var input = Input([], fallback: []);
|
||||
|
||||
var result = Run(input);
|
||||
|
||||
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.FallbackEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SequentialStrategy_MovesToNextElementWhenCurrentExhausted()
|
||||
{
|
||||
var first = Element(1, 30, position: 0);
|
||||
var second = Element(1, 30, position: 1);
|
||||
|
||||
var slot = Slot(
|
||||
T0,
|
||||
30,
|
||||
[first, second],
|
||||
SlotBlockMode.Count,
|
||||
blockValue: 1,
|
||||
cursor: new PlanningCursor(GroupElementKind.Show, first.ElementId, 1)
|
||||
);
|
||||
|
||||
var result = Run(Input([slot], horizonHours: 1));
|
||||
|
||||
var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
|
||||
Assert.Equal(second.Units[0].ShowId, program.ShowId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
public class ScheduleTemplateTests
|
||||
{
|
||||
private static ScheduleTemplate NewTemplate() =>
|
||||
ScheduleTemplate.Create(Guid.NewGuid(), "Основная");
|
||||
|
||||
[Fact]
|
||||
public void Create_AddsBackgroundLayer()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
|
||||
var background = Assert.Single(template.Layers);
|
||||
Assert.True(background.IsBackground);
|
||||
Assert.Equal(GridLayer.BackgroundPriority, background.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLayer_RefusesBackground()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
var background = template.Background!;
|
||||
|
||||
Assert.False(template.RemoveLayer(background.Id));
|
||||
Assert.Single(template.Layers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLayer_KeepsPriorityAboveBackground()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
|
||||
var layer = template.AddLayer("Выходные", 0);
|
||||
|
||||
// Ноль занят фоном: слой с таким же приоритетом сделал бы выбор неоднозначным.
|
||||
Assert.True(layer.Priority > GridLayer.BackgroundPriority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackgroundLayer_KeepsPriority_WhenUpdated()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
var background = template.Background!;
|
||||
|
||||
background.Update("Фон", 99, null, isEnabled: true);
|
||||
|
||||
Assert.Equal(GridLayer.BackgroundPriority, background.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkChanged_MakesPendingUntilApplied()
|
||||
{
|
||||
var template = NewTemplate();
|
||||
Assert.False(template.HasPendingChanges);
|
||||
|
||||
template.MarkChanged();
|
||||
Assert.True(template.HasPendingChanges);
|
||||
|
||||
template.MarkApplied();
|
||||
Assert.False(template.HasPendingChanges);
|
||||
}
|
||||
}
|
||||
|
||||
public class GridLayerOverlapTests
|
||||
{
|
||||
private static GridLayer NewLayer() =>
|
||||
ScheduleTemplate.Create(Guid.NewGuid(), "T").AddLayer("Базовая", 10);
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_DetectsIntersectionOnSameDay()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5);
|
||||
|
||||
Assert.True(layer.HasOverlap(5, new TimeOnly(21, 0), 30, null));
|
||||
Assert.False(layer.HasOverlap(5, new TimeOnly(21, 30), 30, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_IgnoresOtherWeekdays()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5);
|
||||
|
||||
Assert.False(layer.HasOverlap(3, new TimeOnly(20, 30), 30, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_EverydaySlotConflictsWithAnyDay()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
layer.AddSlot("Новости", new TimeOnly(18, 0), 30);
|
||||
|
||||
// Слот без дня недели идёт каждый день, поэтому конфликтует и с конкретным днём.
|
||||
Assert.True(layer.HasOverlap(2, new TimeOnly(18, 15), 30, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasOverlap_SkipsSlotBeingEdited()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5);
|
||||
|
||||
Assert.False(layer.HasOverlap(5, new TimeOnly(20, 0), 120, slot.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateContent_ClearsFieldsForeignToSlotKind()
|
||||
{
|
||||
var layer = NewLayer();
|
||||
var slot = layer.AddSlot("Кино", new TimeOnly(20, 0), 90);
|
||||
slot.UpdateContent(
|
||||
"Кино",
|
||||
SlotKind.Content,
|
||||
Guid.NewGuid(),
|
||||
"{\"type\":\"sequential\"}",
|
||||
null,
|
||||
SlotBlockMode.Count,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
);
|
||||
|
||||
slot.UpdateContent(
|
||||
"Конец вещания",
|
||||
SlotKind.SignOff,
|
||||
Guid.NewGuid(),
|
||||
"{\"type\":\"sequential\"}",
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
);
|
||||
|
||||
// Иначе генератор прочитал бы настройки, оставшиеся от прежнего типа слота.
|
||||
Assert.Null(slot.GroupId);
|
||||
Assert.Null(slot.StrategyJson);
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
[Collection("postgres")]
|
||||
public sealed class ScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 3, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private const int Seg = 2;
|
||||
|
||||
private static MediaAsset ReadyAsset(string name, int seconds)
|
||||
{
|
||||
var a = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||
a.MarkReady(
|
||||
TimeSpan.FromSeconds(seconds),
|
||||
Seg,
|
||||
seconds / Seg,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
$"assets/{a.Id}"
|
||||
);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static ScheduleGenerator NewGenerator(
|
||||
AppDbContext db,
|
||||
IRandomSource random,
|
||||
IBumperRenderQueue queue
|
||||
)
|
||||
{
|
||||
var streaming = Options.Create(new StreamingOptions { SegmentSeconds = Seg });
|
||||
var resolver = new ScheduleBumperResolver(
|
||||
db,
|
||||
queue,
|
||||
Options.Create(new BumperOptions()),
|
||||
streaming
|
||||
);
|
||||
return new ScheduleGenerator(
|
||||
db,
|
||||
random,
|
||||
resolver,
|
||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionHours = 24 }),
|
||||
streaming
|
||||
);
|
||||
}
|
||||
|
||||
private sealed class RecordingQueue : IBumperRenderQueue
|
||||
{
|
||||
public List<Guid> Enqueued { get; } = [];
|
||||
|
||||
public void Enqueue(Guid assetId) => Enqueued.Add(assetId);
|
||||
|
||||
public ValueTask WaitAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GenerateAsync_MaterializesProgramSchedule()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var channel = Channel.Create("Prog", $"prog-{Guid.NewGuid():N}", Now);
|
||||
var show = Show.Create("Show A", ShowKind.Series);
|
||||
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||
var a2 = ReadyAsset("a2.mkv", 1200);
|
||||
show.AddEpisode(a1.Id);
|
||||
show.AddEpisode(a2.Id);
|
||||
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.AddRange(a1, a2);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
int added;
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
added = await NewGenerator(db, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.True(added > 0);
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = await verify
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == channel.Id)
|
||||
.ToListAsync();
|
||||
Assert.NotEmpty(entries);
|
||||
Assert.Contains(entries, e => e.Kind == ScheduleEntryKind.Program);
|
||||
Assert.All(entries, e => Assert.True(e.EndsAtUtc <= Now.AddDays(1).AddSeconds(Seg)));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GenerateAsync_CreatesPendingBumperAssets_ForShowTransitions()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
// Канал с двумя шоу и включёнными заставками — на смене шоу планировщик ставит заставку;
|
||||
// её ассет должен создаться в Pending (Generated) и попасть в очередь фонового рендера.
|
||||
var channel = Channel.Create("Bump", $"bump-{Guid.NewGuid():N}", Now);
|
||||
channel.UpdateSettings(channel.Name, true, AdInsertion.BetweenBlocks, 0, true, null);
|
||||
var showA = Show.Create("A", ShowKind.Series);
|
||||
var showB = Show.Create("B", ShowKind.Series);
|
||||
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||
var b1 = ReadyAsset("b1.mkv", 1200);
|
||||
showA.AddEpisode(a1.Id);
|
||||
showB.AddEpisode(b1.Id);
|
||||
channel.AddShow(showA.Id, 1, BlockMode.Count, 1);
|
||||
channel.AddShow(showB.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.AddRange(showA, showB);
|
||||
seed.MediaAssets.AddRange(a1, b1);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var queue = new RecordingQueue();
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
// Чередуем выбор шоу (0,1,0,1…), чтобы гарантированно были переходы «A→B».
|
||||
await NewGenerator(db, new SequenceRandom(0, 1), queue)
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var bumperCache = await verify
|
||||
.BumperAssets.AsNoTracking()
|
||||
.Where(b => b.ChannelId == channel.Id)
|
||||
.ToListAsync();
|
||||
Assert.NotEmpty(bumperCache);
|
||||
|
||||
var bumperAssetIds = bumperCache.Select(b => b.MediaAssetId).ToList();
|
||||
var bumperAssets = await verify
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => bumperAssetIds.Contains(a.Id))
|
||||
.ToListAsync();
|
||||
Assert.All(
|
||||
bumperAssets,
|
||||
a =>
|
||||
{
|
||||
Assert.Equal(MediaSource.Generated, a.Source);
|
||||
Assert.Equal(MediaAssetStatus.Pending, a.Status);
|
||||
}
|
||||
);
|
||||
Assert.NotEmpty(queue.Enqueued);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ConcurrentGenerate_SerializedByAdvisoryLock_NoOverlappingEntries()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var channel = Channel.Create("Race", $"race-{Guid.NewGuid():N}", Now);
|
||||
var show = Show.Create("Show", ShowKind.Series);
|
||||
var a1 = ReadyAsset("r1.mkv", 1200);
|
||||
var a2 = ReadyAsset("r2.mkv", 1200);
|
||||
show.AddEpisode(a1.Id);
|
||||
show.AddEpisode(a2.Id);
|
||||
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.AddRange(a1, a2);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Два параллельных прогона на РАЗНЫХ соединениях — advisory-lock должен их сериализовать,
|
||||
// иначе оба дописали бы хвост от одной точки продолжения и записи бы перекрылись.
|
||||
await using var db1 = fixture.CreateContext();
|
||||
await using var db2 = fixture.CreateContext();
|
||||
var g1 = NewGenerator(db1, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
var g2 = NewGenerator(db2, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
await Task.WhenAll(g1, g2);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = await verify
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == channel.Id)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.NotEmpty(entries);
|
||||
for (var i = 1; i < entries.Count; i++)
|
||||
Assert.True(
|
||||
entries[i].StartsAtUtc >= entries[i - 1].EndsAtUtc,
|
||||
$"Записи перекрываются: [{entries[i - 1].StartsAtUtc:o}..{entries[i - 1].EndsAtUtc:o}] и [{entries[i].StartsAtUtc:o}..{entries[i].EndsAtUtc:o}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user