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