Update README.md with additional SonarCloud badges and improve CI workflow for coverage reporting
ci / build-backend (push) Successful in 2m34s
ci / build-frontend (push) Successful in 41s
ci / tests (push) Successful in 2m55s
ci / sonar (push) Successful in 5m21s

Enhanced the README.md file by adding new SonarCloud badges for coverage, bugs, code smells, security rating, and maintainability rating. Updated the CI workflow to remove coverage collection from the test step, as it is now handled by SonarCloud, streamlining the process and ensuring accurate badge representation.
This commit is contained in:
Leonid Pershin
2026-07-27 03:19:18 +03:00
parent 464355434b
commit 790d01b587
22 changed files with 2891 additions and 99 deletions
@@ -0,0 +1,229 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// «Почему это здесь»: трейс пишется в момент генерации и разбирается вместе с текущим состоянием
/// сетки. Слот мог быть с тех пор изменён или удалён — подписи тогда пустеют, но ответ остаётся.
/// </summary>
public class EntryTraceTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 18, 0, 0, TimeSpan.Zero);
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public async Task Trace_UnknownEntry_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new GetEntryTraceQueryHandler(db).Handle(
new GetEntryTraceQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(ChannelErrors.NotFound, result.Error);
}
[Fact]
public async Task Trace_ResolvesSlotLayerGroupAndJunction()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Кино");
var collection = Collection.Create("Франшиза");
var show = Show.Create("Фильм", ShowKind.Single);
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
var junction = JunctionTemplate.Create(channel.Id, "Прайм");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Прайм", 10);
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
JunctionAfterId: junction.Id
)
);
var trace = new PlanTrace(
slot.Id,
SlotKind.Content,
GroupElementKind.Show,
show.Id,
SlotStrategyKind.RandomWithCooldown,
CandidatesAfterCooldown: 4,
DriftMinutes: 3,
Snapped: true
);
var entry = ScheduleEntry.FromSlot(
channel.Id,
asset.Id,
ScheduleEntryKind.Program,
T0,
T0.AddMinutes(90),
new ScheduleEntryOrigin(
show.Id,
2,
slot.Id,
JsonSerializer.Serialize(trace, TraceJson),
collection.Id
)
);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.Collections.Add(collection);
seed.Shows.Add(show);
seed.MediaAssets.Add(asset);
seed.JunctionTemplates.Add(junction);
seed.ScheduleTemplates.Add(template);
seed.ScheduleEntries.Add(entry);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new GetEntryTraceQueryHandler(db).Handle(
new GetEntryTraceQuery(entry.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var dto = result.Value;
Assert.Equal("Фильм", dto.ShowName);
Assert.Equal(2, dto.EpisodeIndex);
Assert.Equal("Франшиза", dto.CollectionName);
Assert.Equal("Прайм", dto.LayerName);
Assert.Equal(10, dto.LayerPriority);
Assert.Equal("Вечернее кино", dto.SlotTitle);
Assert.Equal(new TimeOnly(20, 0), dto.SlotTargetStart);
Assert.Equal(120, dto.SlotDurationMinutes);
Assert.Equal("Кино", dto.GroupName);
Assert.Equal(SlotStrategyKind.RandomWithCooldown, dto.Strategy);
// Остывание берётся из текущей стратегии слота, а число кандидатов — из трейса.
Assert.Equal(14, dto.CooldownDays);
Assert.Equal(4, dto.CandidatesAfterCooldown);
Assert.Equal(3, dto.DriftMinutes);
Assert.True(dto.Snapped);
Assert.Equal("Прайм", dto.JunctionName);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("{не json}")]
public async Task Trace_WithoutUsableTrace_ReturnsBareEntry(string? traceJson)
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var show = Show.Create("Фильм", ShowKind.Single);
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
var entry = ScheduleEntry.FromSlot(
channel.Id,
asset.Id,
ScheduleEntryKind.Program,
T0,
T0.AddMinutes(90),
new ScheduleEntryOrigin(show.Id, 1, null, traceJson)
);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Shows.Add(show);
seed.MediaAssets.Add(asset);
seed.ScheduleEntries.Add(entry);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new GetEntryTraceQueryHandler(db).Handle(
new GetEntryTraceQuery(entry.Id),
CancellationToken.None
);
// Старая запись (или битый трейс) — ответ есть, подписи пустые.
Assert.True(result.IsSuccess);
Assert.Equal("Фильм", result.Value.ShowName);
Assert.Null(result.Value.LayerName);
Assert.Null(result.Value.SlotTitle);
Assert.Null(result.Value.Strategy);
Assert.Equal(0, result.Value.DriftMinutes);
Assert.False(result.Value.Snapped);
}
[Fact]
public async Task Trace_WithDeletedSlot_KeepsWhatTraceRemembers()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
var trace = new PlanTrace(
Guid.NewGuid(),
SlotKind.Repeat,
null,
null,
SlotStrategyKind.Sequential,
null,
DriftMinutes: 0,
Snapped: false
);
var entry = ScheduleEntry.FromSlot(
channel.Id,
asset.Id,
ScheduleEntryKind.Program,
T0,
T0.AddMinutes(30),
new ScheduleEntryOrigin(null, null, null, JsonSerializer.Serialize(trace, TraceJson))
);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.MediaAssets.Add(asset);
seed.ScheduleEntries.Add(entry);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new GetEntryTraceQueryHandler(db).Handle(
new GetEntryTraceQuery(entry.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
// Слота уже нет — из трейса остаётся его тип и стратегия, остальное пустеет.
Assert.Equal(SlotKind.Repeat, result.Value.SlotKind);
Assert.Equal(SlotStrategyKind.Sequential, result.Value.Strategy);
Assert.Null(result.Value.SlotTitle);
Assert.Null(result.Value.GroupName);
Assert.Null(result.Value.JunctionName);
Assert.Null(result.Value.ShowName);
}
}
@@ -0,0 +1,532 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Правка сетки: слои, слоты и настройки шаблона. Проверки здесь те, что валидатору не по силам —
/// им нужны соседние слоты и справочник групп, — плюс инвариант «любая правка двигает ревизию».
/// </summary>
public class TemplateEditingTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static SlotInput Input(
string title = "Вечернее кино",
TimeOnly? start = null,
int minutes = 120,
SlotKind kind = SlotKind.Content,
Guid? groupId = null,
RepeatSource? repeat = null,
int? weekday = null
) =>
new(
title,
weekday,
start ?? new TimeOnly(20, 0),
minutes,
Daypart.Prime,
kind,
groupId,
new SlotStrategy(SlotStrategyType.Sequential),
repeat,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
IsAnchor: true,
MaxDriftMinutes: 10,
SnapToMinutes: 15
);
/// <summary>Канал с сеткой: один обычный слой поверх фонового плюс группа контента.</summary>
private sealed record Fixture(
TestDb Db,
Guid ChannelId,
Guid TemplateId,
Guid LayerId,
Guid BackgroundLayerId,
Guid GroupId
);
private static async Task<Fixture> SeedAsync()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Кино");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Прайм", 10);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return new Fixture(
fixture,
channel.Id,
template.Id,
layer.Id,
template.Background!.Id,
group.Id
);
}
private static async Task<Guid> AddSlotAsync(Fixture f, SlotInput input)
{
await using var db = f.Db.New();
var created = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, input),
CancellationToken.None
);
Assert.True(created.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return created.Value;
}
private static async Task<Slot> LoadSlotAsync(Fixture f, Guid slotId)
{
await using var db = f.Db.New();
return await db.Slots.AsNoTracking().FirstAsync(s => s.Id == slotId);
}
private static async Task<int> RevisionAsync(Fixture f)
{
await using var db = f.Db.New();
return (
await db.ScheduleTemplates.AsNoTracking().FirstAsync(t => t.Id == f.TemplateId)
).Revision;
}
[Fact]
public async Task CreateSlot_UnknownLayer_ReturnsLayerNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, result.Error);
}
[Fact]
public async Task CreateSlot_StoresTimingAndContent_AndBumpsRevision()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId, weekday: 3));
var slot = await LoadSlotAsync(f, slotId);
Assert.Equal("Вечернее кино", slot.Title);
Assert.Equal(3, slot.Weekday);
Assert.Equal(new TimeOnly(20, 0), slot.TargetStart);
Assert.Equal(120, slot.TargetDurationMinutes);
Assert.Equal(Daypart.Prime, slot.Daypart);
Assert.Equal(f.GroupId, slot.GroupId);
Assert.True(slot.IsAnchor);
Assert.Equal(15, slot.SnapToMinutes);
Assert.NotNull(SlotStrategy.FromJson(slot.StrategyJson));
// Правка сетки эфир не двигает — только помечает шаблон изменённым.
Assert.Equal(1, await RevisionAsync(f));
}
[Fact]
public async Task CreateSlot_ContentWithoutGroup_ReturnsGroupRequired()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: null)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupRequired, result.Error);
}
[Fact]
public async Task CreateSlot_UnknownGroup_ReturnsGroupNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: Guid.NewGuid())),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
}
[Fact]
public async Task CreateSlot_RepeatWithoutSource_ReturnsRepeatSourceRequired()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(kind: SlotKind.Repeat)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.RepeatSourceRequired, result.Error);
}
[Fact]
public async Task CreateSlot_Repeat_KeepsSourceAndDropsGroup()
{
var f = await SeedAsync();
var repeat = new RepeatSource(1, new TimeOnly(20, 0), 120);
var slotId = await AddSlotAsync(
f,
Input(kind: SlotKind.Repeat, groupId: f.GroupId, repeat: repeat)
);
var slot = await LoadSlotAsync(f, slotId);
// Группа к повтору отношения не имеет: домен гасит поля чужого типа слота.
Assert.Null(slot.GroupId);
Assert.Equal(repeat, RepeatSource.FromJson(slot.RepeatSourceJson));
}
[Fact]
public async Task CreateSlot_OverlappingInSameLayer_ReturnsSlotsOverlap()
{
var f = await SeedAsync();
await AddSlotAsync(f, Input(groupId: f.GroupId));
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(
f.LayerId,
Input(title: "Второй", start: new TimeOnly(21, 0), groupId: f.GroupId)
),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotsOverlap, result.Error);
}
[Fact]
public async Task UpdateSlot_UnknownSlot_ReturnsSlotNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
new UpdateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotNotFound, result.Error);
}
[Fact]
public async Task UpdateSlot_MovesSlot_WithoutCountingItAsOverlap()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using (var db = f.Db.New())
{
// Сдвиг внутрь собственного интервала: слот не должен пересечься сам с собой.
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
new UpdateSlotCommand(
slotId,
Input(title: "Ночное кино", start: new TimeOnly(21, 0), groupId: f.GroupId)
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
var slot = await LoadSlotAsync(f, slotId);
Assert.Equal("Ночное кино", slot.Title);
Assert.Equal(new TimeOnly(21, 0), slot.TargetStart);
Assert.Equal(2, await RevisionAsync(f));
}
[Fact]
public async Task DeleteSlot_RemovesSlot_OrReportsNotFound()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using (var db = f.Db.New())
{
var missing = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
new DeleteSlotCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var deleted = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
new DeleteSlotCommand(slotId),
CancellationToken.None
);
Assert.True(deleted.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
Assert.False(await verify.Slots.AnyAsync(s => s.Id == slotId));
}
[Fact]
public async Task CreateLayer_UnknownTemplate_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateLayerCommandHandler(db).Handle(
new CreateLayerCommand(Guid.NewGuid(), "Лето", 20),
CancellationToken.None
);
Assert.Equal(TemplateErrors.NotFound, result.Error);
}
[Fact]
public async Task CreateLayer_AddsLayerToTemplate()
{
var f = await SeedAsync();
Guid layerId;
await using (var db = f.Db.New())
{
var result = await new CreateLayerCommandHandler(db).Handle(
new CreateLayerCommand(f.TemplateId, "Лето", 20),
CancellationToken.None
);
Assert.True(result.IsSuccess);
layerId = result.Value;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == layerId);
Assert.Equal("Лето", layer.Name);
Assert.Equal(20, layer.Priority);
Assert.False(layer.IsBackground);
}
[Fact]
public async Task UpdateLayer_StoresApplicability_OrReportsNotFound()
{
var f = await SeedAsync();
var applicability = new LayerApplicability(Weekdays: [5, 6]);
await using (var db = f.Db.New())
{
var missing = await new UpdateLayerCommandHandler(db).Handle(
new UpdateLayerCommand(Guid.NewGuid(), "Нет", 5, null, true),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var result = await new UpdateLayerCommandHandler(db).Handle(
new UpdateLayerCommand(f.LayerId, "Выходные", 30, applicability, false),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == f.LayerId);
Assert.Equal("Выходные", layer.Name);
Assert.Equal(30, layer.Priority);
Assert.False(layer.IsEnabled);
var stored = LayerApplicability.FromJson(layer.ApplicabilityJson);
Assert.Equal(new[] { 5, 6 }, stored?.Weekdays);
}
[Fact]
public async Task DeleteLayer_RefusesBackground_AndRemovesOrdinary()
{
var f = await SeedAsync();
await using (var db = f.Db.New())
{
var background = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(f.BackgroundLayerId),
CancellationToken.None
);
// Без фонового слоя первую же дыру в сетке нечем закрыть.
Assert.Equal(TemplateErrors.BackgroundLayerCannotBeDeleted, background.Error);
}
await using (var db = f.Db.New())
{
var missing = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var deleted = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(f.LayerId),
CancellationToken.None
);
Assert.True(deleted.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
Assert.False(await verify.GridLayers.AnyAsync(l => l.Id == f.LayerId));
}
[Fact]
public async Task UpdateTemplate_UnknownTemplate_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(Guid.NewGuid(), "Сетка", null, null, null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.NotFound, result.Error);
}
[Fact]
public async Task UpdateTemplate_UnknownFallbackGroup_ReturnsGroupNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(f.TemplateId, "Сетка", Guid.NewGuid(), null, null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
}
[Fact]
public async Task UpdateTemplate_StoresFallbackAndRules()
{
var f = await SeedAsync();
var rules = new PlanningRules(
MaxAudienceByTime:
[
new AudienceWindow(
new TimeOnly(6, 0),
new TimeOnly(21, 0),
Domain.Library.ShowAudience.Pg
),
],
MaxRepeatsInWindow: new RepeatLimitRule(7, 2)
);
await using (var db = f.Db.New())
{
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(f.TemplateId, " Новая сетка ", f.GroupId, null, rules),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var template = await verify
.ScheduleTemplates.AsNoTracking()
.FirstAsync(t => t.Id == f.TemplateId);
Assert.Equal("Новая сетка", template.Name);
Assert.Equal(f.GroupId, template.FallbackGroupId);
var storedRules = PlanningRules.FromJson(template.RulesJson);
Assert.Equal(rules.MaxRepeatsInWindow, storedRules?.MaxRepeatsInWindow);
Assert.Equal(rules.MaxAudienceByTime, storedRules?.MaxAudienceByTime);
Assert.True(template.HasPendingChanges);
}
[Fact]
public async Task GetChannelTemplate_UnknownChannel_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(ChannelErrors.NotFound, result.Error);
}
[Fact]
public async Task GetChannelTemplate_ChannelWithoutGrid_ReturnsTemplateNotFound()
{
var fixture = new TestDb();
var channel = Channel.Create("Без сетки", "nogrid", 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 GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(channel.Id),
CancellationToken.None
);
Assert.Equal(ChannelErrors.TemplateNotFound, result.Error);
}
[Fact]
public async Task GetChannelTemplate_OrdersLayersByPriority_AndResolvesGroupNames()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using var db = f.Db.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(f.ChannelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var dto = result.Value;
Assert.Equal(f.TemplateId, dto.Id);
Assert.True(dto.HasPendingChanges);
// Приоритетный слой идёт первым, фоновый — последним.
Assert.Equal(
new[] { f.LayerId, f.BackgroundLayerId },
dto.Layers.Select(l => l.Id).ToList()
);
Assert.True(dto.Layers[^1].IsBackground);
var slot = Assert.Single(dto.Layers[0].Slots);
Assert.Equal(slotId, slot.Id);
Assert.Equal("Кино", slot.GroupName);
Assert.Equal(SlotStrategyType.Sequential, slot.Strategy?.Type);
}
}
@@ -0,0 +1,295 @@
using TeleWave.Application.Broadcast;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Проверки сетки до генерации: пустые группы, пересечения слотов, недостижимое остывание,
/// конфликт с детским временем и непокрытые интервалы суток.
/// </summary>
public class ValidateTemplateTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>Слот на сутки целиком — им закрываем сетку, когда дыры не тема теста.</summary>
private static Slot FullDaySlot(GridLayer layer, Guid? groupId, TimeOnly dayStart)
{
var slot = layer.AddSlot("Круглосуточно", dayStart, 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
groupId,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
return slot;
}
private static void Fill(
Slot slot,
Guid? groupId,
SlotStrategy? strategy = null,
SlotKind kind = SlotKind.Content
) =>
slot.UpdateContent(
new SlotContent(
slot.Title,
kind,
groupId,
(strategy ?? new SlotStrategy(SlotStrategyType.Sequential)).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
private static async Task<IReadOnlyList<TemplateIssueDto>> ValidateAsync(
TestDb fixture,
Guid channelId
)
{
await using var db = fixture.New();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(channelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
return result.Value;
}
[Fact]
public async Task Validate_UnknownChannel_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(ChannelErrors.NotFound, result.Error);
}
[Fact]
public async Task Validate_ChannelWithoutGrid_ReturnsTemplateNotFound()
{
var fixture = new TestDb();
var channel = Channel.Create("Без сетки", "nogrid", 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 ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(channel.Id),
CancellationToken.None
);
Assert.Equal(ChannelErrors.TemplateNotFound, result.Error);
}
[Fact]
public async Task Validate_EmptyGrid_ReportsGapForEveryDay()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
// Семь суток без единого слота — семь дыр «весь день».
var gaps = issues.Where(i => i.Kind == TemplateIssueKind.GridGap).ToList();
Assert.Equal(7, gaps.Count);
Assert.All(gaps, g => Assert.Contains("весь день", g.Details));
Assert.All(gaps, g => Assert.Equal(TemplateIssueSeverity.Error, g.Severity));
}
[Fact]
public async Task Validate_SlotWithoutGroup_ReportsGroupMissing()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
FullDaySlot(template.Background!, null, channel.DayStartTime);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupMissing);
// Сутки закрыты слотом — дыр быть не должно.
Assert.DoesNotContain(issues, i => i.Kind == TemplateIssueKind.GridGap);
}
[Fact]
public async Task Validate_EmptyGroup_ReportsGroupEmpty()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Пустая");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
var issue = Assert.Single(issues, i => i.Kind == TemplateIssueKind.GroupEmpty);
Assert.Contains("Пустая", issue.Details);
}
[Fact]
public async Task Validate_SmallGroupWithCooldown_ReportsBothWarnings()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Маленькая");
group.UpdateStats(3, 3, TimeSpan.FromHours(3), T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
// Ежедневный слот — 7 выходов в неделю на 3 позиции, да ещё с двухнедельным остыванием.
Fill(
slot,
group.Id,
new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14)
);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupTooSmall);
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.CooldownUnreachable);
Assert.All(
issues.Where(i => i.Kind != TemplateIssueKind.GridGap),
i => Assert.Equal(TemplateIssueSeverity.Warning, i.Severity)
);
}
[Fact]
public async Task Validate_OverlappingSlotsInOneLayer_AreReported()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Кино");
group.UpdateStats(50, 50, TimeSpan.FromHours(50), T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
var layer = template.AddLayer("Прайм", 10);
var first = layer.AddSlot("Кино", new TimeOnly(20, 0), 120);
Fill(first, group.Id);
var second = layer.AddSlot("Сериал", new TimeOnly(21, 0), 60);
Fill(second, group.Id);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
// Внутри слоя приоритетов нет: второй слот молча пропал бы при генерации.
var overlap = Assert.Single(issues, i => i.Kind == TemplateIssueKind.SlotOverlap);
Assert.Equal(second.Id, overlap.SlotId);
Assert.Equal(layer.Id, overlap.LayerId);
}
[Fact]
public async Task Validate_AdultContentInKidsWindow_ReportsAudienceConflict()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var mild = Show.Create("Мультик", ShowKind.Single);
mild.SetAudience(ShowAudience.G);
var harsh = Show.Create("Ужастик", ShowKind.Single);
harsh.SetAudience(ShowAudience.R);
// Строжайший рейтинг группы приходит через коллекцию: франшиза идёт целиком.
var collection = Collection.Create("Франшиза");
collection.AddShow(harsh.Id);
var group = Group.Create("Вечернее");
group.AddElement(GroupElementKind.Show, mild.Id);
group.AddElement(GroupElementKind.Collection, collection.Id);
group.UpdateStats(2, 2, TimeSpan.FromHours(2), T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
slot.UpdateTiming(
null,
new TimeOnly(10, 0),
24 * 60,
Daypart.Day,
isAnchor: false,
5,
null
);
template.SetRules(
new PlanningRules(
MaxAudienceByTime:
[
new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(21, 0), ShowAudience.Pg),
]
).ToJson()
);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.Collections.Add(collection);
seed.Shows.AddRange(mild, harsh);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
var conflict = Assert.Single(issues, i => i.Kind == TemplateIssueKind.AudienceConflict);
Assert.Contains("Вечернее", conflict.Details);
}
}