Files
ersatztv/ErsatzTV.Tests/Infrastructure/Graphics/TextElementBackgroundBoxTests.cs
T
timothyandtimothy ba6a4b08aa
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
feat(732): On Now / Next gets a background box, and is on by default (#843)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 19:28:25 +00:00

675 lines
25 KiB
C#

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");
}
}