- Add GetChannelGuideDataHandlerTests coverage for the mirror-channel path: asserts programme Start/Stop shift by PlayoutOffset and that the source channel's seeded PlayoutItem entities are not mutated, pinning the WithPlayoutOffset copy fix. - ChannelController.GetGuide Swagger description said end defaults to "now plus XmltvDaysToBuild"; it actually defaults to start plus that window (matters when start is supplied). Reworded and regenerated v1.json.
317 lines
13 KiB
C#
317 lines
13 KiB
C#
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<IConfigElementRepository>();
|
|
|
|
// ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we
|
|
// match by the generic value type (GetValue<T>) 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<XmltvTimeZone>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<XmltvTimeZone>.Some(XmltvTimeZone.Utc));
|
|
_config.GetValue<XmltvBlockBehavior>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<XmltvBlockBehavior>.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<ChannelGuideProgrammeResponseModel> 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<ChannelGuideProgrammeResponseModel> 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<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<int>.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_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<PlayoutItem> 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 = []
|
|
};
|
|
}
|