diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 6086016..86746cc 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -65,6 +65,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs index ba70347..54038a0 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs @@ -102,13 +102,18 @@ public sealed class ChannelDebugCollector( // с записанной лентой по нему видно, расходится ли расчёт с тем, что уже в эфире. var preview = await generator.PreviewAsync(channel.Id, now, horizonDays, cancellationToken); + // Дрейф считается здесь, а не глазами по двум файлам: «слот уехал» — первый вопрос + // к любой сетке, и сводить целевые времена с фактическими вручную приходилось каждый раз. + var drift = Drift(grid, entries); + return [ new DebugFile("README.md", Readme(channel, now, horizonDays)), new DebugFile( "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("template.json", TemplateFacts(template, groupNames, junctionNames)), new DebugFile("slot-states.json", StateFacts(states, slotTitles)), @@ -120,6 +125,120 @@ public sealed class ChannelDebugCollector( ]; } + /// + /// Экземпляр слота глазами разбора: куда он был назначен и что на этом месте вышло на самом деле. + /// + private sealed record SlotDrift( + ScheduledSlot Scheduled, + DateTimeOffset? ActualStartUtc, + DateTimeOffset? ActualEndUtc, + int Entries, + double Minutes + ) + { + public DateTimeOffset TargetEndUtc => + Scheduled.StartUtc.AddMinutes(Scheduled.Slot.TargetDurationMinutes); + + /// Расхождение старта, минуты; null — на месте слота вообще ничего не вышло. + public double? DriftMinutes => + ActualStartUtc is { } actual + ? Math.Round((actual - Scheduled.StartUtc).TotalMinutes, 1) + : null; + + /// Ушёл ли слот дальше собственного допуска — ровно то, о чём предупреждает планировщик. + public bool ExceedsTolerance => + DriftMinutes is { } drift && Math.Abs(drift) > Scheduled.Slot.MaxDriftMinutes; + } + + /// + /// Сводит сетку с лентой: для каждого экземпляра слота ищет записи, пересекающие его окно. + /// Пересечение, а не попадание старта: сетка эластичная, и кино объявленных 21:00 законно + /// начинается в 20:55 — по старту оно в своё окно не попало бы. + /// + private static IReadOnlyList Drift( + EffectiveGrid grid, + IReadOnlyList 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 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, + }), + }; + + /// + /// Свод по дрейфу. «Пустых» экземпляров считаем отдельно: слот без единой записи — это не нулевой + /// дрейф, а совсем другая беда, и смешивать их в одной цифре нельзя. + /// + private static object DriftSummary(IReadOnlyList 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) => $""" # Отладочный дамп канала «{channel.Name}» @@ -130,6 +249,7 @@ public sealed class ChannelDebugCollector( - `summary.json` — что за канал, сколько чего собралось, свод предупреждений. - `channel.json` — настройки канала: время, начало вещательных суток, эпоха, филлер. - `template.json` — сетка: слои, слоты, стратегии, правила канала. JSON-поля разобраны. + - `drift.json` — целевые времена слотов против фактических: где эфир уехал и насколько. - `slot-states.json` — курсоры слотов: на чём каждый слот остановился. - `effective-grid.json` — эффективная сетка на горизонт: экземпляры слотов по суткам с временами в UTC. `background` — перекрытые слоты фонового слоя. @@ -139,6 +259,17 @@ public sealed class ChannelDebugCollector( - `schedule.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{( channel.UtcOffsetMinutes < 0 ? "-" : "+" )}{Math.Abs(channel.UtcOffsetMinutes) / 60:00}:{Math.Abs(channel.UtcOffsetMinutes) @@ -152,7 +283,8 @@ public sealed class ChannelDebugCollector( int horizonDays, EffectiveGrid grid, IReadOnlyList entries, - PlanningResult? preview + PlanningResult? preview, + IReadOnlyList drift ) => new { @@ -179,6 +311,7 @@ public sealed class ChannelDebugCollector( Slots = template.Layers.Sum(l => l.Slots.Count), }, Grid = new { Instances = grid.Slots.Count, Background = grid.Background.Count }, + Drift = DriftSummary(drift), Tape = new { Entries = entries.Count, diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommandHandler.cs index 153e007..903c0fb 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommandHandler.cs @@ -21,6 +21,7 @@ namespace TeleWave.Application.Programming.Planning.DebugExport; public sealed class ExportChannelDebugCommandHandler( IAppDbContext dbContext, ChannelDebugCollector collector, + LibraryDebugCollector library, IDebugExportStore store ) : ICommandHandler> { @@ -58,10 +59,13 @@ public sealed class ExportChannelDebugCommandHandler( return Result.Failure(ChannelErrors.TemplateNotFound); var now = DateTimeOffset.UtcNow; + // Канал и библиотека собираются порознь: «что построилось» и «из чего было строить» — + // два разных вопроса, и второй задают, когда первый ответа не дал. var files = await collector.CollectAsync(channel, template, now, cancellationToken); + var libraryFiles = await library.CollectAsync(cancellationToken); var fileName = FileName(channel.Slug, now); - var content = Pack(files); + var content = Pack([.. files, .. libraryFiles]); var savedTo = await store.SaveAsync(fileName, content, cancellationToken); return Result.Success(new DebugExportDto(fileName, content, savedTo)); diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs new file mode 100644 index 0000000..8a2b2e0 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs @@ -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; + +/// +/// Библиотека в отладочном дампе: шоу с сериями, коллекции, ролики, блоки заставок и группы +/// вместе с их вычисленным составом. +/// +/// Отдельно от намеренно: тот отвечает на «что канал построил», +/// а этот — на «из чего было строить». Второй вопрос задаётся первым, когда в эфир не вышло то, +/// что ожидали: серии нет в шоу, ассет не дорезался, правило динамической группы поймало не то. +/// +public sealed class LibraryDebugCollector( + IAppDbContext dbContext, + DynamicGroupResolver dynamicResolver, + GroupElementResolver elementResolver +) +{ + public async Task> 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) + ), + ]; + } + + /// Ассет в дампе: этого хватает, чтобы понять, почему серия не пошла в эфир. + private sealed record AssetFacts( + Guid Id, + string OriginalFileName, + MediaAssetStatus Status, + TimeSpan? Duration, + int? SegmentCount, + int? Width, + int? Height, + string? ErrorMessage + ); + + /// + /// Шоу со всеми сериями: порядок, распознанные сезон и серия, состояние ассета каждой. + /// «Готовых» считаем отдельно — планировщик видит только их, и расхождение с общим числом + /// объясняет половину вопросов «почему серий меньше, чем файлов». + /// + private static object ShowFacts( + IReadOnlyList shows, + IReadOnlyDictionary genres, + IReadOnlyDictionary 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 collections, + IReadOnlyList shows, + IReadOnlyDictionary 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 + ), + }; + }), + }); + } + + /// + /// Ролики отдельным списком, хотя они те же шоу: в эфир они попадают только врезками стыка, + /// и когда реклама вдруг оказывается в программе, смотрят именно сюда. + /// + private static object InterstitialFacts( + IReadOnlyList shows, + IReadOnlyDictionary 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, + }; + }); + + /// + /// Медиа: свод по состояниям и поимённо всё, что не доехало до Ready. Готовые ассеты + /// перечислять незачем — они уже расписаны по сериям в library-shows. + /// + private static object MediaFacts( + IReadOnlyDictionary assets, + IReadOnlyList 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 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, + }), + }), + }); + + /// + /// Группы с обеих сторон: как настроены и что из этого вычислилось. Для динамической группы + /// это единственный способ увидеть состав — в таблице позиций его нет, он живёт в правиле, + /// и «почему в эфир пошло не то» без него не разобрать. + /// + /// Рядом лежит кэш статистики: если он разошёлся с вычисленным составом, значит библиотека + /// поменялась после последнего пересчёта — и цифры в карточке группы врут. + /// + private async Task GroupFactsAsync( + IReadOnlyList groups, + IReadOnlyDictionary showNames, + IReadOnlyList collections, + CancellationToken cancellationToken + ) + { + var collectionNames = collections.ToDictionary(c => c.Id, c => c.Name); + var facts = new List(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); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs index 94d5836..e2633f3 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs @@ -124,6 +124,7 @@ public class ChannelDebugExportTests generator, Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }) ), + new LibraryDebugCollector(db, GroupServices.Dynamic(db), new GroupElementResolver(db)), store ); } @@ -165,10 +166,17 @@ public class ChannelDebugExportTests Assert.Equal( [ "README.md", + "bumpers.json", "channel.json", + "drift.json", "effective-grid.json", + "groups-composition.json", "groups.json", "junctions.json", + "library-collections.json", + "library-interstitials.json", + "library-media.json", + "library-shows.json", "plan-preview.json", "schedule.json", "slot-states.json", @@ -195,6 +203,80 @@ public class ChannelDebugExportTests 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()) + .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(); + + 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] public async Task Export_DryRunGoesIntoTheDump() { diff --git a/debug-exports/.gitkeep b/debug-exports/.gitkeep deleted file mode 100644 index 28fd582..0000000 --- a/debug-exports/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# Сюда складываются архивы отладочного дампа канала (кнопка «Дебаг-экспорт» в админке). -# Содержимое в гит не едет — см. .gitignore. diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 77b5812..3a76ef5 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -771,10 +771,20 @@ ScheduleEntry — существующая сущность, не про запись, а про весь вход планировщика сразу. Кнопка в карточке канала собирает его архивом: настройки канала, шаблон со слотами и разобранными стратегиями, курсоры слотов, эффективная сетка на горизонт (включая перекрытые слоты фона), развёрнутые группы **вплоть до отдельных единиц**, -стыки с условиями, записанная лента с трейсами и сухой прогон от текущего состояния. Всё, что +стыки с условиями, записанная лента с трейсами и сухой прогон от текущего состояния. +Отдельным файлом — сведённый дрейф: целевое время каждого экземпляра слота против фактического, +с его собственным допуском и пометкой «ушёл дальше допуска». Это первый вопрос к любой сетке, +и сводить два файла руками при каждом разборе не должно быть нужно. Всё, что хранится JSON-строкой, разбирается в объекты, а рядом с каждым идентификатором стоит имя: дамп читают глазами чаще, чем кодом. +Второй половиной дампа идёт библиотека — то, **из чего** было строить: шоу целиком (тип, рейтинг, +жанры, все серии по порядку с распознанными сезоном и номером, состояние ассета каждой), коллекции +с порядком частей, ролики, свод по медиа с поимённым списком всего, что не доехало до `Ready`, +блоки заставок и группы вместе с **вычисленным** составом — у динамической он живёт в правиле, +и увидеть его иначе нельзя. Рядом с составом лежит кэш статистики группы: разошёлся с расчётом — +значит библиотека поменялась после последнего пересчёта, и цифры в карточке врут. + Сухой прогон в дампе принципиален: рядом с записанной лентой он отвечает на первый вопрос разбора — данные плохие или расчёт. Архив всегда уезжает в браузер, а копия ложится в каталог `Debug:ExportDirectory` (в разработке — папка `debug-exports/` репозитория, в гите игнорируется);