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; /// /// 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. /// [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 state) where TState : notnull => null!; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception exception, Func 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.Instance)); var textElement = new TextElement(fonts, element, new ThrowingLogger()); var context = new GraphicsEngineContext( "1", null, [], new Dictionary(), 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 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 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"); } }