Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
590 lines
27 KiB
C#
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. 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.
|
|
/// </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);
|
|
}
|
|
}
|