`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
367 lines
17 KiB
C#
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();
|
|
}
|
|
}
|