diff --git a/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/block.txt b/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/block.txt new file mode 100644 index 000000000..d642db2f2 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/block.txt @@ -0,0 +1,3 @@ +000 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Block Movie 01 +001 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Block Movie 02 +002 | 2026-01-16 09:00:00 - 2026-01-16 10:00:00 | None | Block Movie 03 diff --git a/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/classic-chronological.txt b/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/classic-chronological.txt new file mode 100644 index 000000000..39b55c851 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/classic-chronological.txt @@ -0,0 +1,72 @@ +000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Movie 01 +001 | 2026-01-15 00:30:00 - 2026-01-15 01:15:00 | None | Movie 02 +002 | 2026-01-15 01:15:00 - 2026-01-15 02:15:00 | None | Movie 03 +003 | 2026-01-15 02:15:00 - 2026-01-15 02:45:00 | None | Movie 04 +004 | 2026-01-15 02:45:00 - 2026-01-15 03:30:00 | None | Movie 05 +005 | 2026-01-15 03:30:00 - 2026-01-15 04:30:00 | None | Movie 06 +006 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Movie 01 +007 | 2026-01-15 05:00:00 - 2026-01-15 05:45:00 | None | Movie 02 +008 | 2026-01-15 05:45:00 - 2026-01-15 06:45:00 | None | Movie 03 +009 | 2026-01-15 06:45:00 - 2026-01-15 07:15:00 | None | Movie 04 +010 | 2026-01-15 07:15:00 - 2026-01-15 08:00:00 | None | Movie 05 +011 | 2026-01-15 08:00:00 - 2026-01-15 09:00:00 | None | Movie 06 +012 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Movie 01 +013 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Movie 02 +014 | 2026-01-15 10:15:00 - 2026-01-15 11:15:00 | None | Movie 03 +015 | 2026-01-15 11:15:00 - 2026-01-15 11:45:00 | None | Movie 04 +016 | 2026-01-15 11:45:00 - 2026-01-15 12:30:00 | None | Movie 05 +017 | 2026-01-15 12:30:00 - 2026-01-15 13:30:00 | None | Movie 06 +018 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Movie 01 +019 | 2026-01-15 14:00:00 - 2026-01-15 14:45:00 | None | Movie 02 +020 | 2026-01-15 14:45:00 - 2026-01-15 15:45:00 | None | Movie 03 +021 | 2026-01-15 15:45:00 - 2026-01-15 16:15:00 | None | Movie 04 +022 | 2026-01-15 16:15:00 - 2026-01-15 17:00:00 | None | Movie 05 +023 | 2026-01-15 17:00:00 - 2026-01-15 18:00:00 | None | Movie 06 +024 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Movie 01 +025 | 2026-01-15 18:30:00 - 2026-01-15 19:15:00 | None | Movie 02 +026 | 2026-01-15 19:15:00 - 2026-01-15 20:15:00 | None | Movie 03 +027 | 2026-01-15 20:15:00 - 2026-01-15 20:45:00 | None | Movie 04 +028 | 2026-01-15 20:45:00 - 2026-01-15 21:30:00 | None | Movie 05 +029 | 2026-01-15 21:30:00 - 2026-01-15 22:30:00 | None | Movie 06 +030 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Movie 01 +031 | 2026-01-15 23:00:00 - 2026-01-15 23:45:00 | None | Movie 02 +032 | 2026-01-15 23:45:00 - 2026-01-16 00:45:00 | None | Movie 03 +033 | 2026-01-16 00:45:00 - 2026-01-16 01:15:00 | None | Movie 04 +034 | 2026-01-16 01:15:00 - 2026-01-16 02:00:00 | None | Movie 05 +035 | 2026-01-16 02:00:00 - 2026-01-16 03:00:00 | None | Movie 06 +036 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Movie 01 +037 | 2026-01-16 03:30:00 - 2026-01-16 04:15:00 | None | Movie 02 +038 | 2026-01-16 04:15:00 - 2026-01-16 05:15:00 | None | Movie 03 +039 | 2026-01-16 05:15:00 - 2026-01-16 05:45:00 | None | Movie 04 +040 | 2026-01-16 05:45:00 - 2026-01-16 06:30:00 | None | Movie 05 +041 | 2026-01-16 06:30:00 - 2026-01-16 07:30:00 | None | Movie 06 +042 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Movie 01 +043 | 2026-01-16 08:00:00 - 2026-01-16 08:45:00 | None | Movie 02 +044 | 2026-01-16 08:45:00 - 2026-01-16 09:45:00 | None | Movie 03 +045 | 2026-01-16 09:45:00 - 2026-01-16 10:15:00 | None | Movie 04 +046 | 2026-01-16 10:15:00 - 2026-01-16 11:00:00 | None | Movie 05 +047 | 2026-01-16 11:00:00 - 2026-01-16 12:00:00 | None | Movie 06 +048 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Movie 01 +049 | 2026-01-16 12:30:00 - 2026-01-16 13:15:00 | None | Movie 02 +050 | 2026-01-16 13:15:00 - 2026-01-16 14:15:00 | None | Movie 03 +051 | 2026-01-16 14:15:00 - 2026-01-16 14:45:00 | None | Movie 04 +052 | 2026-01-16 14:45:00 - 2026-01-16 15:30:00 | None | Movie 05 +053 | 2026-01-16 15:30:00 - 2026-01-16 16:30:00 | None | Movie 06 +054 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Movie 01 +055 | 2026-01-16 17:00:00 - 2026-01-16 17:45:00 | None | Movie 02 +056 | 2026-01-16 17:45:00 - 2026-01-16 18:45:00 | None | Movie 03 +057 | 2026-01-16 18:45:00 - 2026-01-16 19:15:00 | None | Movie 04 +058 | 2026-01-16 19:15:00 - 2026-01-16 20:00:00 | None | Movie 05 +059 | 2026-01-16 20:00:00 - 2026-01-16 21:00:00 | None | Movie 06 +060 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Movie 01 +061 | 2026-01-16 21:30:00 - 2026-01-16 22:15:00 | None | Movie 02 +062 | 2026-01-16 22:15:00 - 2026-01-16 23:15:00 | None | Movie 03 +063 | 2026-01-16 23:15:00 - 2026-01-16 23:45:00 | None | Movie 04 +064 | 2026-01-16 23:45:00 - 2026-01-17 00:30:00 | None | Movie 05 +065 | 2026-01-17 00:30:00 - 2026-01-17 01:30:00 | None | Movie 06 +066 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Movie 01 +067 | 2026-01-17 02:00:00 - 2026-01-17 02:45:00 | None | Movie 02 +068 | 2026-01-17 02:45:00 - 2026-01-17 03:45:00 | None | Movie 03 +069 | 2026-01-17 03:45:00 - 2026-01-17 04:15:00 | None | Movie 04 +070 | 2026-01-17 04:15:00 - 2026-01-17 05:00:00 | None | Movie 05 +071 | 2026-01-17 05:00:00 - 2026-01-17 06:00:00 | None | Movie 06 diff --git a/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs b/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs new file mode 100644 index 000000000..0276d7bfc --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs @@ -0,0 +1,602 @@ +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 _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 options = new DbContextOptionsBuilder() + .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 regen in new Func[] { 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 Items, Dictionary Titles)>> build) + { + (List items, Dictionary 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 Items, Dictionary 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(), _dbContextFactory), + new TelevisionRepository(_dbContextFactory, NullLogger.Instance), + new ArtistRepository(_dbContextFactory), + Substitute.For(), + new MockFileSystem(), + Substitute.For(), + NullLogger.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 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 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 { 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(); + for (var i = 1; i <= 6; i++) + { + var movie = new Movie + { + MediaVersions = new List + { + new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } + }, + MovieMetadata = new List + { + 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().ToList() + }; + await context.Collections.AddAsync(collection, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + var scheduleItems = new List + { + 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 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.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 Items, Dictionary 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(), _dbContextFactory), + new TelevisionRepository(_dbContextFactory, NullLogger.Instance), + new ArtistRepository(_dbContextFactory), + Substitute.For(), + NullLogger.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 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 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 { 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(); + for (var i = 1; i <= 6; i++) + { + var movie = new Movie + { + MediaVersions = new List + { + new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } + }, + MovieMetadata = new List + { + 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().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 + { + 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() + }; + 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 GetBlockReferenceData(TvContext dbContext, int playoutId) + { + Channel channel = await dbContext.Channels + .AsNoTracking() + .Where(c => c.Playouts.Any(p => p.Id == playoutId)) + .FirstOrDefaultAsync(); + + List existingItems = await dbContext.PlayoutItems + .AsNoTracking() + .Where(pi => pi.PlayoutId == playoutId) + .ToListAsync(); + + List 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.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 items, Dictionary 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 options) : IDbContextFactory + { + public TvContext CreateDbContext() => + new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger.Instance)); + } +} diff --git a/docs/testing.md b/docs/testing.md index 81f7328b8..480ee5ddb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,25 +8,38 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points | Project | Covers | Notes | |---|---|---| | `ErsatzTV.Tests` | API controllers + MediatR handlers | In-memory SQLite fixture: a shared `SqliteConnection("Data Source=:memory:;Foreign Keys=False")` kept open + `EnsureCreatedAsync()` (**not** full migration replay) + `PRAGMA foreign_keys=OFF`, then seed; a tiny `IDbContextFactory` wraps `new TvContext(...)`. 828 tests currently. | -| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. 493 tests + 1 skipped. | +| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. 540 tests + 1 skipped under `TZ=UTC` (the Block playout golden additionally skips under a non-UTC `TZ`; see Golden-file nets). | | `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. | | `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). | | `web/` (vitest) | React SPA unit tests | 330 tests; run alongside typecheck + build (see below). | ## Golden-file nets -Two golden-file suites guard the Jellyfin-facing output formats: +Three golden-file suites guard the highest-value, most-subtle output: -- **M3U**: `ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs` (ersatztv#11) -- **XMLTV**: `ChannelGuideGoldenTests` (ersatztv#28) +- **M3U**: `ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs` (ersatztv#11) — env var `ETV_UPDATE_GOLDENS` +- **XMLTV**: `ChannelGuideGoldenTests` (ersatztv#28) — env var `ETV_UPDATE_GOLDENS` +- **Playout build**: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` (ersatztv#163) + — env var **`ETV_UPDATE_PLAYOUT_GOLDENS`** (deliberately separate from `ETV_UPDATE_GOLDENS` so + regenerating one net can't silently rewrite the other). Snapshots the `PlayoutItem`s each builder + produces over a pinned build window. Covers the **Classic** (`PlaybackOrder.Chronological`) and + **Block** builders; **Sequential (YAML)** + **Scripted** are tracked in ersatztv#381. The build reads + no wall clock — time enters only via the caller-supplied `start` — so a pinned `start` is fully + deterministic. Snapshots the raw `PlayoutItem.Start`/`Finish` (UTC), **not** the `*Offset` properties + (those call `.ToLocalTime()` and would make the golden machine-TZ dependent). The **Block** case is + TZ-sensitive by construction (`BlockPlayoutBuilder` maps template times via `TimeZoneInfo.Local`), so + it is guarded with `Assume.That(TimeZoneInfo.Local.BaseUtcOffset == Zero)`: it runs under `TZ=UTC` + (CI) and reports **inconclusive** (a graceful skip, not a failure) under any other TZ. A real TZ seam + for the block builder is ersatztv#380's scope. -Both locate their golden files via `[CallerFilePath]`. A missing golden is a hard fail, not a -skip. Regenerate via `ETV_UPDATE_GOLDENS=1 dotnet test ...`. +All three locate their golden files via `[CallerFilePath]`. A missing golden is a hard fail, not a +skip. Regenerate via `ETV_UPDATE_GOLDENS=1 dotnet test ...` (M3U/XMLTV) or +`ETV_UPDATE_PLAYOUT_GOLDENS=1 dotnet test ...` (playout build). -**Never set `ETV_UPDATE_GOLDENS` in CI or from an agent.** A golden diff during normal test runs -means the code broke the output format — regenerating to make the diff go away hides the bug -instead of fixing it. Only a human who has confirmed the format change is intentional should -regenerate. +**Never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS` in CI or from an agent.** A golden +diff during normal test runs means the code changed the output — regenerating to make the diff go +away hides the change instead of surfacing it. Only a human who has confirmed the change is +intentional should regenerate. ## Timezone independence