using ErsatzTV.Application.Channels; using ErsatzTV.Application.Configuration; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Core.Tests.Channels; // Issue #77 (XMLTV half): the clock-boundary Pad machinery snaps content to :00/:15/:30/:45 by emitting // trailing PostRoll filler up to the boundary (proven at the builder level by // PlayoutBuildGoldenTests.Classic_clock_padded). This test locks the OTHER half: that the guide // projection — the single source of truth shared by the XMLTV cache builder and the JSON guide query — // coalesces that trailing filler into the programme window, so each guide programme STOPS on the padded // clock boundary. Together the two tests cover the full chain: schedule -> PlayoutItems -> guide. [TestFixture] public class ChannelGuideProjectorClockPadTests { // Deterministic UTC instants mirroring the Classic_clock_padded golden shape: off-boundary content // (22/37/37-min) each followed by a single PostRoll filler run that ends exactly on the next :15 mark. private static readonly DateTime Anchor = new(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc); [Test] public void Padded_programmes_stop_on_quarter_hour_boundary() { var items = new List { // Programme 1: content 00:00-00:22, padded to 00:30 with PostRoll filler. Content(guideGroup: 1, start: T(0, 0), finish: T(0, 22), title: "Movie 01"), Pad(guideGroup: 1, start: T(0, 22), finish: T(0, 30)), // Programme 2: content 00:30-01:07, padded to 01:15. Content(guideGroup: 2, start: T(0, 30), finish: T(1, 7), title: "Movie 02"), Pad(guideGroup: 2, start: T(1, 7), finish: T(1, 15)), // Programme 3: content 01:15-01:52, padded to 02:00. Content(guideGroup: 3, start: T(1, 15), finish: T(1, 52), title: "Movie 03"), Pad(guideGroup: 3, start: T(1, 52), finish: T(2, 0)) }; List entries = ChannelGuideProjector .Project(PlayoutScheduleKind.Classic, items, XmltvTimeZone.Utc, XmltvBlockBehavior.UseActualTimes) .ToList(); // One programme per content item; trailing filler is coalesced in, not surfaced as its own entry. entries.Count.ShouldBe(3); entries.ShouldAllBe(e => e.DisplayItem.FillerKind == FillerKind.None); // The #77 invariant: every programme STOPS on a :15 clock boundary (the padded target), and the // 2nd/3rd programmes also START on one (the previous programme padded up to it). foreach (ChannelGuideEntry entry in entries) { (entry.Stop.Minute % 15).ShouldBe(0, $"programme '{entry.DisplayItem.CustomTitle}' stops off-boundary at {entry.Stop:HH:mm:ss}"); entry.Stop.Second.ShouldBe(0); } entries[0].Stop.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero)); entries[1].Start.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero)); entries[1].Stop.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero)); entries[2].Start.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero)); entries[2].Stop.ShouldBe(new DateTimeOffset(T(2, 0), TimeSpan.Zero)); } private static DateTime T(int hour, int minute) => Anchor.AddHours(hour).AddMinutes(minute); private static PlayoutItem Content(int guideGroup, DateTime start, DateTime finish, string title) => new() { Start = start, Finish = finish, FillerKind = FillerKind.None, GuideGroup = guideGroup, CustomTitle = title }; private static PlayoutItem Pad(int guideGroup, DateTime start, DateTime finish) => new() { Start = start, Finish = finish, FillerKind = FillerKind.PostRoll, GuideGroup = guideGroup }; }