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.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -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:0012: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:0001:00 пересекает полночь на пятницу.
var day = (int)Epoch.UtcDateTime.DayOfWeek;
var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 1380, 60); // 23:0001: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:0001: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}]"
);
}
}