feat(732): On Now / Next gets a background box, and is on by default (#843)
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
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>
This commit was merged in pull request #843.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// #732: the On Now / Next overlay is a default rather than an opt-in, so every newly created channel
|
||||
/// gets the built-in element attached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This lives in one place because there is more than one channel-creation path and they diverged
|
||||
/// once already: <c>CreateChannelHandler</c> had it and <c>CreateChannelFromLineupHandler</c> -- the
|
||||
/// SPA's primary "Add Channel" flow, and the one Auto-Tune bulk-creates through -- did not. Any new
|
||||
/// site that persists a <c>Channel</c> must call this. The third site, <c>DbInitializer</c>'s default
|
||||
/// channel, needs no call: it runs before <c>AttachOnNowNextByDefault</c> in the same startup, so the
|
||||
/// backfill covers it.
|
||||
/// </remarks>
|
||||
public static class ChannelGraphicsDefaults
|
||||
{
|
||||
public static async Task Attach(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
// HLS Direct is skipped because ErsatzTV is not transcoding there -- there is no frame
|
||||
// pipeline to draw into, and the editor disables the toggle for the same reason. Identity is
|
||||
// the element's filename, never its user-editable Name (the #67 lesson).
|
||||
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Option<int> maybeElementId =
|
||||
await GraphicsElementSeeder.GetBuiltInElementId(dbContext, cancellationToken);
|
||||
|
||||
foreach (int elementId in maybeElementId)
|
||||
{
|
||||
// Add rather than assign: a future create path that carries graphics ids would otherwise
|
||||
// be silently discarded here.
|
||||
channel.ChannelGraphicsElements ??= [];
|
||||
channel.ChannelGraphicsElements.Add(new ChannelGraphicsElement { GraphicsElementId = elementId });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ public class CreateChannelFromLineupHandler(
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ChannelGraphicsDefaults.Attach(dbContext, prepared.Channel, cancellationToken);
|
||||
dbContext.Channels.Add(prepared.Channel);
|
||||
if (prepared.Playlist is not null)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
@@ -35,7 +36,8 @@ public class CreateChannelHandler(
|
||||
Right: async logoPath =>
|
||||
{
|
||||
ApplyResolvedLogo(request, channel, logoPath);
|
||||
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
|
||||
return Right<BaseError, CreateChannelResult>(
|
||||
await PersistChannel(dbContext, channel, cancellationToken));
|
||||
},
|
||||
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
|
||||
},
|
||||
@@ -75,8 +77,12 @@ public class CreateChannelHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
private async Task<CreateChannelResult> PersistChannel(
|
||||
TvContext dbContext,
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ChannelGraphicsDefaults.Attach(dbContext, channel, cancellationToken);
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
@@ -26,6 +26,9 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
|
||||
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
|
||||
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
|
||||
|
||||
public static ConfigElementKey GraphicsOnNowNextDefaultAttached =>
|
||||
new("graphics.on_now_next_default_attached");
|
||||
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
|
||||
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
|
||||
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
|
||||
|
||||
@@ -4,4 +4,7 @@ public static class GraphicsElementDefaults
|
||||
{
|
||||
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
|
||||
public const string OnNowNextFileName = "on-now-next.yml";
|
||||
|
||||
// Display name only. Never use it for identity -- that is the filename above (#67 / #74).
|
||||
public const string OnNowNextName = "On Now / Next";
|
||||
}
|
||||
|
||||
@@ -31,6 +31,28 @@ public class TextGraphicsElement : BaseGraphicsElement
|
||||
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)]
|
||||
public int? ZIndex { get; set; }
|
||||
|
||||
// Background box (ersatztv#732). Element-level, not per-style: the graphics engine renders one
|
||||
// TextBlock into one bitmap, so a single box behind the whole element is the only shape the
|
||||
// renderer can express. Unset background_color means no FILL; a border_color alone still draws
|
||||
// an outlined box. With neither there is no box and no insets -- the pre-#732 geometry.
|
||||
[YamlMember(Alias = "background_color", ApplyNamingConventions = false)]
|
||||
public string BackgroundColor { get; set; }
|
||||
|
||||
[YamlMember(Alias = "background_opacity_percent", ApplyNamingConventions = false)]
|
||||
public int? BackgroundOpacityPercent { get; set; }
|
||||
|
||||
[YamlMember(Alias = "background_padding", ApplyNamingConventions = false)]
|
||||
public double? BackgroundPadding { get; set; }
|
||||
|
||||
[YamlMember(Alias = "background_corner_radius", ApplyNamingConventions = false)]
|
||||
public double? BackgroundCornerRadius { get; set; }
|
||||
|
||||
[YamlMember(Alias = "border_color", ApplyNamingConventions = false)]
|
||||
public string BorderColor { get; set; }
|
||||
|
||||
[YamlMember(Alias = "border_width", ApplyNamingConventions = false)]
|
||||
public double? BorderWidth { get; set; }
|
||||
|
||||
public List<StyleDefinition> Styles { get; set; } = [];
|
||||
|
||||
[YamlMember(Alias = "base_style", ApplyNamingConventions = false)]
|
||||
|
||||
@@ -4,12 +4,16 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public static class GraphicsElementSeeder
|
||||
{
|
||||
private const string OnNowNextYaml =
|
||||
// The pre-#732 default, kept verbatim so an installation still carrying it byte-for-byte can
|
||||
// be recognised as unmodified and upgraded. Never edit an entry here -- it is a fingerprint of
|
||||
// what we shipped, not a template. Add a new entry when the current default changes again.
|
||||
private const string OnNowNextYamlV1 =
|
||||
"""
|
||||
name: On Now / Next
|
||||
epg_entries: 2
|
||||
@@ -51,18 +55,88 @@ public static class GraphicsElementSeeder
|
||||
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
|
||||
{
|
||||
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
if (alreadySeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
private const string OnNowNextYaml =
|
||||
"""
|
||||
name: On Now / Next
|
||||
epg_entries: 2
|
||||
location: BottomLeft
|
||||
horizontal_margin_percent: 4
|
||||
vertical_margin_percent: 8
|
||||
width_percent: 42
|
||||
text_fit: Wrap
|
||||
text_align: Left
|
||||
z_index: 100
|
||||
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
|
||||
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
|
||||
# #732: a translucent box carries legibility over both bright and dark content. The halo is
|
||||
# cut from 2 to 1 rather than dropped -- the box is translucent, so bright content still
|
||||
# shows through behind the glyphs, but 2px of halo ON TOP of a box over-darkens the text.
|
||||
background_color: "#000000"
|
||||
background_opacity_percent: 65
|
||||
background_padding: 14
|
||||
background_corner_radius: 8
|
||||
# A translucent black box vanishes into dark content, so the box needs an edge of its own.
|
||||
# Low-alpha white reads as a hairline on dark frames without becoming a hard line on bright ones.
|
||||
border_color: "#59FFFFFF"
|
||||
border_width: 1
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
font_family: "Noto Sans"
|
||||
font_size: 30
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
- name: sub
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
- name: next
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
text: |
|
||||
[now]NOW {{ Epg[0].Title }}[/now]
|
||||
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
|
||||
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
// Every default we have ever shipped, most recent first. A file matching one of these was
|
||||
// written by us and never touched, so replacing it is an upgrade rather than a clobber.
|
||||
private static readonly string[] SupersededDefaults = [OnNowNextYamlV1];
|
||||
|
||||
public static async Task SeedOnNowNext(
|
||||
TvContext context,
|
||||
IFileSystem fileSystem,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
|
||||
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
|
||||
|
||||
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
|
||||
if (alreadySeeded)
|
||||
{
|
||||
// Already-seeded installations never revisit the file, so a change to the default would
|
||||
// otherwise reach new databases only. Upgrade the ones still carrying an untouched
|
||||
// earlier default; anything an operator edited no longer matches and is left alone.
|
||||
//
|
||||
// Deliberately no CreateDirectory on this branch: before #732 it touched the filesystem
|
||||
// not at all, so an already-seeded install stays bootable on a read-only /config.
|
||||
await UpgradeUnmodifiedTemplate(fileSystem, target, logger, cancellationToken);
|
||||
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileSystem.Directory.Exists(folder))
|
||||
{
|
||||
fileSystem.Directory.CreateDirectory(folder);
|
||||
@@ -78,5 +152,218 @@ public static class GraphicsElementSeeder
|
||||
new ConfigElement { Key = seededKey, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `RefreshGraphicsElements` is what normally turns a template file into a `GraphicsElement` row,
|
||||
/// but it runs on the scheduler/stream-start path -- long after startup. Creating the row here
|
||||
/// removes that ordering dependency, so `AttachOnNowNextByDefault` below can never mark itself
|
||||
/// done against an element that simply had not been discovered yet.
|
||||
/// </summary>
|
||||
private static async Task EnsureBuiltInElementRow(
|
||||
TvContext context,
|
||||
IFileSystem fileSystem,
|
||||
string target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!fileSystem.File.Exists(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Name is display-only (identity is the filename), but leaving it null sorts the built-in
|
||||
// element into the unnamed bucket at the bottom of the SPA list until the first refresh.
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new Core.Domain.GraphicsElement
|
||||
{
|
||||
Path = target,
|
||||
Kind = GraphicsElementKind.Text,
|
||||
Name = GraphicsElementDefaults.OnNowNextName
|
||||
},
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #732: the On Now / Next overlay is a default, not an opt-in. Existing channels predate that
|
||||
/// decision, so attach the built-in element to them once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The marker is written only once the built-in element RESOLVES, so an install whose row does
|
||||
/// not exist yet is retried on the next startup rather than stranded permanently. Once written,
|
||||
/// no channel is ever re-attached. While still armed the backfill cannot tell a deliberately
|
||||
/// cleared channel from an untouched one -- a single global flag cannot express both
|
||||
/// properties; see <c>graphics.on-now-next-on-by-default</c> for why that trade is made this
|
||||
/// way. Every channel created after the marker gets the element from
|
||||
/// <c>ChannelGraphicsDefaults.Attach</c> instead, which BOTH create paths call.
|
||||
/// </remarks>
|
||||
public static async Task AttachOnNowNextByDefault(TvContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
string key = ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key;
|
||||
if (await context.ConfigElements.AnyAsync(c => c.Key == key, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Option<int> maybeElementId = await GetBuiltInElementId(context, cancellationToken);
|
||||
if (maybeElementId.IsNone)
|
||||
{
|
||||
// Nothing to attach TO. Writing the marker here would strand every channel permanently
|
||||
// on the one population this exists for, so stay armed and try again next startup.
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int elementId in maybeElementId)
|
||||
{
|
||||
// HLS Direct has no frame pipeline to draw into, so an attachment there would be inert
|
||||
// while still reading as "on" in the editor.
|
||||
List<int> channelIds = await context.Channels
|
||||
.Where(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
.Where(c => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != elementId))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (int channelId in channelIds)
|
||||
{
|
||||
await context.AddAsync(
|
||||
new ChannelGraphicsElement { ChannelId = channelId, GraphicsElementId = elementId },
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await context.ConfigElements.AddAsync(
|
||||
new ConfigElement { Key = key, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity is the filename, never the user-editable Name (the #67 lesson carried into #74).
|
||||
/// The Kind is part of it: the five template folders are separate namespaces, so an unrelated
|
||||
/// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and
|
||||
/// filename alone would hand back whichever row the unordered query happened to return first.
|
||||
/// </summary>
|
||||
public static async Task<Option<int>> GetBuiltInElementId(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<(int Id, string Path)> candidates = await context.GraphicsElements
|
||||
.Where(e => e.Kind == GraphicsElementKind.Text)
|
||||
.Select(e => new { e.Id, e.Path })
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(rows => rows.Select(r => (r.Id, r.Path)).ToList());
|
||||
|
||||
var matches = candidates
|
||||
.Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName)
|
||||
.OrderBy(c => c.Id)
|
||||
.ToList();
|
||||
|
||||
// Lowest id wins if two text templates somehow share the filename, so the choice is stable
|
||||
// across restarts rather than dependent on query order.
|
||||
return matches.Count == 0 ? Option<int>.None : matches[0].Id;
|
||||
}
|
||||
|
||||
private static async Task UpgradeUnmodifiedTemplate(
|
||||
IFileSystem fileSystem,
|
||||
string target,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This runs inside the blocking database-startup path, ahead of DatabaseIsReady(). Before
|
||||
// #732 the already-seeded branch never touched the filesystem at all, so an unreadable or
|
||||
// read-only template is a state that used to boot fine -- it must not become a failure to
|
||||
// start. Cosmetic upgrade, best effort.
|
||||
try
|
||||
{
|
||||
if (!fileSystem.File.Exists(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string existing = await fileSystem.File.ReadAllTextAsync(target, cancellationToken);
|
||||
if (!SupersededDefaults.Any(d => IsSameTemplate(existing, d)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Write-then-move, never write in place. WriteAllTextAsync truncates first, so an
|
||||
// interrupted write (disk full, IO fault, cancellation) would leave a partial file that
|
||||
// matches no fingerprint and is therefore never repaired on a later boot -- the overlay
|
||||
// would just be gone, permanently, on every channel carrying it.
|
||||
//
|
||||
// The temp name is random per call. A fixed one is shared by two containers on the same
|
||||
// config volume, where one can truncate it while the other is mid-write and then rename
|
||||
// the partial file over the live template. The process id is NOT good enough here: the
|
||||
// image uses an exec-form ENTRYPOINT, so every container's PID namespace makes this
|
||||
// process 1 and every container computes the same name. A random name also means a temp
|
||||
// left by a crashed earlier boot is never reused. Only ever delete the path this call
|
||||
// created.
|
||||
string temp = $"{target}.{fileSystem.Path.GetRandomFileName()}.upgrade.tmp";
|
||||
try
|
||||
{
|
||||
await fileSystem.File.WriteAllTextAsync(temp, OnNowNextYaml, cancellationToken);
|
||||
|
||||
// Deliberately NO in-place fallback when this throws. rename(2) onto a mountpoint
|
||||
// is EBUSY, so a single-file bind mount of this template will not be upgraded --
|
||||
// accepted, because reaching that case needs a pinned file that is ALSO byte-identical
|
||||
// to a shipped default, and the alternative is reintroducing the truncation this
|
||||
// whole dance exists to prevent, on every IO fault rather than just that one.
|
||||
fileSystem.File.Move(temp, target, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup must never REPLACE the exception that brought us here. Without this inner
|
||||
// catch, a delete that throws while unwinding a cancellation swaps the
|
||||
// OperationCanceledException for an IOException, which the outer filter then
|
||||
// swallows -- so a real shutdown would be silently downgraded to a warning.
|
||||
try
|
||||
{
|
||||
if (fileSystem.File.Exists(temp))
|
||||
{
|
||||
fileSystem.File.Delete(temp);
|
||||
}
|
||||
}
|
||||
catch (Exception cleanupEx)
|
||||
{
|
||||
logger.LogDebug(cleanupEx, "Could not remove the temporary upgrade file {Path}", temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recoverable filesystem faults only. Catching everything would swallow genuinely fatal
|
||||
// runtime failures (OutOfMemory and friends) and continue booting a compromised process;
|
||||
// letting IO escape would turn a file permission into a restart loop. Cancellation
|
||||
// propagates so shutdown is not swallowed.
|
||||
// OperationCanceledException is deliberately absent from this list so a real shutdown
|
||||
// propagates -- but only a real one: an OCE raised while the token is NOT cancelled is just
|
||||
// another faulty read, and letting it escape is the restart loop this catch exists to stop.
|
||||
catch (Exception ex) when ((ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException)
|
||||
|| (ex is OperationCanceledException
|
||||
&& !cancellationToken.IsCancellationRequested))
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Could not upgrade the built-in graphics template at {Path}; leaving it as-is",
|
||||
target);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare on content, ignoring the line endings and trailing whitespace an editor or a volume
|
||||
// mount may rewrite. This is a fingerprint check, not a parse: anything that is not one of our
|
||||
// own shipped defaults must fall through untouched.
|
||||
private static bool IsSameTemplate(string left, string right) =>
|
||||
string.Equals(Normalize(left), Normalize(right), StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public partial class TextElement(
|
||||
ILogger logger)
|
||||
: GraphicsElement, IDisposable
|
||||
{
|
||||
// Far larger than any sane overlay on an 8K frame, and small enough that every downstream
|
||||
// int cast stays well inside range.
|
||||
private const float MaxBoxDimension = 10_000f;
|
||||
|
||||
private static readonly Regex StylePattern = StyleRegex();
|
||||
private SKBitmap _image;
|
||||
private SKPointI _location;
|
||||
@@ -62,30 +66,96 @@ public partial class TextElement(
|
||||
}
|
||||
}
|
||||
|
||||
BackgroundBox box = BuildBackgroundBox();
|
||||
|
||||
|
||||
RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text);
|
||||
|
||||
if (textElement.WidthPercent.HasValue)
|
||||
// Padding and border sit OUTSIDE the laid-out text on every side, so they shrink the
|
||||
// space the text may occupy and grow the bitmap that holds it. Zero when there is no
|
||||
// box, which reproduces the pre-#732 geometry exactly.
|
||||
//
|
||||
// Round ONCE, here, and use the same integer on both sides: the bitmap grows by
|
||||
// 2 * insetPixels, so subtracting the unrounded inset from the wrap budget would let a
|
||||
// fractional padding push the finished box a pixel past width_percent.
|
||||
var insetPixels = (int)Math.Ceiling(box?.Inset ?? 0f);
|
||||
|
||||
// Bound the inset against the FRAME even when there is no width_percent. Sanitize caps
|
||||
// each field individually, but padding and border add up, and without a budget nothing
|
||||
// else clamps them -- a two-field fat-finger would otherwise allocate a bitmap far
|
||||
// larger than the frame it is drawn onto. Pre-#732 no config value could inflate the
|
||||
// bitmap independently of the measured text.
|
||||
int frameInsetCap = Math.Max(0, Math.Min(context.FrameSize.Width, context.FrameSize.Height) / 2);
|
||||
if (insetPixels > frameInsetCap)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Background padding/border of {Inset}px exceeds the frame; clamping to {Clamped}px",
|
||||
insetPixels,
|
||||
frameInsetCap);
|
||||
|
||||
insetPixels = frameInsetCap;
|
||||
box = box?.ClampedTo(frameInsetCap);
|
||||
}
|
||||
|
||||
// A width_percent of 1e300 makes maxWidth Infinity, and every int cast below it is then
|
||||
// unspecified. Treat a non-finite budget as "no budget", which is what an absent
|
||||
// width_percent already means.
|
||||
if (textElement.WidthPercent.HasValue
|
||||
&& float.IsFinite((float)(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width)))
|
||||
{
|
||||
var maxWidth = (float)Math.Round(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width);
|
||||
|
||||
// A padding wider than the budget itself cannot be honoured AND stay inside it.
|
||||
// Clamp the inset rather than squeezing the text to 1px: an unclamped floor turns a
|
||||
// fat-fingered background_padding into a box several times the requested width.
|
||||
int maxInset = Math.Max(0, (int)Math.Floor((maxWidth - 1) / 2));
|
||||
if (insetPixels > maxInset)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Background padding/border of {Inset}px does not fit within width_percent "
|
||||
+ "({MaxWidth}px); clamping to {Clamped}px",
|
||||
insetPixels,
|
||||
maxWidth,
|
||||
maxInset);
|
||||
|
||||
// Clamp the BOX, not just the bitmap's inset. Shrinking insetPixels alone leaves
|
||||
// DrawBackgroundBox stroking at the original border width, which is centred on a
|
||||
// rect that no longer has room for it -- the stroke then floods the element.
|
||||
insetPixels = maxInset;
|
||||
box = box?.ClampedTo(maxInset);
|
||||
}
|
||||
|
||||
// width_percent bounds the ELEMENT, so the text gets what is left after the insets.
|
||||
// With no box the budget is passed through untouched -- not through Math.Max -- so a
|
||||
// width_percent that rounds to 0 keeps its exact pre-#732 behavior.
|
||||
float textMaxWidth = insetPixels == 0
|
||||
? maxWidth
|
||||
: Math.Max(1f, maxWidth - (2 * insetPixels));
|
||||
|
||||
switch (textElement.Fit)
|
||||
{
|
||||
case TextFit.Wrap:
|
||||
textBlock.MaxWidth = maxWidth;
|
||||
textBlock.MaxWidth = textMaxWidth;
|
||||
break;
|
||||
case TextFit.Scale:
|
||||
FitTextBlock(textBlock, maxWidth);
|
||||
FitTextBlock(textBlock, textMaxWidth);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_image = new SKBitmap(
|
||||
(int)Math.Ceiling(textBlock.MeasuredWidth),
|
||||
(int)Math.Ceiling(textBlock.MeasuredHeight));
|
||||
(int)Math.Ceiling(textBlock.MeasuredWidth) + (2 * insetPixels),
|
||||
(int)Math.Ceiling(textBlock.MeasuredHeight) + (2 * insetPixels));
|
||||
using (var canvas = new SKCanvas(_image))
|
||||
{
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
textBlock.Paint(canvas, new SKPoint(0, 0));
|
||||
|
||||
if (box is not null)
|
||||
{
|
||||
DrawBackgroundBox(canvas, box, _image.Width, _image.Height);
|
||||
}
|
||||
|
||||
textBlock.Paint(canvas, new SKPoint(insetPixels, insetPixels));
|
||||
}
|
||||
|
||||
var horizontalMargin =
|
||||
@@ -134,6 +204,158 @@ public partial class TextElement(
|
||||
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, ZIndex, false));
|
||||
}
|
||||
|
||||
// A background box is drawn only when a colour actually parses. An unparseable colour is
|
||||
// warned about and skipped rather than substituted, so a typo never silently changes the
|
||||
// look into something that appears deliberate.
|
||||
private BackgroundBox BuildBackgroundBox()
|
||||
{
|
||||
SKColor? fill = ParseOptionalColor(textElement.BackgroundColor, "background_color");
|
||||
if (fill.HasValue)
|
||||
{
|
||||
fill = ApplyOpacityPercent(fill.Value, textElement.BackgroundOpacityPercent);
|
||||
}
|
||||
|
||||
SKColor? border = ParseOptionalColor(textElement.BorderColor, "border_color");
|
||||
|
||||
// A border colour with no explicit width means a hairline border, not an invisible one:
|
||||
// "border_color set, nothing drawn" is the more confusing of the two readings.
|
||||
float borderWidth = Sanitize(textElement.BorderWidth ?? 1, "border_width");
|
||||
if (!border.HasValue)
|
||||
{
|
||||
borderWidth = 0;
|
||||
}
|
||||
|
||||
if (!fill.HasValue && borderWidth <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BackgroundBox(
|
||||
fill,
|
||||
border,
|
||||
borderWidth,
|
||||
Sanitize(textElement.BackgroundCornerRadius, "background_corner_radius"),
|
||||
Sanitize(textElement.BackgroundPadding, "background_padding"));
|
||||
}
|
||||
|
||||
// YAML happily yields 1e100 or NaN. Cast to float those become Infinity/NaN, and
|
||||
// (int)Math.Ceiling(Infinity) is an unspecified value -- in practice int.MinValue, which sails
|
||||
// straight past every `> maxInset` clamp and can wrap 2 * inset back to zero. Sanitize at the
|
||||
// boundary so no downstream arithmetic ever sees a non-finite value.
|
||||
private float Sanitize(double? value, string fieldName)
|
||||
{
|
||||
if (value is not { } raw)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (double.IsNaN(raw) || raw < 0)
|
||||
{
|
||||
logger.LogWarning("Ignoring out-of-range {Field} value {Value}", fieldName, raw);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (raw > MaxBoxDimension)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Clamping {Field} value {Value} to {Max}",
|
||||
fieldName,
|
||||
raw,
|
||||
MaxBoxDimension);
|
||||
|
||||
return MaxBoxDimension;
|
||||
}
|
||||
|
||||
return (float)raw;
|
||||
}
|
||||
|
||||
private SKColor? ParseOptionalColor(string value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SKColor.TryParse(value, out SKColor parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Unable to parse {Field} value {Value}; that part of the background box will not be drawn",
|
||||
fieldName,
|
||||
value);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SKColor ApplyOpacityPercent(SKColor color, int? opacityPercent)
|
||||
{
|
||||
if (opacityPercent is not { } percent)
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
int clamped = Math.Clamp(percent, 0, 100);
|
||||
return color.WithAlpha((byte)Math.Round(color.Alpha * clamped / 100.0));
|
||||
}
|
||||
|
||||
private static void DrawBackgroundBox(SKCanvas canvas, BackgroundBox box, int width, int height)
|
||||
{
|
||||
// Skia strokes centred on the path, so half the border would fall outside the bitmap and
|
||||
// be clipped. Inset the rect by half the width to keep the whole border visible.
|
||||
float half = box.BorderWidth / 2f;
|
||||
var rect = new SKRect(half, half, width - half, height - half);
|
||||
|
||||
// A radius larger than half the shorter side is not expressible as a rounded rect.
|
||||
float radius = Math.Min(box.CornerRadius, Math.Min(rect.Width, rect.Height) / 2f);
|
||||
radius = Math.Max(0, radius);
|
||||
|
||||
if (box.Fill is { } fill)
|
||||
{
|
||||
using var fillPaint = new SKPaint
|
||||
{
|
||||
Color = fill,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
canvas.DrawRoundRect(rect, radius, radius, fillPaint);
|
||||
}
|
||||
|
||||
if (box.Border is { } border && box.BorderWidth > 0)
|
||||
{
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = border,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = box.BorderWidth,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
canvas.DrawRoundRect(rect, radius, radius, borderPaint);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BackgroundBox(
|
||||
SKColor? Fill,
|
||||
SKColor? Border,
|
||||
float BorderWidth,
|
||||
float CornerRadius,
|
||||
float Padding)
|
||||
{
|
||||
public float Inset => Padding + BorderWidth;
|
||||
|
||||
// Border first, then whatever is left goes to padding: a border that cannot be drawn inside
|
||||
// the bitmap is worse than a thin one, and padding degrades gracefully to zero.
|
||||
public BackgroundBox ClampedTo(float maxInset)
|
||||
{
|
||||
float borderWidth = Math.Min(BorderWidth, maxInset);
|
||||
float padding = Math.Max(0, maxInset - borderWidth);
|
||||
return this with { BorderWidth = borderWidth, Padding = padding };
|
||||
}
|
||||
}
|
||||
|
||||
private RichTextKit.TextBlock BuildTextBlock(string textToRender)
|
||||
{
|
||||
var textBlock = new RichTextKit.TextBlock
|
||||
@@ -211,6 +433,17 @@ public partial class TextElement(
|
||||
finalStyle.TextColor = parsedColor;
|
||||
}
|
||||
|
||||
// Halo is per-style in the schema and was being dropped here, so a non-base style's
|
||||
// halo_* silently inherited the base style's. The seeded template only looked correct
|
||||
// because all three of its styles declare the same halo.
|
||||
finalStyle.HaloWidth = s.HaloWidth ?? finalStyle.HaloWidth;
|
||||
finalStyle.HaloBlur = s.HaloBlur ?? finalStyle.HaloBlur;
|
||||
|
||||
if (s.HaloColor != null && SKColor.TryParse(s.HaloColor, out SKColor parsedHalo))
|
||||
{
|
||||
finalStyle.HaloColor = parsedHalo;
|
||||
}
|
||||
|
||||
styles[s.Name] = finalStyle;
|
||||
}
|
||||
|
||||
@@ -286,6 +519,11 @@ public partial class TextElement(
|
||||
|
||||
foreach ((string text, RichTextKit.IStyle style) in originalContent)
|
||||
{
|
||||
// Carry across every property the YAML schema can set, not just the ones the scale
|
||||
// needs (the rest are RichTextKit defaults we never touch). Halo and
|
||||
// line height were being dropped here, which only mattered once #732 gave the box
|
||||
// insets that can push a previously-fitting element into the Scale path: adding a
|
||||
// background would then silently remove the halo it sits behind.
|
||||
var newStyle = new RichTextKit.Style
|
||||
{
|
||||
FontFamily = style.FontFamily,
|
||||
@@ -294,7 +532,11 @@ public partial class TextElement(
|
||||
FontWidth = style.FontWidth,
|
||||
FontWeight = style.FontWeight,
|
||||
LetterSpacing = style.LetterSpacing,
|
||||
TextColor = style.TextColor
|
||||
LineHeight = style.LineHeight,
|
||||
TextColor = style.TextColor,
|
||||
HaloColor = style.HaloColor,
|
||||
HaloWidth = style.HaloWidth,
|
||||
HaloBlur = style.HaloBlur
|
||||
};
|
||||
|
||||
float newSize = newStyle.FontSize * scale;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// #732: the On Now / Next overlay is a default rather than an opt-in, so a channel created after
|
||||
/// that decision gets the built-in element without the operator toggling anything.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class CreateChannelDefaultGraphicsElementTests : ChannelHandlerTestBase
|
||||
{
|
||||
private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
|
||||
|
||||
private async Task<int> SeedBuiltInElement()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
context.GraphicsElements.Add(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
|
||||
private async Task<List<int>> AttachedElementIds(int channelId)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Channel reloaded = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channelId);
|
||||
|
||||
return reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ToList();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Attaches_The_Built_In_Element_To_A_New_Channel()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
int elementId = await SeedBuiltInElement();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
int channelId = result.RightToSeq().Head().ChannelId;
|
||||
|
||||
(await AttachedElementIds(channelId)).ShouldBe([elementId]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedBuiltInElement();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result = await MakeHandler().Handle(
|
||||
MakeCreate(streamingMode: StreamingMode.HttpLiveStreamingDirect),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
int channelId = result.RightToSeq().Head().ChannelId;
|
||||
|
||||
(await AttachedElementIds(channelId)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Creates_The_Channel_Even_When_The_Built_In_Element_Does_Not_Exist()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
int channelId = result.RightToSeq().Head().ChannelId;
|
||||
|
||||
(await AttachedElementIds(channelId)).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
@@ -964,4 +966,66 @@ public class CreateChannelFromLineupHandlerTests
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r);
|
||||
|
||||
// #732: this is the SPA's primary "Add Channel" flow and the one Auto-Tune bulk-creates through.
|
||||
// It was the channel-creation site the default attach originally missed, so a channel made here
|
||||
// would silently never get the overlay once the one-time backfill marker had landed.
|
||||
[Test]
|
||||
public async Task Should_Attach_The_Built_In_On_Now_Next_Element()
|
||||
{
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMovie(42);
|
||||
int elementId = await SeedBuiltInGraphicsElement();
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
||||
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
|
||||
|
||||
CreateChannelFromLineupResponseModel response = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
DomainChannel channel = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == response.ChannelId);
|
||||
|
||||
channel.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe([elementId]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel()
|
||||
{
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMovie(42);
|
||||
await SeedBuiltInGraphicsElement();
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result = await MakeHandler().Handle(
|
||||
MakeRequest(advanced: new CreateChannelFromLineupAdvancedOptions(
|
||||
PlaybackOrder.Shuffle,
|
||||
StreamingMode: StreamingMode.HttpLiveStreamingDirect)),
|
||||
CancellationToken.None);
|
||||
|
||||
CreateChannelFromLineupResponseModel response = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
DomainChannel channel = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == response.ChannelId);
|
||||
|
||||
channel.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task<int> SeedBuiltInGraphicsElement()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
context.GraphicsElements.Add(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using LanguageExt;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel-level cover for the #732 background box. These assert the RENDERED BITMAP rather than the
|
||||
/// parsed model, because every failure mode this feature has is a silent no-op: the YAML parses, the
|
||||
/// element initializes, and nothing is drawn. Geometry assertions are all RELATIVE to a no-box
|
||||
/// baseline so they do not depend on which typeface the host resolves.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class TextElementBackgroundBoxTests
|
||||
{
|
||||
private const int FrameWidth = 1920;
|
||||
|
||||
// Derived from the documented rule, not a hardcoded 1080p result: hardcoding it makes correct
|
||||
// production code fail the moment the test frame size changes.
|
||||
private static int FrameInsetCap => Math.Min(FrameWidth, FrameHeight) / 2;
|
||||
private const int FrameHeight = 1080;
|
||||
|
||||
private static TextGraphicsElement BaseElement() =>
|
||||
new()
|
||||
{
|
||||
Name = "test",
|
||||
Location = WatermarkLocation.BottomLeft,
|
||||
BaseStyle = "body",
|
||||
Styles =
|
||||
[
|
||||
new StyleDefinition
|
||||
{
|
||||
Name = "body",
|
||||
// Required: a null font_family makes CustomFontMapper throw on a null dictionary
|
||||
// key, which TextElement swallows into "disable for this content" (the #570 trap).
|
||||
// The family need not resolve -- an unknown one falls back to Skia's default.
|
||||
FontFamily = "Roboto",
|
||||
FontSize = 40,
|
||||
TextColor = "#FFFFFF"
|
||||
}
|
||||
],
|
||||
Text = "Hello"
|
||||
};
|
||||
|
||||
// TextElement swallows every initialization failure into a logged warning, so a broken render
|
||||
// would otherwise surface as a null bitmap with no explanation. Capture the warning and rethrow.
|
||||
private sealed class ThrowingLogger : ILogger
|
||||
{
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => null!;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception exception,
|
||||
Func<TState, Exception, string> formatter)
|
||||
{
|
||||
if (logLevel >= LogLevel.Warning && exception is not null)
|
||||
{
|
||||
throw new InvalidOperationException(formatter(state, exception), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SKBitmap Render(TextGraphicsElement element)
|
||||
{
|
||||
var fonts = new GraphicsEngineFonts(new CustomFontMapper(NullLogger<CustomFontMapper>.Instance));
|
||||
var textElement = new TextElement(fonts, element, new ThrowingLogger());
|
||||
|
||||
var context = new GraphicsEngineContext(
|
||||
"1",
|
||||
null,
|
||||
[],
|
||||
new Dictionary<string, object>(),
|
||||
new Resolution { Width = FrameWidth, Height = FrameHeight },
|
||||
new Resolution { Width = FrameWidth, Height = FrameHeight },
|
||||
new FrameRate("30"),
|
||||
DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(1));
|
||||
|
||||
textElement.InitializeAsync(context, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Option<PreparedElementImage> maybeImage = textElement
|
||||
.PrepareImage(TimeSpan.Zero, TimeSpan.Zero, TimeSpan.FromMinutes(1), TimeSpan.Zero, CancellationToken.None)
|
||||
.AsTask().GetAwaiter().GetResult();
|
||||
|
||||
PreparedElementImage prepared = maybeImage.IfNone(() => throw new InvalidOperationException(
|
||||
"the element produced no image; initialization failed"));
|
||||
|
||||
return prepared.Image;
|
||||
}
|
||||
|
||||
// The whole suite is meaningless if the host cannot lay out any text at all, so prove the
|
||||
// baseline is non-degenerate rather than letting a 0x0 bitmap pass every relative assertion.
|
||||
[Test]
|
||||
public void Baseline_Renders_A_Non_Empty_Bitmap()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
baseline.Width.ShouldBeGreaterThan(0);
|
||||
baseline.Height.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Background_Fields_Leaves_Corner_Transparent()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
baseline.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
||||
baseline.GetPixel(baseline.Width - 1, baseline.Height - 1).Alpha.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Background_Color_Fills_The_Box()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
SKColor corner = rendered.GetPixel(0, 0);
|
||||
corner.Alpha.ShouldBe((byte)255);
|
||||
corner.Red.ShouldBe((byte)255);
|
||||
corner.Green.ShouldBe((byte)0);
|
||||
corner.Blue.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Background_Padding_Grows_The_Bitmap_On_Every_Side()
|
||||
{
|
||||
const int Padding = 12;
|
||||
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap padded = Render(element);
|
||||
|
||||
padded.Width.ShouldBe(baseline.Width + (2 * Padding));
|
||||
padded.Height.ShouldBe(baseline.Height + (2 * Padding));
|
||||
}
|
||||
|
||||
// Growing the bitmap is not the same as moving the text into it. If the text were still painted
|
||||
// at (0,0) the geometry assertions above would all still hold while the glyphs sat on the box
|
||||
// edge, so pin the padding band itself as pure background.
|
||||
[Test]
|
||||
public void Background_Padding_Actually_Insets_The_Text()
|
||||
{
|
||||
const int Padding = 16;
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// A full-width band inside the top padding must contain nothing but the fill colour.
|
||||
for (var x = 0; x < rendered.Width; x++)
|
||||
{
|
||||
SKColor pixel = rendered.GetPixel(x, Padding / 2);
|
||||
pixel.Red.ShouldBe((byte)255, $"pixel at x={x} in the padding band is not the fill colour");
|
||||
pixel.Green.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
|
||||
pixel.Blue.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Background_Opacity_Percent_Scales_The_Alpha()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundOpacityPercent = 50;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// 255 * 0.5, rounded. Skia stores premultiplied alpha, so assert the alpha channel only.
|
||||
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)128);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Corner_Radius_Rounds_The_Corner_Away()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = 20;
|
||||
element.BackgroundCornerRadius = 20;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// The extreme corner falls outside a 20px radius, the middle of the left edge does not.
|
||||
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
||||
rendered.GetPixel(0, rendered.Height / 2).Alpha.ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Border_Color_Draws_A_Border_Distinct_From_The_Fill()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = 20;
|
||||
element.BorderColor = "#00FF00";
|
||||
element.BorderWidth = 4;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// On the border ring...
|
||||
SKColor edge = rendered.GetPixel(1, rendered.Height / 2);
|
||||
edge.Green.ShouldBeGreaterThan((byte)200);
|
||||
edge.Red.ShouldBeLessThan((byte)100);
|
||||
|
||||
// ...and inside it, still the fill.
|
||||
SKColor inside = rendered.GetPixel(10, rendered.Height / 2);
|
||||
inside.Red.ShouldBe((byte)255);
|
||||
inside.Green.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Border_Width_Is_Included_In_The_Bitmap_So_The_Border_Is_Not_Clipped()
|
||||
{
|
||||
const int BorderWidth = 6;
|
||||
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BorderColor = "#00FF00";
|
||||
element.BorderWidth = BorderWidth;
|
||||
|
||||
SKBitmap bordered = Render(element);
|
||||
|
||||
bordered.Width.ShouldBe(baseline.Width + (2 * BorderWidth));
|
||||
bordered.Height.ShouldBe(baseline.Height + (2 * BorderWidth));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unparseable_Background_Color_Draws_No_Box_Rather_Than_Substituting_One()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "not-a-color";
|
||||
element.BackgroundPadding = 25;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// No box means no padding either: the geometry is the untouched baseline.
|
||||
rendered.Width.ShouldBe(baseline.Width);
|
||||
rendered.Height.ShouldBe(baseline.Height);
|
||||
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Border_Color_Without_An_Explicit_Width_Draws_A_Hairline()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BorderColor = "#00FF00";
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
rendered.Width.ShouldBe(baseline.Width + 2);
|
||||
rendered.GetPixel(0, rendered.Height / 2).Green.ShouldBeGreaterThan((byte)200);
|
||||
}
|
||||
|
||||
// A box with no padding or border must not move anything: this is the seam where "adding a
|
||||
// background" could silently change an existing overlay's geometry.
|
||||
[Test]
|
||||
public void A_Fill_With_No_Padding_Or_Border_Leaves_The_Geometry_Untouched()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
|
||||
SKBitmap filled = Render(element);
|
||||
|
||||
filled.Width.ShouldBe(baseline.Width);
|
||||
filled.Height.ShouldBe(baseline.Height);
|
||||
}
|
||||
|
||||
// The inset is rounded up to a whole pixel and the SAME integer is used on both sides. Subtracting
|
||||
// the unrounded value from the wrap budget while adding its ceiling to the bitmap overflows
|
||||
// width_percent by a rounding remainder, which an integer padding cannot expose.
|
||||
[Test]
|
||||
public void Fractional_Padding_Still_Respects_Width_Percent()
|
||||
{
|
||||
const double WidthPercent = 20;
|
||||
|
||||
foreach (double padding in new[] { 12.3, 14.0, 29.7, 0.5 })
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog and keeps running past the edge";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = WidthPercent;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
||||
rendered.Width.ShouldBeLessThanOrEqualTo(budget, $"padding {padding} overflowed the budget");
|
||||
}
|
||||
}
|
||||
|
||||
// An inset wider than the budget cannot be honoured AND stay inside it, so the INSET is clamped
|
||||
// rather than the text being squeezed to nothing. Asserting a width bound here would be wrong:
|
||||
// wrapping cannot break below one glyph, so a narrow width_percent overflows with or without a
|
||||
// box (pre-existing). What the clamp guarantees is that the box's own contribution stops growing
|
||||
// at the budget -- so a runaway padding renders identically to the largest one that fits.
|
||||
[Test]
|
||||
public void An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits()
|
||||
{
|
||||
const double WidthPercent = 20;
|
||||
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
||||
int maxInset = (budget - 1) / 2;
|
||||
|
||||
static TextGraphicsElement WithPadding(double padding)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = WidthPercent;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = padding;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap runaway = Render(WithPadding(400));
|
||||
SKBitmap clamped = Render(WithPadding(maxInset));
|
||||
|
||||
runaway.Width.ShouldBe(clamped.Width);
|
||||
runaway.Height.ShouldBe(clamped.Height);
|
||||
|
||||
// and the clamp actually bit -- an unclamped 400px padding would add 800px of box
|
||||
runaway.Width.ShouldBeLessThan(budget + (2 * maxInset));
|
||||
}
|
||||
|
||||
// FitTextBlock rebuilds every style from scratch. It used to drop the halo, which only became
|
||||
// reachable once a box's insets could push a previously-fitting element into the Scale path --
|
||||
// adding a background would then silently remove the halo behind the text.
|
||||
[Test]
|
||||
public void The_Scale_Path_Preserves_The_Halo()
|
||||
{
|
||||
static TextGraphicsElement Scaled(bool withHalo)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Scale;
|
||||
element.WidthPercent = 15;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = 12;
|
||||
element.Styles[0].HaloColor = withHalo ? "#00FF00" : null;
|
||||
element.Styles[0].HaloWidth = withHalo ? 3 : null;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap withHalo = Render(Scaled(true));
|
||||
SKBitmap withoutHalo = Render(Scaled(false));
|
||||
|
||||
withHalo.Width.ShouldBe(withoutHalo.Width);
|
||||
withHalo.Height.ShouldBe(withoutHalo.Height);
|
||||
|
||||
var differing = 0;
|
||||
for (var x = 0; x < withHalo.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < withHalo.Height; y++)
|
||||
{
|
||||
if (withHalo.GetPixel(x, y) != withoutHalo.GetPixel(x, y))
|
||||
{
|
||||
differing++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the Scale path dropped the halo, the two renders would be pixel-identical.
|
||||
differing.ShouldBeGreaterThan(0, "the halo made no difference through the Scale path");
|
||||
}
|
||||
|
||||
// width_percent bounds the ELEMENT. If the insets were not subtracted from the wrap width the
|
||||
// box would overflow the budget by 2*inset, which is exactly the bug this pins.
|
||||
[Test]
|
||||
public void Width_Percent_Bounds_The_Whole_Box_Including_Padding()
|
||||
{
|
||||
const double WidthPercent = 20;
|
||||
const int Padding = 30;
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog and keeps on running well past the edge";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = WidthPercent;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
||||
rendered.Width.ShouldBeLessThanOrEqualTo(budget);
|
||||
}
|
||||
|
||||
// YAML yields doubles, so 1e100 and NaN are reachable from a config file. Cast to float they
|
||||
// become Infinity/NaN, and (int)Math.Ceiling of those is an unspecified value that sails past
|
||||
// every clamp and can wrap the doubled inset back to zero.
|
||||
[Test]
|
||||
public void Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
foreach (double bad in new[] { 1e100, double.NaN, double.PositiveInfinity, -5.0 })
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = bad;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
rendered.Width.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate width");
|
||||
rendered.Height.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate height");
|
||||
rendered.Width.ShouldBeGreaterThanOrEqualTo(baseline.Width, $"padding {bad} shrank the element");
|
||||
rendered.Width.ShouldBeLessThanOrEqualTo(baseline.Width + (2 * FrameInsetCap), $"padding {bad} was not clamped");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Non_Finite_Width_Percent_Is_Treated_As_No_Budget()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
// This pins the OUTCOME -- a non-finite budget behaves like no budget -- not the
|
||||
// float.IsFinite guard, which no mutation can distinguish because .NET saturates float-to-int
|
||||
// conversion. See the "Not covered by any mutation" note in docs/graphics-elements.md.
|
||||
const int Padding = 10;
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = 1e300;
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
rendered.Width.ShouldBe(baseline.Width + (2 * Padding));
|
||||
rendered.Height.ShouldBe(baseline.Height + (2 * Padding));
|
||||
}
|
||||
|
||||
// Clamping insetPixels alone leaves DrawBackgroundBox stroking at the ORIGINAL border width,
|
||||
// centred on a rect that no longer has room for it -- the stroke then floods the whole element
|
||||
// and paints over the interior. The box's own fields must be clamped too.
|
||||
[Test]
|
||||
public void An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = 20;
|
||||
element.BackgroundColor = "#0000FF";
|
||||
element.BorderColor = "#FF0000";
|
||||
element.BorderWidth = 400;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// Somewhere inside the element there must still be fill, not solid border.
|
||||
var fillPixels = 0;
|
||||
for (var x = 0; x < rendered.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < rendered.Height; y++)
|
||||
{
|
||||
SKColor px = rendered.GetPixel(x, y);
|
||||
if (px.Blue > 200 && px.Red < 100)
|
||||
{
|
||||
fillPixels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fillPixels.ShouldBeGreaterThan(0, "the border flooded the element; no fill survived");
|
||||
}
|
||||
|
||||
// Sanitize bounds each field on its own, but padding and border ADD UP, and with no
|
||||
// width_percent nothing else bounded them -- two fields could allocate a bitmap far larger than
|
||||
// the frame it is drawn onto. This pins the frame cap itself; the non-finite test above cannot,
|
||||
// because its bound is looser than the growth this clause prevents.
|
||||
[Test]
|
||||
public void The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = 4000;
|
||||
element.BorderColor = "#00FF00";
|
||||
element.BorderWidth = 4000;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// 8000px of requested inset collapses to the frame cap, not to 16000px of bitmap growth.
|
||||
rendered.Width.ShouldBe(baseline.Width + (2 * FrameInsetCap));
|
||||
rendered.Height.ShouldBe(baseline.Height + (2 * FrameInsetCap));
|
||||
}
|
||||
|
||||
// halo_* is documented as a per-style field. The merge loop used to rebuild each style from the
|
||||
// base and then override only font/colour, so a non-base style's halo silently inherited the
|
||||
// base one -- invisible in the seeded template, whose three styles declare identical halos.
|
||||
[Test]
|
||||
public void A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles()
|
||||
{
|
||||
static TextGraphicsElement WithSubHalo(float haloWidth)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Styles.Add(new StyleDefinition
|
||||
{
|
||||
Name = "sub",
|
||||
FontFamily = "Roboto",
|
||||
FontSize = 40,
|
||||
TextColor = "#FFFFFF",
|
||||
HaloColor = "#00FF00",
|
||||
HaloWidth = haloWidth
|
||||
});
|
||||
element.Text = "[sub]Hello[/sub]";
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap thin = Render(WithSubHalo(1));
|
||||
SKBitmap thick = Render(WithSubHalo(6));
|
||||
|
||||
static int HaloPixels(SKBitmap b)
|
||||
{
|
||||
var n = 0;
|
||||
for (var x = 0; x < b.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < b.Height; y++)
|
||||
{
|
||||
SKColor px = b.GetPixel(x, y);
|
||||
if (px.Green > 150 && px.Red < 120)
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
// If the style's own halo were ignored, both renders would use the base style's (none) and
|
||||
// neither would contain halo pixels.
|
||||
HaloPixels(thin).ShouldBeGreaterThan(0, "the style's own halo was not applied");
|
||||
HaloPixels(thick).ShouldBeGreaterThan(HaloPixels(thin), "halo_width had no effect per style");
|
||||
}
|
||||
|
||||
private static int PixelsMatching(SKBitmap b, Func<SKColor, bool> predicate)
|
||||
{
|
||||
var n = 0;
|
||||
for (var x = 0; x < b.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < b.Height; y++)
|
||||
{
|
||||
if (predicate(b.GetPixel(x, y)))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
// halo_blur is a per-style field too, and the merge loop drops it just as silently as halo_color
|
||||
// did. Blur spreads the halo over more pixels at lower alpha, so a blurred halo covers more area.
|
||||
[Test]
|
||||
public void A_Non_Base_Style_Uses_Its_Own_Halo_Blur()
|
||||
{
|
||||
static TextGraphicsElement WithSubBlur(float blur)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Styles.Add(new StyleDefinition
|
||||
{
|
||||
Name = "sub",
|
||||
FontFamily = "Roboto",
|
||||
FontSize = 40,
|
||||
TextColor = "#FFFFFF",
|
||||
HaloColor = "#00FF00",
|
||||
HaloWidth = 3,
|
||||
HaloBlur = blur
|
||||
});
|
||||
element.Text = "[sub]Hello[/sub]";
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap sharp = Render(WithSubBlur(0));
|
||||
SKBitmap blurred = Render(WithSubBlur(5));
|
||||
|
||||
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
|
||||
|
||||
PixelsMatching(blurred, Greenish)
|
||||
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur had no effect per style");
|
||||
}
|
||||
|
||||
// FitTextBlock rebuilds every style from scratch on the Scale path. line_height and halo_blur are
|
||||
// carried across there; without them a scaled element silently loses line spacing and halo blur.
|
||||
[Test]
|
||||
public void The_Scale_Path_Preserves_Line_Height()
|
||||
{
|
||||
static TextGraphicsElement Scaled(float lineHeight)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Scale;
|
||||
element.WidthPercent = 15;
|
||||
element.Styles[0].LineHeight = lineHeight;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap tight = Render(Scaled(1.0f));
|
||||
SKBitmap loose = Render(Scaled(2.5f));
|
||||
|
||||
loose.Height.ShouldBeGreaterThan(tight.Height, "line_height was dropped by the Scale path");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void The_Scale_Path_Preserves_Halo_Blur()
|
||||
{
|
||||
static TextGraphicsElement Scaled(float blur)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Scale;
|
||||
element.WidthPercent = 15;
|
||||
element.Styles[0].HaloColor = "#00FF00";
|
||||
element.Styles[0].HaloWidth = 3;
|
||||
element.Styles[0].HaloBlur = blur;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap sharp = Render(Scaled(0));
|
||||
SKBitmap blurred = Render(Scaled(5));
|
||||
|
||||
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
|
||||
|
||||
PixelsMatching(blurred, Greenish)
|
||||
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur was dropped by the Scale path");
|
||||
}
|
||||
|
||||
// The documented range is 0-100. The sole opacity test used 50, so Math.Clamp could be removed
|
||||
// with everything green while an out-of-range value wrapped to an unrelated alpha.
|
||||
[Test]
|
||||
public void Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range()
|
||||
{
|
||||
static SKColor CornerAt(int percent)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundOpacityPercent = percent;
|
||||
return Render(element).GetPixel(0, 0);
|
||||
}
|
||||
|
||||
CornerAt(-50).Alpha.ShouldBe((byte)0, "a negative opacity did not clamp to 0");
|
||||
CornerAt(400).Alpha.ShouldBe((byte)255, "an opacity over 100 did not clamp to 100");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using YamlDotNet.Serialization;
|
||||
@@ -36,7 +37,7 @@ public class GraphicsElementSeederTests
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeTrue();
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
|
||||
@@ -51,7 +52,7 @@ public class GraphicsElementSeederTests
|
||||
await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
|
||||
}
|
||||
@@ -62,9 +63,9 @@ public class GraphicsElementSeederTests
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
fs.File.Delete(_seededPath);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeFalse();
|
||||
}
|
||||
@@ -77,7 +78,7 @@ public class GraphicsElementSeederTests
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
string yaml = await fs.File.ReadAllTextAsync(_seededPath);
|
||||
IDeserializer deserializer = new DeserializerBuilder()
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using Testably.Abstractions.Testing.FileSystem;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// #732: the seeder writes the On Now / Next template once and never revisits it, so a change to the
|
||||
/// shipped default would reach new databases only. These pin the upgrade path that fixes that, and
|
||||
/// the boundary that keeps it from clobbering an operator's edits.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GraphicsElementSeederUpgradeTests
|
||||
{
|
||||
// Byte-for-byte the default shipped before #732. Verified 2026-08-26 against the live prod
|
||||
// install at 192.168.1.29 (md5 ef9afc088cf6dba252f725babbf3334f), so this is a real
|
||||
// fingerprint rather than a copy of the constant it is meant to detect.
|
||||
private const string OnNowNextYamlV1 =
|
||||
"""
|
||||
name: On Now / Next
|
||||
epg_entries: 2
|
||||
location: BottomLeft
|
||||
horizontal_margin_percent: 4
|
||||
vertical_margin_percent: 8
|
||||
width_percent: 42
|
||||
text_fit: Wrap
|
||||
text_align: Left
|
||||
z_index: 100
|
||||
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
|
||||
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
font_family: "Noto Sans"
|
||||
font_size: 30
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: sub
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: next
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
text: |
|
||||
[now]NOW {{ Epg[0].Title }}[/now]
|
||||
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
|
||||
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private string _target = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_target = Path.Combine(
|
||||
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
|
||||
GraphicsElementDefaults.OnNowNextFileName);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private async Task<MockFileSystem> RunSeederOverAlreadySeededDatabase(string existingContent)
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
|
||||
if (existingContent is not null)
|
||||
{
|
||||
await fs.File.WriteAllTextAsync(_target, existingContent);
|
||||
}
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
// The fixture DB is shared across calls within a test, so only seed the marker once.
|
||||
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
if (!context.ConfigElements.Any(c => c.Key == key))
|
||||
{
|
||||
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
return fs;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Upgrades_An_Untouched_Previous_Default()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
|
||||
|
||||
string result = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
result.ShouldNotBe(OnNowNextYamlV1);
|
||||
result.ShouldContain("background_color");
|
||||
result.ShouldContain("background_padding");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(
|
||||
OnNowNextYamlV1.Replace("\n", "\r\n"));
|
||||
|
||||
string result = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
result.ShouldContain("background_color");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_An_Operator_Modified_File_Alone()
|
||||
{
|
||||
// One changed value is enough to stop matching the fingerprint.
|
||||
string edited = OnNowNextYamlV1.Replace("width_percent: 42", "width_percent: 30");
|
||||
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(edited);
|
||||
|
||||
string result = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
result.ShouldBe(edited);
|
||||
result.ShouldNotContain("background_color");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_The_Current_Default_Alone_So_The_Upgrade_Is_Idempotent()
|
||||
{
|
||||
MockFileSystem first = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
|
||||
string upgraded = await first.File.ReadAllTextAsync(_target);
|
||||
|
||||
MockFileSystem second = await RunSeederOverAlreadySeededDatabase(upgraded);
|
||||
string again = await second.File.ReadAllTextAsync(_target);
|
||||
|
||||
again.ShouldBe(upgraded);
|
||||
}
|
||||
|
||||
// The upgrade runs inside DatabaseMigratorService, ahead of DatabaseIsReady(). Before #732 the
|
||||
// already-seeded branch touched the filesystem not at all, so a template the app cannot read --
|
||||
// e.g. edited as root via `docker exec` while the app runs as PUID/PGID -- used to boot fine.
|
||||
// It must not become a failure to start.
|
||||
[Test]
|
||||
public async Task An_Unwritable_Template_Does_Not_Fail_Startup()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
// the file matches a shipped default, so the upgrade WILL try to rewrite it -- and that write
|
||||
// is denied, standing in for a root-owned or read-only template
|
||||
var intercepted = 0;
|
||||
fs.Intercept.Changing(
|
||||
FileSystemTypes.File,
|
||||
_ =>
|
||||
{
|
||||
intercepted++;
|
||||
throw new UnauthorizedAccessException("simulated permission denial");
|
||||
});
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
|
||||
// Without this the test would pass just as happily if the upgrade never reached the write.
|
||||
intercepted.ShouldBeGreaterThan(0, "the write interceptor never fired");
|
||||
|
||||
// and the original template survives the denied write
|
||||
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
|
||||
}
|
||||
|
||||
// The write path is not the only one that can fault. A template the app cannot READ used to be
|
||||
// harmless on an already-seeded install; it must stay that way. An exclusive lock produces a
|
||||
// genuine ReadAllTextAsync failure rather than an intercepted write dressed up as one.
|
||||
[Test]
|
||||
public async Task A_Read_Failure_On_The_Template_Does_Not_Fail_Startup()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await using Stream exclusive = fs.File.Open(_target, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
|
||||
// prove the lock actually denies a read, so the test cannot pass by never hitting one
|
||||
Should.Throw<IOException>(() => fs.File.ReadAllText(_target));
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Does_Not_Create_The_File_When_It_Is_Absent()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(null);
|
||||
|
||||
fs.File.Exists(_target).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
|
||||
string yaml = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
IDeserializer deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
var element = deserializer.Deserialize<TextGraphicsElement>(yaml);
|
||||
|
||||
element.ShouldNotBeNull();
|
||||
element.BackgroundColor.ShouldBe("#000000");
|
||||
element.BackgroundOpacityPercent.ShouldBe(65);
|
||||
element.BackgroundPadding.ShouldBe(14);
|
||||
element.BackgroundCornerRadius.ShouldBe(8);
|
||||
|
||||
// The border is what makes the box visible over dark content; without it the translucent
|
||||
// black fill is indistinguishable from the frame behind it.
|
||||
element.BorderColor.ShouldBe("#59FFFFFF");
|
||||
element.BorderWidth.ShouldBe(1);
|
||||
|
||||
// #570: every style needs a font_family, and base_style must resolve.
|
||||
element.Styles.ShouldNotBeEmpty();
|
||||
element.Styles.ShouldAllBe(s => s.FontFamily != null);
|
||||
element.Styles.ShouldContain(s => s.Name == element.BaseStyle);
|
||||
}
|
||||
|
||||
// Without the duplicate guard in EnsureBuiltInElementRow the already-seeded branch inserts a
|
||||
// fresh row on EVERY boot: RefreshGraphicsElements will neither reap them (the file exists) nor
|
||||
// dedupe them, so the row set grows without bound. Idempotence of the FILE is not idempotence
|
||||
// of the ROW, and the existing idempotence test only looks at the file.
|
||||
[Test]
|
||||
public async Task Repeated_Seeding_Does_Not_Accumulate_Element_Rows()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
}
|
||||
|
||||
(await context.GraphicsElements.ToListAsync()).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// A failed write must not leave a truncated template behind: it would match no fingerprint, so
|
||||
// the upgrade could never repair it, and the loader rejects malformed YAML outright.
|
||||
[Test]
|
||||
public async Task A_Failed_Write_Leaves_The_Original_Template_Intact()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
// Capture WHICH path the write targets. Asserting only "the original survived" cannot tell
|
||||
// an atomic write from an in-place one here: Testably raises the interception BEFORE it
|
||||
// truncates, so a plain WriteAllTextAsync(target) would leave the file intact too -- on a
|
||||
// real filesystem it would not. The path is what actually distinguishes them.
|
||||
var writtenPaths = new List<string>();
|
||||
fs.Intercept.Changing(
|
||||
FileSystemTypes.File,
|
||||
c =>
|
||||
{
|
||||
writtenPaths.Add(c.Path);
|
||||
throw new IOException("simulated disk full");
|
||||
});
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
|
||||
writtenPaths.ShouldNotBeEmpty("the write interceptor never fired");
|
||||
writtenPaths.ShouldAllBe(path => path.EndsWith(".upgrade.tmp"), "the upgrade wrote the live template in place instead of a temp file");
|
||||
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
|
||||
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
|
||||
.ShouldBeEmpty("a temp file was left behind");
|
||||
}
|
||||
|
||||
// The sibling test faults on the FIRST write, so it never reaches File.Move or the cleanup. Fault
|
||||
// the replace instead, after a complete temp write: that is the path where a non-atomic
|
||||
// implementation would already have truncated the live template.
|
||||
[Test]
|
||||
public async Task A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
var seenPaths = new List<string>();
|
||||
fs.Intercept.Event(
|
||||
c =>
|
||||
{
|
||||
seenPaths.Add($"{c.ChangeType}:{c.Path}");
|
||||
|
||||
// let the temp file be written in full; fail only when the live template is touched
|
||||
if (c.Path == _target)
|
||||
{
|
||||
throw new IOException("simulated replace failure");
|
||||
}
|
||||
},
|
||||
_ => true);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
|
||||
seenPaths.ShouldContain(path => path.EndsWith(".upgrade.tmp"), "no temp file was ever written");
|
||||
|
||||
// Assert the replace is a RENAME, not merely "the target was touched after the temp was".
|
||||
// A File.Copy(temp, target, true) also touches both in that order and would leave the
|
||||
// original intact under this mock (interception runs before the change), so path ordering
|
||||
// alone cannot tell an atomic replace from a truncating one -- the change TYPE can.
|
||||
seenPaths.ShouldContain($"Renamed:{_target}", "the replace was not an atomic rename");
|
||||
|
||||
// The whole point of write-then-move: the live template is untouched by a failed replace.
|
||||
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
|
||||
|
||||
// and nothing this call created is left behind
|
||||
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
|
||||
.ShouldBeEmpty("a temp file was left behind");
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,8 @@ public abstract class ChannelHandlerTestBase
|
||||
bool showInEpg = false,
|
||||
string logoPath = "",
|
||||
string name = "Test",
|
||||
string group = "ErsatzTV") =>
|
||||
string group = "ErsatzTV",
|
||||
StreamingMode streamingMode = StreamingMode.TransportStreamHybrid) =>
|
||||
new(
|
||||
name,
|
||||
number,
|
||||
@@ -105,7 +106,7 @@ public abstract class ChannelHandlerTestBase
|
||||
ChannelPlayoutMode.Continuous,
|
||||
null,
|
||||
null,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
streamingMode,
|
||||
null,
|
||||
null,
|
||||
string.Empty,
|
||||
|
||||
@@ -90,7 +90,8 @@ public class DatabaseMigratorService : BackgroundService
|
||||
await DbInitializer.Initialize(dbContext, stoppingToken);
|
||||
|
||||
var fileSystem = scope.ServiceProvider.GetRequiredService<System.IO.Abstractions.IFileSystem>();
|
||||
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, stoppingToken);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, _logger, stoppingToken);
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(dbContext, stoppingToken);
|
||||
|
||||
_systemStartup.DatabaseIsReady();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ doc below, or that changes which sections a task signal points to.**
|
||||
| Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` |
|
||||
| Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` |
|
||||
| Explaining a consequential settings field in the SPA (summary → hover/tap panel → docs link) | `docs/spa-conventions.md` §15 — use the shared `FieldHelp` trigger and put the copy in the screen's own `FIELD_HELP` record; the icon, the gesture and the a11y contract are fixed |
|
||||
| Graphics element / overlay work (text bug, On Now / Next, watermark-vs-`[vge]`) | `docs/graphics-elements.md`, then decisions catalog rows keyed `graphics.*` |
|
||||
| Scheduling / playout engine work | `docs/domain-model.md` + decisions catalog rows keyed `sched.*` (`docs/decisions/README.md`) |
|
||||
| Adding or changing a paged list handler (a page plus a `TotalCount`) | Resolve `api.paged-count-matches-page-query` via `docs/decisions/README.md` — for an EF-backed filtered list, count the SAME query you page, with includes appended to the page chain only; where the count and the page are separate methods, a test pins their agreement. Then `api.paging-zero-based` for the `pageNum`/`pageSize` contract |
|
||||
| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` |
|
||||
@@ -100,6 +101,10 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
|
||||
Largely superseded day-to-day by `docs/api-conventions.md`; read this for the original rationale.
|
||||
- **`docs/mcp.md`** — the `ErsatzTV.Mcp` stdio JSON-RPC MCP server (#58): how it wraps `/api/v1` as
|
||||
read + cautious-write tools, its config/env vars, auth, security posture, and the tool catalog.
|
||||
- **`docs/graphics-elements.md`** — graphics element (overlay) schema reference: how elements are
|
||||
discovered and attached, the YAML parsing traps (an unknown key disables the element outright), the
|
||||
full text-element field table including the #732 background box, and why `[vge]` in a filter graph
|
||||
does not imply a graphics element is bound.
|
||||
- **`docs/channels.md`** — Channel entity field reference.
|
||||
- **`docs/m3u-xmltv.md`** — M3U/XMLTV generation overview (`ChannelPlaylist`, `GetChannelGuideHandler`).
|
||||
- **`docs/fork-strategy.md`** — divergence policy vs upstream ErsatzTV.
|
||||
|
||||
@@ -97,6 +97,8 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ffmpeg.work-ahead-slot-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](records/ffmpeg/work-ahead-slot-release-never-negative.md) |
|
||||
| `graphics.channel-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](records/graphics/channel-level-attachment.md) |
|
||||
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](records/graphics/channel-logo-caching.md) |
|
||||
| `graphics.on-now-next-on-by-default` | The built-in On Now / Next element is attached to new channels by `ChannelGraphicsDefaults.Attach`, called from BOTH create paths, and to pre-existing channels by a one-time `AttachOnNowNextByDefault` backfill guarded by `graphics.on_now_next_default_attached`. The marker is written only once the built-in element RESOLVES, so an install whose element row does not exist yet is retried rather than stranded; the cost is that while the backfill is still armed it cannot tell a deliberately cleared channel from an untouched one. HLS Direct is excluded at both sites. | 2026-08-26 | [link](records/graphics/on-now-next-on-by-default.md) |
|
||||
| `graphics.seeded-template-upgrade-by-fingerprint` | `GraphicsElementSeeder` keeps every default it has ever shipped as a verbatim fingerprint; on an already-seeded database it rewrites the on-disk template only when the file still matches one of them (line-endings and trailing whitespace normalised), so an untouched install gets the new default while any operator edit is left alone. | 2026-08-26 | [link](records/graphics/seeded-template-upgrade-by-fingerprint.md) |
|
||||
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](records/iptv/base-url.md) |
|
||||
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](records/iptv/logo-drives-bug-preset.md) |
|
||||
| `locking.entitylocker-atomic-flags` | `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing. | 2026-07-11 | [link](records/locking/entitylocker-atomic-flags.md) |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
key: graphics.on-now-next-on-by-default
|
||||
title: 2026-08-26 — The On Now / Next overlay is on by default, backfilled exactly once (#732)
|
||||
status: active
|
||||
since: '2026-08-26'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'The built-in On Now / Next element is attached to new channels by `ChannelGraphicsDefaults.Attach`, called from BOTH create paths, and to pre-existing channels by a one-time `AttachOnNowNextByDefault` backfill guarded by `graphics.on_now_next_default_attached`. The marker is written only once the built-in element RESOLVES, so an install whose element row does not exist yet is retried rather than stranded; the cost is that while the backfill is still armed it cannot tell a deliberately cleared channel from an untouched one. HLS Direct is excluded at both sites.'
|
||||
signals: 'AttachOnNowNextByDefault, GraphicsOnNowNextDefaultAttached, ChannelGraphicsDefaults.Attach, ChannelGraphicsElement default, On Now Next default on, create-time graphics default, CreateChannelFromLineup graphics · paths: `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ErsatzTV.Application/Channels/ChannelGraphicsDefaults.cs`, `ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs`, `ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs` · issues: #732, #74'
|
||||
---
|
||||
|
||||
#74 shipped the overlay as a per-channel opt-in toggle. #732 made it a default. The binding level is
|
||||
`ChannelGraphicsElement` — the base layer added at `GraphicsElementSelector`'s final fall-through, so a
|
||||
deco in `Override`/`Disable` mode still suppresses it exactly as before (`graphics.channel-level-attachment`).
|
||||
|
||||
Two mechanisms, because they answer different questions:
|
||||
|
||||
- **New channels** — `ChannelGraphicsDefaults.Attach` is called as the channel is persisted. The
|
||||
create requests carry no graphics field and the SPA's channel editor is update-only, so the server
|
||||
is the only place a create-time default can live.
|
||||
- **Pre-existing channels** — `AttachOnNowNextByDefault` runs once at startup, after the seeder.
|
||||
|
||||
**The create-time mechanism has more than one call site, and enumerating them from the source is the
|
||||
only reliable way to find them.** There are three places that persist a `Channel`:
|
||||
`CreateChannelHandler` (the SPA's "New blank channel"), `CreateChannelFromLineupHandler` (the SPA's
|
||||
primary "Add Channel" flow, and what Auto-Tune bulk-creates through), and `DbInitializer`'s default
|
||||
channel. The first two both call the shared helper — they diverged once, with only the first covered,
|
||||
which silently excluded the busier path. The third needs no call because it runs before
|
||||
`AttachOnNowNextByDefault` in the same startup, so the backfill picks it up. A new persisting site
|
||||
must call the helper; `grep` for `Channels.Add` rather than trusting this list to stay complete.
|
||||
|
||||
**The marker is written only once the element resolves**, and the built-in `GraphicsElement` row is
|
||||
created by the seeder (`EnsureBuiltInElementRow`) rather than waited for. Those two go together.
|
||||
|
||||
Writing the marker unconditionally is the tempting simplification, and it is wrong: on an install
|
||||
upgrading from before #74 the template is seeded on the same boot, so a marker written while the row
|
||||
is merely *undiscovered* strands every pre-existing channel permanently — the exact population the
|
||||
backfill exists for. Creating the row in the seeder removes that ordering dependency for every normal
|
||||
path, and skipping the marker when nothing resolves covers the rest.
|
||||
|
||||
**This is a real trade, not a free win, and a single global marker cannot represent both halves.**
|
||||
The two properties wanted here are "never re-add to a channel the operator cleared" and "never strand
|
||||
a channel that had no element to attach". A boolean that means *the backfill has run* can express one
|
||||
or the other, never both:
|
||||
|
||||
- While the backfill is **armed** (nothing has resolved yet — the template file is absent, e.g. an
|
||||
operator deleted it and `RefreshGraphicsElements` reaped the row), a channel cleared by the
|
||||
operator is indistinguishable from one never considered. If the element is later restored, the
|
||||
next boot attaches it to every eligible channel, including that one.
|
||||
- Once the marker is **written**, no channel is ever re-attached, on any restart.
|
||||
|
||||
The armed window is narrow (it requires the built-in template to be absent at startup) and the
|
||||
failure is visible and reversible — an overlay reappears — whereas stranding is silent and permanent.
|
||||
That is why it is resolved this way. Closing it properly needs per-channel "considered / opted-out"
|
||||
state rather than one global flag, which is a schema change and is tracked separately.
|
||||
|
||||
**It also cannot reconstruct pre-upgrade operator intent.** On an install that predates #732, an
|
||||
operator who enabled the overlay and later turned it off left no record that survives — the join row
|
||||
is simply absent, indistinguishable from never having enabled it — so the one-time backfill
|
||||
re-attaches it. That is inherent to "enable it on all channels by default" rather than a defect. The
|
||||
never-re-attach guarantee therefore holds *from the marker onwards*, not across the upgrade boundary.
|
||||
|
||||
Identity is the element's **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the
|
||||
user-editable `Name` — the #67 lesson carried through #74.
|
||||
|
||||
**HLS Direct is excluded at both sites.** ErsatzTV is not transcoding there, so `GraphicsElementSelector`
|
||||
returns empty and the editor disables the toggle; an attachment would be inert while still reading as
|
||||
"on". Excluding it keeps the stored state honest rather than merely harmless.
|
||||
|
||||
HLS Direct is **not** the only inert case: `FFmpegLibraryProcessService` gates the graphics engine on
|
||||
`videoFormat != VideoFormat.Copy`, so a channel whose FFmpeg profile is set to `Copy` also renders
|
||||
nothing while showing the toggle on. That one is deliberately *not* excluded here — the profile is
|
||||
mutable and shared, so the streaming-mode exclusion is a property of the channel while the `Copy`
|
||||
gate is a property of a setting that can change under it. The render-site gate is the only correct
|
||||
place for it.
|
||||
|
||||
**Residual risk, stated rather than reassured away.** On 2026-08-26 all 43 channels on the live
|
||||
install already carried the element (measured by reading `graphicsElementIds` from
|
||||
`GET /api/v1/channels/{id}` for every id in `GET /api/v1/channels`). The backfill is therefore a no-op
|
||||
in the only place it has ever run, and its behaviour against real pre-existing data is covered by
|
||||
tests and a local live run, not by production evidence.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
key: graphics.seeded-template-upgrade-by-fingerprint
|
||||
title: 2026-08-26 — A seeded graphics template is upgraded only when it still matches a shipped default (#732)
|
||||
status: active
|
||||
since: '2026-08-26'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: '`GraphicsElementSeeder` keeps every default it has ever shipped as a verbatim fingerprint; on an already-seeded database it rewrites the on-disk template only when the file still matches one of them (line-endings and trailing whitespace normalised), so an untouched install gets the new default while any operator edit is left alone.'
|
||||
signals: 'GraphicsElementSeeder, OnNowNextYamlV1, SupersededDefaults, UpgradeUnmodifiedTemplate, on-now-next.yml upgrade, graphics.on_now_next_seeded · paths: `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs` · issues: #732, #74'
|
||||
---
|
||||
|
||||
#74 seeded `on-now-next.yml` behind the `graphics.on_now_next_seeded` `ConfigElement` marker, writing
|
||||
the file only when absent so operator edits are never clobbered. That is the right rule for *content*,
|
||||
but it has a consequence nobody stated at the time: **an already-seeded installation never revisits the
|
||||
file at all**, so a change to the shipped default reaches new databases only. #732 hit this directly —
|
||||
the background box is useless if the one install that has the overlay keeps rendering the pre-#732
|
||||
template forever.
|
||||
|
||||
**Decision: upgrade by fingerprint, not by version number or by marker bump.** Every default we have
|
||||
shipped stays in the source as a verbatim constant (`OnNowNextYamlV1`, …) collected in
|
||||
`SupersededDefaults`. On an already-seeded database the seeder reads the file and rewrites it **only**
|
||||
if it still normalises equal to one of those. The comparison ignores line endings and trailing
|
||||
whitespace, because a volume mount or an editor rewrites those without the operator touching content.
|
||||
|
||||
Why this shape:
|
||||
|
||||
- **The safety property is derived, not asserted.** "Did the operator edit this file?" is answered by
|
||||
comparing bytes against what we wrote, rather than assumed — which is exactly the assumption #74
|
||||
refused to make, and which a marker bump would have to make.
|
||||
|
||||
The comparison is not byte-exact in one direction: line endings are normalised and the result is
|
||||
`TrimEnd`ed, so an edit consisting *only* of trailing whitespace at end-of-file does not opt the
|
||||
file out and would be overwritten. That is deliberate (a volume mount or editor rewrites those
|
||||
without operator intent) and the cost is bounded to whitespace nobody can see. Every edit with any
|
||||
visible effect opts out permanently.
|
||||
- **It is self-limiting, so it needs no new marker.** After the rewrite the content equals the
|
||||
*current* default, which is not in `SupersededDefaults`, so the next startup is a no-op.
|
||||
- **A fingerprint entry is not a template.** Never edit an entry in `SupersededDefaults` — it is a
|
||||
record of what we shipped. Changing the current default means adding a new constant and pushing the
|
||||
outgoing one into the list; editing an existing entry silently un-recognises every install carrying
|
||||
it, and the failure is invisible (the upgrade just never fires).
|
||||
|
||||
**The rewrite is write-then-move, with no in-place fallback.** `WriteAllTextAsync` truncates before
|
||||
it writes, so an interrupted write would leave a partial file matching no fingerprint — never
|
||||
repairable by a later boot, and rejected outright by the loader. The temp name is random per call,
|
||||
because a fixed one is shared by two containers on the same config volume — and a process id is not
|
||||
random enough: the image's ENTRYPOINT is exec-form, so every container is PID 1 in its own namespace
|
||||
and would compute the same name. The accepted cost: a
|
||||
template bind-mounted as a single file cannot be replaced by `rename(2)` (EBUSY), so that install
|
||||
never receives the upgrade. Reaching that needs a pinned file that is *also* byte-identical to a
|
||||
shipped default, and the alternative — falling back to an in-place copy — reintroduces the truncation
|
||||
on every IO fault rather than just that one.
|
||||
|
||||
**The upgrade is a one-way door, and a downgrade is lossy.** `GraphicsElementLoader.FromYaml<T>` does
|
||||
not set `IgnoreUnmatchedProperties`, so an unknown YAML key throws and the element is dropped with
|
||||
only a logged warning. Once a template has been upgraded, rolling ErsatzTV back to a build that does
|
||||
not know the new keys makes that element fail to load on **every** channel it is attached to — which,
|
||||
since `graphics.on-now-next-on-by-default`, is every eligible channel that still has it (HLS Direct
|
||||
was never attached, and an operator may have cleared individual channels). The symptom is "the overlay vanished
|
||||
everywhere" and recovery is hand-editing the YAML back. Rollback is a supported operation here
|
||||
(`:prod` is a floating tag promoted manually), so this belongs in the release notes of any version
|
||||
that adds fields to a seeded template, not only in this record.
|
||||
|
||||
**Verify the fingerprint against a real install, not against the constant it was copied from.** The
|
||||
#732 V1 entry was checked byte-for-byte against the live install (md5 `ef9afc088cf6dba252f725babbf3334f`,
|
||||
2026-08-26) before being trusted. A fingerprint that does not match anything in the field is a
|
||||
permanent silent no-op, and no test written from the same source can detect that.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Graphics elements — schema reference
|
||||
|
||||
Graphics elements are YAML-authored overlays composited onto a channel's **transcoded** frames by the
|
||||
SkiaSharp graphics engine (`ErsatzTV.Infrastructure/Streaming/Graphics/`). They appear in the FFmpeg
|
||||
filter graph as the `[vge]` stage.
|
||||
|
||||
`[vge]` is **not** proof that a graphics element is bound. Watermarks are a separate, older system that
|
||||
falls back onto the graphics engine whenever it cannot use ffmpeg's native `overlay` shortcut — an
|
||||
`Intermittent`/`OpacityExpression` mode, more than one active watermark, or an external-URL image all
|
||||
route a *watermark* through `[vge]` with zero graphics-element rows. The native shortcut is only taken
|
||||
for a single `Permanent` local-file watermark
|
||||
(`ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs`, `CanUseFFmpegNativeWatermark`).
|
||||
|
||||
`StreamingMode.HttpLiveStreamingDirect` renders nothing: ErsatzTV is not transcoding, so there is no
|
||||
frame pipeline to draw into and `GraphicsElementSelector` returns empty.
|
||||
|
||||
## Discovery and attachment
|
||||
|
||||
- Files live under `FileSystemLayout.GraphicsElements*TemplatesFolder` (`text/`, `image/`, `motion/`,
|
||||
`subtitle/`, `script/`). `RefreshGraphicsElementsHandler` scans them and maintains one
|
||||
`GraphicsElement` row per file, removing rows whose file vanished.
|
||||
- Five join tables attach an element: `ChannelGraphicsElement`, `PlayoutItemGraphicsElement`,
|
||||
`ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`.
|
||||
Resolution order and the deco `Merge`/`Override`/`Disable`/`Inherit` semantics are in
|
||||
`graphics.channel-level-attachment`; the channel level is the **base layer**.
|
||||
|
||||
## Parsing rules that bite
|
||||
|
||||
- Deserialization is YamlDotNet **without** `IgnoreUnmatchedProperties`, so **an unknown key throws**
|
||||
and the whole element is silently disabled for that content with only a logged warning
|
||||
(`GraphicsElementLoader.FromYaml<T>`). A typo is not a partial render — it is no render.
|
||||
- There is no schema validation (`GraphicsElementLoader` carries an explicit `TODO: validate schema`).
|
||||
- Colour strings are parsed with SkiaSharp's `SKColor.TryParse` (`#RRGGBB`, `#AARRGGBB`, named
|
||||
colours). Failure handling differs by field and by whether the style is the base one. An unparseable
|
||||
**background/border** colour is warned about and that part of the box is skipped. Everywhere else it
|
||||
is silent: on the **base** style an unparseable `text_color` falls back to white and an unparseable
|
||||
`halo_color` leaves the halo unset; on a **non-base** style an unparseable `text_color` OR
|
||||
`halo_color` is simply not applied, so the value inherited from the base style stands.
|
||||
- **`font_family` is required on the base style.** Non-base styles inherit it; a null one reaching
|
||||
the mapper makes `CustomFontMapper` throw on a null
|
||||
dictionary key, which the renderer swallows into "disable for this content" (#570).
|
||||
- `{{ … }}` template expressions are rendered by Scriban before the YAML is parsed.
|
||||
|
||||
## Text element fields
|
||||
|
||||
Element level (`TextGraphicsElement`):
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `name` | string | Display name; **never** used for identity. |
|
||||
| `text` | string | Scriban-templated. `[styleName]…[/styleName]` selects a style for a span. |
|
||||
| `base_style` | string | Must name an entry in `styles`, or initialization throws. |
|
||||
| `styles` | list | See below. |
|
||||
| `epg_entries` | int | How many EPG entries to expose as `Epg[…]`. |
|
||||
| `location` | enum | `TopLeft`, `BottomLeft`, … |
|
||||
| `horizontal_margin_percent` / `vertical_margin_percent` | double | Percent of frame width/height. |
|
||||
| `width_percent` | double | Percent of frame width. A wrapping/scaling **budget** for the whole element, box included — not a hard clamp; see the geometry notes. |
|
||||
| `text_fit` | `None` \| `Wrap` \| `Scale` | Only meaningful with `width_percent`. |
|
||||
| `text_align` | `Left` \| `Center` \| `Right` | |
|
||||
| `z_index` | int | |
|
||||
| `opacity_percent` | int | Ignored when `opacity_expression` is set. |
|
||||
| `opacity_expression` | string | NCalc expression, e.g. `LinearFadeDuration(content_seconds, 4, 1, 6)`. |
|
||||
| `include_fonts_from` | string | Directory of `.ttf`/`.otf` to load. |
|
||||
|
||||
Background box (#732) — element level, because the engine lays the whole element out as one text block
|
||||
and rasterises it into one bitmap, so there is exactly one box per element:
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `background_color` | string | unset | Unset means no **fill**. A `border_color` alone still draws an outlined box; with neither, there is no box and no insets. |
|
||||
| `background_opacity_percent` | int | 100 | Clamped to 0–100; multiplies the colour's own alpha, so `#AARRGGBB` and this field compose. |
|
||||
| `background_padding` | double px | 0 | Space between the text and the box edge, on all four sides. |
|
||||
| `background_corner_radius` | double px | 0 | Clamped to half the shorter side. |
|
||||
| `border_color` | string | unset | |
|
||||
| `border_width` | double px | 1 when `border_color` is set | A border colour with no width draws a hairline rather than nothing. |
|
||||
|
||||
Geometry notes:
|
||||
|
||||
- Padding and border grow the element's bitmap and are subtracted from the text's wrap/scale budget, so
|
||||
`width_percent` applies to the visible box rather than to the text inside it. The inset is rounded up
|
||||
to a whole pixel once and the same integer is used on both sides, so the box cannot exceed the budget
|
||||
by a rounding remainder.
|
||||
- **`width_percent` is a budget, not a guarantee.** Wrapping cannot break below a single glyph, so a
|
||||
narrow `width_percent` overflows with or without a box — that is pre-existing behaviour, not
|
||||
something the box introduced. `text_fit: Scale` shrinks the font instead, down to a 5px floor.
|
||||
- If the inset does not fit inside the budget at all, the **inset is clamped** (with a logged warning)
|
||||
rather than the text being squeezed to nothing, so an oversized `background_padding` stops growing
|
||||
its box at the budget instead of producing one several times the requested width.
|
||||
- The inset is **also capped at `min(frameWidth, frameHeight) / 2`**, with a logged warning, whether or
|
||||
not `width_percent` is set. Each field is bounded on its own, but padding and border add up, so
|
||||
without this a two-field mistake could allocate a bitmap far larger than the frame it is drawn onto.
|
||||
- With no box fields set the wrap/scale budget is passed through untouched, so existing overlays keep
|
||||
their exact pre-#732 geometry.
|
||||
- The box is drawn behind the text and fades with the element, so `opacity_expression` dims box and
|
||||
text together.
|
||||
- Sizes are in **pixels at frame scale**, matching `font_size`. They do not scale with resolution.
|
||||
|
||||
Per-style (`StyleDefinition`): `name`, `font_family` (required), `font_size`, `font_weight`,
|
||||
`font_italic`, `text_color`, `letter_spacing`, `line_height`, `halo_color`, `halo_width`, `halo_blur`.
|
||||
Every one of these overrides the `base_style`'s value when the style declares it — the halo fields
|
||||
did not until #732, so before that a non-base style silently rendered the base style's halo. The
|
||||
seeded `on-now-next.yml` is unaffected because all three of its styles declare the same halo: its
|
||||
rendered bitmap is byte-identical either way. That invariant holds on any host; a specific hash would
|
||||
not, because the render depends on the host's fonts (see `docs/testing.md`). Re-derive it by rendering
|
||||
`OnNowNextYaml` through `TextElement` at 1920x1080 with and without the per-style halo merge and
|
||||
comparing the encoded PNGs — they must be equal, whatever their hash is.
|
||||
|
||||
**This is an operator-visible change and belongs in the release notes.** A custom template whose
|
||||
non-base style declares its own `halo_color` / `halo_width` / `halo_blur` renders differently after
|
||||
this version — correctly, but differently. So does one using `line_height` with `text_fit: Scale`:
|
||||
the Scale path now carries line height through, which changes measured height and therefore where the
|
||||
element anchors.
|
||||
Halo is drawn by RichTextKit as part of the text, not by the box code — a wide halo **on top of** a
|
||||
background box over-darkens the glyphs, which is why the seeded template cuts it to 1px.
|
||||
|
||||
## The built-in On Now / Next element
|
||||
|
||||
`GraphicsElementSeeder` writes `on-now-next.yml` once per database (`graphics.on_now_next_seeded`) and
|
||||
never overwrites an operator's file. Two rules govern it after that:
|
||||
|
||||
- Updating the shipped default → `graphics.seeded-template-upgrade-by-fingerprint`.
|
||||
- It is attached to channels by default → `graphics.on-now-next-on-by-default`.
|
||||
|
||||
Identity is the **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the editable `Name`;
|
||||
`GraphicsElementResponseModel.BuiltIn` is derived from it server-side so the SPA never name-matches.
|
||||
|
||||
## Tests
|
||||
|
||||
`ErsatzTV.Tests/Infrastructure/Graphics/TextElementBackgroundBoxTests.cs` renders real bitmaps and
|
||||
asserts pixels — the box's failure mode is a silent no-op, which a model-level test cannot see.
|
||||
Geometry assertions are relative to a no-box baseline so they do not depend on the host's typeface,
|
||||
and `Baseline_Renders_A_Non_Empty_Bitmap` turns a fontless host into a loud red instead of a suite
|
||||
that passes vacuously on a 0x0 bitmap (see `docs/testing.md` → font dependency).
|
||||
|
||||
Attachment behaviour is covered by `GraphicsElementDefaultAttachTests`,
|
||||
`GraphicsElementSeederUpgradeTests` and `CreateChannelDefaultGraphicsElementTests`.
|
||||
|
||||
### Mutation record (#732)
|
||||
|
||||
Every clause below was mutated in turn against this tree and **the whole `ErsatzTV.Tests` project**
|
||||
re-run; the "Reddens" column is the measured failure set. The working tree was confirmed clean after
|
||||
each run. Reproduce one by making the edit and running
|
||||
`dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj` — **not** a `--filter`ed run.
|
||||
|
||||
Three traps this table is built to avoid:
|
||||
|
||||
- **Measure against the whole project, never a per-fixture filter.** A filtered run structurally
|
||||
cannot observe a red in another fixture, so it under-reports the failure set while looking precise.
|
||||
Thirteen rows below redden more than one test, and two of them span two fixture classes — those two
|
||||
are exactly what a per-fixture filter cannot see.
|
||||
- **A mutation that fails to COMPILE is not a result.** Warnings-as-error turn the obvious mutation
|
||||
shapes into build failures — `CS0162` for `if (true) { return; }`, `CS1717` for self-assignment,
|
||||
Sonar `S3981` for a constant-folded condition — and a build failure emits no test summary at all,
|
||||
which reads exactly like a pass in a scrolled log. Require a summary line, and detect build failure
|
||||
with `: error `, not `error CS`.
|
||||
- **Re-measure the red set; never carry it over.** Test renames and added cases both change which
|
||||
tests a clause reddens, so a row written from anything but a fresh run describes a tree that no
|
||||
longer exists.
|
||||
|
||||
**This table is not an enumeration of the change.** It lists the clauses that have a measured proof;
|
||||
it makes no claim about the ones that do not appear. A clause absent from it has no proof — which is a
|
||||
reason to go and check, never evidence that none is possible. A "the only uncovered clause is X"
|
||||
sentence was tried here and abandoned: it was falsified three times, each time by a different clause,
|
||||
because it silently becomes false the moment a guard is added without its row.
|
||||
|
||||
Known clauses with no red, as examples rather than a closed set. Each was measured, and each is
|
||||
recorded with why a test could not reach it — an unreachable clause is a reason to say so, not to ship
|
||||
a test that cannot fail:
|
||||
|
||||
- the `float.IsFinite` guard on the `width_percent` budget — .NET saturates float→int conversion, so
|
||||
removing it changes nothing observable;
|
||||
- the `insetPixels == 0 ? maxWidth : …` compatibility branch — a `width_percent` that rounds to zero
|
||||
renders identically whether RichTextKit is given a `MaxWidth` of 0 or 1;
|
||||
- the absent-target early return and the temp-file existence check in `UpgradeUnmodifiedTemplate` —
|
||||
behaviourally redundant; the first re-raises as a caught `IOException`, the second guards a delete
|
||||
that is already a no-op;
|
||||
- the inner `catch` around that cleanup delete — it stops a failing delete from replacing the
|
||||
exception being unwound (which C# otherwise does, and which would downgrade a real cancellation to a
|
||||
swallowed warning). `MockFileSystem` does not propagate an interceptor throw raised during the
|
||||
delete, so the clause is correct by language semantics but not reachable from a test here.
|
||||
|
||||
Rows are written per clause, not per block: a row naming a whole `if` or a whole style-merge block
|
||||
hides which individual fields inside it are actually reachable from a test.
|
||||
|
||||
| # | Clause mutated | Reddens (measured) |
|
||||
|---|---|---|
|
||||
| 1 | DrawBackgroundBox returns immediately | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element`<br>`Background_Color_Fills_The_Box`<br>`Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha`<br>`Background_Padding_Actually_Insets_The_Text`<br>`Border_Color_Draws_A_Border_Distinct_From_The_Fill`<br>`Border_Color_Without_An_Explicit_Width_Draws_A_Hairline`<br>`Corner_Radius_Rounds_The_Corner_Away` |
|
||||
| 2 | inset not subtracted from the wrap budget | `Fractional_Padding_Still_Respects_Width_Percent`<br>`Width_Percent_Bounds_The_Whole_Box_Including_Padding` |
|
||||
| 3 | text painted at (0,0) instead of the inset | `Background_Padding_Actually_Insets_The_Text` |
|
||||
| 4 | ApplyOpacityPercent returns the colour unchanged | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha` |
|
||||
| 5 | UpgradeUnmodifiedTemplate call removed | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`An_Unwritable_Template_Does_Not_Fail_Startup`<br>`The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style`<br>`Upgrades_An_Untouched_Previous_Default`<br>`Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
|
||||
| 6 | fingerprint check ignored (upgrade unconditionally) | `Leaves_An_Operator_Modified_File_Alone` |
|
||||
| 7 | line-ending normalisation dropped from Normalize | `Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
|
||||
| 8 | HLS-Direct exclusion removed from the backfill | `Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render` |
|
||||
| 9 | already-attached filter removed | `Does_Not_Duplicate_An_Existing_Attachment`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||||
| 10 | filename check removed from the built-in lookup | `Ignores_A_Non_Built_In_Element_With_A_Different_Filename` |
|
||||
| 11 | graphics.on_now_next_default_attached guard never fires | `Does_Not_Re_Attach_After_An_Operator_Clears_It` |
|
||||
| 12 | create-time `ChannelGraphicsDefaults.Attach` call removed from `CreateChannelHandler` | `Attaches_The_Built_In_Element_To_A_New_Channel` |
|
||||
| 13 | HLS-Direct skip removed from the create path | `Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There`<br>`Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel` |
|
||||
| 14 | upgrade no longer catches filesystem exceptions | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`A_Read_Failure_On_The_Template_Does_Not_Fail_Startup`<br>`An_Unwritable_Template_Does_Not_Fail_Startup` |
|
||||
| 15 | FitTextBlock drops HaloColor again | `The_Scale_Path_Preserves_Halo_Blur`<br>`The_Scale_Path_Preserves_The_Halo` |
|
||||
| 16 | unrounded inset subtracted from the budget | `Fractional_Padding_Still_Respects_Width_Percent` |
|
||||
| 17 | inset clamp removed | `An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits` |
|
||||
| 18 | Kind == Text filter dropped from the lookup | `Ignores_A_Same_Named_Element_Of_A_Different_Kind` |
|
||||
| 19 | Sanitize passes values through unchecked | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry` |
|
||||
| 20 | the BOX itself is no longer clamped (only insetPixels) | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element` |
|
||||
| 21 | duplicate guard removed from EnsureBuiltInElementRow | `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||||
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||||
| 23 | non-atomic in-place write restored | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact` |
|
||||
| 24 | marker written even with nothing resolved | `Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||||
| 25 | the atomic replace becomes a non-atomic copy | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact` |
|
||||
| 26 | the frame cap on the inset is disarmed | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry`<br>`The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent` |
|
||||
| 27 | per-style halo overrides dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur`<br>`A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles` |
|
||||
| 28 | FitTextBlock drops HaloBlur from the rebuilt style | `The_Scale_Path_Preserves_Halo_Blur` |
|
||||
| 29 | FitTextBlock drops LineHeight from the rebuilt style | `The_Scale_Path_Preserves_Line_Height` |
|
||||
| 30 | per-style halo_blur override dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur` |
|
||||
| 31 | the 0-100 opacity clamp is removed | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range` |
|
||||
| 32 | the lineup create path stops attaching the built-in element | `Should_Attach_The_Built_In_On_Now_Next_Element` |
|
||||
+9
-1
@@ -5,9 +5,17 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
|
||||
|
||||
## Test projects
|
||||
|
||||
> **Font dependency (ersatztv#732).** `Infrastructure/Graphics/TextElementBackgroundBoxTests` is the
|
||||
> only suite that rasterises text, so it needs at least one system font to lay anything out. The CI
|
||||
> image installs none explicitly — fonts arrive via `playwright install --with-deps` in
|
||||
> `docker/ci/Dockerfile` (351 present in the pinned image, measured 2026-08-26). This is a real
|
||||
> dependency, declared here so a future slimming of that install produces a known cause rather than a
|
||||
> mystery red. `Baseline_Renders_A_Non_Empty_Bitmap` exists to make that failure loud: without it a
|
||||
> fontless host would render a 0x0 bitmap and every relative geometry assertion would pass vacuously.
|
||||
|
||||
| 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(...)`. ~1,870 tests (approximate on purpose — an exact count goes stale on every PR that adds one; the previous hardcoded 828 was off by over a thousand). |
|
||||
| `ErsatzTV.Tests` | API controllers + MediatR handlers, plus the SkiaSharp text-overlay rasteriser (`Infrastructure/Graphics/`) | 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(...)`. ~1,870 tests (approximate on purpose — an exact count goes stale on every PR that adds one; the previous hardcoded 828 was off by over a thousand). |
|
||||
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. ~650 tests + 1 skipped under `TZ=UTC` (the Block playout golden additionally skips under a non-UTC `TZ`; see Golden-file nets). |
|
||||
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests` — `[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
|
||||
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
|
||||
|
||||
Reference in New Issue
Block a user