using ErsatzTV.Application.Channels; using ErsatzTV.Application.Configuration; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using NSubstitute; using NUnit.Framework; using Shouldly; using DomainChannel = ErsatzTV.Core.Domain.Channel; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class GetChannelGuideDataHandlerTests { private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc); private InMemoryTvContext _db = null!; private IConfigElementRepository _config = null!; [SetUp] public async Task SetUp() { _db = await InMemoryTvContext.CreateAsync(); _config = Substitute.For(); // ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we // match by the generic value type (GetValue) with Arg.Any key. Pin the time zone to UTC so // projected times are deterministic regardless of the machine/CI time zone, and split block time // evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test. _config.GetValue(Arg.Any(), Arg.Any()) .Returns(Option.Some(XmltvTimeZone.Utc)); _config.GetValue(Arg.Any(), Arg.Any()) .Returns(Option.Some(XmltvBlockBehavior.SplitTimeEvenly)); } [TearDown] public async Task TearDown() => await _db.DisposeAsync(); private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config); [Test] public async Task Handle_Should_Only_Include_Visible_Channels() { await using (TvContext context = _db.CreateContext()) { DomainChannel visible = NewChannel("2", "Visible", showInEpg: true); DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false); visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))]; hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))]; context.Channels.AddRange(visible, hidden); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); result.Channels.Select(c => c.Number).ShouldBe(["2"]); } [Test] public async Task Handle_Should_Order_Channels_By_Decimal_Number() { await using (TvContext context = _db.CreateContext()) { context.Channels.Add(NewChannel("10", "Ten", showInEpg: true)); context.Channels.Add(NewChannel("2", "Two", showInEpg: true)); context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true)); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); // decimal order: 2 < 5.1 < 10 (string order would put "10" first) result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]); } [Test] public async Task Handle_Should_Filter_Programmes_To_Requested_Window() { await using (TvContext context = _db.CreateContext()) { DomainChannel channel = NewChannel("2", "Two", showInEpg: true); channel.Playouts = [ MakeFloodPlayout( channel, (BaseTime, BaseTime.AddHours(1), 1, "Before Window"), (BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window")) ]; context.Channels.Add(channel); await context.SaveChangesAsync(); } // window starts after the first programme has finished ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)), CancellationToken.None); List programmes = result.Channels.Single().Programmes; programmes.Select(p => p.Title).ShouldBe(["In Window"]); } [Test] public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle() { await using (TvContext context = _db.CreateContext()) { DomainChannel channel = NewChannel("2", "Two", showInEpg: true); PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored"); item.CustomTitle = "Custom Title"; channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])]; context.Channels.Add(channel); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single(); programme.Title.ShouldBe("Custom Title"); programme.SubTitle.ShouldBeNull(); programme.Category.ShouldBe("Movie"); } [Test] public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme() { await using (TvContext context = _db.CreateContext()) { DomainChannel channel = NewChannel("2", "Two", showInEpg: true); PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper"); preRoll.FillerKind = FillerKind.PreRoll; PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature"); channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])]; context.Channels.Add(channel); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single(); // filler is merged: the programme starts at the pre-roll start but displays the feature metadata programme.Title.ShouldBe("Feature"); programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero)); programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero)); programme.FillerKind.ShouldBe(FillerKind.None); } [Test] public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items() { await using (TvContext context = _db.CreateContext()) { DomainChannel channel = NewChannel("2", "Two", showInEpg: true); PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One"); PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two"); foreach (PlayoutItem item in new[] { one, two }) { item.GuideStart = BaseTime; item.GuideFinish = BaseTime.AddHours(1); } channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])]; context.Channels.Add(channel); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); List programmes = result.Channels.Single().Programmes; programmes.Count.ShouldBe(2); // 1-hour guide window split evenly across the 2 content items -> 30 minutes each programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero)); programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero)); programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero)); programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero)); } [Test] public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild() { _config.GetValue(Arg.Any(), Arg.Any()) .Returns(Option.Some(3)); DateTimeOffset before = DateTimeOffset.UtcNow; ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(null, null), CancellationToken.None); DateTimeOffset after = DateTimeOffset.UtcNow; result.Start.ShouldBeInRange(before, after); (result.End - result.Start).ShouldBe(TimeSpan.FromDays(3)); } [Test] public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items() { PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show"); var playoutOffset = TimeSpan.FromHours(3); await using (TvContext context = _db.CreateContext()) { DomainChannel source = NewChannel("1", "Source", showInEpg: false); source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])]; DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true); mirror.PlayoutSource = ChannelPlayoutSource.Mirror; mirror.MirrorSourceChannel = source; mirror.PlayoutOffset = playoutOffset; context.Channels.AddRange(source, mirror); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single(); programme.Title.ShouldBe("Live Show"); programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero)); programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero)); // the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's // own (shared, AsNoTracking) PlayoutItem entities in place. sourceItem.Start.ShouldBe(BaseTime); sourceItem.Finish.ShouldBe(BaseTime.AddHours(1)); } [Test] public async Task Handle_Should_Root_Uploaded_Logo_And_Null_When_Absent() { await using (TvContext context = _db.CreateContext()) { DomainChannel withLogo = NewChannel("2", "WithLogo", showInEpg: true); withLogo.Artwork = [new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" }]; DomainChannel withoutLogo = NewChannel("3", "NoLogo", showInEpg: true); context.Channels.AddRange(withLogo, withoutLogo); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); // Uploaded logo is rooted for the SPA's ; a channel with no logo returns null so the SPA // renders its generated initials fallback. result.Channels.Single(c => c.Number == "2").Logo.ShouldBe("/iptv/logos/abc123.png"); result.Channels.Single(c => c.Number == "3").Logo.ShouldBeNull(); } [Test] public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout() { await using (TvContext context = _db.CreateContext()) { context.Channels.Add(NewChannel("2", "Two", showInEpg: true)); await context.SaveChangesAsync(); } ChannelGuideResponseModel result = await MakeHandler().Handle( new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), CancellationToken.None); result.Channels.Single().Programmes.ShouldBeEmpty(); } // --- seeding helpers --- private static Playout MakeFloodPlayout( DomainChannel channel, params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) => MakePlayout( channel, PlayoutScheduleKind.Classic, items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList()); private static Playout MakePlayout( DomainChannel channel, PlayoutScheduleKind scheduleKind, List items) => new() { Channel = channel, ScheduleKind = scheduleKind, ScheduleFile = string.Empty, Items = items }; private static PlayoutItem MakeItem( DateTime start, DateTime finish, int guideGroup, string movieTitle) => new() { Start = start, Finish = finish, GuideGroup = guideGroup, FillerKind = FillerKind.None, MediaItem = new Movie { MovieMetadata = [new MovieMetadata { Title = movieTitle }], MediaVersions = [] } }; private static DomainChannel NewChannel(string number, string name, bool showInEpg) => new(Guid.NewGuid()) { Number = number, Name = name, Group = "ErsatzTV", Categories = string.Empty, StreamSelector = string.Empty, PreferredAudioLanguageCode = string.Empty, PreferredAudioTitle = string.Empty, PreferredSubtitleLanguageCode = string.Empty, MusicVideoCreditsTemplate = string.Empty, PlayoutSource = ChannelPlayoutSource.Generated, PlayoutMode = ChannelPlayoutMode.Continuous, ShowInEpg = showInEpg, Playouts = [] }; }