Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 40s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 7m33s
Follow-up to the cold adversarial review of PR #382 (MERGEABLE-WITH-NITS): - Block TZ guard: BaseUtcOffset -> GetUtcOffset(Start). BaseUtcOffset is zero year-round for DST zones like Europe/London, so it would pass in a summer-dated fixture where London != UTC; GetUtcOffset pins the actual build instant and is correct regardless of fixture date. (Safe today — mid-Jan fixture — but removes the latent fixture-date dependency the reviewer flagged.) - Document the determinism invariants the fixture relies on: golden captures raw builder output (pre-trim AddedItems), Classic is TZ-independent for the captured fields (no guard needed), and ResetPlayout's random Seed can't perturb the Chronological fixture (RandomStartPoint/ShuffleScheduleItems false, distinct release dates). Re-verified: TZ=UTC both goldens pass; TZ=America/New_York Block skips, Classic passes; no golden drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
603 lines
25 KiB
C#
603 lines
25 KiB
C#
using System.Globalization;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Text;
|
||
using ErsatzTV.Core;
|
||
using ErsatzTV.Core.Domain;
|
||
using ErsatzTV.Core.Domain.Scheduling;
|
||
using ErsatzTV.Core.Interfaces.Metadata;
|
||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||
using ErsatzTV.Core.Interfaces.Search;
|
||
using ErsatzTV.Core.Scheduling;
|
||
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||
using ErsatzTV.Infrastructure;
|
||
using ErsatzTV.Infrastructure.Data;
|
||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||
using Microsoft.Data.Sqlite;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using NSubstitute;
|
||
using NUnit.Framework;
|
||
using Shouldly;
|
||
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;
|
||
|
||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||
|
||
// Golden-file characterization tests that lock the output of the playout builders — the core
|
||
// scheduling surface that turns a ProgramSchedule/Block calendar + Collection into a concrete list of
|
||
// PlayoutItems (issue #163). This slice covers the Classic builder (PlaybackOrder.Chronological) and
|
||
// the Block builder. Sequential (YAML) + Scripted goldens are tracked as a follow-up in #381 (they need
|
||
// a YAML fixture / an external-process harness respectively — not just a clock seam).
|
||
//
|
||
// This is the scheduling counterpart to ChannelPlaylistGoldenTests (#11, M3U) and
|
||
// ChannelGuideGoldenTests (#28, XMLTV). Goldens live under Goldens/Goldens/ and are regenerated via
|
||
// the Regenerate_goldens test or ETV_UPDATE_PLAYOUT_GOLDENS=1 — review the diff before committing.
|
||
//
|
||
// DETERMINISM: time enters the build ONLY via the pinned start (finish = start + 2 days); no wall clock
|
||
// is read. We snapshot the raw PlayoutItem.Start/Finish (DateTime, treated as UTC) — NOT the *Offset
|
||
// properties, which call .ToLocalTime() and would make the golden machine-timezone dependent.
|
||
//
|
||
// A few determinism invariants worth stating so a future reader doesn't "helpfully" break them:
|
||
// * We snapshot the builder's raw output (buildResult.AddedItems), NOT the persisted playout. With
|
||
// TrimStart, production would delete items before RemoveBefore (~start - 4h), so the golden's early
|
||
// lines are pre-trim. That is intentional: this locks the BUILDER's output, and it is deterministic.
|
||
// * Classic is TZ-independent for the captured fields (its internal DateTime->offset conversions only
|
||
// gate the day-by-day loop; the anchor carries currentTime forward as UTC), so it needs no TZ guard —
|
||
// unlike Block below. Do not add/remove a guard without re-checking this.
|
||
// * ResetPlayout randomizes playout.Seed, but the classic fixture neutralizes it: RandomStartPoint and
|
||
// ShuffleScheduleItems both default false and Chronological orders by (distinct) release date, so the
|
||
// seed cannot perturb output. Introducing release-date ties or flipping those flags would reintroduce
|
||
// nondeterminism.
|
||
[TestFixture]
|
||
public class PlayoutBuildGoldenTests
|
||
{
|
||
// Pinned build window — deterministic, UTC, no wall-clock dependency.
|
||
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
|
||
|
||
private SqliteConnection _connection;
|
||
private IDbContextFactory<TvContext> _dbContextFactory;
|
||
|
||
[OneTimeSetUp]
|
||
public async Task SetUpDatabase()
|
||
{
|
||
// Shared in-memory SQLite: the connection must stay open for the DB to live across contexts.
|
||
_connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
|
||
await _connection.OpenAsync();
|
||
|
||
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
|
||
.UseSqlite(_connection)
|
||
.Options;
|
||
|
||
_dbContextFactory = new TestTvContextFactory(options);
|
||
|
||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||
|
||
// EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than
|
||
// replaying every migration. The MediaCollectionRepository's Dapper queries run against it fine.
|
||
await context.Database.EnsureCreatedAsync();
|
||
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
|
||
}
|
||
|
||
[OneTimeTearDown]
|
||
public void TearDownDatabase() => _connection?.Dispose();
|
||
|
||
[Test]
|
||
public Task Classic_chronological() => Verify("classic-chronological.txt", BuildChronologicalPlayout);
|
||
|
||
[Test]
|
||
public Task Block_playout() => Verify("block.txt", BuildBlockPlayout);
|
||
|
||
[Test]
|
||
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
|
||
public async Task Regenerate_goldens()
|
||
{
|
||
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", "1");
|
||
try
|
||
{
|
||
foreach (Func<Task> regen in new Func<Task>[] { Classic_chronological, Block_playout })
|
||
{
|
||
try
|
||
{
|
||
await regen();
|
||
}
|
||
catch (InconclusiveException)
|
||
{
|
||
// expected — Verify writes its golden then reports inconclusive
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", null);
|
||
}
|
||
}
|
||
|
||
// --- harness ---
|
||
|
||
private async Task Verify(
|
||
string goldenName,
|
||
Func<Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)>> build)
|
||
{
|
||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await build();
|
||
|
||
string actual = Canonicalize(Snapshot(items, titles));
|
||
|
||
string path = Path.Combine(GoldenDir(), goldenName);
|
||
|
||
if (Environment.GetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS") == "1")
|
||
{
|
||
Directory.CreateDirectory(GoldenDir());
|
||
await File.WriteAllTextAsync(path, actual);
|
||
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
|
||
return;
|
||
}
|
||
|
||
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI.
|
||
File.Exists(path).ShouldBeTrue(
|
||
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_PLAYOUT_GOLDENS=1) and commit it.");
|
||
|
||
string expected = Canonicalize(await File.ReadAllTextAsync(path));
|
||
actual.ShouldBe(expected);
|
||
}
|
||
|
||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildChronologicalPlayout()
|
||
{
|
||
var cancellationToken = CancellationToken.None;
|
||
|
||
// Seed a fresh, deterministic dataset for this build. Titles + release dates are fixed and the
|
||
// durations vary (30/45/60) so the chronological ordering and item boundaries are visible.
|
||
var (playoutId, titles) = await SeedData(cancellationToken);
|
||
|
||
var builder = new PlayoutBuilder(
|
||
new ConfigElementRepository(_dbContextFactory),
|
||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||
new ArtistRepository(_dbContextFactory),
|
||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||
new MockFileSystem(),
|
||
Substitute.For<IRerunHelper>(),
|
||
NullLogger<PlayoutBuilder>.Instance);
|
||
|
||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||
|
||
Playout playout = await context.Playouts
|
||
.Include(p => p.ProgramScheduleAnchors)
|
||
.ThenInclude(a => a.EnumeratorState)
|
||
.Include(p => p.FillGroupIndices)
|
||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||
|
||
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
|
||
|
||
// Build ONCE with Reset over the pinned 2-day window (internal overload = explicit start/finish).
|
||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||
playout,
|
||
referenceData,
|
||
PlayoutBuildResult.Empty,
|
||
PlayoutBuildMode.Reset,
|
||
Start,
|
||
Start.AddDays(2),
|
||
cancellationToken);
|
||
|
||
PlayoutBuildResult buildResult = result.Match(
|
||
r => r,
|
||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||
|
||
return (buildResult.AddedItems, titles);
|
||
}
|
||
|
||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
|
||
{
|
||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||
|
||
var path = new LibraryPath { Path = "Test LibraryPath" };
|
||
var library = new LocalLibrary
|
||
{
|
||
MediaKind = LibraryMediaKind.Movies,
|
||
Paths = new List<LibraryPath> { path },
|
||
MediaSource = new LocalMediaSource()
|
||
};
|
||
await context.Libraries.AddAsync(library, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
// Six movies, fixed titles + release dates, varied durations to make ordering/boundaries visible.
|
||
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
|
||
var movies = new List<Movie>();
|
||
for (var i = 1; i <= 6; i++)
|
||
{
|
||
var movie = new Movie
|
||
{
|
||
MediaVersions = new List<MediaVersion>
|
||
{
|
||
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
|
||
},
|
||
MovieMetadata = new List<MovieMetadata>
|
||
{
|
||
new()
|
||
{
|
||
Title = $"Movie {i:D2}",
|
||
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
|
||
}
|
||
},
|
||
LibraryPath = path,
|
||
LibraryPathId = path.Id
|
||
};
|
||
movies.Add(movie);
|
||
}
|
||
|
||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||
|
||
var collection = new Collection
|
||
{
|
||
Name = "Test Collection",
|
||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||
};
|
||
await context.Collections.AddAsync(collection, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var scheduleItems = new List<ProgramScheduleItem>
|
||
{
|
||
new ProgramScheduleItemDuration
|
||
{
|
||
Collection = collection,
|
||
CollectionId = collection.Id,
|
||
CollectionType = CollectionType.Collection,
|
||
PlayoutDuration = TimeSpan.FromHours(1),
|
||
TailMode = TailMode.None,
|
||
PlaybackOrder = PlaybackOrder.Chronological
|
||
}
|
||
};
|
||
|
||
var ffmpegProfile = new FFmpegProfile { Name = "Test FFmpeg Profile" };
|
||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
||
{
|
||
Name = "Test Channel",
|
||
Number = "1",
|
||
FFmpegProfile = ffmpegProfile,
|
||
FFmpegProfileId = ffmpegProfile.Id
|
||
};
|
||
await context.Channels.AddAsync(channel, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var schedule = new ProgramSchedule { Name = "Test Schedule", Items = scheduleItems };
|
||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var playout = new Playout
|
||
{
|
||
Channel = channel,
|
||
ChannelId = channel.Id,
|
||
ProgramSchedule = schedule,
|
||
ProgramScheduleId = schedule.Id,
|
||
ScheduleKind = PlayoutScheduleKind.Classic
|
||
};
|
||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
return (playout.Id, titles);
|
||
}
|
||
|
||
private static async Task<PlayoutReferenceData> GetReferenceData(TvContext dbContext, int playoutId)
|
||
{
|
||
Channel channel = await dbContext.Channels
|
||
.AsNoTracking()
|
||
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
|
||
.FirstOrDefaultAsync();
|
||
|
||
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
|
||
.AsNoTracking()
|
||
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
|
||
.Include(ps => ps.Items)
|
||
.ThenInclude(psi => psi.Collection)
|
||
.Include(ps => ps.Items)
|
||
.ThenInclude(psi => psi.MediaItem)
|
||
.FirstOrDefaultAsync();
|
||
|
||
return new PlayoutReferenceData(
|
||
channel,
|
||
Option<Deco>.None,
|
||
[],
|
||
[],
|
||
programSchedule,
|
||
[],
|
||
[],
|
||
TimeSpan.Zero);
|
||
}
|
||
|
||
// --- Block builder ---
|
||
//
|
||
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
|
||
// EffectiveBlock.GetEffectiveBlocks(..., TimeZoneInfo.Local, ...), so its output is machine-timezone
|
||
// dependent. This is a CHARACTERIZATION test: rather than change production code to inject the zone
|
||
// (that seam is issue #380's scope), we capture the golden under UTC and GUARD with Assume.That so the
|
||
// test RUNS under TZ=UTC (CI) and reports INCONCLUSIVE (a graceful skip, not a failure) under any other
|
||
// TZ — mirroring ChannelPlaylistGoldenTests' GuardVolatileEnvironment. Classic + other TZ-independent
|
||
// goldens are unaffected.
|
||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildBlockPlayout()
|
||
{
|
||
// Guard on the offset AT the build instant (GetUtcOffset(Start)), not BaseUtcOffset: the latter is
|
||
// zero for DST zones like Europe/London year-round, so it would pass in a summer-dated fixture where
|
||
// London != UTC. GetUtcOffset pins the actual instant and stays correct regardless of fixture date.
|
||
Assume.That(
|
||
TimeZoneInfo.Local.GetUtcOffset(Start),
|
||
Is.EqualTo(TimeSpan.Zero),
|
||
"Block golden is captured under UTC; run with TZ=UTC. A real TZ seam is issue #380's scope.");
|
||
|
||
var cancellationToken = CancellationToken.None;
|
||
|
||
var (playoutId, titles) = await SeedBlockData(cancellationToken);
|
||
|
||
var builder = new BlockPlayoutBuilder(
|
||
new ConfigElementRepository(_dbContextFactory),
|
||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||
new ArtistRepository(_dbContextFactory),
|
||
Substitute.For<ICollectionEtag>(),
|
||
NullLogger<BlockPlayoutBuilder>.Instance);
|
||
|
||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||
|
||
Playout playout = await context.Playouts
|
||
.Include(p => p.ProgramScheduleAnchors)
|
||
.ThenInclude(a => a.EnumeratorState)
|
||
.Include(p => p.FillGroupIndices)
|
||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||
|
||
PlayoutReferenceData referenceData = await GetBlockReferenceData(context, playoutId);
|
||
|
||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||
Start,
|
||
playout,
|
||
referenceData,
|
||
PlayoutBuildMode.Reset,
|
||
cancellationToken);
|
||
|
||
PlayoutBuildResult buildResult = result.Match(
|
||
r => r,
|
||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||
|
||
return (buildResult.AddedItems, titles);
|
||
}
|
||
|
||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedBlockData(
|
||
CancellationToken cancellationToken)
|
||
{
|
||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||
|
||
var path = new LibraryPath { Path = "Block LibraryPath" };
|
||
var library = new LocalLibrary
|
||
{
|
||
MediaKind = LibraryMediaKind.Movies,
|
||
Paths = new List<LibraryPath> { path },
|
||
MediaSource = new LocalMediaSource()
|
||
};
|
||
await context.Libraries.AddAsync(library, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
// Six movies, fixed titles + release dates, varied durations so chronological ordering and block
|
||
// boundaries are visible across the scheduled blocks.
|
||
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
|
||
var movies = new List<Movie>();
|
||
for (var i = 1; i <= 6; i++)
|
||
{
|
||
var movie = new Movie
|
||
{
|
||
MediaVersions = new List<MediaVersion>
|
||
{
|
||
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
|
||
},
|
||
MovieMetadata = new List<MovieMetadata>
|
||
{
|
||
new()
|
||
{
|
||
Title = $"Block Movie {i:D2}",
|
||
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
|
||
}
|
||
},
|
||
LibraryPath = path,
|
||
LibraryPathId = path.Id
|
||
};
|
||
movies.Add(movie);
|
||
}
|
||
|
||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||
|
||
var collection = new Collection
|
||
{
|
||
Name = "Block Test Collection",
|
||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||
};
|
||
await context.Collections.AddAsync(collection, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
// A single 60-minute block with three chronological items over the same collection. With
|
||
// AfterDurationEnd, each block fills until currentTime passes the block finish; history carries the
|
||
// chronological cursor across the blocks scheduled on successive days.
|
||
var blockGroup = new BlockGroup { Name = "Block Test Group" };
|
||
await context.BlockGroups.AddAsync(blockGroup, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var block = new Block
|
||
{
|
||
BlockGroup = blockGroup,
|
||
BlockGroupId = blockGroup.Id,
|
||
Name = "Test Block",
|
||
Minutes = 60,
|
||
StopScheduling = BlockStopScheduling.AfterDurationEnd,
|
||
Items = new List<BlockItem>
|
||
{
|
||
new()
|
||
{
|
||
Index = 1,
|
||
CollectionType = CollectionType.Collection,
|
||
Collection = collection,
|
||
CollectionId = collection.Id,
|
||
PlaybackOrder = PlaybackOrder.Chronological
|
||
},
|
||
new()
|
||
{
|
||
Index = 2,
|
||
CollectionType = CollectionType.Collection,
|
||
Collection = collection,
|
||
CollectionId = collection.Id,
|
||
PlaybackOrder = PlaybackOrder.Chronological
|
||
},
|
||
new()
|
||
{
|
||
Index = 3,
|
||
CollectionType = CollectionType.Collection,
|
||
Collection = collection,
|
||
CollectionId = collection.Id,
|
||
PlaybackOrder = PlaybackOrder.Chronological
|
||
}
|
||
}
|
||
};
|
||
await context.Blocks.AddAsync(block, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var templateGroup = new TemplateGroup { Name = "Template Test Group" };
|
||
await context.TemplateGroups.AddAsync(templateGroup, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var template = new Template
|
||
{
|
||
TemplateGroup = templateGroup,
|
||
TemplateGroupId = templateGroup.Id,
|
||
Name = "Test Template",
|
||
Items = new List<TemplateItem>()
|
||
};
|
||
template.Items.Add(new TemplateItem
|
||
{
|
||
Block = block,
|
||
BlockId = block.Id,
|
||
StartTime = TimeSpan.FromHours(9)
|
||
});
|
||
await context.Templates.AddAsync(template, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var ffmpegProfile = new FFmpegProfile { Name = "Block FFmpeg Profile" };
|
||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000002"))
|
||
{
|
||
Name = "Block Test Channel",
|
||
Number = "2",
|
||
FFmpegProfile = ffmpegProfile,
|
||
FFmpegProfileId = ffmpegProfile.Id
|
||
};
|
||
await context.Channels.AddAsync(channel, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var playout = new Playout
|
||
{
|
||
Channel = channel,
|
||
ChannelId = channel.Id,
|
||
ScheduleKind = PlayoutScheduleKind.Block
|
||
};
|
||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
var playoutTemplate = new PlayoutTemplate
|
||
{
|
||
Playout = playout,
|
||
PlayoutId = playout.Id,
|
||
Template = template,
|
||
TemplateId = template.Id,
|
||
Index = 1,
|
||
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
|
||
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
|
||
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
|
||
};
|
||
await context.PlayoutTemplates.AddAsync(playoutTemplate, cancellationToken);
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
|
||
return (playout.Id, titles);
|
||
}
|
||
|
||
private static async Task<PlayoutReferenceData> GetBlockReferenceData(TvContext dbContext, int playoutId)
|
||
{
|
||
Channel channel = await dbContext.Channels
|
||
.AsNoTracking()
|
||
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
|
||
.FirstOrDefaultAsync();
|
||
|
||
List<PlayoutItem> existingItems = await dbContext.PlayoutItems
|
||
.AsNoTracking()
|
||
.Where(pi => pi.PlayoutId == playoutId)
|
||
.ToListAsync();
|
||
|
||
List<PlayoutTemplate> playoutTemplates = await dbContext.PlayoutTemplates
|
||
.AsNoTracking()
|
||
.Where(pt => pt.PlayoutId == playoutId)
|
||
.Include(t => t.Template)
|
||
.ThenInclude(t => t.Items)
|
||
.ThenInclude(i => i.Block)
|
||
.ThenInclude(b => b.Items)
|
||
.Include(t => t.DecoTemplate)
|
||
.ThenInclude(t => t.Items)
|
||
.ThenInclude(i => i.Deco)
|
||
.ToListAsync();
|
||
|
||
return new PlayoutReferenceData(
|
||
channel,
|
||
Option<Deco>.None,
|
||
existingItems,
|
||
playoutTemplates,
|
||
null,
|
||
[],
|
||
[],
|
||
TimeSpan.Zero);
|
||
}
|
||
|
||
// One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
|
||
// serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
|
||
private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
|
||
{
|
||
var ordered = items
|
||
.OrderBy(i => i.Start)
|
||
.ThenBy(i => i.MediaItemId)
|
||
.ToList();
|
||
|
||
var sb = new StringBuilder();
|
||
for (var index = 0; index < ordered.Count; index++)
|
||
{
|
||
PlayoutItem item = ordered[index];
|
||
string title = titles.TryGetValue(item.MediaItemId, out string t) ? t : $"#{item.MediaItemId}";
|
||
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
|
||
sb.Append(" | ");
|
||
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
||
sb.Append(" - ");
|
||
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
||
sb.Append(" | ");
|
||
sb.Append(item.FillerKind.ToString());
|
||
sb.Append(" | ");
|
||
sb.Append(title);
|
||
sb.Append('\n');
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static string Canonicalize(string text) =>
|
||
text.TrimStart('').ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
|
||
|
||
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
|
||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
|
||
|
||
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
|
||
{
|
||
public TvContext CreateDbContext() =>
|
||
new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
||
}
|
||
}
|