Refactor CI workflow for improved test coverage reporting and enhance BumperPlaceholders logic
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user