From 194d3fdf341a39191690717ed08a674f1bd75718 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 27 Jun 2026 10:16:52 +0200 Subject: [PATCH] test: golden-file tests for M3U output (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock the M3U shape Jellyfin/Kodi consume (ChannelPlaylist.ToM3U) 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. Six scenarios snapshot today's output: simple channel (generated logo), internal artwork logo, external-URL logo, multi-channel (sorted by number), Kodi user-agent (KODIPROP lines), and base-URL + access-token threading. Deterministic: fixed UniqueIds, and a guard skips (Assume) when ETV_INSTANCE_ID is set (it feeds the tvg-id). Goldens live next to the test under Goldens/, located via [CallerFilePath]; regenerate via the explicit Regenerate_goldens test or ETV_UPDATE_GOLDENS=1. Verified: 6/6 pass, and proven to have teeth (tampering a golden fails the test). XMLTV guide + channel-logo image golden coverage split to #28 (DB/filesystem fixtures / non-deterministic across platforms). Refs #11 Co-Authored-By: Claude Opus 4.8 --- .../Iptv/ChannelPlaylistGoldenTests.cs | 180 ++++++++++++++++++ .../Iptv/Goldens/access-token-base-url.m3u | 3 + .../Iptv/Goldens/external-logo.m3u | 3 + .../Iptv/Goldens/internal-logo.m3u | 3 + .../Iptv/Goldens/kodi-user-agent.m3u | 6 + .../Iptv/Goldens/multi-channel.m3u | 7 + .../Iptv/Goldens/simple-generated-logo.m3u | 3 + 7 files changed, 205 insertions(+) create mode 100644 ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/access-token-base-url.m3u create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/external-logo.m3u create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/internal-logo.m3u create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/kodi-user-agent.m3u create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/multi-channel.m3u create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/simple-generated-logo.m3u diff --git a/ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs b/ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs new file mode 100644 index 000000000..2a8159458 --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs @@ -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"); +} diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/access-token-base-url.m3u b/ErsatzTV.Core.Tests/Iptv/Goldens/access-token-base-url.m3u new file mode 100644 index 000000000..78af00ca1 --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/access-token-base-url.m3u @@ -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 diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/external-logo.m3u b/ErsatzTV.Core.Tests/Iptv/Goldens/external-logo.m3u new file mode 100644 index 000000000..f33497abb --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/external-logo.m3u @@ -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 diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/internal-logo.m3u b/ErsatzTV.Core.Tests/Iptv/Goldens/internal-logo.m3u new file mode 100644 index 000000000..2fa468971 --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/internal-logo.m3u @@ -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 diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/kodi-user-agent.m3u b/ErsatzTV.Core.Tests/Iptv/Goldens/kodi-user-agent.m3u new file mode 100644 index 000000000..8f55b6312 --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/kodi-user-agent.m3u @@ -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 diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/multi-channel.m3u b/ErsatzTV.Core.Tests/Iptv/Goldens/multi-channel.m3u new file mode 100644 index 000000000..bed14e02f --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/multi-channel.m3u @@ -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 diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/simple-generated-logo.m3u b/ErsatzTV.Core.Tests/Iptv/Goldens/simple-generated-logo.m3u new file mode 100644 index 000000000..a0817096e --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/simple-generated-logo.m3u @@ -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 -- 2.47.3