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

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
This commit was merged in pull request #843.
This commit is contained in:
2026-08-26 19:28:25 +00:00
committed by timothy
parent f2551b778e
commit ba6a4b08aa
22 changed files with 2501 additions and 27 deletions
@@ -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();
+3
View File
@@ -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();
+5
View File
@@ -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.
+2
View File
@@ -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.
+218
View File
@@ -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 0100; 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
View File
@@ -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. |