208 lines
7.8 KiB
C#
208 lines
7.8 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.FFmpeg;
|
|
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>
|
|
/// Covers <see cref="ChannelWatermarkImageSource.ChannelLogo" /> resolution at all three watermark
|
|
/// precedence levels (playout item, channel, global). The shared fixture in
|
|
/// <see cref="WatermarkSelectorTests" /> deliberately makes every watermark file exist, so it cannot
|
|
/// express the "logo is an external URL" or "logo file is gone" cases this fixture exists for (#502).
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class WatermarkSelectorChannelLogoTests
|
|
{
|
|
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
|
|
private const string LocalLogoPath = "abc123.png";
|
|
private const string LocalLogoCachePath = "/cache/logos/ab/abc123.png";
|
|
|
|
private WatermarkSelector _selector;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
var mockFileSystem = new MockFileSystem();
|
|
mockFileSystem.Initialize().WithFile(LocalLogoCachePath);
|
|
|
|
var fakeImageCache = Substitute.For<IImageCache>();
|
|
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
|
|
.Returns(_ => LocalLogoCachePath);
|
|
|
|
_selector = new WatermarkSelector(
|
|
mockFileSystem,
|
|
fakeImageCache,
|
|
Substitute.For<IDecoSelector>(),
|
|
NullLogger<WatermarkSelector>.Instance);
|
|
}
|
|
|
|
private static ChannelWatermark ChannelLogoWatermark(int id, string name) =>
|
|
new()
|
|
{
|
|
Id = id,
|
|
Name = name,
|
|
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
|
|
Mode = ChannelWatermarkMode.Permanent
|
|
};
|
|
|
|
private static Channel ChannelWithLogo(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;
|
|
}
|
|
|
|
// ---- external URL logo: render path must degrade to no bug, never fetch (#525) --------------
|
|
//
|
|
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path can only be a
|
|
// row that failed migration. The render/watermark path must NOT fetch at compositing time: it degrades
|
|
// to None (no on-screen bug) with a warning, rather than handing the URL downstream as a renderable
|
|
// ImagePath (the #502 behavior these tests previously pinned).
|
|
|
|
[Test]
|
|
public void PlayoutItemWatermark_Should_Ignore_External_Url_Channel_Logo()
|
|
{
|
|
ChannelWatermark watermark = ChannelLogoWatermark(1, "PlayoutItem");
|
|
Channel channel = ChannelWithLogo(ExternalLogoUrl);
|
|
|
|
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
|
channel,
|
|
watermark,
|
|
Option<ChannelWatermark>.None);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo()
|
|
{
|
|
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
|
Channel channel = ChannelWithLogo(ExternalLogoUrl, watermark);
|
|
|
|
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
|
channel,
|
|
Option<ChannelWatermark>.None,
|
|
Option<ChannelWatermark>.None);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
// never hand the URL downstream as a renderable path
|
|
result.IfSome(o => o.ImagePath.ShouldNotBe(ExternalLogoUrl));
|
|
}
|
|
|
|
[Test]
|
|
public void GlobalWatermark_Should_Ignore_External_Url_Channel_Logo()
|
|
{
|
|
ChannelWatermark watermark = ChannelLogoWatermark(3, "Global");
|
|
Channel channel = ChannelWithLogo(ExternalLogoUrl);
|
|
|
|
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
|
channel,
|
|
Option<ChannelWatermark>.None,
|
|
watermark);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scheme comparison goes through <see cref="Uri" />, which lower-cases it. Pinned because the fix
|
|
/// turns on <c>Artwork.IsExternalUrl</c>, and a case-sensitive check would silently fall back to the
|
|
/// existence-gated branch and re-introduce the defect for an oddly-cased URL.
|
|
/// </summary>
|
|
[Test]
|
|
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo_Regardless_Of_Scheme_Case()
|
|
{
|
|
const string UpperCaseUrl = "HTTPS://cdn.example.com/logos/channel.png";
|
|
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
|
Channel channel = ChannelWithLogo(UpperCaseUrl, watermark);
|
|
|
|
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
|
channel,
|
|
Option<ChannelWatermark>.None,
|
|
Option<ChannelWatermark>.None);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
}
|
|
|
|
// ---- regressions: local-file behavior must not change ---------------------------------------
|
|
|
|
[Test]
|
|
public void ChannelWatermark_Should_Use_Cached_Path_For_Local_Channel_Logo()
|
|
{
|
|
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
|
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
|
|
|
|
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
|
channel,
|
|
Option<ChannelWatermark>.None,
|
|
Option<ChannelWatermark>.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LocalLogoCachePath);
|
|
}
|
|
|
|
[Test]
|
|
public void ChannelWatermark_Should_Be_Ignored_When_Local_Channel_Logo_File_Is_Missing()
|
|
{
|
|
var mockFileSystem = new MockFileSystem(); // nothing on disk
|
|
var fakeImageCache = Substitute.For<IImageCache>();
|
|
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
|
|
.Returns(_ => LocalLogoCachePath);
|
|
|
|
var selector = new WatermarkSelector(
|
|
mockFileSystem,
|
|
fakeImageCache,
|
|
Substitute.For<IDecoSelector>(),
|
|
NullLogger<WatermarkSelector>.Instance);
|
|
|
|
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
|
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
|
|
|
|
Option<WatermarkOptions> result = selector.GetWatermarkOptions(
|
|
channel,
|
|
Option<ChannelWatermark>.None,
|
|
Option<ChannelWatermark>.None);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scope guard for #502: with no logo artwork at all, the resolved path is the generated-initials
|
|
/// URL from <see cref="Images.ChannelLogoGenerator.GenerateChannelLogoUrl" />, which hardcodes
|
|
/// localhost (issue #1). That fallback stays disabled here — reviving it is deliberately deferred
|
|
/// in docs/decisions.md and is not part of this fix.
|
|
/// </summary>
|
|
[Test]
|
|
public void ChannelWatermark_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
|
|
{
|
|
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
|
Channel channel = ChannelWithLogo(null, watermark);
|
|
|
|
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
|
channel,
|
|
Option<ChannelWatermark>.None,
|
|
Option<ChannelWatermark>.None);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
}
|
|
}
|