using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
///
/// Pins ersatztv#510: a watermark attached through a DECO resolves by exactly the same policy as the three
/// precedence levels (playout item, channel, global).
///
///
/// Before #510 the deco path had its own copy of the image-source switch that resolved paths *unchecked*, so
/// one channel could disagree with itself about whether a bug rendered purely by how the watermark was
/// attached. The divergence covered all three values, not just
/// ChannelLogo :
///
/// - a missing local file was handed downstream as a dead path (and a dead LOCAL path can reach
/// ffmpeg as a bare
-i argument via CanUseFFmpegNativeWatermark , so it is worse than a
/// skipped overlay);
/// - an un-migrated external-URL logo was handed down as a renderable URL, which
///
graphics.channel-logo-caching (#525) forbids the render path from fetching;
/// - a channel with no logo artwork got the generated-initials localhost URL, which a live-E2E on a
/// real transcoded frame confirmed DID render — the deco path only. #510 resolved that split in favour
/// of "no on-screen bug" everywhere.
///
/// The Deco_And_Channel_Level_Should_Resolve_Identically cases are the structural guard: they assert
/// the two callers agree, so re-introducing a per-caller policy fails here rather than silently in prod.
///
[TestFixture]
public class WatermarkSelectorDecoResolutionTests
{
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
private const string LogoStoredPath = "abc123.png";
private const string LogoCachePath = "/cache/logos/ab/abc123.png";
private const string CustomStoredPath = "def456.png";
private const string CustomCachePath = "/cache/watermarks/de/def456.png";
private const string ResourceImage = "song-progress.png";
private static string ResourcePath => Path.Combine(FileSystemLayout.ResourcesCacheFolder, ResourceImage);
/// Builds a selector whose mock filesystem contains exactly .
private static WatermarkSelector Selector(Deco playoutDeco, params string[] existingFiles)
{
// one Initialize() call, chained -- calling it per file would leave "does a second Initialize()
// preserve the first file?" untested, and a silently under-seeded filesystem makes a
// "resolves to nothing" assertion pass for the wrong reason
var mockFileSystem = new MockFileSystem();
if (existingFiles.Length > 0)
{
var initialized = mockFileSystem.Initialize().WithFile(existingFiles[0]);
foreach (string file in existingFiles.Skip(1))
{
initialized = initialized.WithFile(file);
}
}
var fakeImageCache = Substitute.For();
fakeImageCache.GetPathForImage(Arg.Any(), Arg.Is(ArtworkKind.Logo), Arg.Any>())
.Returns(_ => LogoCachePath);
fakeImageCache.GetPathForImage(Arg.Any(), Arg.Is(ArtworkKind.Watermark), Arg.Any>())
.Returns(_ => CustomCachePath);
// Faithful to the real ImageCache.GetPathForImage, which does fileName[..2] and therefore THROWS on a
// blank/null name. Modelling that is what makes the blank-image guard tests mutation-sensitive: before
// #510 the channel and global arms had no guard and this threw out of stream startup.
fakeImageCache
.GetPathForImage(
Arg.Is(s => string.IsNullOrWhiteSpace(s)),
Arg.Any(),
Arg.Any>())
.Returns(_ => throw new ArgumentOutOfRangeException(nameof(IImageCache.GetPathForImage)));
var decoSelector = Substitute.For();
decoSelector.GetDecoEntries(Arg.Any(), Arg.Any())
.Returns(new DecoEntries(Option.None, Optional(playoutDeco)));
return new WatermarkSelector(
mockFileSystem,
fakeImageCache,
decoSelector,
NullLogger.Instance);
}
private static ChannelWatermark Watermark(ChannelWatermarkImageSource source, string image = "") =>
new()
{
Id = 7,
Name = "Deco Bug",
ImageSource = source,
Image = image,
Mode = ChannelWatermarkMode.Permanent
};
private static Deco DecoWith(ChannelWatermark watermark) =>
new()
{
Id = 1,
Name = "Test Deco",
WatermarkMode = DecoMode.Override,
UseWatermarkDuringFiller = true,
DecoWatermarks = [new DecoWatermark { WatermarkId = watermark.Id, Watermark = watermark }],
Watermarks = []
};
private static Channel ChannelWith(string logoPath, ChannelWatermark channelWatermark = null)
{
var channel = new Channel(Guid.Empty)
{
Id = 1,
Number = "1",
Name = "Test",
StreamingMode = StreamingMode.TransportStream,
Artwork = [],
Watermark = channelWatermark,
WatermarkId = channelWatermark?.Id
};
if (logoPath is not null)
{
channel.Artwork.Add(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = logoPath });
}
return channel;
}
private static PlayoutItem PlayoutItem() =>
new()
{
FillerKind = FillerKind.None,
DisableWatermarks = false,
Watermarks = [],
Playout = new Playout()
};
private static List SelectViaDeco(
ChannelWatermark watermark,
Channel channel,
params string[] existingFiles)
{
WatermarkSelector selector = Selector(DecoWith(watermark), existingFiles);
return selector.SelectWatermarks(
Option.None,
channel,
PlayoutItem(),
DateTimeOffset.Now);
}
// ---- positive control: the arrangement CAN produce a watermark ------------------------------
//
// Without this, every "resolves to nothing" assertion below could pass vacuously (a broken deco
// arrangement that never reaches the resolver at all looks identical to a correct refusal).
[Test]
public void Deco_ChannelLogo_Should_Use_Cached_Path_When_Local_Logo_Exists()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(LogoStoredPath);
List result = SelectViaDeco(watermark, channel, LogoCachePath);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(LogoCachePath);
}
// ---- ChannelLogo: the three cases #510 was filed for ----------------------------------------
[Test]
public void Deco_ChannelLogo_Should_Be_Ignored_When_Logo_Is_An_External_Url()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(ExternalLogoUrl);
List result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
[Test]
public void Deco_ChannelLogo_Should_Be_Ignored_When_Local_Logo_File_Is_Missing()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(LogoStoredPath);
// nothing on disk
List result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
///
/// The #510 policy decision: with no logo artwork the generated-initials fallback is NOT used. It
/// genuinely rendered here before (confirmed by live-E2E on a real frame), so this is a deliberate,
/// recorded behavior change — not a no-op cleanup.
///
[Test]
public void Deco_ChannelLogo_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(null);
List result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
// Folded in from a separate test that asserted only this. On its own it was vacuous — an empty list
// trivially contains no URL — so it is a second assertion here rather than a test implying independent
// coverage. It earns its place by naming the value if this ever starts returning options again (#652).
result.Select(o => o.ImagePath)
.ShouldNotContain(ChannelLogoGenerator.GenerateChannelLogoUrl(channel));
}
// ---- Custom and Resource: the two arms #510 did not mention but that diverged too -----------
[Test]
public void Deco_Custom_Should_Use_Cached_Path_When_File_Exists()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
Channel channel = ChannelWith(LogoStoredPath);
List result = SelectViaDeco(watermark, channel, CustomCachePath);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(CustomCachePath);
}
[Test]
public void Deco_Custom_Should_Be_Ignored_When_File_Is_Missing()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
Channel channel = ChannelWith(LogoStoredPath);
List result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
[Test]
public void Deco_Custom_Should_Be_Ignored_When_Image_Is_Blank()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
Channel channel = ChannelWith(LogoStoredPath);
List result = SelectViaDeco(watermark, channel, CustomCachePath);
result.ShouldBeEmpty();
}
[Test]
public void Deco_Resource_Should_Use_Resource_Path_When_File_Exists()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
Channel channel = ChannelWith(LogoStoredPath);
List result = SelectViaDeco(watermark, channel, ResourcePath);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(ResourcePath);
}
[Test]
public void Deco_Resource_Should_Be_Ignored_When_File_Is_Missing()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
Channel channel = ChannelWith(LogoStoredPath);
List result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
// ---- non-deco consequences of the SAME unification -------------------------------------------
//
// These pin precedence-level behavior rather than deco behavior, but they exist because of the #510
// unification: one is the single piece of per-caller policy deliberately kept, the others are arms that
// used to throw. Without them a future refactor can delete the survivor, or re-introduce the crash, with
// a fully green suite.
///
/// The one surviving per-caller policy: a playout-item `Custom` watermark with a blank image falls
/// THROUGH to the channel/global watermark rather than resolving to "no watermark". Unifying
/// resolution must not change which watermark WINS.
///
[Test]
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Channel_Watermark()
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option result = Selector(null, CustomCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option.None);
// the CHANNEL watermark wins -- not None, and not the blank playout-item one
result.IsSome.ShouldBeTrue();
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
options.ImagePath.ShouldBe(CustomCachePath);
options.Watermark.Id.ShouldBe(8);
}
///
/// Before #510 the channel and global arms had no blank-image guard, so they reached
/// ImageCache.GetPathForImage whose fileName[..2] threw out of stream startup. Now a
/// warning plus no watermark.
///
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Channel_Level_Blank_Custom_Watermark_Should_Resolve_To_None_Not_Throw(string image)
{
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Custom, image);
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option result = Should.NotThrow(
() => Selector(null).GetWatermarkOptions(
channel,
Option.None,
Option.None));
result.IsNone.ShouldBeTrue();
}
///
/// Same fall-through, but landing on the GLOBAL watermark — the channel-level variant above cannot
/// distinguish "fell through correctly" from "stopped at the channel by accident".
///
[Test]
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Global_Watermark()
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
globalWatermark.Id = 9;
// no channel-level watermark, so the only remaining candidate is the global one
Channel channel = ChannelWith(LogoStoredPath);
Option result = Selector(null, CustomCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, globalWatermark);
result.IsSome.ShouldBeTrue();
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
options.ImagePath.ShouldBe(CustomCachePath);
options.Watermark.Id.ShouldBe(9);
}
///
/// The complement of the fall-through cases: a NON-blank custom image whose file is merely missing must
/// NOT fall through — it resolves to "no watermark" and the channel watermark never gets a turn.
/// Without this, widening the blank-image guard to "any unresolvable custom" would pass unnoticed.
///
///
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
/// cached file exists. Giving the fallback the same missing custom path as the playout-item watermark
/// would make the test unfalsifiable: a wrongly-widened guard would fall through to a fallback that also
/// resolves to None, so the assertion would hold either way.
///
[Test]
public void Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through()
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
// the channel logo's cached file EXISTS, so a fall-through would return it and fail this test;
// the custom watermark's file does not, so the playout-item watermark is unresolvable
Option result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option.None);
result.IsNone.ShouldBeTrue();
}
///
/// Positive control for the test above: the same arrangement, but with the playout-item watermark BLANK
/// rather than missing, must fall through and return the resolvable channel logo. Together the pair
/// shows the guard distinguishes blank from unresolvable, rather than both landing on None.
///
///
/// Parameterized over all three blank forms because the guard is IsNullOrWhiteSpace : testing only
/// " " would let a mutation to image == " " pass while silently breaking fall-through
/// for null and "" — and null is the form the API actually persists.
///
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_A_Resolvable_Channel_Logo(string image)
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, image);
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option.None);
result.IsSome.ShouldBeTrue();
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LogoCachePath);
}
///
/// Pins the ImageSource is Custom half of the blank-image guard, which nothing else covers.
///
///
/// A ChannelLogo watermark's Image is NORMALLY blank — the API persists `Image = null` for
/// every non-`Custom` source — so if the guard's `is Custom` discriminator were dropped, leaving only
/// `IsNullOrWhiteSpace(Image)`, every playout-item `ChannelLogo` watermark would fall through to
/// channel/global instead of resolving the channel's own logo. This test fails on that mutation: the
/// playout-item watermark carries a distinguishing Id, so falling through is observable even though both
/// levels would resolve to the same cached path.
///
[Test]
public void Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through()
{
// Image is left blank, exactly as the API stores a ChannelLogo watermark
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
playoutItemWatermark.Id = 42;
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option.None);
result.IsSome.ShouldBeTrue();
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
options.ImagePath.ShouldBe(LogoCachePath);
// the PLAYOUT-ITEM watermark won; a fall-through would have returned the channel's (Id 8)
options.Watermark.Id.ShouldBe(42);
}
///
/// `CreateWatermarkHandler`/`UpdateWatermarkHandler` write `Image = null` for every non-`Custom`
/// watermark, so an API-created `Resource` watermark hits `Path.Combine(folder, null)` — an
/// `ArgumentNullException` out of stream startup. Uses the persisted shape (null), not a hand-made
/// filename, which is what the rest of the fixture would otherwise assume.
///
[TestCase(null)]
[TestCase("")]
public void Resource_Watermark_With_No_Image_Name_Should_Resolve_To_None_Not_Throw(string image)
{
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Resource, image);
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option result = Should.NotThrow(
() => Selector(null).GetWatermarkOptions(
channel,
Option.None,
Option.None));
result.IsNone.ShouldBeTrue();
}
///
/// Dropping an unresolvable watermark shortens the list handed to
/// CanUseFFmpegNativeWatermark , whose predicate includes `Count == 1`. So this is also the pin on
/// the observable routing change: two attached permanent watermarks, one missing, now yield ONE option
/// (ffmpeg-native) where they previously yielded two (graphics engine).
///
[Test]
public void Deco_With_One_Valid_And_One_Missing_Watermark_Should_Return_Only_The_Valid_One()
{
ChannelWatermark valid = Watermark(ChannelWatermarkImageSource.ChannelLogo);
ChannelWatermark missing = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
missing.Id = 8;
var deco = new Deco
{
Id = 1,
Name = "Test Deco",
WatermarkMode = DecoMode.Override,
UseWatermarkDuringFiller = true,
DecoWatermarks =
[
new DecoWatermark { WatermarkId = valid.Id, Watermark = valid },
new DecoWatermark { WatermarkId = missing.Id, Watermark = missing }
],
Watermarks = []
};
// only the channel logo's cached file exists; the custom watermark's does not
List result = Selector(deco, LogoCachePath).SelectWatermarks(
Option.None,
ChannelWith(LogoStoredPath),
PlayoutItem(),
DateTimeOffset.Now);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(LogoCachePath);
// The routing claim itself, not just the filtering: call the real predicate. Asserting Count == 1 alone
// would leave the decision record's "now routes ffmpeg-native" statement unpinned, since the decision
// lives in FFmpegLibraryProcessService rather than in the selector.
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, result).ShouldBeTrue();
}
///
/// Before #510 the global arm had no Resource case and hit default: throw .
///
[Test]
public void Global_Level_Resource_Watermark_Should_Resolve_Instead_Of_Throwing()
{
ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
Channel channel = ChannelWith(LogoStoredPath);
Option result = Should.NotThrow(
() => Selector(null, ResourcePath).GetWatermarkOptions(
channel,
Option.None,
globalWatermark));
result.IsSome.ShouldBeTrue();
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(ResourcePath);
}
// ---- the structural guard: deco and channel-level must agree, case for case ------------------
private static IEnumerable ParityCases()
{
// (image source, watermark.Image, channel logo path, files that exist)
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", LogoStoredPath, new[] { LogoCachePath })
.SetName("ChannelLogo, local file present");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", LogoStoredPath, Array.Empty())
.SetName("ChannelLogo, local file missing");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", ExternalLogoUrl, Array.Empty())
.SetName("ChannelLogo, external URL");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", null, Array.Empty())
.SetName("ChannelLogo, no logo artwork");
yield return new TestCaseData(
ChannelWatermarkImageSource.Custom, CustomStoredPath, LogoStoredPath, new[] { CustomCachePath })
.SetName("Custom, file present");
yield return new TestCaseData(
ChannelWatermarkImageSource.Custom, CustomStoredPath, LogoStoredPath, Array.Empty())
.SetName("Custom, file missing");
// Both sides agree here by construction (each returns nothing), which is the point: it documents that
// the blank-image fall-through asymmetry lives ONLY at the playout-item level -- covered by
// Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Channel_Watermark -- rather than leaving
// the omission looking like an evasion.
yield return new TestCaseData(
ChannelWatermarkImageSource.Custom, " ", LogoStoredPath, Array.Empty())
.SetName("Custom, blank image");
yield return new TestCaseData(
ChannelWatermarkImageSource.Resource, ResourceImage, LogoStoredPath, new[] { ResourcePath })
.SetName("Resource, file present");
yield return new TestCaseData(
ChannelWatermarkImageSource.Resource, ResourceImage, LogoStoredPath, Array.Empty())
.SetName("Resource, file missing");
}
[TestCaseSource(nameof(ParityCases))]
public void Deco_And_Channel_Level_Should_Resolve_Identically(
ChannelWatermarkImageSource source,
string image,
string logoPath,
string[] existingFiles)
{
// deco path
ChannelWatermark decoWatermark = Watermark(source, image);
List viaDeco = SelectViaDeco(decoWatermark, ChannelWith(logoPath), existingFiles);
// channel precedence level, same watermark definition and same channel
ChannelWatermark channelWatermark = Watermark(source, image);
Channel channel = ChannelWith(logoPath, channelWatermark);
Option viaChannel = Selector(null, existingFiles)
.GetWatermarkOptions(channel, Option.None, Option.None);
List decoPaths = viaDeco.Select(o => o.ImagePath).ToList();
// built explicitly rather than via Option.ToList(), which yields a LanguageExt Lst
var channelPaths = new List();
viaChannel.IfSome(o => channelPaths.Add(o.ImagePath));
decoPaths.ShouldBe(channelPaths);
}
}