Files
ersatztv/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorDecoResolutionTests.cs
T
timothy bc1a37ff01
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Review verdict / Set review-verdict status (pull_request) Successful in 4s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / Script tests (pytest) (pull_request) Successful in 35s
review-verdict/h10 Review-verdict: MERGEABLE @ bc1a37f
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m32s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m17s
fix(510): pin the blank-guard's is-Custom discriminator, verified by mutation
Round-4 review found the fall-through tests, while now falsifiable, still did
not pin the whole guard. Two gaps, both closed and both verified by running the
mutation rather than by asserting the test would catch it:

1. All three fall-through tests used only "   ", so narrowing
   IsNullOrWhiteSpace to `image == "   "` would have passed while breaking
   fall-through for null and "" -- and null is the form the API actually
   persists. Parameterized over null, "" and "   ".

2. Nothing pinned the guard's `ImageSource is Custom` clause. This is the
   sharper of the two: a ChannelLogo watermark's Image is NORMALLY blank
   (the API writes Image = null for every non-Custom source), so dropping the
   discriminator would send every playout-item ChannelLogo watermark down the
   fall-through path instead of resolving the channel's own logo -- with a
   fully green suite. Added
   Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through,
   which distinguishes the two levels by watermark Id so a fall-through is
   observable even though both resolve to the same cached path.

Mutation results (each mutation applied on its own, then reverted):

  drop `is Custom` from the guard  -> 1 failure, and the new test is the ONLY
                                      test that catches it
  IsNullOrWhiteSpace -> == "   "   -> the null and "" parameterized cases fail

Negative control re-measured on the final 32-case fixture: 19 fail against the
origin/main resolver. The 13 that pass both ways pin deliberately preserved
behavior plus the positive control, which the record now states explicitly
along with the mutation table.

This round's lesson, recorded in the record: a test's NAME is not evidence it
pins what it claims, and a whole-file revert cannot show that a test aimed at a
specific clause actually reaches that clause -- only mutating the clause can.

Gates: 2661 tests green across 4 projects, 225/225 script tests (the gate I
skipped before the last push), decisions-validate OK, format exit 0, no BOMs.

refs #510
2026-07-26 21:53:01 +02:00

590 lines
27 KiB
C#

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;
/// <summary>
/// Pins ersatztv#510: a watermark attached through a DECO resolves by exactly the same policy as the three
/// precedence levels (playout item, channel, global).
/// </summary>
/// <remarks>
/// 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 <see cref="ChannelWatermarkImageSource" /> values, not just
/// <c>ChannelLogo</c>:
/// <list type="bullet">
/// <item>a missing local file was handed downstream as a dead path (and a dead LOCAL path can reach
/// ffmpeg as a bare <c>-i</c> argument via <c>CanUseFFmpegNativeWatermark</c>, so it is worse than a
/// skipped overlay);</item>
/// <item>an un-migrated external-URL logo was handed down as a renderable URL, which
/// <c>graphics.channel-logo-caching</c> (#525) forbids the render path from fetching;</item>
/// <item>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.</item>
/// </list>
/// The <c>Deco_And_Channel_Level_Should_Resolve_Identically</c> 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.
/// </remarks>
[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);
/// <summary>Builds a selector whose mock filesystem contains exactly <paramref name="existingFiles" />.</summary>
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<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
.Returns(_ => LogoCachePath);
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
.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<string>(s => string.IsNullOrWhiteSpace(s)),
Arg.Any<ArtworkKind>(),
Arg.Any<Option<int>>())
.Returns<string>(_ => throw new ArgumentOutOfRangeException(nameof(IImageCache.GetPathForImage)));
var decoSelector = Substitute.For<IDecoSelector>();
decoSelector.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>())
.Returns(new DecoEntries(Option<Deco>.None, Optional(playoutDeco)));
return new WatermarkSelector(
mockFileSystem,
fakeImageCache,
decoSelector,
NullLogger<WatermarkSelector>.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<WatermarkOptions> SelectViaDeco(
ChannelWatermark watermark,
Channel channel,
params string[] existingFiles)
{
WatermarkSelector selector = Selector(DecoWith(watermark), existingFiles);
return selector.SelectWatermarks(
Option<ChannelWatermark>.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<WatermarkOptions> 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<WatermarkOptions> 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<WatermarkOptions> result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void Deco_ChannelLogo_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(null);
List<WatermarkOptions> 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<WatermarkOptions> 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<WatermarkOptions> 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<WatermarkOptions> 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<WatermarkOptions> 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<WatermarkOptions> 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.
/// <summary>
/// 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.
/// </summary>
[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<WatermarkOptions> result = Selector(null, CustomCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.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);
}
/// <summary>
/// Before #510 the channel and global arms had no blank-image guard, so they reached
/// <c>ImageCache.GetPathForImage</c> whose <c>fileName[..2]</c> threw out of stream startup. Now a
/// warning plus no watermark.
/// </summary>
[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<WatermarkOptions> result = Should.NotThrow(
() => Selector(null).GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None));
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// 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".
/// </summary>
[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<WatermarkOptions> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
/// cached file exists. An earlier version of this test gave the fallback the same missing custom path as
/// the playout-item watermark, which made it unfalsifiable: a wrongly-widened guard would have fallen
/// through to a fallback that also resolved to None, so the assertion held either way.
/// </remarks>
[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<WatermarkOptions> result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Parameterized over all three blank forms because the guard is <c>IsNullOrWhiteSpace</c>: testing only
/// <c>" "</c> would let a mutation to <c>image == " "</c> pass while silently breaking fall-through
/// for <c>null</c> and <c>""</c> — and <c>null</c> is the form the API actually persists.
/// </remarks>
[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<WatermarkOptions> result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
result.IsSome.ShouldBeTrue();
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LogoCachePath);
}
/// <summary>
/// Pins the <c>ImageSource is Custom</c> half of the blank-image guard, which nothing else covers.
/// </summary>
/// <remarks>
/// A <c>ChannelLogo</c> watermark's <c>Image</c> 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.
/// </remarks>
[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<WatermarkOptions> result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.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);
}
/// <summary>
/// `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.
/// </summary>
[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<WatermarkOptions> result = Should.NotThrow(
() => Selector(null).GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None));
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Dropping an unresolvable watermark shortens the list handed to
/// <c>CanUseFFmpegNativeWatermark</c>, 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).
/// </summary>
[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<WatermarkOptions> result = Selector(deco, LogoCachePath).SelectWatermarks(
Option<ChannelWatermark>.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();
}
/// <summary>
/// Before #510 the global arm had no <c>Resource</c> case and hit <c>default: throw</c>.
/// </summary>
[Test]
public void Global_Level_Resource_Watermark_Should_Resolve_Instead_Of_Throwing()
{
ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
Channel channel = ChannelWith(LogoStoredPath);
Option<WatermarkOptions> result = Should.NotThrow(
() => Selector(null, ResourcePath).GetWatermarkOptions(
channel,
Option<ChannelWatermark>.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<TestCaseData> 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<string>())
.SetName("ChannelLogo, local file missing");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", ExternalLogoUrl, Array.Empty<string>())
.SetName("ChannelLogo, external URL");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", null, Array.Empty<string>())
.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<string>())
.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<string>())
.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<string>())
.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<WatermarkOptions> 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<WatermarkOptions> viaChannel = Selector(null, existingFiles)
.GetWatermarkOptions(channel, Option<ChannelWatermark>.None, Option<ChannelWatermark>.None);
List<string> decoPaths = viaDeco.Select(o => o.ImagePath).ToList();
// built explicitly rather than via Option.ToList(), which yields a LanguageExt Lst<string>
var channelPaths = new List<string>();
viaChannel.IfSome(o => channelPaths.Add(o.ImagePath));
decoPaths.ShouldBe(channelPaths);
}
}