Files
ersatztv/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs
T
timothyandClaude Opus 4.8 1131ecacfb
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
test(163): apply review nits — instant-accurate Block TZ guard + determinism notes
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>
2026-07-16 23:47:54 +02:00

603 lines
25 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}