Merge pull request 'test(163): golden characterization tests for playout building (Classic + Block)' (#382) from feat/163-playout-goldens into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m40s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m3s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled

This commit was merged in pull request #382.
This commit is contained in:
2026-07-16 22:01:33 +00:00
4 changed files with 700 additions and 10 deletions
@@ -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
@@ -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
@@ -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<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));
}
}
+23 -10
View File
@@ -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