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