using ErsatzTV.Core.Domain; using GraphicsElement = ErsatzTV.Core.Domain.GraphicsElement; using ErsatzTV.Core.Graphics; using ErsatzTV.FFmpeg.State; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Streaming.Graphics; using ErsatzTV.Tests.Support; using Microsoft.EntityFrameworkCore; using System.IO.Abstractions; using Microsoft.Extensions.Logging.Abstractions; using Testably.Abstractions.Testing; using ErsatzTV.Core; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Infrastructure; /// /// #732 part 2: the On Now / Next overlay is a default rather than an opt-in. Channels that predate /// that decision are backfilled once -- and only once, so a channel an operator deliberately clears /// is never silently re-attached on the next restart. /// [TestFixture] public class GraphicsElementDefaultAttachTests { private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); private static async Task SeedBuiltInElement(TvContext context) { var element = new GraphicsElement { Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}", Name = "On Now / Next", Kind = GraphicsElementKind.Text }; await context.GraphicsElements.AddAsync(element); await context.SaveChangesAsync(); return element.Id; } private static async Task SeedChannel( TvContext context, string number, StreamingMode mode = StreamingMode.HttpLiveStreamingSegmenter) { var channel = new Channel(Guid.NewGuid()) { Name = $"Channel {number}", Number = number, StreamingMode = mode, ChannelGraphicsElements = [] }; await context.Channels.AddAsync(channel); await context.SaveChangesAsync(); return channel; } private static async Task> AttachedElementIds(TvContext context, int channelId) => await context.Set() .AsNoTracking() .Where(cge => cge.ChannelId == channelId) .Select(cge => cge.GraphicsElementId) .ToListAsync(); [Test] public async Task Attaches_The_Built_In_Element_To_Existing_Channels() { await using TvContext context = _db.CreateContext(); int elementId = await SeedBuiltInElement(context); Channel one = await SeedChannel(context, "1"); Channel two = await SeedChannel(context, "2"); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, one.Id)).ShouldBe([elementId]); (await AttachedElementIds(context, two.Id)).ShouldBe([elementId]); } [Test] public async Task Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render() { await using TvContext context = _db.CreateContext(); await SeedBuiltInElement(context); Channel direct = await SeedChannel(context, "1", StreamingMode.HttpLiveStreamingDirect); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, direct.Id)).ShouldBeEmpty(); } [Test] public async Task Does_Not_Duplicate_An_Existing_Attachment() { await using TvContext context = _db.CreateContext(); int elementId = await SeedBuiltInElement(context); Channel channel = await SeedChannel(context, "1"); await context.AddAsync( new ChannelGraphicsElement { ChannelId = channel.Id, GraphicsElementId = elementId }); await context.SaveChangesAsync(); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1); } // The load-bearing property: a default must not fight the operator. [Test] public async Task Does_Not_Re_Attach_After_An_Operator_Clears_It() { await using TvContext context = _db.CreateContext(); await SeedBuiltInElement(context); Channel channel = await SeedChannel(context, "1"); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1); // operator turns the overlay off for this channel context.Set() .RemoveRange(context.Set().Where(cge => cge.ChannelId == channel.Id)); await context.SaveChangesAsync(); // ...and the app restarts await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, channel.Id)).ShouldBeEmpty(); } // The upgrade population this backfill exists for -- an install seeding the template for the // first time on this boot -- must not be stranded. The GraphicsElement row is normally created // by RefreshGraphicsElements, which runs long after startup, so the seeder ensures it itself. // Without that, the marker would be written against an unresolved element and every pre-existing // channel would go permanently unattached. [Test] public async Task Backfills_On_The_Same_Boot_That_First_Seeds_The_Template() { var fs = new MockFileSystem(); await using TvContext context = _db.CreateContext(); Channel channel = await SeedChannel(context, "1"); // no ConfigElement markers and no GraphicsElement row: a pre-#74 install meeting #732 await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1); } // Filename alone is ambiguous: the five template folders are separate namespaces, so an element // of another kind may legitimately carry the same filename. [Test] public async Task Ignores_A_Same_Named_Element_Of_A_Different_Kind() { await using TvContext context = _db.CreateContext(); await context.GraphicsElements.AddAsync( new GraphicsElement { Path = $"/templates/image/{GraphicsElementDefaults.OnNowNextFileName}", Kind = GraphicsElementKind.Image }); await context.SaveChangesAsync(); Channel channel = await SeedChannel(context, "1"); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, channel.Id)).ShouldBeEmpty(); } [Test] public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename() { await using TvContext context = _db.CreateContext(); await context.GraphicsElements.AddAsync( new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text }); await context.SaveChangesAsync(); Channel channel = await SeedChannel(context, "1"); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, channel.Id)).ShouldBeEmpty(); } // The marker is permanent, so writing it with nothing resolved would strand every channel. Stay // armed instead and pick the work up once the element exists. [Test] public async Task Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach() { await using TvContext context = _db.CreateContext(); Channel channel = await SeedChannel(context, "1"); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await context.ConfigElements .AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key)) .ShouldBeFalse("the marker was written with nothing to attach"); // the element turns up later; the backfill must still do its job await SeedBuiltInElement(context); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1); } // The already-seeded branch of SeedOnNowNext has its own EnsureBuiltInElementRow call. Removing // it reddened nothing until this test existed -- the other tests all exercise a FRESH seed. [Test] public async Task An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One() { var fs = new MockFileSystem(); fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder); string target = Path.Combine( FileSystemLayout.GraphicsElementsTextTemplatesFolder, GraphicsElementDefaults.OnNowNextFileName); await fs.File.WriteAllTextAsync(target, "name: On Now / Next\n"); await using TvContext context = _db.CreateContext(); context.ConfigElements.Add( new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" }); await context.SaveChangesAsync(); (await context.GraphicsElements.CountAsync()).ShouldBe(0); await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None); // Assert the ROW, not just that one exists: a lookup-only assertion is satisfied by a row // with a null Name, which sorts the built-in element into the unnamed bucket in the SPA. List rows = await context.GraphicsElements.ToListAsync(); rows.Count.ShouldBe(1); rows[0].Path.ShouldBe(target); rows[0].Kind.ShouldBe(GraphicsElementKind.Text); rows[0].Name.ShouldBe(GraphicsElementDefaults.OnNowNextName); (await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None)).IsSome.ShouldBeTrue(); } // The armed path is a real, reachable state: an operator deletes the template, refresh reaps the // row, and the backfill then has nothing to resolve. Pin what happens when the element comes // back -- a single global marker cannot both avoid stranding and avoid re-adding, and this is // the half we accept. See graphics.on-now-next-on-by-default. [Test] public async Task While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel() { await using TvContext context = _db.CreateContext(); Channel kept = await SeedChannel(context, "1"); Channel cleared = await SeedChannel(context, "2"); // nothing to resolve yet -> stays armed, no marker await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); (await context.ConfigElements .AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key)) .ShouldBeFalse(); // The element reappears and is attached to BOTH channels; the operator then clears `cleared`. // Doing the attach-then-remove for real matters: seeding `cleared` with no join at all would // only prove an untouched channel gets backfilled, which is not the claim. int elementId = await SeedBuiltInElement(context); await context.AddAsync(new ChannelGraphicsElement { ChannelId = kept.Id, GraphicsElementId = elementId }); await context.AddAsync(new ChannelGraphicsElement { ChannelId = cleared.Id, GraphicsElementId = elementId }); await context.SaveChangesAsync(); (await AttachedElementIds(context, cleared.Id)).Count.ShouldBe(1); context.Set() .RemoveRange(context.Set().Where(cge => cge.ChannelId == cleared.Id)); await context.SaveChangesAsync(); (await AttachedElementIds(context, cleared.Id)).ShouldBeEmpty(); await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None); // documented consequence: the still-armed backfill cannot see that deliberate clear (await AttachedElementIds(context, kept.Id)).Count.ShouldBe(1); (await AttachedElementIds(context, cleared.Id)).Count.ShouldBe(1); // ...but it is now marked, so it never fires again (await context.ConfigElements .AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key)) .ShouldBeTrue(); } }