Refactor CI workflow for improved test coverage reporting and enhance BumperPlaceholders logic
ci / build-backend (push) Successful in 1m27s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 10m43s
ci / sonar (push) Successful in 6m18s

Updated the CI workflow to run tests for individual projects, ensuring accurate coverage reports are generated for each module. Enhanced the BumperPlaceholders class to handle dangling separators more effectively, improving text resolution logic and maintaining clarity in output.
This commit is contained in:
Leonid Pershin
2026-07-27 22:46:02 +03:00
parent 8ae8eebc12
commit 3d2ae20368
9 changed files with 2015 additions and 10 deletions
@@ -0,0 +1,257 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Разворачивание стыка генератором: то, что происходит между шаблоном стыка в базе и чистым
/// планировщиком. Проверяется сухим прогоном — он ничего не пишет, но проходит тот же сбор входа.
/// </summary>
public class GridJunctionPreviewTests
{
private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero);
private sealed class FirstAlways : IRandomSource
{
public int Next(int maxExclusive) => 0;
}
private static GridScheduleGenerator Generator(AppDbContext db) =>
new(
db,
new GroupExpander(db, GroupServices.Dynamic(db)),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), new FirstAlways()),
new PostCheckRunner(db),
new FirstAlways(),
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
private static MediaAsset ReadyAsset(string name, TimeSpan duration)
{
var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
duration,
2,
(int)(duration.TotalSeconds / 2),
1920,
1080,
"h264",
"aac",
"assets/x"
)
);
return asset;
}
/// <summary>Канал с одним слотом на сутки, группой из двух серий и группой роликов для врезок.</summary>
private static async Task<(
TestDb Fixture,
Guid ChannelId,
JunctionTemplate Junction
)> SeedAsync(Daypart daypart = Daypart.Prime)
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var show = Show.Create("Симпсоны", ShowKind.Series);
var episodes = new[]
{
ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)),
ReadyAsset("s01e02.mkv", TimeSpan.FromMinutes(20)),
};
foreach (var episode in episodes)
show.AddEpisode(episode.Id);
var spot = Show.Create("Ролик", ShowKind.Interstitial);
var spotAsset = ReadyAsset("spot.mkv", TimeSpan.FromSeconds(30));
spot.AddEpisode(spotAsset.Id);
var content = Group.Create("Мультсериалы");
content.AddElement(GroupElementKind.Show, show.Id);
var ads = Group.Create("Реклама");
ads.AddElement(GroupElementKind.Show, spot.Id);
var bumper = BumperTemplate.Create("Блок", "Текст 1");
bumper.SetAudio(".mp3", 8);
bumper
.Variants[0]
.SetLines([
BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
]);
var junction = JunctionTemplate.Create("Прайм");
junction.Update("Прайм", 600);
junction
.AddElement(JunctionElementKind.Ad)
.Update(
new JunctionElementSettings(
JunctionElementKind.Ad,
ads.Id,
null,
null,
JunctionAmountMode.Count,
1,
true,
null,
1,
null
)
);
junction
.AddElement(JunctionElementKind.Bumper)
.Update(
new JunctionElementSettings(
JunctionElementKind.Bumper,
null,
bumper.Id,
null,
JunctionAmountMode.Count,
1,
false,
null,
1,
// Только в прайме: дейпарт — свойство слота, и отсекается он при сборке входа.
new JunctionConditions(Dayparts: [Daypart.Prime]).ToJson()
)
);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateTiming(null, new TimeOnly(6, 0), 24 * 60, daypart, false, 30, null);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
content.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.Count,
2,
OverflowPolicy.ContinueNext,
junction.Id
)
);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.Shows.AddRange(show, spot);
seed.MediaAssets.AddRange([.. episodes, spotAsset]);
seed.Groups.AddRange(content, ads);
seed.BumperTemplates.Add(bumper);
seed.JunctionTemplates.Add(junction);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return (fixture, channel.Id, junction);
}
[Fact]
public async Task Preview_ExpandsJunctionWithAdsAndBumperReservation()
{
var (fixture, channelId, _) = await SeedAsync();
await using var db = fixture.New();
var result = await Generator(db).PreviewAsync(channelId, T0, 1, CancellationToken.None);
Assert.NotNull(result);
// Слот выходит в каждые вещательные сутки горизонта — врезки повторяются вместе с ним.
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
var bumpers = result.Items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
Assert.NotEmpty(ads);
Assert.Equal(ads.Count, bumpers.Count);
var ad = ads[0];
var bumper = bumpers[0];
// Заставка резервирует длину по звуку (8 с), выровненную на сегмент.
Assert.Equal(TimeSpan.FromSeconds(8), bumper.EndsAtUtc - bumper.StartsAtUtc);
Assert.Equal(TimeSpan.FromSeconds(30), ad.EndsAtUtc - ad.StartsAtUtc);
// Ассет заставки в сухом прогоне не рендерится — только место под него.
Assert.Equal(Guid.Empty, bumper.MediaAssetId);
Assert.NotNull(bumper.BumperTemplateId);
}
[Fact]
public async Task Preview_DropsBreaksOutsideTheirDayparts()
{
var (fixture, channelId, _) = await SeedAsync(Daypart.Night);
await using var db = fixture.New();
var result = await Generator(db).PreviewAsync(channelId, T0, 1, CancellationToken.None);
Assert.NotNull(result);
// Заставка разрешена только в прайме, а слот ночной — в ленте её быть не должно.
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Bumper);
Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Ad);
}
[Fact]
public async Task Preview_SkipsBreaksWithoutSource()
{
var (fixture, channelId, junction) = await SeedAsync();
await using (var db = fixture.New())
{
// Врезка ссылается на блок, которого нет: настроена не до конца — играть ей нечем.
var stored = await JunctionWithElementsAsync(db, junction.Id);
var element = stored.Elements.Single(e => e.Kind == JunctionElementKind.Bumper);
element.Update(
new JunctionElementSettings(
JunctionElementKind.Bumper,
null,
Guid.NewGuid(),
null,
JunctionAmountMode.Count,
1,
false,
null,
1,
null
)
);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var context = fixture.New();
var result = await Generator(context)
.PreviewAsync(channelId, T0, 1, CancellationToken.None);
Assert.NotNull(result);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Bumper);
}
[Fact]
public async Task Preview_UnknownChannel_ReturnsNull()
{
var fixture = new TestDb();
await using var db = fixture.New();
Assert.Null(
await Generator(db).PreviewAsync(Guid.NewGuid(), T0, 1, CancellationToken.None)
);
}
private static Task<JunctionTemplate> JunctionWithElementsAsync(
AppDbContext db,
Guid junctionId
) => db.JunctionTemplates.Include(j => j.Elements).SingleAsync(j => j.Id == junctionId);
}
@@ -0,0 +1,510 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Junctions;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Команды стыка. Стык общий для всех каналов, поэтому проверки здесь про две вещи: источник врезки
/// должен существовать, а правка — помечать изменёнными все шаблоны, которые на этот стык ссылаются.
/// </summary>
public class JunctionHandlersTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static JunctionElementInput Input(
JunctionElementKind kind = JunctionElementKind.Ad,
Guid? groupId = null,
Guid? bumperTemplateId = null,
Guid? bumperVariantId = null,
int amountValue = 2,
bool isRequired = false,
string? choiceKey = null,
int choiceWeight = 1,
JunctionConditions? conditions = null
) =>
new(
kind,
groupId,
bumperTemplateId,
bumperVariantId,
JunctionAmountMode.Count,
amountValue,
isRequired,
choiceKey,
choiceWeight,
conditions
);
/// <summary>Канал со своей сеткой и слотом, который ссылается на стык, — минимум для проверки отметок.</summary>
private static async Task<(
TestDb Fixture,
Guid JunctionId,
Guid TemplateId,
Guid GroupId
)> SeedAsync()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Реклама");
var junction = JunctionTemplate.Create("Прайм");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = template.Background!.AddSlot("Круглосуточно", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
null,
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
null,
junction.Id
)
);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.JunctionTemplates.Add(junction);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
// Шаблон создаётся уже «изменённым» — снимаем отметку, иначе проверять нечего.
template.MarkApplied("{}");
await seed.SaveChangesAsync(CancellationToken.None);
return (fixture, junction.Id, template.Id, group.Id);
}
private static Task<bool> HasPendingChangesAsync(AppDbContext db, Guid templateId) =>
db
.ScheduleTemplates.AsNoTracking()
.Where(t => t.Id == templateId)
.Select(t => t.Revision != t.AppliedRevision)
.FirstAsync();
[Fact]
public async Task Create_AddsGlobalJunction()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new CreateJunctionCommandHandler(db).Handle(
new CreateJunctionCommand(" Ночь "),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
await using var verify = fixture.New();
var junction = await verify.JunctionTemplates.SingleAsync();
Assert.Equal("Ночь", junction.Name);
}
[Fact]
public async Task Update_RenamesAndMarksReferencingTemplates()
{
var (fixture, junctionId, templateId, _) = await SeedAsync();
await using var db = fixture.New();
var result = await new UpdateJunctionCommandHandler(db).Handle(
new UpdateJunctionCommand(junctionId, "Ночь", 180),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
await using var verify = fixture.New();
var junction = await verify.JunctionTemplates.AsNoTracking().SingleAsync();
Assert.Equal("Ночь", junction.Name);
Assert.Equal(180, junction.MaxTotalSeconds);
// Стык общий: чужой канал не должен молча поехать по новым врезкам без применения.
Assert.True(await HasPendingChangesAsync(verify, templateId));
}
[Fact]
public async Task Update_UnknownJunction_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new UpdateJunctionCommandHandler(db).Handle(
new UpdateJunctionCommand(Guid.NewGuid(), "Ночь", null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionNotFound, result.Error);
}
[Fact]
public async Task Delete_RefusesWhenUsedBySlotOrDefault()
{
var (fixture, junctionId, templateId, _) = await SeedAsync();
await using (var db = fixture.New())
{
var used = await new DeleteJunctionCommandHandler(db).Handle(
new DeleteJunctionCommand(junctionId),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionInUse, used.Error);
}
// Снимаем ссылку слота, но оставляем стыком по умолчанию — удалять всё равно нельзя.
await using (var db = fixture.New())
{
var template = await db
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.SingleAsync(t => t.Id == templateId);
var slot = template.Layers.SelectMany(l => l.Slots).Single();
slot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
null,
null,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy
)
);
template.SetDefaultJunction(junctionId);
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var db = fixture.New())
{
var asDefault = await new DeleteJunctionCommandHandler(db).Handle(
new DeleteJunctionCommand(junctionId),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionInUse, asDefault.Error);
}
}
[Fact]
public async Task Delete_RemovesUnusedJunction()
{
var fixture = new TestDb();
var junction = JunctionTemplate.Create("Ночь");
await using (var seed = fixture.New())
{
seed.JunctionTemplates.Add(junction);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new DeleteJunctionCommandHandler(db).Handle(
new DeleteJunctionCommand(junction.Id),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
await using var verify = fixture.New();
Assert.Empty(verify.JunctionTemplates);
}
[Fact]
public async Task AddAndRemoveElement_TouchReferencingTemplates()
{
var (fixture, junctionId, templateId, _) = await SeedAsync();
Guid elementId;
await using (var db = fixture.New())
{
var added = await new AddJunctionElementCommandHandler(db).Handle(
new AddJunctionElementCommand(junctionId, JunctionElementKind.Ad),
CancellationToken.None
);
Assert.True(added.IsSuccess);
elementId = added.Value;
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var verify = fixture.New())
Assert.True(await HasPendingChangesAsync(verify, templateId));
await using (var db = fixture.New())
{
var removed = await new RemoveJunctionElementCommandHandler(db).Handle(
new RemoveJunctionElementCommand(junctionId, elementId),
CancellationToken.None
);
Assert.True(removed.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var db = fixture.New())
{
var missing = await new RemoveJunctionElementCommandHandler(db).Handle(
new RemoveJunctionElementCommand(junctionId, elementId),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionElementNotFound, missing.Error);
}
}
[Fact]
public async Task UpdateElement_RequiresExistingSource()
{
var (fixture, junctionId, _, groupId) = await SeedAsync();
var bumper = BumperTemplate.Create("Блок", "Текст 1");
Guid elementId;
await using (var db = fixture.New())
{
db.BumperTemplates.Add(bumper);
var junction = await db
.JunctionTemplates.Include(j => j.Elements)
.SingleAsync(j => j.Id == junctionId);
elementId = junction.AddElement(JunctionElementKind.Ad).Id;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var context = fixture.New();
var handler = new UpdateJunctionElementCommandHandler(context);
var noGroup = await handler.Handle(
new UpdateJunctionElementCommand(junctionId, elementId, Input()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionGroupRequired, noGroup.Error);
var unknownGroup = await handler.Handle(
new UpdateJunctionElementCommand(junctionId, elementId, Input(groupId: Guid.NewGuid())),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, unknownGroup.Error);
var noBumper = await handler.Handle(
new UpdateJunctionElementCommand(
junctionId,
elementId,
Input(kind: JunctionElementKind.Bumper)
),
CancellationToken.None
);
Assert.Equal(BumperErrors.TemplateNotFound, noBumper.Error);
var alienVariant = await handler.Handle(
new UpdateJunctionElementCommand(
junctionId,
elementId,
Input(
kind: JunctionElementKind.Bumper,
bumperTemplateId: bumper.Id,
bumperVariantId: Guid.NewGuid()
)
),
CancellationToken.None
);
Assert.Equal(BumperErrors.VariantNotFound, alienVariant.Error);
var ok = await handler.Handle(
new UpdateJunctionElementCommand(
junctionId,
elementId,
Input(
groupId: groupId,
isRequired: true,
conditions: new JunctionConditions(true, 30, [Daypart.Prime], null, 40)
)
),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
await context.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var element = await verify.JunctionElements.AsNoTracking().SingleAsync();
var conditions = JunctionConditions.FromJson(element.ConditionsJson);
Assert.Equal(groupId, element.GroupId);
Assert.True(element.IsRequired);
Assert.Equal(40, conditions!.Chance);
Assert.Equal([Daypart.Prime], conditions.Dayparts);
}
[Fact]
public async Task UpdateElement_UnknownElement_ReturnsNotFound()
{
var (fixture, junctionId, _, groupId) = await SeedAsync();
await using var db = fixture.New();
var result = await new UpdateJunctionElementCommandHandler(db).Handle(
new UpdateJunctionElementCommand(junctionId, Guid.NewGuid(), Input(groupId: groupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionElementNotFound, result.Error);
}
[Fact]
public async Task Reorder_AppliesOrderAndForkMembership()
{
var (fixture, junctionId, _, _) = await SeedAsync();
Guid first;
Guid second;
await using (var db = fixture.New())
{
var junction = await db
.JunctionTemplates.Include(j => j.Elements)
.SingleAsync(j => j.Id == junctionId);
first = junction.AddElement(JunctionElementKind.Ad).Id;
second = junction.AddElement(JunctionElementKind.Promo).Id;
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var db = fixture.New())
{
// Перетаскивание меняет порядок и принадлежность к развилке одним жестом.
var result = await new ReorderJunctionCommandHandler(db).Handle(
new ReorderJunctionCommand(
junctionId,
[
new JunctionElementOrder(second, "fork"),
new JunctionElementOrder(first, "fork"),
]
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = fixture.New();
var elements = await verify
.JunctionElements.AsNoTracking()
.OrderBy(e => e.Position)
.ToListAsync();
Assert.Equal(second, elements[0].Id);
Assert.All(elements, e => Assert.Equal("fork", e.ChoiceKey));
}
[Fact]
public async Task Reorder_UnknownJunction_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new ReorderJunctionCommandHandler(db).Handle(
new ReorderJunctionCommand(Guid.NewGuid(), []),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionNotFound, result.Error);
}
[Fact]
public async Task List_ResolvesNamesAndChannelUsage()
{
var (fixture, junctionId, templateId, groupId) = await SeedAsync();
var bumper = BumperTemplate.Create("Блок", "Текст 1");
await using (var db = fixture.New())
{
db.BumperTemplates.Add(bumper);
var junction = await db
.JunctionTemplates.Include(j => j.Elements)
.SingleAsync(j => j.Id == junctionId);
junction.Update("Прайм", 240);
junction
.AddElement(JunctionElementKind.Ad)
.Update(
new JunctionElementSettings(
JunctionElementKind.Ad,
groupId,
null,
null,
JunctionAmountMode.Count,
2,
true,
null,
1,
null
)
);
junction
.AddElement(JunctionElementKind.Bumper)
.Update(
new JunctionElementSettings(
JunctionElementKind.Bumper,
null,
bumper.Id,
bumper.Variants[0].Id,
JunctionAmountMode.Count,
1,
false,
"fork",
3,
new JunctionConditions(OnlyOnElementChange: true).ToJson()
)
);
// Второй канал ссылается на тот же стык — счётчик обязан это увидеть.
var other = Channel.Create("Второй", "two", T0);
var otherTemplate = ScheduleTemplate.Create(other.Id, "Сетка 2");
otherTemplate.SetDefaultJunction(junctionId);
other.SetTemplate(otherTemplate.Id);
db.Channels.Add(other);
db.ScheduleTemplates.Add(otherTemplate);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var read = fixture.New();
var junctions = await new ListJunctionsQueryHandler(read).Handle(
new ListJunctionsQuery(),
CancellationToken.None
);
var dto = Assert.Single(junctions);
Assert.Equal("Прайм", dto.Name);
Assert.Equal(240, dto.MaxTotalSeconds);
Assert.Equal(2, dto.ChannelUsageCount);
Assert.Equal("Реклама", dto.Elements[0].GroupName);
Assert.Equal("Блок", dto.Elements[1].BumperTemplateName);
Assert.Equal("Текст 1", dto.Elements[1].BumperVariantName);
Assert.Equal("fork", dto.Elements[1].ChoiceKey);
Assert.True(dto.Elements[1].Conditions!.OnlyOnElementChange);
Assert.Equal(templateId, templateId);
}
[Fact]
public void Conditions_RoundTripAndDaypartFilter()
{
var conditions = new JunctionConditions(
true,
30,
[Daypart.Prime, Daypart.Night],
new JunctionTimeWindow(new TimeOnly(23, 0), new TimeOnly(6, 0)),
40
);
var restored = JunctionConditions.FromJson(conditions.ToJson())!;
// Сравниваем по полям: у записи со списком внутри равенство ссылочное.
Assert.Equal(conditions.OnlyOnElementChange, restored.OnlyOnElementChange);
Assert.Equal(conditions.MinMinutesBetween, restored.MinMinutesBetween);
Assert.Equal(conditions.Dayparts, restored.Dayparts);
Assert.Equal(conditions.TimeWindow, restored.TimeWindow);
Assert.Equal(conditions.Chance, restored.Chance);
Assert.True(restored.AllowsDaypart(Daypart.Prime));
Assert.False(restored.AllowsDaypart(Daypart.Morning));
// Пустой список дейпартов не ограничивает ничего — иначе врезка молча исчезла бы из эфира.
Assert.True(new JunctionConditions().AllowsDaypart(Daypart.Morning));
Assert.Null(JunctionConditions.FromJson("{не json}"));
Assert.Null(JunctionConditions.FromJson(null));
}
}