Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
289 lines
12 KiB
C#
289 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
[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<int> 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<Channel> 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<List<int>> AttachedElementIds(TvContext context, int channelId) =>
|
|
await context.Set<ChannelGraphicsElement>()
|
|
.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<ChannelGraphicsElement>()
|
|
.RemoveRange(context.Set<ChannelGraphicsElement>().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<GraphicsElement> 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<ChannelGraphicsElement>()
|
|
.RemoveRange(context.Set<ChannelGraphicsElement>().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();
|
|
}
|
|
}
|