test: golden-file tests for M3U output (#11) #29

Merged
timothy merged 1 commits from foundations/11-golden-tests into main 2026-06-27 10:22:28 +02:00
7 changed files with 205 additions and 0 deletions
@@ -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