using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.State; using ErsatzTV.Infrastructure.Streaming.Graphics; using LanguageExt; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.PixelFormats; using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Infrastructure.Tests.Streaming.Graphics; /// /// The degradation contract for a remote watermark image: whatever the fetcher throws, the /// element disables itself and the stream survives. (ersatztv#511) /// [TestFixture] public class WatermarkElementRemoteImageTests { private const string RemoteLogo = "https://example.com/logo.png"; private static readonly Exception[] FetchFailures = [ new TimeoutException("timed out fetching remote image"), new InvalidOperationException("remote image exceeds the byte limit"), new HttpRequestException("no route to host") ]; [TestCaseSource(nameof(FetchFailures))] public async Task Should_Disable_The_Watermark_When_The_Fetch_Fails(Exception failure) { var fetcher = Substitute.For(); fetcher.Fetch(Arg.Any(), Arg.Any()).Returns>(_ => throw failure); var element = new WatermarkElement( RemoteWatermarkOptions(), fetcher, Substitute.For()); // must not throw -- a killed graphics element must never propagate into the stream await element.InitializeAsync(Context(), CancellationToken.None); element.IsFinished.ShouldBeTrue(); } // the counterpart: caller cancellation is not a fetch failure, but it must still not escape // into the streaming pipeline as an unhandled exception [Test] public async Task Should_Disable_The_Watermark_When_The_Caller_Cancels() { var fetcher = Substitute.For(); fetcher.Fetch(Arg.Any(), Arg.Any()) .Returns>(_ => throw new OperationCanceledException()); var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For()); await element.InitializeAsync(Context(), CancellationToken.None); element.IsFinished.ShouldBeTrue(); } // a remote path really does route to the fetcher -- without this the tests above would pass // even if LoadImage stopped recognising http(s) urls [Test] public async Task Should_Route_A_Remote_Path_Through_The_Fetcher() { var fetcher = Substitute.For(); fetcher.Fetch(Arg.Any(), Arg.Any()) .Returns>(_ => throw new TimeoutException()); var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For()); await element.InitializeAsync(Context(), CancellationToken.None); await fetcher.Received(1).Fetch(new Uri(RemoteLogo), Arg.Any()); } // ...and a local path must not touch the network at all [Test] public async Task Should_Not_Use_The_Fetcher_For_A_Local_Path() { var fetcher = Substitute.For(); var options = new WatermarkOptions(Watermark(), "/no/such/logo.png", Option.None); var element = new WatermarkElement(options, fetcher, Substitute.For()); await element.InitializeAsync(Context(), CancellationToken.None); await fetcher.DidNotReceive().Fetch(Arg.Any(), Arg.Any()); element.IsFinished.ShouldBeTrue(); } // --- the RETENTION budget is wired in, and is remote-only (M4 from re-review) --- // // These two differ ONLY in the scale percent, so together they detect deletion of the // `if (isRemoteUri) EnsureScaledFramesAffordable(...)` call site: without it the first case // would succeed. The source is trivial to decode (300 x 64x64 = 1.2 MP, well inside the decode // budget) but retains ~2.5 GB of SKBitmap once every frame is scaled to 1080p. [Test] public async Task Should_Disable_The_Watermark_When_Scaled_Frames_Blow_The_Retention_Budget() { var element = new WatermarkElement( RemoteWatermarkOptions(widthPercent: 100), FetcherReturning(Apng(64, 64, 300)), Substitute.For()); await element.InitializeAsync(Context(), CancellationToken.None); element.IsFinished.ShouldBeTrue(); } [Test] public async Task Should_Keep_The_Watermark_When_The_Same_Animation_Is_Scaled_Small() { var element = new WatermarkElement( RemoteWatermarkOptions(widthPercent: 10), FetcherReturning(Apng(64, 64, 300)), Substitute.For()); await element.InitializeAsync(Context(), CancellationToken.None); element.IsFinished.ShouldBeFalse(); } private static IRemoteImageFetcher FetcherReturning(Stream stream) { var fetcher = Substitute.For(); fetcher.Fetch(Arg.Any(), Arg.Any()).Returns(stream); return fetcher; } private static MemoryStream Apng(int width, int height, int frames) { using var image = new Image(width, height); for (var i = 1; i < frames; i++) { image.Frames.CreateFrame(); } var stream = new MemoryStream(); image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha }); stream.Position = 0; return stream; } private static WatermarkOptions RemoteWatermarkOptions(double widthPercent = 10) => new(Watermark(widthPercent), RemoteLogo, Option.None); private static ChannelWatermark Watermark(double widthPercent = 10) => new() { Name = "test", Mode = ChannelWatermarkMode.Permanent, Location = WatermarkLocation.BottomRight, Size = WatermarkSize.Scaled, WidthPercent = widthPercent, HorizontalMarginPercent = 5, VerticalMarginPercent = 5, Opacity = 100 }; private static GraphicsEngineContext Context() => new( "1", null, [], [], new Resolution { Width = 1920, Height = 1080 }, new Resolution { Width = 1920, Height = 1080 }, new FrameRate("30"), DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, TimeSpan.Zero, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); }