Enhance scheduling and trace functionalities: add CollectionId to ScheduleEntry and PlannedItem models, update related query handlers and frontend components to support collection information in entry traces. Improve data handling in scheduling logic and enhance user experience in trace display.
build / backend (push) Successful in 2m4s
build / frontend (push) Successful in 39s
tests / backend-tests (push) Successful in 2m10s

This commit is contained in:
Leonid Pershin
2026-07-26 14:59:54 +03:00
parent f699576582
commit 2445ba56b5
16 changed files with 2467 additions and 580 deletions
@@ -142,7 +142,8 @@ public sealed class GridScheduleGenerator(
item.SlotId, item.SlotId,
item.Trace is null item.Trace is null
? null ? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions) : JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
item.CollectionId
) )
); );
added++; added++;
@@ -27,6 +27,8 @@ public sealed record EntryTraceDto(
int? SlotDurationMinutes, int? SlotDurationMinutes,
string? GroupName, string? GroupName,
int? GroupItemCount, int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy, SlotStrategyKind? Strategy,
int? CooldownDays, int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary> /// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
@@ -41,6 +41,14 @@ public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
: null; : null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson); var trace = Parse(entry.TraceJson);
if (trace is null) if (trace is null)
return Result.Success( return Result.Success(
@@ -59,6 +67,7 @@ public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
null, null,
null, null,
null, null,
collectionName,
null, null,
null, null,
null, null,
@@ -114,6 +123,7 @@ public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
slot?.TargetDurationMinutes, slot?.TargetDurationMinutes,
group?.Name, group?.Name,
group?.ItemCount, group?.ItemCount,
collectionName,
trace.Strategy, trace.Strategy,
strategy?.CooldownDays, strategy?.CooldownDays,
trace.CandidatesAfterCooldown, trace.CandidatesAfterCooldown,
@@ -22,9 +22,15 @@ public class ScheduleEntry
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary> /// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; } public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — старая ротация либо служебная запись).</summary> /// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; } public Guid? SlotId { get; private set; }
/// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary>
public Guid? CollectionId { get; private set; }
/// <summary> /// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент /// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается /// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
@@ -44,7 +50,8 @@ public class ScheduleEntry
Guid? showId, Guid? showId,
int? episodeIndex, int? episodeIndex,
Guid? slotId, Guid? slotId,
string? traceJson string? traceJson,
Guid? collectionId = null
) => ) =>
new() new()
{ {
@@ -58,6 +65,7 @@ public class ScheduleEntry
EpisodeIndex = episodeIndex, EpisodeIndex = episodeIndex,
SlotId = slotId, SlotId = slotId,
TraceJson = traceJson, TraceJson = traceJson,
CollectionId = collectionId,
}; };
public static ScheduleEntry Program( public static ScheduleEntry Program(
+3 -2
View File
@@ -2,8 +2,9 @@ namespace TeleWave.Domain.Library;
/// <summary> /// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка. /// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; курсор показа на канале хранится /// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// отдельно на связке канал↔шоу (см. Broadcast/ChannelShow). /// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary> /// </summary>
public class Show public class Show
{ {
@@ -135,7 +135,9 @@ public sealed record PlannedItem(
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary> /// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
Guid? BumperTemplateId = null, Guid? BumperTemplateId = null,
Guid? FromShowId = null, Guid? FromShowId = null,
Guid? ToShowId = null Guid? ToShowId = null,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = null
); );
public enum PlannedItemKind public enum PlannedItemKind
@@ -333,7 +333,7 @@ public static class SchedulePlanner
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd)) if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break; break;
items.Add(Program(unit, cursor, slot.SlotId, slotTrace)); items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration; cursor += unit.Duration;
accumulated += unit.Duration; accumulated += unit.Duration;
unitIndex++; unitIndex++;
@@ -451,11 +451,16 @@ public static class SchedulePlanner
return cursor; return cursor;
} }
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program( private static PlannedItem Program(
PlanningUnit unit, PlanningUnit unit,
DateTimeOffset start, DateTimeOffset start,
Guid slotId, Guid slotId,
PlanTrace trace PlanTrace trace,
Guid? collectionId = null
) => ) =>
new( new(
unit.MediaAssetId, unit.MediaAssetId,
@@ -465,7 +470,8 @@ public static class SchedulePlanner
unit.UnitIndex, unit.UnitIndex,
slotId, slotId,
PlannedItemKind.Program, PlannedItemKind.Program,
trace trace,
CollectionId: collectionId
); );
private static PlanningCursorUpdate CursorUpdate( private static PlanningCursorUpdate CursorUpdate(
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryCollection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CollectionId",
table: "ScheduleEntries",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CollectionId",
table: "ScheduleEntries");
}
}
}
@@ -399,6 +399,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("ChannelId") b.Property<Guid>("ChannelId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid?>("CollectionId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc") b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -0,0 +1,290 @@
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext();
var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc)
.ToList();
Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False(verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges);
}
[SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added);
}
[SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id)
.ToHashSet();
Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id)
.ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext();
var programs = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.ToList();
Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
}
[SkippableFact]
public async Task Preview_WritesNothing()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True(verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges);
}
[SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains(
report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
);
await using var verify = fixture.CreateContext();
Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
);
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
{
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(db),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db),
random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
}
private sealed record World(
Guid ChannelId,
Guid TemplateId,
Guid SlotId,
Guid ShowId,
Guid? CollectionId
);
/// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary>
private static async Task<World> SeedAsync(
AppDbContext db,
int episodes,
bool asCollection = false
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++)
{
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id);
}
db.Shows.Add(show);
Collection? collection = null;
if (asCollection)
{
collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id);
db.Collections.Add(collection);
}
var group = Group.Create($"Группа {suffix}");
group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id
);
db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
);
channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
duration,
segmentSeconds: 2,
segmentCount: (int)(duration.TotalSeconds / 2),
width: 1920,
height: 1080,
videoCodec: "h264",
audioCodec: "aac",
relativePath: $"segments/{asset.Id}"
);
db.MediaAssets.Add(asset);
return asset;
}
}
@@ -0,0 +1,136 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
/// </summary>
[Collection("postgres")]
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new CopyTemplateCommandHandler(db).Handle(
new CopyTemplateCommand(sourceId, targetId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
Assert.Equal(1, result.Value.Layers);
Assert.Equal(1, result.Value.Slots);
Assert.Equal(1, result.Value.Junctions);
await using var verify = fixture.CreateContext();
var target = verify.Channels.Single(c => c.Id == targetId);
var copied = verify
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.Single(t => t.ChannelId == targetId);
// У приёмника ровно один шаблон, и канал смотрит именно на него.
Assert.Equal(copied.Id, target.TemplateId);
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
// Группы общие — ссылка переносится как есть, а не копией группы.
Assert.Equal(groupId, slot.GroupId);
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
Assert.Equal(junction.Id, slot.JunctionAfterId);
Assert.NotEqual(
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
slot.JunctionAfterId
);
}
[Fact]
public void Copy_ToItself_IsRejectedByValidator()
{
var validator = new CopyTemplateCommandValidator();
var id = Guid.NewGuid();
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
}
[SkippableFact]
public async Task Validate_ReportsEmptyGroupAndGridGap()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, _, _) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(sourceId),
default
);
Assert.True(result.IsSuccess);
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
}
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(AppDbContext db)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var group = Group.Create($"Группа {suffix}");
db.Groups.Add(group);
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
var junction = JunctionTemplate.Create(source.Id, "Прайм");
var ad = junction.AddElement(JunctionElementKind.Ad);
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
db.JunctionTemplates.Add(junction);
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
var layer = sourceTemplate.AddLayer("Прайм", 10);
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
slot.UpdateContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
junctionAfterId: junction.Id
);
sourceTemplate.SetDefaultJunction(junction.Id);
source.SetTemplate(sourceTemplate.Id);
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
target.SetTemplate(targetTemplate.Id);
db.Channels.AddRange(source, target);
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
await db.SaveChangesAsync();
return (source.Id, target.Id, group.Id);
}
}
+6 -1
View File
@@ -32,7 +32,12 @@
переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей, переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей,
часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено. часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено.
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck. **Проверено:** 142 доменных, 72 прикладных и 11 интеграционных тестов. Интеграционные поднимают
настоящий Postgres в контейнере, применяют к нему все миграции и гоняют конвейер генерации,
копирование шаблона и проверки сетки — то есть миграции и слой «Application ↔ EF» работают.
**Не проверено:** приложение ни разу не поднималось целиком, ни один экран не открывался в браузере,
на реальной базе с реальным контентом ничего не запускалось.
--- ---
@@ -69,6 +69,7 @@ export function EntryTraceDialog({
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}` ? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
: null} : null}
</Row> </Row>
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
<Row label={t('admin.channels.traceStrategy')}> <Row label={t('admin.channels.traceStrategy')}>
{data.strategy {data.strategy
? [ ? [
+2
View File
@@ -559,6 +559,8 @@ export type EntryTraceDto = {
slotDurationMinutes: number | null slotDurationMinutes: number | null
groupName: string | null groupName: string | null
groupItemCount: number | null groupItemCount: number | null
/** Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу. */
collectionName: string | null
strategy: SlotStrategyType | null strategy: SlotStrategyType | null
cooldownDays: number | null cooldownDays: number | null
candidatesAfterCooldown: number | null candidatesAfterCooldown: number | null
+2
View File
@@ -417,6 +417,7 @@ const resources = {
traceLayer: 'Слой', traceLayer: 'Слой',
traceSlot: 'Слот', traceSlot: 'Слот',
traceGroup: 'Группа', traceGroup: 'Группа',
traceCollection: 'Коллекция',
traceStrategy: 'Стратегия', traceStrategy: 'Стратегия',
traceJunction: 'Врезки', traceJunction: 'Врезки',
traceDrift: 'дрейф {{minutes}} мин', traceDrift: 'дрейф {{minutes}} мин',
@@ -1077,6 +1078,7 @@ const resources = {
traceLayer: 'Layer', traceLayer: 'Layer',
traceSlot: 'Slot', traceSlot: 'Slot',
traceGroup: 'Group', traceGroup: 'Group',
traceCollection: 'Collection',
traceStrategy: 'Strategy', traceStrategy: 'Strategy',
traceJunction: 'Breaks', traceJunction: 'Breaks',
traceDrift: 'drift {{minutes}} min', traceDrift: 'drift {{minutes}} min',