Files
ersatztv/ErsatzTV.Tests/Infrastructure/GraphicsElementDefaultAttachTests.cs
T
timothyandClaude Fable 5.1 7fa223a567 test(568): drop the different-kind case no single-clause mutation can redden, and measure the API-site clause
`Ignores_A_Same_Named_Element_Of_A_Different_Kind` seeded an Image row at
`/templates/image/on-now-next.yml` and asserted the backfill ignores it. Under the
bare-filename lookup that row was rejected by the `Kind == Text` filter alone, which is
what its comment described. Under the full-path predicate the path rejects it first, so
neither clause is load-bearing for it: dropping the `Kind` filter reddens only
`A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` (row 18) and
dropping `IsOnNowNext` reddens only the three tests of row 10. The test survived both and
its comment claimed a mechanism it no longer exercised. Its scenario is the conjunction of
two already-pinned negatives and is strictly weaker than
`Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder`, so it is retired rather
than reshaped, and graphics-elements.md now says why the combination is deliberately not
shipped — otherwise the next reader re-adds it.

Row 40 records the API-side half of the discriminator, which had a measured red and no row.
Measured whole-project on this tree, `dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj`:
baseline `Failed: 0, Passed: 2123, Skipped: 6, Total: 2129`; with
`BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path)` reverted to
`Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName`,
`Failed: 1, Passed: 2122`, the sole red being
`GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn`.
The table's two self-counts were recounted from the table after adding the row and both
still hold: sixteen rows redden more than one test, three of those span two fixture classes
(13, 22, 33).

Refs #568

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 21:35:15 +02:00

367 lines
17 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 = GraphicsElementDefaults.OnNowNextSeededPath,
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);
}
[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();
}
// #568: filename-only matching was folder-agnostic -- a user Text element named exactly
// "on-now-next.yml" outside the seeded text-template folder used to also count as built-in.
// Same Kind as the real seeded row, deliberately a different folder, so this only reddens if
// GetBuiltInElementId goes back to comparing Path.GetFileName(...) instead of the full path.
[Test]
public async Task Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement
{
Path = System.IO.Path.Combine(
"/config/graphics-elements/text/some-subfolder",
GraphicsElementDefaults.OnNowNextFileName),
Kind = GraphicsElementKind.Text
});
await context.SaveChangesAsync();
Channel channel = await SeedChannel(context, "1");
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
}
// #568: GetBuiltInElementId resolves through GraphicsElementDefaults.IsOnNowNext, an ORDINAL
// comparison, so a Text row in the seeded folder whose filename differs only in case is a
// different element. Reddens if that comparison is loosened to OrdinalIgnoreCase. It does NOT
// pin provider independence -- under SQLite's BINARY collation an equivalent SQL `Where` answers
// identically; that is held by keeping the comparison in memory (see IsOnNowNext's remarks).
[Test]
public async Task Ignores_A_Case_Variant_Of_The_Seeded_Path()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement
{
Path = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant()),
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();
}
// #568: EnsureBuiltInElementRow's "does it exist already?" test and the lookup every consumer
// resolves through must be the same question. While that test was its own SQL comparison
// (`e.Path == target`), a row at the seeded path of ANY kind answered yes, so the Text row
// GetBuiltInElementId actually matches was never created and the built-in element stayed
// undiscoverable for the life of the install.
//
// This is the half of that divergence SQLite can observe. The other half -- a case-variant path,
// which MySQL's normally case-insensitive default collation matches in SQL while the ordinal
// lookup never does -- cannot be shown from a test here, because SQLite's BINARY collation and
// an ordinal comparison agree on every input; it is held by keeping the comparison out of SQL.
//
// RefreshGraphicsElements derives Kind from the template FOLDER, so it cannot itself produce the
// wrong-kind row seeded below. The point is not that this state is common but that the seeder
// must not answer a question differently from the code that consumes its answer.
[Test]
public async Task A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row()
{
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.GraphicsElements.AddAsync(
new GraphicsElement { Path = target, Kind = GraphicsElementKind.Image });
await context.SaveChangesAsync();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
List<GraphicsElement> rows = await context.GraphicsElements.OrderBy(e => e.Id).ToListAsync();
rows.Count.ShouldBe(2, "the built-in Text row must be created alongside the wrong-kind row");
rows[1].Path.ShouldBe(target);
rows[1].Kind.ShouldBe(GraphicsElementKind.Text);
rows[1].Name.ShouldBe(GraphicsElementDefaults.OnNowNextName);
// The lookup must land on the Text row, not on the lower-id wrong-kind row that shares its
// path. Both clauses this test covers are measured rows of the mutation table in
// docs/graphics-elements.md -- 18 (the Kind filter) and 34 (the existence check re-derived
// in SQL) -- rather than an outcome asserted only here.
int builtInId = (await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None))
.IfNone(-1);
builtInId.ShouldBe(rows[1].Id);
}
// 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();
}
}