Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management.
This commit is contained in:
@@ -106,7 +106,8 @@ public static class TemplateEndpoints
|
||||
templateId,
|
||||
body.Name,
|
||||
body.FallbackGroupId,
|
||||
body.DefaultJunctionId
|
||||
body.DefaultJunctionId,
|
||||
body.Rules
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
@@ -197,7 +198,8 @@ public static class TemplateEndpoints
|
||||
public sealed record UpdateTemplateBody(
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId
|
||||
Guid? DefaultJunctionId,
|
||||
PlanningRules? Rules
|
||||
);
|
||||
|
||||
public sealed record CreateLayerBody(string Name, int Priority);
|
||||
|
||||
@@ -258,7 +258,19 @@ public sealed class GridScheduleGenerator(
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken);
|
||||
// Правила канала: детское время резолвится на каждый слот по его времени, а под потолок
|
||||
// повторов нужна история показов — её глубина и задаётся окном правила.
|
||||
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||
var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit
|
||||
? new RepeatLimit(limit.WindowDays, limit.Max)
|
||||
: null;
|
||||
|
||||
var elementsByGroup = await expander.ExpandAsync(
|
||||
groupIds,
|
||||
channel.Id,
|
||||
cancellationToken,
|
||||
repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays)
|
||||
);
|
||||
|
||||
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
|
||||
var states = await dbContext
|
||||
@@ -308,7 +320,13 @@ public sealed class GridScheduleGenerator(
|
||||
junctions,
|
||||
elementsByGroup,
|
||||
channel
|
||||
)
|
||||
),
|
||||
rules?.AudienceAt(
|
||||
TimeOnly.FromDateTime(
|
||||
item.StartUtc.ToOffset(TimeSpan.FromMinutes(channel.UtcOffsetMinutes)).DateTime
|
||||
)
|
||||
),
|
||||
repeatLimit
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +1,232 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
|
||||
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
|
||||
/// фильм — в одну единицу.
|
||||
///
|
||||
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
|
||||
/// обрабатывается, значит получить дыру в раздаче.
|
||||
/// </summary>
|
||||
public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
||||
IReadOnlyCollection<Guid> groupIds,
|
||||
Guid channelId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
||||
if (groupIds.Count == 0)
|
||||
return result;
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var collectionIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var collectionParts = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.OrderBy(i => i.Position)
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.Concat(collectionParts.Select(p => p.ShowId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var episodes = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
Episodes = s
|
||||
.Episodes.OrderBy(e => e.Position)
|
||||
.Select(e => new { e.MediaAssetId, e.Position })
|
||||
.ToList(),
|
||||
})
|
||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||
|
||||
var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
|
||||
var durations = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a =>
|
||||
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||
|
||||
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
||||
|
||||
List<PlanningUnit> UnitsOfShow(Guid showId)
|
||||
{
|
||||
if (!episodes.TryGetValue(showId, out var show))
|
||||
return [];
|
||||
|
||||
return show
|
||||
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
|
||||
.Select((e, index) => new PlanningUnit(
|
||||
e.MediaAssetId,
|
||||
durations[e.MediaAssetId],
|
||||
showId,
|
||||
index
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var elements = new List<PlanningElement>();
|
||||
|
||||
foreach (var item in group.Items.OrderBy(i => i.Position))
|
||||
{
|
||||
if (item.ElementKind == GroupElementKind.Show)
|
||||
{
|
||||
elements.Add(
|
||||
new PlanningElement(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
UnitsOfShow(item.ElementId),
|
||||
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var partShowIds = collectionParts
|
||||
.Where(p => p.CollectionId == item.ElementId)
|
||||
.Select(p => p.ShowId)
|
||||
.ToList();
|
||||
|
||||
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
|
||||
|
||||
elements.Add(
|
||||
new PlanningElement(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
units,
|
||||
// Остывание коллекции считается по самой свежей из её частей: показ любой
|
||||
// из них означает, что франшиза недавно была в эфире.
|
||||
partShowIds
|
||||
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
|
||||
.Where(p => p is not null)
|
||||
.DefaultIfEmpty(null)
|
||||
.Max()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
result[group.Id] = elements;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
||||
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
||||
/// остывание среди правил.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
|
||||
Guid channelId,
|
||||
IReadOnlyCollection<Guid> showIds,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.ShowId != null
|
||||
&& showIds.Contains(e.ShowId!.Value)
|
||||
)
|
||||
.GroupBy(e => e.ShowId!.Value)
|
||||
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
||||
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
|
||||
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
|
||||
/// фильм — в одну единицу.
|
||||
///
|
||||
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
|
||||
/// обрабатывается, значит получить дыру в раздаче.
|
||||
/// </summary>
|
||||
public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
{
|
||||
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
|
||||
/// null — история не нужна и не загружается.</param>
|
||||
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
||||
IReadOnlyCollection<Guid> groupIds,
|
||||
Guid channelId,
|
||||
CancellationToken cancellationToken,
|
||||
DateTimeOffset? historyFrom = null
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
||||
if (groupIds.Count == 0)
|
||||
return result;
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var collectionIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var collectionParts = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.OrderBy(i => i.Position)
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.Concat(collectionParts.Select(p => p.ShowId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var episodes = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Audience,
|
||||
Episodes = s
|
||||
.Episodes.OrderBy(e => e.Position)
|
||||
.Select(e => new { e.MediaAssetId, e.Position })
|
||||
.ToList(),
|
||||
})
|
||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||
|
||||
var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
|
||||
var durations = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a =>
|
||||
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||
|
||||
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
||||
var recentPlays = await LoadRecentPlaysAsync(
|
||||
channelId,
|
||||
showIds,
|
||||
historyFrom,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
List<PlanningUnit> UnitsOfShow(Guid showId)
|
||||
{
|
||||
if (!episodes.TryGetValue(showId, out var show))
|
||||
return [];
|
||||
|
||||
return show
|
||||
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
|
||||
.Select((e, index) => new PlanningUnit(
|
||||
e.MediaAssetId,
|
||||
durations[e.MediaAssetId],
|
||||
showId,
|
||||
index
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var elements = new List<PlanningElement>();
|
||||
|
||||
foreach (var item in group.Items.OrderBy(i => i.Position))
|
||||
{
|
||||
if (item.ElementKind == GroupElementKind.Show)
|
||||
{
|
||||
elements.Add(
|
||||
new PlanningElement(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
UnitsOfShow(item.ElementId),
|
||||
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null,
|
||||
episodes.TryGetValue(item.ElementId, out var show) ? show.Audience : null,
|
||||
recentPlays.TryGetValue(item.ElementId, out var plays) ? plays : null
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var partShowIds = collectionParts
|
||||
.Where(p => p.CollectionId == item.ElementId)
|
||||
.Select(p => p.ShowId)
|
||||
.ToList();
|
||||
|
||||
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
|
||||
|
||||
elements.Add(
|
||||
new PlanningElement(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
units,
|
||||
// Остывание коллекции считается по самой свежей из её частей: показ любой
|
||||
// из них означает, что франшиза недавно была в эфире.
|
||||
partShowIds
|
||||
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
|
||||
.Where(p => p is not null)
|
||||
.DefaultIfEmpty(null)
|
||||
.Max(),
|
||||
// Категория коллекции — строжайшая среди частей: франшиза идёт целиком,
|
||||
// и одна взрослая часть делает взрослой всю.
|
||||
partShowIds
|
||||
.Select(id =>
|
||||
episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null
|
||||
)
|
||||
.Where(a => a is not null)
|
||||
.DefaultIfEmpty(null)
|
||||
.Max(),
|
||||
partShowIds
|
||||
.SelectMany(id =>
|
||||
recentPlays.TryGetValue(id, out var p) ? p : []
|
||||
)
|
||||
.ToList()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
result[group.Id] = elements;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
||||
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
||||
/// остывание среди правил.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
|
||||
Guid channelId,
|
||||
IReadOnlyCollection<Guid> showIds,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.ShowId != null
|
||||
&& showIds.Contains(e.ShowId!.Value)
|
||||
)
|
||||
.GroupBy(e => e.ShowId!.Value)
|
||||
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
||||
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
|
||||
/// это лишние сотни строк на каждый прогон.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
|
||||
Guid channelId,
|
||||
IReadOnlyCollection<Guid> showIds,
|
||||
DateTimeOffset? from,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (from is not { } since || showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var plays = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.ShowId != null
|
||||
&& showIds.Contains(e.ShowId!.Value)
|
||||
&& e.StartsAtUtc >= since
|
||||
)
|
||||
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return plays
|
||||
.GroupBy(p => p.ShowId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -88,6 +88,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
||||
template.Name,
|
||||
template.FallbackGroupId,
|
||||
template.DefaultJunctionId,
|
||||
PlanningRules.FromJson(template.RulesJson),
|
||||
template.Revision,
|
||||
template.AppliedRevision,
|
||||
template.HasPendingChanges,
|
||||
|
||||
@@ -92,6 +92,7 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
||||
template.Rename(command.Name);
|
||||
template.SetFallbackGroup(command.FallbackGroupId);
|
||||
template.SetDefaultJunction(command.DefaultJunctionId);
|
||||
template.SetRules(command.Rules?.ToJson());
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ public sealed record UpdateLayerCommand(
|
||||
|
||||
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
||||
|
||||
/// <summary>Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой.</summary>
|
||||
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
|
||||
public sealed record UpdateTemplateCommand(
|
||||
Guid TemplateId,
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId
|
||||
Guid? DefaultJunctionId,
|
||||
PlanningRules? Rules
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
||||
@@ -45,5 +46,22 @@ public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerC
|
||||
|
||||
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
||||
{
|
||||
public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
public UpdateTemplateCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
|
||||
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
|
||||
// всё равно ограничена глубиной хранения ленты.
|
||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
|
||||
.InclusiveBetween(1, 365)
|
||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
|
||||
.InclusiveBetween(1, 1000)
|
||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||
|
||||
RuleForEach(x => x.Rules!.MaxAudienceByTime)
|
||||
.Must(window => window.From != window.To)
|
||||
.WithMessage("Окно нулевой длины ничего не ограничивает.")
|
||||
.When(x => x.Rules?.MaxAudienceByTime is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||
/// </summary>
|
||||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||
{
|
||||
public bool Contains(TimeOnly moment) =>
|
||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||
///
|
||||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||
/// </summary>
|
||||
public sealed record PlanningRules(
|
||||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||
RepeatLimitRule? MaxRepeatsInWindow = null
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||
{
|
||||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||
return null;
|
||||
|
||||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||
// отменяться более широким окном, случайно наложенным сверху.
|
||||
ShowAudience? strictest = null;
|
||||
foreach (var window in windows.Where(w => w.Contains(moment)))
|
||||
strictest = strictest is { } current && current <= window.MaxAudience
|
||||
? current
|
||||
: window.MaxAudience;
|
||||
return strictest;
|
||||
}
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static PlanningRules? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
||||
@@ -41,6 +41,8 @@ public sealed record ScheduleTemplateDto(
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
|
||||
PlanningRules? Rules,
|
||||
int Revision,
|
||||
int AppliedRevision,
|
||||
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
||||
|
||||
@@ -22,7 +22,11 @@ public static class ElementSelector
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
|
||||
// Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
|
||||
// начатый в общее время сериал не должен доигрываться в детское.
|
||||
var playable = slot
|
||||
.Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e))
|
||||
.ToList();
|
||||
if (playable.Count == 0)
|
||||
return null;
|
||||
|
||||
@@ -95,10 +99,14 @@ public static class ElementSelector
|
||||
if (current is not null && HasUnitsLeft(slot, current))
|
||||
return Continue(slot, current);
|
||||
|
||||
// Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной
|
||||
// стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа.
|
||||
var withinLimit = ApplyRepeatLimit(slot, playable, moment);
|
||||
|
||||
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
|
||||
var eligible = cooldown <= TimeSpan.Zero
|
||||
? playable
|
||||
: playable
|
||||
? withinLimit
|
||||
: withinLimit
|
||||
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
|
||||
.ToList();
|
||||
|
||||
@@ -106,16 +114,47 @@ public static class ElementSelector
|
||||
if (exhausted)
|
||||
{
|
||||
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
|
||||
// пустой эфир хуже раннего повтора в обоих случаях.
|
||||
// пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком
|
||||
// повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
|
||||
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
|
||||
? playable
|
||||
: [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
|
||||
? withinLimit
|
||||
: [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
|
||||
}
|
||||
|
||||
var picked = WeightedPick(eligible, random);
|
||||
return new ElementPick(picked, 0, eligible.Count, exhausted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому
|
||||
/// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит
|
||||
/// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер.
|
||||
/// </summary>
|
||||
public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) =>
|
||||
slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max;
|
||||
|
||||
/// <summary>
|
||||
/// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов;
|
||||
/// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает.
|
||||
/// </summary>
|
||||
private static List<PlanningElement> ApplyRepeatLimit(
|
||||
PlanningSlot slot,
|
||||
List<PlanningElement> playable,
|
||||
DateTimeOffset moment
|
||||
)
|
||||
{
|
||||
if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
|
||||
return playable;
|
||||
|
||||
var from = moment.AddDays(-limit.WindowDays);
|
||||
var filtered = playable
|
||||
.Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max)
|
||||
.ToList();
|
||||
|
||||
// Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
|
||||
return filtered.Count == 0 ? playable : filtered;
|
||||
}
|
||||
|
||||
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
|
||||
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Domain.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
@@ -16,9 +18,16 @@ public sealed record PlanningElement(
|
||||
int Weight,
|
||||
int Position,
|
||||
IReadOnlyList<PlanningUnit> Units,
|
||||
DateTimeOffset? LastPlayedUtc = null
|
||||
DateTimeOffset? LastPlayedUtc = null,
|
||||
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
|
||||
ShowAudience? Audience = null,
|
||||
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
|
||||
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
|
||||
);
|
||||
|
||||
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
|
||||
public sealed record RepeatLimit(int WindowDays, int Max);
|
||||
|
||||
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
|
||||
public sealed record PlanningStrategy(
|
||||
SlotStrategyKind Kind,
|
||||
@@ -66,7 +75,11 @@ public sealed record PlanningSlot(
|
||||
/// <summary>Врезки между единицами внутри блока.</summary>
|
||||
PlanningJunction? JunctionBetween = null,
|
||||
/// <summary>Врезки в конце блока.</summary>
|
||||
PlanningJunction? JunctionAfter = null
|
||||
PlanningJunction? JunctionAfter = null,
|
||||
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
|
||||
ShowAudience? MaxAudience = null,
|
||||
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
|
||||
RepeatLimit? RepeatLimit = null
|
||||
)
|
||||
{
|
||||
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
||||
@@ -189,4 +202,7 @@ public enum PlanningWarningKind
|
||||
|
||||
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
|
||||
FallbackEmpty = 4,
|
||||
|
||||
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
|
||||
CandidatesFiltered = 5,
|
||||
}
|
||||
|
||||
@@ -244,12 +244,26 @@ public static class SchedulePlanner
|
||||
var pick = ElementSelector.Select(slot, cursor, random);
|
||||
if (pick is null)
|
||||
{
|
||||
// Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть,
|
||||
// но не подходит по правилам, и админу надо чинить правило, а не состав группы.
|
||||
var filteredOut =
|
||||
slot.Elements.Any(e => e.Units.Count > 0)
|
||||
&& !slot.Elements.Any(e =>
|
||||
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
|
||||
);
|
||||
|
||||
warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.SlotEmpty,
|
||||
slot.SlotId,
|
||||
"Слот не дал контента — место закрыл фон."
|
||||
)
|
||||
filteredOut
|
||||
? new PlanningWarning(
|
||||
PlanningWarningKind.CandidatesFiltered,
|
||||
slot.SlotId,
|
||||
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
|
||||
)
|
||||
: new PlanningWarning(
|
||||
PlanningWarningKind.SlotEmpty,
|
||||
slot.SlotId,
|
||||
"Слот не дал контента — место закрыл фон."
|
||||
)
|
||||
);
|
||||
return FillWithFallback(
|
||||
cursor,
|
||||
|
||||
@@ -22,6 +22,12 @@ public class ScheduleTemplate
|
||||
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
|
||||
public Guid? DefaultJunctionId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
|
||||
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
|
||||
/// </summary>
|
||||
public string? RulesJson { get; private set; }
|
||||
|
||||
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
@@ -63,6 +69,9 @@ public class ScheduleTemplate
|
||||
|
||||
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
|
||||
|
||||
public void SetRules(string? rulesJson) =>
|
||||
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
|
||||
|
||||
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
||||
public void MarkChanged() => Revision++;
|
||||
|
||||
|
||||
Generated
+1371
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TemplatePlanningRules : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RulesJson",
|
||||
table: "ScheduleTemplates",
|
||||
type: "jsonb",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RulesJson",
|
||||
table: "ScheduleTemplates");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -928,6 +928,9 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration<ScheduleTe
|
||||
public void Configure(EntityTypeBuilder<ScheduleTemplate> builder)
|
||||
{
|
||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
|
||||
builder.Property(x => x.RulesJson).HasColumnType("jsonb");
|
||||
builder.HasIndex(x => x.ChannelId);
|
||||
|
||||
builder
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
|
||||
/// </summary>
|
||||
public class LayerApplicabilityTests
|
||||
{
|
||||
private static readonly TimeOnly DayStart = new(6, 0);
|
||||
|
||||
[Fact]
|
||||
public void Empty_CoversEveryDate()
|
||||
{
|
||||
var applicability = new LayerApplicability();
|
||||
|
||||
Assert.True(applicability.IsEmpty);
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
|
||||
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
|
||||
[InlineData(2026, 12, 20, true)] // ровно начало
|
||||
[InlineData(2026, 1, 8, true)] // ровно конец
|
||||
[InlineData(2026, 12, 19, false)] // за день до начала
|
||||
[InlineData(2026, 1, 9, false)] // на следующий день после конца
|
||||
[InlineData(2026, 6, 15, false)] // середина года
|
||||
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
|
||||
int year,
|
||||
int month,
|
||||
int day,
|
||||
bool expected
|
||||
)
|
||||
{
|
||||
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
|
||||
// по паре (месяц, день), а не по датам.
|
||||
var applicability = new LayerApplicability(
|
||||
AnnualRanges: [new AnnualRange(12, 20, 1, 8)]
|
||||
);
|
||||
|
||||
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnnualRange_WithinOneYear_DoesNotWrap()
|
||||
{
|
||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_AreCombinedWithOr()
|
||||
{
|
||||
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
|
||||
var applicability = new LayerApplicability(
|
||||
Weekdays: [1],
|
||||
SpecificDates: [new DateOnly(2026, 3, 21)]
|
||||
);
|
||||
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
|
||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
|
||||
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
|
||||
{
|
||||
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
|
||||
// половину настройки — своей группы и стратегии у половинки нет.
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var top = template.AddLayer("Прайм", 100);
|
||||
var bottom = template.AddLayer("Обычный", 50);
|
||||
|
||||
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
|
||||
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
|
||||
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
|
||||
|
||||
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
|
||||
|
||||
Assert.Contains("Кино", titles);
|
||||
Assert.Contains("Ночь", titles);
|
||||
Assert.DoesNotContain("Сериал", titles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DropsLayersThatDoNotApplyOnTheDate()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var newYear = template.AddLayer("Новогодний", 100);
|
||||
newYear.Update(
|
||||
newYear.Name,
|
||||
newYear.Priority,
|
||||
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
|
||||
isEnabled: true
|
||||
);
|
||||
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
|
||||
|
||||
var usual = template.AddLayer("Обычный", 50);
|
||||
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
|
||||
|
||||
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
|
||||
.Select(s => s.Slot.Title)
|
||||
.ToList();
|
||||
|
||||
Assert.Contains("Вечерний сериал", march);
|
||||
Assert.DoesNotContain("Ирония судьбы", march);
|
||||
Assert.Contains("Ирония судьбы", december);
|
||||
Assert.DoesNotContain("Вечерний сериал", december);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DisabledLayerIsIgnored()
|
||||
{
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
var layer = template.AddLayer("Выключённый", 100);
|
||||
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
|
||||
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
|
||||
|
||||
Assert.Empty(Build(template));
|
||||
}
|
||||
|
||||
private static void AddSlot(GridLayer layer, string title, TimeOnly start, int durationMinutes) =>
|
||||
layer.AddSlot(title, start, durationMinutes);
|
||||
|
||||
private static IReadOnlyList<ScheduledSlot> Build(
|
||||
ScheduleTemplate template,
|
||||
DateTimeOffset? from = null
|
||||
)
|
||||
{
|
||||
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
return EffectiveGridBuilder.Build(
|
||||
template,
|
||||
utcOffsetMinutes: 180,
|
||||
DayStart,
|
||||
start,
|
||||
start.AddHours(18)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Жёсткие фильтры кандидатов (см. 3.8): детское время и потолок повторов. Оба отсекают до жребия,
|
||||
/// поэтому проверяются на выборе элемента, а не на готовой ленте.
|
||||
/// </summary>
|
||||
public class CandidateFilterTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class FirstAlways : IRandomSource
|
||||
{
|
||||
public int Next(int maxExclusive) => 0;
|
||||
}
|
||||
|
||||
private static PlanningElement Element(
|
||||
ShowAudience? audience = null,
|
||||
int position = 0,
|
||||
DateTimeOffset? lastPlayed = null,
|
||||
IReadOnlyList<DateTimeOffset>? recentPlays = null
|
||||
)
|
||||
{
|
||||
var showId = Guid.NewGuid();
|
||||
return new PlanningElement(
|
||||
GroupElementKind.Show,
|
||||
Guid.NewGuid(),
|
||||
Weight: 1,
|
||||
position,
|
||||
[new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(30), showId, 0)],
|
||||
lastPlayed,
|
||||
audience,
|
||||
recentPlays
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanningSlot Slot(
|
||||
IReadOnlyList<PlanningElement> elements,
|
||||
ShowAudience? maxAudience = null,
|
||||
RepeatLimit? repeatLimit = null,
|
||||
SlotStrategyKind strategy = SlotStrategyKind.RandomWithCooldown
|
||||
) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
T0,
|
||||
TargetDurationMinutes: 60,
|
||||
SlotKind.Content,
|
||||
IsAnchor: false,
|
||||
MaxDriftMinutes: 30,
|
||||
SnapToMinutes: null,
|
||||
SlotBlockMode.FillSlot,
|
||||
BlockValue: 1,
|
||||
OverflowPolicy.ContinueNext,
|
||||
new PlanningStrategy(strategy),
|
||||
elements,
|
||||
Cursor: null,
|
||||
MaxAudience: maxAudience,
|
||||
RepeatLimit: repeatLimit
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void Audience_FiltersOutStricterContent()
|
||||
{
|
||||
var kids = Element(ShowAudience.Kids);
|
||||
var adult = Element(ShowAudience.Adult, position: 1);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([adult, kids], maxAudience: ShowAudience.Family),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(kids.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_WithoutLimit_KeepsEverything()
|
||||
{
|
||||
var adult = Element(ShowAudience.Adult);
|
||||
|
||||
var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways());
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(adult.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_UnknownCategory_IsNotDropped()
|
||||
{
|
||||
// Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча.
|
||||
var unknown = Element(audience: null);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([unknown], maxAudience: ShowAudience.Kids),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(unknown.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_AllFilteredOut_ReturnsNothing()
|
||||
{
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.Null(pick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audience_AppliesToSequentialStrategyToo()
|
||||
{
|
||||
var adult = Element(ShowAudience.Adult, position: 0);
|
||||
var teen = Element(ShowAudience.Teen, position: 1);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(teen.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_DropsElementsOverTheCap()
|
||||
{
|
||||
var overCap = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||
var fresh = Element(position: 1, recentPlays: [T0.AddDays(-1)]);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([overCap, fresh], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(fresh.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_IgnoresPlaysOutsideTheWindow()
|
||||
{
|
||||
// Показы старше окна не считаются — иначе правило «не чаще двух раз в неделю» запирало бы
|
||||
// элемент навсегда.
|
||||
var old = Element(recentPlays: [T0.AddDays(-30), T0.AddDays(-31)]);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([old], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(old.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_WhenEveryoneIsOverTheCap_StillPicksSomething()
|
||||
{
|
||||
// Пустой эфир хуже раннего повтора — как и при исчерпанном остывании.
|
||||
var a = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||
var b = Element(position: 1, recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||
|
||||
var pick = ElementSelector.Select(
|
||||
Slot([a, b], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 1)),
|
||||
T0,
|
||||
new FirstAlways()
|
||||
);
|
||||
|
||||
Assert.NotNull(pick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatLimit_AppliesBeforeCooldown()
|
||||
{
|
||||
// Порядок проверяется через исчерпание: потолок повторов выбрасывает «давний» элемент ещё
|
||||
// до остывания, поэтому в вырожденном случае остаётся свежий. В обратном порядке остывание
|
||||
// оставило бы давний, и выбор был бы другим.
|
||||
var overCap = Element(
|
||||
lastPlayed: T0.AddDays(-10),
|
||||
recentPlays: [T0.AddDays(-10), T0.AddDays(-9), T0.AddDays(-8)]
|
||||
);
|
||||
var recent = Element(position: 1, lastPlayed: T0.AddHours(-1), recentPlays: [T0.AddHours(-1)]);
|
||||
|
||||
var slot = Slot([overCap, recent], repeatLimit: new RepeatLimit(WindowDays: 30, Max: 2)) with
|
||||
{
|
||||
Strategy = new PlanningStrategy(SlotStrategyKind.RandomWithCooldown, CooldownDays: 2),
|
||||
};
|
||||
|
||||
var pick = ElementSelector.Select(slot, T0, new FirstAlways());
|
||||
|
||||
Assert.NotNull(pick);
|
||||
Assert.Equal(recent.ElementId, pick.Element.ElementId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user