Add LibraryDebugCollector and enhance ChannelDebugCollector for drift analysis
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 50s
ci / tests (push) Successful in 1m25s
ci / sonar (push) Successful in 3m47s

Introduced the LibraryDebugCollector to gather library-related data for channel exports. Updated the ChannelDebugCollector to calculate and include drift information, comparing scheduled and actual slot times. Enhanced the ExportChannelDebugCommandHandler to integrate library data into the export process. Added tests to verify the correct functionality of the new drift analysis and library export features, ensuring comprehensive debug data for channel analysis.
This commit is contained in:
Leonid Pershin
2026-07-31 03:18:24 +03:00
parent 9cd364b174
commit 789357481d
7 changed files with 622 additions and 6 deletions
@@ -65,6 +65,7 @@ public static class DependencyInjection
services.AddScoped<PostCheckRunner>(); services.AddScoped<PostCheckRunner>();
services.AddScoped<GridScheduleGenerator>(); services.AddScoped<GridScheduleGenerator>();
services.AddScoped<ChannelDebugCollector>(); services.AddScoped<ChannelDebugCollector>();
services.AddScoped<LibraryDebugCollector>();
return services; return services;
} }
@@ -102,13 +102,18 @@ public sealed class ChannelDebugCollector(
// с записанной лентой по нему видно, расходится ли расчёт с тем, что уже в эфире. // с записанной лентой по нему видно, расходится ли расчёт с тем, что уже в эфире.
var preview = await generator.PreviewAsync(channel.Id, now, horizonDays, cancellationToken); var preview = await generator.PreviewAsync(channel.Id, now, horizonDays, cancellationToken);
// Дрейф считается здесь, а не глазами по двум файлам: «слот уехал» — первый вопрос
// к любой сетке, и сводить целевые времена с фактическими вручную приходилось каждый раз.
var drift = Drift(grid, entries);
return return
[ [
new DebugFile("README.md", Readme(channel, now, horizonDays)), new DebugFile("README.md", Readme(channel, now, horizonDays)),
new DebugFile( new DebugFile(
"summary.json", "summary.json",
Summary(channel, template, now, horizonDays, grid, entries, preview) Summary(channel, template, now, horizonDays, grid, entries, preview, drift)
), ),
new DebugFile("drift.json", DriftFacts(drift)),
new DebugFile("channel.json", ChannelFacts(channel)), new DebugFile("channel.json", ChannelFacts(channel)),
new DebugFile("template.json", TemplateFacts(template, groupNames, junctionNames)), new DebugFile("template.json", TemplateFacts(template, groupNames, junctionNames)),
new DebugFile("slot-states.json", StateFacts(states, slotTitles)), new DebugFile("slot-states.json", StateFacts(states, slotTitles)),
@@ -120,6 +125,120 @@ public sealed class ChannelDebugCollector(
]; ];
} }
/// <summary>
/// Экземпляр слота глазами разбора: куда он был назначен и что на этом месте вышло на самом деле.
/// </summary>
private sealed record SlotDrift(
ScheduledSlot Scheduled,
DateTimeOffset? ActualStartUtc,
DateTimeOffset? ActualEndUtc,
int Entries,
double Minutes
)
{
public DateTimeOffset TargetEndUtc =>
Scheduled.StartUtc.AddMinutes(Scheduled.Slot.TargetDurationMinutes);
/// <summary>Расхождение старта, минуты; null — на месте слота вообще ничего не вышло.</summary>
public double? DriftMinutes =>
ActualStartUtc is { } actual
? Math.Round((actual - Scheduled.StartUtc).TotalMinutes, 1)
: null;
/// <summary>Ушёл ли слот дальше собственного допуска — ровно то, о чём предупреждает планировщик.</summary>
public bool ExceedsTolerance =>
DriftMinutes is { } drift && Math.Abs(drift) > Scheduled.Slot.MaxDriftMinutes;
}
/// <summary>
/// Сводит сетку с лентой: для каждого экземпляра слота ищет записи, пересекающие его окно.
/// Пересечение, а не попадание старта: сетка эластичная, и кино объявленных 21:00 законно
/// начинается в 20:55 — по старту оно в своё окно не попало бы.
/// </summary>
private static IReadOnlyList<SlotDrift> Drift(
EffectiveGrid grid,
IReadOnlyList<ScheduleEntry> entries
)
{
var bySlot = entries.Where(e => e.SlotId is not null).ToLookup(e => e.SlotId!.Value);
return
[
.. grid.Slots.Select(scheduled =>
{
var end = scheduled.StartUtc.AddMinutes(scheduled.Slot.TargetDurationMinutes);
var hits = bySlot[scheduled.Slot.Id]
.Where(e => e.StartsAtUtc < end && e.EndsAtUtc > scheduled.StartUtc)
.ToList();
return new SlotDrift(
scheduled,
hits.Count == 0 ? null : hits.Min(e => e.StartsAtUtc),
hits.Count == 0 ? null : hits.Max(e => e.EndsAtUtc),
hits.Count,
Math.Round(hits.Sum(e => (e.EndsAtUtc - e.StartsAtUtc).TotalMinutes), 1)
);
}),
];
}
private static object DriftFacts(IReadOnlyList<SlotDrift> drift) =>
new
{
Summary = DriftSummary(drift),
Instances = drift
.OrderBy(d => d.Scheduled.StartUtc)
.Select(d => new
{
d.Scheduled.Slot.Id,
d.Scheduled.Slot.Title,
d.Scheduled.BroadcastDate,
TargetStartUtc = d.Scheduled.StartUtc,
d.TargetEndUtc,
d.Scheduled.Slot.TargetDurationMinutes,
d.Scheduled.Slot.MaxDriftMinutes,
d.ActualStartUtc,
d.ActualEndUtc,
d.DriftMinutes,
d.ExceedsTolerance,
d.Entries,
d.Minutes,
}),
};
/// <summary>
/// Свод по дрейфу. «Пустых» экземпляров считаем отдельно: слот без единой записи — это не нулевой
/// дрейф, а совсем другая беда, и смешивать их в одной цифре нельзя.
/// </summary>
private static object DriftSummary(IReadOnlyList<SlotDrift> drift)
{
var measured = drift.Where(d => d.DriftMinutes is not null).ToList();
return new
{
Instances = drift.Count,
WithoutEntries = drift.Count - measured.Count,
ExceedingTolerance = measured.Count(d => d.ExceedsTolerance),
MaxAheadMinutes = measured.Count == 0 ? 0 : measured.Min(d => d.DriftMinutes!.Value),
MaxBehindMinutes = measured.Count == 0 ? 0 : measured.Max(d => d.DriftMinutes!.Value),
AverageAbsMinutes = measured.Count == 0
? 0
: Math.Round(measured.Average(d => Math.Abs(d.DriftMinutes!.Value)), 1),
Worst = measured
.Where(d => d.ExceedsTolerance)
.OrderByDescending(d => Math.Abs(d.DriftMinutes!.Value))
.Take(10)
.Select(d => new
{
d.Scheduled.Slot.Title,
TargetStartUtc = d.Scheduled.StartUtc,
d.ActualStartUtc,
d.DriftMinutes,
d.Scheduled.Slot.MaxDriftMinutes,
}),
};
}
private static string Readme(Channel channel, DateTimeOffset now, int horizonDays) => private static string Readme(Channel channel, DateTimeOffset now, int horizonDays) =>
$""" $"""
# Отладочный дамп канала «{channel.Name}» # Отладочный дамп канала «{channel.Name}»
@@ -130,6 +249,7 @@ public sealed class ChannelDebugCollector(
- `summary.json` — что за канал, сколько чего собралось, свод предупреждений. - `summary.json` — что за канал, сколько чего собралось, свод предупреждений.
- `channel.json` — настройки канала: время, начало вещательных суток, эпоха, филлер. - `channel.json` — настройки канала: время, начало вещательных суток, эпоха, филлер.
- `template.json` — сетка: слои, слоты, стратегии, правила канала. JSON-поля разобраны. - `template.json` — сетка: слои, слоты, стратегии, правила канала. JSON-поля разобраны.
- `drift.json` — целевые времена слотов против фактических: где эфир уехал и насколько.
- `slot-states.json` — курсоры слотов: на чём каждый слот остановился. - `slot-states.json` — курсоры слотов: на чём каждый слот остановился.
- `effective-grid.json` — эффективная сетка на горизонт: экземпляры слотов по суткам - `effective-grid.json` — эффективная сетка на горизонт: экземпляры слотов по суткам
с временами в UTC. `background` — перекрытые слоты фонового слоя. с временами в UTC. `background` — перекрытые слоты фонового слоя.
@@ -139,6 +259,17 @@ public sealed class ChannelDebugCollector(
- `schedule.json` — записанная лента с трейсом «почему это здесь». - `schedule.json` — записанная лента с трейсом «почему это здесь».
- `plan-preview.json` — сухой прогон от текущего состояния: что построилось бы сейчас. - `plan-preview.json` — сухой прогон от текущего состояния: что построилось бы сейчас.
Библиотека — то, из чего было строить:
- `library-shows.json` — шоу целиком: тип, рейтинг, жанры, все серии по порядку
с распознанными сезоном/номером и состоянием ассета каждой.
- `library-collections.json` — коллекции и порядок частей: по нему считается «часть N из M».
- `library-interstitials.json` — ролики: длительности и состояние.
- `library-media.json` — свод по ассетам и поимённо всё, что не доехало до Ready.
- `bumpers.json` — блоки заставок: подблоки, триггеры, строки, длина звука.
- `groups-composition.json` — группы как настроены и что из этого вычислилось
(у динамических состав живёт в правиле), плюс кэш статистики для сверки.
Времена везде UTC. Время канала = UTC{( Времена везде UTC. Время канала = UTC{(
channel.UtcOffsetMinutes < 0 ? "-" : "+" channel.UtcOffsetMinutes < 0 ? "-" : "+"
)}{Math.Abs(channel.UtcOffsetMinutes) / 60:00}:{Math.Abs(channel.UtcOffsetMinutes) )}{Math.Abs(channel.UtcOffsetMinutes) / 60:00}:{Math.Abs(channel.UtcOffsetMinutes)
@@ -152,7 +283,8 @@ public sealed class ChannelDebugCollector(
int horizonDays, int horizonDays,
EffectiveGrid grid, EffectiveGrid grid,
IReadOnlyList<ScheduleEntry> entries, IReadOnlyList<ScheduleEntry> entries,
PlanningResult? preview PlanningResult? preview,
IReadOnlyList<SlotDrift> drift
) => ) =>
new new
{ {
@@ -179,6 +311,7 @@ public sealed class ChannelDebugCollector(
Slots = template.Layers.Sum(l => l.Slots.Count), Slots = template.Layers.Sum(l => l.Slots.Count),
}, },
Grid = new { Instances = grid.Slots.Count, Background = grid.Background.Count }, Grid = new { Instances = grid.Slots.Count, Background = grid.Background.Count },
Drift = DriftSummary(drift),
Tape = new Tape = new
{ {
Entries = entries.Count, Entries = entries.Count,
@@ -21,6 +21,7 @@ namespace TeleWave.Application.Programming.Planning.DebugExport;
public sealed class ExportChannelDebugCommandHandler( public sealed class ExportChannelDebugCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
ChannelDebugCollector collector, ChannelDebugCollector collector,
LibraryDebugCollector library,
IDebugExportStore store IDebugExportStore store
) : ICommandHandler<ExportChannelDebugCommand, Result<DebugExportDto>> ) : ICommandHandler<ExportChannelDebugCommand, Result<DebugExportDto>>
{ {
@@ -58,10 +59,13 @@ public sealed class ExportChannelDebugCommandHandler(
return Result.Failure<DebugExportDto>(ChannelErrors.TemplateNotFound); return Result.Failure<DebugExportDto>(ChannelErrors.TemplateNotFound);
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
// Канал и библиотека собираются порознь: «что построилось» и «из чего было строить» —
// два разных вопроса, и второй задают, когда первый ответа не дал.
var files = await collector.CollectAsync(channel, template, now, cancellationToken); var files = await collector.CollectAsync(channel, template, now, cancellationToken);
var libraryFiles = await library.CollectAsync(cancellationToken);
var fileName = FileName(channel.Slug, now); var fileName = FileName(channel.Slug, now);
var content = Pack(files); var content = Pack([.. files, .. libraryFiles]);
var savedTo = await store.SaveAsync(fileName, content, cancellationToken); var savedTo = await store.SaveAsync(fileName, content, cancellationToken);
return Result.Success(new DebugExportDto(fileName, content, savedTo)); return Result.Success(new DebugExportDto(fileName, content, savedTo));
@@ -0,0 +1,388 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Planning.DebugExport;
/// <summary>
/// Библиотека в отладочном дампе: шоу с сериями, коллекции, ролики, блоки заставок и группы
/// вместе с их вычисленным составом.
///
/// Отдельно от <see cref="ChannelDebugCollector"/> намеренно: тот отвечает на «что канал построил»,
/// а этот — на «из чего было строить». Второй вопрос задаётся первым, когда в эфир не вышло то,
/// что ожидали: серии нет в шоу, ассет не дорезался, правило динамической группы поймало не то.
/// </summary>
public sealed class LibraryDebugCollector(
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver,
GroupElementResolver elementResolver
)
{
public async Task<IReadOnlyList<DebugFile>> CollectAsync(CancellationToken cancellationToken)
{
var shows = await dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Include(s => s.Genres)
.ToListAsync(cancellationToken);
var genres = await dbContext
.Genres.AsNoTracking()
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var assets = await dbContext
.MediaAssets.AsNoTracking()
.Select(a => new AssetFacts(
a.Id,
a.OriginalFileName,
a.Status,
a.Duration,
a.SegmentCount,
a.Width,
a.Height,
a.ErrorMessage
))
.ToDictionaryAsync(a => a.Id, cancellationToken);
var collections = await dbContext
.Collections.AsNoTracking()
.Include(c => c.Items)
.ToListAsync(cancellationToken);
var bumpers = await dbContext
.BumperTemplates.AsNoTracking()
.Include(b => b.Variants)
.ToListAsync(cancellationToken);
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.ToListAsync(cancellationToken);
var showNames = shows.ToDictionary(s => s.Id, s => s.Name);
return
[
new DebugFile("library-shows.json", ShowFacts(shows, genres, assets)),
new DebugFile("library-collections.json", CollectionFacts(collections, shows, assets)),
new DebugFile("library-interstitials.json", InterstitialFacts(shows, assets)),
new DebugFile("library-media.json", MediaFacts(assets, shows)),
new DebugFile("bumpers.json", BumperFacts(bumpers)),
new DebugFile(
"groups-composition.json",
await GroupFactsAsync(groups, showNames, collections, cancellationToken)
),
];
}
/// <summary>Ассет в дампе: этого хватает, чтобы понять, почему серия не пошла в эфир.</summary>
private sealed record AssetFacts(
Guid Id,
string OriginalFileName,
MediaAssetStatus Status,
TimeSpan? Duration,
int? SegmentCount,
int? Width,
int? Height,
string? ErrorMessage
);
/// <summary>
/// Шоу со всеми сериями: порядок, распознанные сезон и серия, состояние ассета каждой.
/// «Готовых» считаем отдельно — планировщик видит только их, и расхождение с общим числом
/// объясняет половину вопросов «почему серий меньше, чем файлов».
/// </summary>
private static object ShowFacts(
IReadOnlyList<Show> shows,
IReadOnlyDictionary<Guid, string> genres,
IReadOnlyDictionary<Guid, AssetFacts> assets
) =>
shows
.OrderBy(s => s.Kind)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(show =>
{
var episodes = show.Episodes.OrderBy(e => e.Position).ToList();
var ready = episodes
.Select(e => assets.GetValueOrDefault(e.MediaAssetId))
.Where(a => a is { Status: MediaAssetStatus.Ready, Duration: not null })
.ToList();
var seasons = episodes
.Where(e => e.Season is not null)
.Select(e => e.Season!.Value)
.Distinct()
.Order()
.ToList();
return new
{
show.Id,
show.Name,
show.OriginalName,
show.Kind,
show.Audience,
show.Year,
show.MetadataProvider,
show.MetadataExternalId,
show.FranchiseName,
Genres = show
.Genres.Select(g => new
{
g.GenreId,
Name = genres.GetValueOrDefault(g.GenreId),
g.IsPrimary,
})
.OrderByDescending(g => g.IsPrimary),
Episodes = episodes.Count,
ReadyEpisodes = ready.Count,
TotalMinutes = Math.Round(ready.Sum(a => a!.Duration!.Value.TotalMinutes), 1),
Seasons = seasons,
// Серии без распознанного номера идут в эфир по Position — это и есть их порядок,
// и по нему видно, почему «S02E05» вышла между первым и вторым сезоном.
UnnumberedEpisodes = episodes.Count(e => e.Season is null || e.Episode is null),
Items = episodes.Select(episode =>
{
var asset = assets.GetValueOrDefault(episode.MediaAssetId);
return new
{
episode.Position,
episode.Season,
episode.Episode,
episode.Title,
episode.AirDate,
episode.MediaAssetId,
File = asset?.OriginalFileName,
Status = asset?.Status,
Minutes = asset?.Duration is { } duration
? Math.Round(duration.TotalMinutes, 2)
: (double?)null,
asset?.SegmentCount,
Error = asset?.ErrorMessage,
};
}),
};
});
private static object CollectionFacts(
IReadOnlyList<Collection> collections,
IReadOnlyList<Show> shows,
IReadOnlyDictionary<Guid, AssetFacts> assets
)
{
var byId = shows.ToDictionary(s => s.Id);
return collections
.OrderBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(collection => new
{
collection.Id,
collection.Name,
Parts = collection.Items.Count,
// Порядок частей — это порядок показа франшизы: «часть 3 из 3» считается по нему.
Items = collection
.Items.OrderBy(i => i.Position)
.Select(item =>
{
var show = byId.GetValueOrDefault(item.ShowId);
var ready = show is null
? []
: show
.Episodes.Select(e => assets.GetValueOrDefault(e.MediaAssetId))
.Where(a =>
a is { Status: MediaAssetStatus.Ready, Duration: not null }
)
.ToList();
return new
{
item.Position,
item.ShowId,
Show = show?.Name,
show?.Kind,
show?.Audience,
ReadyEpisodes = ready.Count,
Minutes = Math.Round(
ready.Sum(a => a!.Duration!.Value.TotalMinutes),
1
),
};
}),
});
}
/// <summary>
/// Ролики отдельным списком, хотя они те же шоу: в эфир они попадают только врезками стыка,
/// и когда реклама вдруг оказывается в программе, смотрят именно сюда.
/// </summary>
private static object InterstitialFacts(
IReadOnlyList<Show> shows,
IReadOnlyDictionary<Guid, AssetFacts> assets
) =>
shows
.Where(s => s.Kind == ShowKind.Interstitial)
.OrderBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(show =>
{
var asset = show
.Episodes.Select(e => assets.GetValueOrDefault(e.MediaAssetId))
.FirstOrDefault();
return new
{
show.Id,
show.Name,
Episodes = show.Episodes.Count,
Seconds = asset?.Duration is { } duration
? Math.Round(duration.TotalSeconds, 1)
: (double?)null,
Status = asset?.Status,
File = asset?.OriginalFileName,
};
});
/// <summary>
/// Медиа: свод по состояниям и поимённо всё, что не доехало до Ready. Готовые ассеты
/// перечислять незачем — они уже расписаны по сериям в library-shows.
/// </summary>
private static object MediaFacts(
IReadOnlyDictionary<Guid, AssetFacts> assets,
IReadOnlyList<Show> shows
)
{
var attached = shows
.SelectMany(s => s.Episodes.Select(e => (e.MediaAssetId, s.Name)))
.ToDictionary(pair => pair.MediaAssetId, pair => pair.Name);
return new
{
Total = assets.Count,
ByStatus = assets
.Values.GroupBy(a => a.Status)
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
Orphans = assets.Values.Count(a => !attached.ContainsKey(a.Id)),
Problems = assets
.Values.Where(a => a.Status != MediaAssetStatus.Ready || a.Duration is null)
.OrderBy(a => a.OriginalFileName, StringComparer.CurrentCultureIgnoreCase)
.Select(a => new
{
a.Id,
a.OriginalFileName,
a.Status,
Show = attached.GetValueOrDefault(a.Id),
a.ErrorMessage,
}),
};
}
private static object BumperFacts(IReadOnlyList<BumperTemplate> bumpers) =>
bumpers
.OrderBy(b => b.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(bumper => new
{
bumper.Id,
bumper.Name,
bumper.AudioDurationSeconds,
bumper.BackgroundImageId,
bumper.Revision,
Variants = bumper.Variants.Select(variant => new
{
variant.Id,
variant.Name,
variant.Trigger,
variant.Weight,
variant.Background,
Lines = variant
.Lines.OrderBy(l => l.Position)
.Select(l => new
{
l.Position,
l.Style,
l.Color,
l.Text,
}),
}),
});
/// <summary>
/// Группы с обеих сторон: как настроены и что из этого вычислилось. Для динамической группы
/// это единственный способ увидеть состав — в таблице позиций его нет, он живёт в правиле,
/// и «почему в эфир пошло не то» без него не разобрать.
///
/// Рядом лежит кэш статистики: если он разошёлся с вычисленным составом, значит библиотека
/// поменялась после последнего пересчёта — и цифры в карточке группы врут.
/// </summary>
private async Task<object> GroupFactsAsync(
IReadOnlyList<Group> groups,
IReadOnlyDictionary<Guid, string> showNames,
IReadOnlyList<Collection> collections,
CancellationToken cancellationToken
)
{
var collectionNames = collections.ToDictionary(c => c.Id, c => c.Name);
var facts = new List<object>(groups.Count);
foreach (var group in groups.OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
{
var resolved = await dynamicResolver.ResolveAsync(group, cancellationToken);
var info = await elementResolver.ResolveAsync(
resolved.Select(e => (e.Kind, e.Id)),
cancellationToken
);
facts.Add(
new
{
group.Id,
group.Name,
group.Mode,
Filter = GroupFilter.FromJson(group.FilterJson),
StatsCache = new
{
group.ItemCount,
group.UnitCount,
group.TotalDuration,
group.StatsComputedAt,
},
Items = group
.Items.OrderBy(i => i.Position)
.Select(item => new
{
item.Position,
item.ElementKind,
item.ElementId,
Name = Name(item.ElementKind, item.ElementId),
item.Weight,
item.Role,
}),
Composition = resolved.Select(element => new
{
element.Kind,
element.Id,
Name = Name(element.Kind, element.Id),
element.Position,
element.Weight,
element.Pinned,
UnitCount = info.GetValueOrDefault((element.Kind, element.Id))?.UnitCount,
Minutes = info.GetValueOrDefault((element.Kind, element.Id))
is { } resolvedInfo
? Math.Round(resolvedInfo.TotalDuration.TotalMinutes, 1)
: (double?)null,
ShowKind = info.GetValueOrDefault((element.Kind, element.Id))?.ShowKind,
Audience = info.GetValueOrDefault((element.Kind, element.Id))?.Audience,
}),
}
);
}
return facts;
string? Name(GroupElementKind kind, Guid id) =>
kind == GroupElementKind.Collection
? collectionNames.GetValueOrDefault(id)
: showNames.GetValueOrDefault(id);
}
}
@@ -124,6 +124,7 @@ public class ChannelDebugExportTests
generator, generator,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }) Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 })
), ),
new LibraryDebugCollector(db, GroupServices.Dynamic(db), new GroupElementResolver(db)),
store store
); );
} }
@@ -165,10 +166,17 @@ public class ChannelDebugExportTests
Assert.Equal( Assert.Equal(
[ [
"README.md", "README.md",
"bumpers.json",
"channel.json", "channel.json",
"drift.json",
"effective-grid.json", "effective-grid.json",
"groups-composition.json",
"groups.json", "groups.json",
"junctions.json", "junctions.json",
"library-collections.json",
"library-interstitials.json",
"library-media.json",
"library-shows.json",
"plan-preview.json", "plan-preview.json",
"schedule.json", "schedule.json",
"slot-states.json", "slot-states.json",
@@ -195,6 +203,80 @@ public class ChannelDebugExportTests
Assert.Equal("Симпсоны", units[0].GetProperty("show").GetString()); Assert.Equal("Симпсоны", units[0].GetProperty("show").GetString());
} }
[Fact]
public async Task Export_DescribesTheLibraryItWasBuiltFrom()
{
// Вторая половина разбора: когда в эфир вышло не то, смотрят не на ленту, а на то,
// из чего её собирали — сколько у шоу серий, готовы ли ассеты, что дало правило группы.
var (fixture, channelId) = await SeedAsync();
await using var db = fixture.New();
var result = await Handler(db, Substitute.For<IDebugExportStore>())
.Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None);
Assert.True(result.IsSuccess);
var files = Unpack(result.Value.Content);
using var shows = JsonDocument.Parse(files["library-shows.json"]);
var show = shows.RootElement.EnumerateArray().Single();
Assert.Equal("Симпсоны", show.GetProperty("name").GetString());
Assert.Equal(2, show.GetProperty("episodes").GetInt32());
Assert.Equal(2, show.GetProperty("readyEpisodes").GetInt32());
// Серии перечислены по порядку показа — по нему планировщик их и ставит.
var items = show.GetProperty("items").EnumerateArray().ToList();
Assert.Equal(2, items.Count);
Assert.Equal("s01e01.mkv", items[0].GetProperty("file").GetString());
Assert.Equal("Ready", items[0].GetProperty("status").GetString());
using var groups = JsonDocument.Parse(files["groups-composition.json"]);
var group = groups.RootElement.EnumerateArray().Single();
Assert.Equal("Мультсериалы", group.GetProperty("name").GetString());
// Состав вычислен, а не переписан из позиций: у динамической группы он только так и виден.
var composition = group.GetProperty("composition").EnumerateArray().Single();
Assert.Equal("Симпсоны", composition.GetProperty("name").GetString());
Assert.Equal(2, composition.GetProperty("unitCount").GetInt32());
using var media = JsonDocument.Parse(files["library-media.json"]);
Assert.Equal(2, media.RootElement.GetProperty("total").GetInt32());
Assert.Empty(media.RootElement.GetProperty("problems").EnumerateArray());
}
[Fact]
public async Task Export_MeasuresDriftAgainstTheGrid()
{
// «Слот уехал» — первый вопрос к любой сетке, и сводить целевые времена с фактическими
// вручную по двум файлам приходилось каждый раз.
var (fixture, channelId) = await SeedAsync();
var store = Substitute.For<IDebugExportStore>();
await using var db = fixture.New();
var result = await Handler(db, store)
.Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None);
Assert.True(result.IsSuccess);
var files = Unpack(result.Value.Content);
using var drift = JsonDocument.Parse(files["drift.json"]);
var summary = drift.RootElement.GetProperty("summary");
var instances = drift.RootElement.GetProperty("instances").EnumerateArray().ToList();
Assert.NotEmpty(instances);
Assert.Equal(instances.Count, summary.GetProperty("instances").GetInt32());
// Ленты ещё нет — значит и дрейф мерить не по чему: это «без записей», а не нулевой сдвиг.
Assert.Equal(instances.Count, summary.GetProperty("withoutEntries").GetInt32());
Assert.All(
instances,
i =>
{
Assert.Equal(JsonValueKind.Null, i.GetProperty("actualStartUtc").ValueKind);
Assert.Equal(JsonValueKind.Null, i.GetProperty("driftMinutes").ValueKind);
Assert.False(i.GetProperty("exceedsTolerance").GetBoolean());
Assert.True(i.GetProperty("maxDriftMinutes").GetInt32() > 0);
}
);
}
[Fact] [Fact]
public async Task Export_DryRunGoesIntoTheDump() public async Task Export_DryRunGoesIntoTheDump()
{ {
-2
View File
@@ -1,2 +0,0 @@
# Сюда складываются архивы отладочного дампа канала (кнопка «Дебаг-экспорт» в админке).
# Содержимое в гит не едет — см. .gitignore.
+11 -1
View File
@@ -771,10 +771,20 @@ ScheduleEntry — существующая сущность,
не про запись, а про весь вход планировщика сразу. Кнопка в карточке канала собирает его архивом: не про запись, а про весь вход планировщика сразу. Кнопка в карточке канала собирает его архивом:
настройки канала, шаблон со слотами и разобранными стратегиями, курсоры слотов, эффективная сетка настройки канала, шаблон со слотами и разобранными стратегиями, курсоры слотов, эффективная сетка
на горизонт (включая перекрытые слоты фона), развёрнутые группы **вплоть до отдельных единиц**, на горизонт (включая перекрытые слоты фона), развёрнутые группы **вплоть до отдельных единиц**,
стыки с условиями, записанная лента с трейсами и сухой прогон от текущего состояния. Всё, что стыки с условиями, записанная лента с трейсами и сухой прогон от текущего состояния.
Отдельным файлом — сведённый дрейф: целевое время каждого экземпляра слота против фактического,
с его собственным допуском и пометкой «ушёл дальше допуска». Это первый вопрос к любой сетке,
и сводить два файла руками при каждом разборе не должно быть нужно. Всё, что
хранится JSON-строкой, разбирается в объекты, а рядом с каждым идентификатором стоит имя: дамп хранится JSON-строкой, разбирается в объекты, а рядом с каждым идентификатором стоит имя: дамп
читают глазами чаще, чем кодом. читают глазами чаще, чем кодом.
Второй половиной дампа идёт библиотека — то, **из чего** было строить: шоу целиком (тип, рейтинг,
жанры, все серии по порядку с распознанными сезоном и номером, состояние ассета каждой), коллекции
с порядком частей, ролики, свод по медиа с поимённым списком всего, что не доехало до `Ready`,
блоки заставок и группы вместе с **вычисленным** составом — у динамической он живёт в правиле,
и увидеть его иначе нельзя. Рядом с составом лежит кэш статистики группы: разошёлся с расчётом —
значит библиотека поменялась после последнего пересчёта, и цифры в карточке врут.
Сухой прогон в дампе принципиален: рядом с записанной лентой он отвечает на первый вопрос Сухой прогон в дампе принципиален: рядом с записанной лентой он отвечает на первый вопрос
разбора — данные плохие или расчёт. Архив всегда уезжает в браузер, а копия ложится в каталог разбора — данные плохие или расчёт. Архив всегда уезжает в браузер, а копия ложится в каталог
`Debug:ExportDirectory` (в разработке — папка `debug-exports/` репозитория, в гите игнорируется); `Debug:ExportDirectory` (в разработке — папка `debug-exports/` репозитория, в гите игнорируется);