test: golden-file tests for M3U output (#11) #29
@@ -0,0 +1,180 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
// Golden-file tests that lock the M3U output shape Jellyfin/Kodi consume (ChannelPlaylist.ToM3U).
|
||||
// Baselines capture TODAY's behaviour so a regression — e.g. while implementing the paused #1
|
||||
// (tvg-logo base URL) — fails CI instead of only surfacing as Jellyfin misbehaving in prod.
|
||||
//
|
||||
// Goldens live next to this file under Goldens/ and are located via [CallerFilePath], so they
|
||||
// resolve from the checked-out source in CI and locally without embedded-resource plumbing.
|
||||
// To regenerate after an INTENTIONAL output change, run the (explicit) Regenerate_goldens test
|
||||
// or set ETV_UPDATE_GOLDENS=1, then review and commit the diff.
|
||||
[TestFixture]
|
||||
public class ChannelPlaylistGoldenTests
|
||||
{
|
||||
private const string Scheme = "https";
|
||||
private const string Host = "tv.example.com";
|
||||
private const string BaseUrl = "";
|
||||
|
||||
[SetUp]
|
||||
public void GuardVolatileEnvironment() =>
|
||||
// tvg-id (ChannelIdentifier.FromNumber) appends SystemEnvironment.InstanceId when set;
|
||||
// the goldens assume it is unset (the CI/default case). Skip rather than falsely fail.
|
||||
Assume.That(
|
||||
string.IsNullOrEmpty(SystemEnvironment.InstanceId),
|
||||
"ETV_INSTANCE_ID is set; M3U goldens assume it is unset.");
|
||||
|
||||
[Test]
|
||||
public void Simple_channel_generated_logo() =>
|
||||
Verify(
|
||||
"simple-generated-logo.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[BuildChannel(1, "1", "News", StreamingMode.HttpLiveStreamingDirect)],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Channel_with_internal_logo() =>
|
||||
Verify(
|
||||
"internal-logo.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[WithLogo(BuildChannel(2, "2", "Movies", StreamingMode.HttpLiveStreamingSegmenter), "abc123def")],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Channel_with_external_logo() =>
|
||||
Verify(
|
||||
"external-logo.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[WithLogo(BuildChannel(3, "3", "Music", StreamingMode.TransportStream), "https://cdn.example.com/music.png")],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Multi_channel_sorted_by_number() =>
|
||||
Verify(
|
||||
"multi-channel.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[
|
||||
BuildChannel(10, "10", "Ten", StreamingMode.HttpLiveStreamingDirect),
|
||||
BuildChannel(2, "2", "Two", StreamingMode.TransportStreamHybrid),
|
||||
BuildChannel(1, "1.5", "OnePointFive", StreamingMode.HttpLiveStreamingSegmenter)
|
||||
],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Kodi_user_agent_adds_kodiprops() =>
|
||||
Verify(
|
||||
"kodi-user-agent.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[BuildChannel(4, "4", "Kids", StreamingMode.TransportStream)],
|
||||
userAgent: "Kodi/21.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void With_access_token_and_base_url() =>
|
||||
Verify(
|
||||
"access-token-base-url.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
baseUrl: "/etv",
|
||||
[WithLogo(BuildChannel(5, "5", "Docs", StreamingMode.HttpLiveStreamingDirect), "logo5")],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: "SECRET-TOKEN"));
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static Channel BuildChannel(int seed, string number, string name, StreamingMode mode) =>
|
||||
new(new Guid($"00000000-0000-0000-0000-{seed:D12}"))
|
||||
{
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
StreamingMode = mode,
|
||||
Artwork = [],
|
||||
FFmpegProfile = new FFmpegProfile
|
||||
{
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
}
|
||||
};
|
||||
|
||||
private static Channel WithLogo(Channel channel, string path)
|
||||
{
|
||||
channel.Artwork = [new Artwork { ArtworkKind = ArtworkKind.Logo, Path = path }];
|
||||
return channel;
|
||||
}
|
||||
|
||||
private static void Verify(string goldenName, ChannelPlaylist playlist)
|
||||
{
|
||||
string actual = playlist.ToM3U().ReplaceLineEndings("\n");
|
||||
string path = Path.Combine(GoldenDir(), goldenName);
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ETV_UPDATE_GOLDENS") == "1")
|
||||
{
|
||||
Directory.CreateDirectory(GoldenDir());
|
||||
File.WriteAllText(path, actual);
|
||||
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
|
||||
return;
|
||||
}
|
||||
|
||||
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline
|
||||
// can't pass CI. Create it with the Regenerate_goldens test or ETV_UPDATE_GOLDENS=1.
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_GOLDENS=1) and commit it.");
|
||||
|
||||
string expected = File.ReadAllText(path).ReplaceLineEndings("\n");
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all M3U goldens from current output; review the diff before committing.")]
|
||||
public void Regenerate_goldens()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", "1");
|
||||
try
|
||||
{
|
||||
Simple_channel_generated_logo();
|
||||
Channel_with_internal_logo();
|
||||
Channel_with_external_logo();
|
||||
Multi_channel_sorted_by_number();
|
||||
Kodi_user_agent_adds_kodiprops();
|
||||
With_access_token_and_base_url();
|
||||
}
|
||||
catch (InconclusiveException)
|
||||
{
|
||||
// expected — each Verify writes its golden then reports inconclusive
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
|
||||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/etv/iptv/xmltv.xml?access_token=SECRET-TOKEN" x-tvg-url="https://tv.example.com/etv/iptv/xmltv.xml?access_token=SECRET-TOKEN"
|
||||
#EXTINF:0 tvg-id="C5.149.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAABQ" channel-number="5" CUID="AAAAAAAAAAAAAAAAAAAABQ" tvg-chno="5" tvg-name="Docs" tvg-logo="https://tv.example.com/etv/iptv/logos/logo5.jpg?access_token=SECRET-TOKEN" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Docs
|
||||
https://tv.example.com/etv/iptv/channel/5.m3u8?mode=hls-direct&access_token=SECRET-TOKEN
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C3.147.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAw" channel-number="3" CUID="AAAAAAAAAAAAAAAAAAAAAw" tvg-chno="3" tvg-name="Music" tvg-logo="https://cdn.example.com/music.png" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Music
|
||||
https://tv.example.com/iptv/channel/3.ts?mode=ts-legacy
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C2.146.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAg" channel-number="2" CUID="AAAAAAAAAAAAAAAAAAAAAg" tvg-chno="2" tvg-name="Movies" tvg-logo="https://tv.example.com/iptv/logos/abc123def.jpg" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Movies
|
||||
https://tv.example.com/iptv/channel/2.m3u8?mode=segmenter
|
||||
@@ -0,0 +1,6 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#KODIPROP:inputstream=inputstream.ffmpegdirect
|
||||
#KODIPROP:mimetype=video/mp2t
|
||||
#KODIPROP:inputstream.ffmpegdirect.open_mode=ffmpeg
|
||||
#EXTINF:0 tvg-id="C4.148.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAABA" channel-number="4" CUID="AAAAAAAAAAAAAAAAAAAABA" tvg-chno="4" tvg-name="Kids" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Kids" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Kids
|
||||
https://tv.example.com/iptv/channel/4.ts?mode=ts-legacy
|
||||
@@ -0,0 +1,7 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C1.5.150.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAQ" channel-number="1.5" CUID="AAAAAAAAAAAAAAAAAAAAAQ" tvg-chno="1.5" tvg-name="OnePointFive" tvg-logo="https://tv.example.com/iptv/logos/gen?text=OnePointFive" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", OnePointFive
|
||||
https://tv.example.com/iptv/channel/1.5.m3u8?mode=segmenter
|
||||
#EXTINF:0 tvg-id="C2.146.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAg" channel-number="2" CUID="AAAAAAAAAAAAAAAAAAAAAg" tvg-chno="2" tvg-name="Two" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Two" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Two
|
||||
https://tv.example.com/iptv/channel/2.ts
|
||||
#EXTINF:0 tvg-id="C10.193.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAEA" channel-number="10" CUID="AAAAAAAAAAAAAAAAAAAAEA" tvg-chno="10" tvg-name="Ten" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Ten" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Ten
|
||||
https://tv.example.com/iptv/channel/10.m3u8?mode=hls-direct
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C1.145.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAQ" channel-number="1" CUID="AAAAAAAAAAAAAAAAAAAAAQ" tvg-chno="1" tvg-name="News" tvg-logo="https://tv.example.com/iptv/logos/gen?text=News" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", News
|
||||
https://tv.example.com/iptv/channel/1.m3u8?mode=hls-direct
|
||||
Reference in New Issue
Block a user